package template import ( "salty/terrain/internal/field" "salty/terrain/internal/world" ) // Map is a classified template projected onto a planet grid: one legend index per planet cell, including // the polar pad. type Map struct { P world.Planet L *Legend Class []uint8 Sea []bool } // Project resamples a paint-resolution raster onto the planet grid by nearest neighbour, and fills the // polar pad with padClass. // // Nearest neighbour is not a shortcut, it is the only correct choice: a class index is a name, not a // quantity, and interpolating between "desert" and "ocean" would invent a class that is neither. The blend // rule in Docs/Terrain-Next.md 3.2 - the painted map owns the wavelengths above its pixel size and noise // owns those below - is honoured downstream, where the continuous fields the classes stand for are smoothed // and then given sub-pixel variation. Doing it here instead would smear the coastline, which is the one // thing in the whole template an author draws deliberately. func (r *Raster) Project(p world.Planet, l *Legend, padClass int) *Map { m := &Map{P: p, L: l, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)} sea := make([]bool, len(l.Classes)) for i := range l.Classes { sea[i] = l.Classes[i].Sea } pad := uint8(padClass) paintH := p.PaintH() field.Rows(p.H, func(y0, y1 int) { for y := y0; y < y1; y++ { if p.InPad(y) { for x := 0; x < p.W; x++ { i := y*p.W + x m.Class[i] = pad m.Sea[i] = sea[pad] } continue } // Sample at the cell's centre, so a run of planet cells maps evenly across the paint rather // than favouring its left edge. py := (2*(y-p.PadY) + 1) * r.H / (2 * paintH) if py >= r.H { py = r.H - 1 } for x := 0; x < p.W; x++ { px := (2*x + 1) * r.W / (2 * p.W) if px >= r.W { px = r.W - 1 } i := y*p.W + x c := r.Class[py*r.W+px] m.Class[i] = c m.Sea[i] = sea[c] } } }) return m } // Counts is how many planet cells each class covers, and how many of them are land. The pad is excluded, // because it is not part of anybody's world. func (m *Map) Counts() (perClass []int, land, total int) { perClass = make([]int, len(m.L.Classes)) for y := m.P.PadY; y < m.P.H-m.P.PadY; y++ { for x := 0; x < m.P.W; x++ { i := y*m.P.W + x perClass[m.Class[i]]++ total++ if !m.Sea[i] { land++ } } } return perClass, land, total } // Rates is the uplift rate in metres a year for every class, indexed by class. Sea classes are zero: the // solve holds an ocean cell at base level for its whole run and never reads the rate there. func (l *Legend) Rates() []float32 { out := make([]float32, len(l.Classes)) for i := range l.Classes { if l.Classes[i].Land() { out[i] = float32(l.Classes[i].RateMYr()) } } return out } // Erodibilities is the multiplier on stream-power K for every class. Sea classes get 1 rather than 0, so // that a field built from this never carries a zero into a division. func (l *Legend) Erodibilities() []float32 { out := make([]float32, len(l.Classes)) for i := range l.Classes { out[i] = 1 if l.Classes[i].Land() { out[i] = float32(l.Classes[i].K()) } } return out } // CoastalPlains is, per class, how far inland the rate ramps up to its full value, in metres, and the rate // it starts from at the waterline. func (l *Legend) CoastalPlains() (plainM []float64, floor []float32) { plainM = make([]float64, len(l.Classes)) floor = make([]float32, len(l.Classes)) for i := range l.Classes { c := l.Classes[i] if c.Land() && c.CoastalPlainKm > 0 { plainM[i] = c.CoastalPlainKm * 1000 floor[i] = float32(c.PlainFloorMYr()) } } return plainM, floor } // Massifs is, per class, the plain's uplift rate in metres a year and the share of the class that stands // above the midpoint between that floor and the class rate. A class with no massif reports a zero fraction, // which is what internal/uplift reads as "one rate all over", and its floor is then its own rate. func (l *Legend) Massifs() (floor []float32, fraction []float64) { floor = make([]float32, len(l.Classes)) fraction = make([]float64, len(l.Classes)) for i := range l.Classes { c := l.Classes[i] if !c.Land() { continue } floor[i] = float32(c.MassifFloorMYr()) fraction[i] = c.MassifFraction() } return floor, fraction } // LithologyMixes is, per class, how much of the planet's rock field shows through. Sea is zero: the solve // holds every sea cell at base level and never reads K there, and leaving it at 1 would put rock provinces on // the diagnostic map out in the open ocean. func (l *Legend) LithologyMixes() []float64 { out := make([]float64, len(l.Classes)) for i := range l.Classes { if l.Classes[i].Land() { out[i] = l.Classes[i].LithMix() } } return out } // Snow is, per class, whether it is permanently under ice. A display and material hint; no pass reads it. func (l *Legend) Snow() []bool { out := make([]bool, len(l.Classes)) for i := range l.Classes { out[i] = l.Classes[i].Snow } return out } // SnowMask marks every planet cell whose class is permanently under ice, painted rows only. func (m *Map) SnowMask() []bool { snow := m.L.Snow() any := false for _, s := range snow { any = any || s } if !any { return nil } p := m.P out := make([]bool, p.W*p.PaintH()) for i := range out { out[i] = snow[m.Class[p.PadY*p.W+i]] } return out } // Depths is how far below sea level the open water of each class sits, in metres, positive. Land is zero. func (l *Legend) Depths() []float32 { out := make([]float32, len(l.Classes)) for i := range l.Classes { if l.Classes[i].Sea { out[i] = float32(l.Classes[i].DepthM) } } return out } // ClassDetailTables are the per-class detail overrides, resolved against the pipeline's own numbers so a pass // can index them without asking whether a class overrode anything. type ClassDetailTables struct { Droplets []float64 AmpLo []float64 AmpHi []float64 Contrast []float64 } // DetailTables resolves every class against the pipeline defaults it is given. func (l *Legend) DetailTables(droplets, ampLo, ampHi, contrast float64) ClassDetailTables { n := len(l.Classes) t := ClassDetailTables{ Droplets: make([]float64, n), AmpLo: make([]float64, n), AmpHi: make([]float64, n), Contrast: make([]float64, n), } for i := range l.Classes { t.Droplets[i], t.AmpLo[i], t.AmpHi[i], t.Contrast[i] = droplets, ampLo, ampHi, contrast d := l.Classes[i].Detail if d == nil { continue } if d.DropletsPerCell > 0 { t.Droplets[i] = d.DropletsPerCell } if d.AmplitudeM != nil { t.AmpLo[i], t.AmpHi[i] = d.AmplitudeM[0], d.AmplitudeM[1] } if d.StrataContrast > 0 { t.Contrast[i] = d.StrataContrast } } return t } // Overrides reports whether any class asks the detail passes for anything different, so a caller can skip // carrying a class raster through them when nothing would read it. func (l *Legend) Overrides() bool { for i := range l.Classes { if l.Classes[i].Detail != nil { return true } } return false }