This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+236
View File
@@ -0,0 +1,236 @@
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
}