Files
UnrealPrototyping/Tools/Terrain/internal/template/legend.go
T
2026-09-25 17:02:24 +03:00

569 lines
24 KiB
Go

package template
import (
"bytes"
"encoding/json"
"fmt"
"os"
"salty/terrain/internal/field"
)
// Class is one painted colour and everything it means.
//
// A class says whether the author painted sea or land, and for land it carries the two numbers the
// solve actually reads: the rock uplift rate, which is what produces relief, and a multiplier on the
// stream-power erodibility, which is what makes one range read differently from the next. Neither is a
// height. See the package comment.
type Class struct {
Name string `json:"name"`
RGB [3]int `json:"rgb"`
Sea bool `json:"sea"`
// DepthM is how deep this water is, in metres below sea level, positive. It is scenery: the coastal
// pass owns the sea floor within its reach of a shore and lays a derived shelf there, so this only
// decides the open ocean beyond it. Sea only.
DepthM float64 `json:"depth_m"`
// UpliftMmYr is rock uplift in millimetres a year, which is the field everything else is a
// consequence of. The reporting buckets in internal/stats read plain below 0.1, rolling to 0.5 and
// mountain above, so those are the numbers to think in. Land only.
UpliftMmYr float64 `json:"uplift_mm_yr"`
// KMult multiplies the stream-power erodibility K. Soft rock above 1, hard rock below. Land only;
// zero is read as 1, because an erodibility of zero is never what anyone means.
KMult float64 `json:"k_mult"`
// Stroke marks a colour that is decoration rather than data - the white outline an artist draws
// around every island. A stroke is dissolved into whichever real class is nearest, so it never
// becomes a ring of land or a moat of water.
Stroke bool `json:"stroke"`
// Snow marks land that is permanently under ice or snow. It is a *display and material* hint and nothing
// else - it changes no height and enters no pass - but the preview needs it, because the hypsometric ramp
// tops out at snow by elevation and a polar cap at fifty metres therefore comes out the same green as a
// meadow. An ice sheet that reads as a meadow is a map that lies about the one thing it is for.
Snow bool `json:"snow"`
// CoastalPlainKm puts the range inland.
//
// For n = 1 the uplift rate alone fixes the hillslope angle (D-49), so a uniformly painted island sits at
// the angle of repose everywhere, the shore included: the rivers cut down to sea level but the ground
// between them does not care how far from the coast it is. Real coasts have a plain in front of the
// range. This ramps the rate from CoastalFloorMmYr at the waterline up to the class rate over this
// distance inland, so the first few kilometres are plain and the range stands behind them.
//
// It is deliberately opt-in and deliberately not a taper to zero, which is the distinction from D-52:
// that was a *hidden* taper - a side effect of multiplying by the continent mask - and it flattened the
// hundred-metre strip the surf works in, moving every cliff inland. This is an author saying where their
// range starts, and the waterline keeps a real rate.
CoastalPlainKm float64 `json:"coastal_plain_km"`
// CoastalFloorMmYr is the rate at the waterline. Zero means the default, and it is never raised above the
// class rate - a plain in front of a plain is still a plain.
CoastalFloorMmYr float64 `json:"coastal_floor_mm_yr"`
// Massif breaks this class into plain and upland instead of one rate over every cell of it.
Massif *Massif `json:"massif"`
// LithologyMix is how much of the planet's rock field shows through on this class's ground, 0 to 1.
//
// The rock field is one low-frequency pattern over the whole planet, cut into the manifest's
// `pipeline.lithology` types, and it multiplies K on top of this class's own `k_mult`. At 1 the class
// takes all of it; at 0 it is one uniform rock, which is what every painted class was before D-58 and
// what a polar cap or a crater floor should stay - there is no bedrock province showing through an ice
// sheet. A pointer, so "not set" is 1 and "set to zero" is uniform; those are different answers.
LithologyMix *float64 `json:"lithology_mix"`
// Faults places traces in the ground this class was painted on. Absent means none, which is right for a
// plain: faults belong to orogens, and an author saying which classes are faulted is saying where the
// orogens are. See internal/uplift's painted_faults.go for what a trace then does.
Faults *ClassFaults `json:"faults"`
// Crater reshapes this class's painted blobs into rim and floor, *after* the solve.
Crater *Crater `json:"crater"`
// Detail overrides what the detail passes do on this class's ground. Optional; every field left out
// keeps the manifest's pipeline value.
//
// It exists because at the geology grid a class is only an uplift rate and an erodibility, and those two
// numbers cannot tell a desert from a wet lowland - both are "low ground". The difference is at two
// metres: a desert has sparse sharp wadis instead of a dendritic gully network, it holds its mesas and
// ledges because there is no soil creep to round them off, and a good deal of it is dunes.
Detail *ClassDetail `json:"detail"`
// Derived marks a class that is never painted: it takes part in no colour matching and exists only as
// something another class turns into. Its rgb, if it has one, is for the diagnostic maps alone.
//
// The case it exists for is the one every hand-painted world map has. White is drawn twice - as the
// polar caps and as the outline stroke around every island - so exactly one class can own that colour,
// and it has to be the stroke, because the stroke is the one that needs to be recognised everywhere it
// appears. What the caps become is then a class with no colour of its own.
Derived bool `json:"derived"`
// EdgeClass rescues the ambiguous case, which in practice is always white: the same colour is the
// polar ice cap and the outline stroke. A stroke region that touches the top or bottom row of the
// map is not a stroke at all; it becomes the class named here. Everything else dissolves.
EdgeClass string `json:"edge_class"`
}
// ClassDetail is what the detail passes do differently on one class's ground.
type ClassDetail struct {
// DropletsPerCell is how much running water this ground sees. The single most useful number here: drop it
// and the dendritic gully network thins out to isolated channels, which is the difference between a
// rain-fed landscape and an arid one. Zero keeps the pipeline value.
DropletsPerCell float64 `json:"droplets_per_cell"`
// AmplitudeM is the detail noise, low end to high end by slope. Raise it for dune fields - flat desert
// ground with tens of metres of relief on it is a sand sea, and flat ground with two metres is a plain.
AmplitudeM *[2]float64 `json:"amplitude_m"`
// StrataContrast is how hard the hard bands are. Ledges and mesas come from here, and they survive in a
// desert because there is nothing wearing them round.
StrataContrast float64 `json:"strata_contrast"`
}
// Massif breaks one painted colour into plain and upland, which is the difference between a landmass and a
// landscape.
//
// The reason it has to exist is arithmetic. For n = 1 the steady-state divide slope is U/(K*cell^2m), so a
// class's uplift rate *is* its hillslope angle - 0.08 mm/yr is 11.3 degrees at an 8 m cell and K 5e-5 - and a
// class is one rate over every cell an author painted with it. A uniformly painted landmass therefore comes
// out uniformly dissected from the waterline to the summit at whatever angle its rate names, with no flat
// ground anywhere on it, and that is what the first painted planet looked like. Europe away from the Alps is
// not that. It is a plain at a fraction of a degree with isolated massifs standing out of it, and what
// separates the two is not the rate, it is that the rate is not the same everywhere.
//
// So the class rate is re-read as the rate a *massif* reaches, FloorMmYr is the plain between them, and
// Fraction is how much of the ground rises above the halfway point. The cut is made in one field - the
// planet's upland fabric, whose size is planet.massif_wavelength_km - so a highland belt and the hills in the
// lowland next door are outliers of one structure rather than two unrelated noises, which is how a foreland
// works on Earth.
//
// Fraction is a share of the *planet's surface*, and because the fabric knows nothing about the painting it
// is also, in expectation, the share of any one class. The difference is the variance, and the variance is
// the point: a small island may get all of a massif or none of it, exactly as it would if it were a real
// island that happened to sit on or off an orogen. Normalising per landmass would hand every island its
// quota of hills, which is the thing this exists to stop.
type Massif struct {
// FloorMmYr is the plain: the rate everywhere the fabric is low. It is the number that decides whether
// this class has flat ground at all, and it wants to be about a tenth of the class rate - 0.012 mm/yr is
// a 1.7 degree hillslope, which is a plain a player can build on, where 0.08 is continuous hill country.
FloorMmYr float64 `json:"floor_mm_yr"`
// Fraction is how much of this class stands above the midpoint between floor and class rate. Half that
// again reaches the class rate outright and half again above that is off the plain at all, so 0.15 means
// roughly a seventh upland, a quarter touched, and the rest plain.
Fraction float64 `json:"fraction"`
}
// ClassFaults is a class's fault set: how many, how long, and how much they throw.
//
// A density rather than a count, because a class covers whatever an author painted it over and a count would
// mean something different on every template. The throw is the *total displacement over the whole run*, which
// the solve turns into a rate - so it is the height of the scarp the fault would build if nothing eroded it,
// which is a number an author can picture, unlike millimetres a year.
type ClassFaults struct {
Per1000Km2 float64 `json:"per_1000km2"`
// ThrowM and LengthKm are low-to-high ranges the seed picks between, so one class's faults are not all
// the same size.
ThrowM [2]float64 `json:"throw_m"`
LengthKm [2]float64 `json:"length_km"`
}
// Crater is an impact, stamped onto the finished terrain rather than solved.
//
// It is not an uplift rate and it cannot be one, for a reason worth writing down: a closed basin does not
// survive the fluvial solve. The priority-flood runs every step and *raises* every depression to its spill
// level, so a crater built out of negative uplift would be filled in before the run was a hundred steps old.
// It is also the wrong model. A crater is an event, not a rate - it postdates the landscape it sits in, which
// is exactly what a pass running after the solve expresses.
//
// The shape is derived from the painted blob rather than drawn: distance inward from the blob's own boundary,
// normalised by its widest point, gives a coordinate that is 0 at the shore and 1 at the centre whatever size
// and shape the author painted.
type Crater struct {
// RimM is the crest height above sea level and FloorM the basin floor, also above sea level. The
// difference is the depth; real simple craters run about a fifth of their diameter deep.
RimM float64 `json:"rim_m"`
FloorM float64 `json:"floor_m"`
// RimAt is where the crest sits as a fraction of the way in from the shore, and WallAt where the inner
// wall has finished falling to the floor. Everything past WallAt is floor.
RimAt float64 `json:"rim_at"`
WallAt float64 `json:"wall_at"`
}
// Land is the complement of Sea, spelled out because it is read far more often than it is written.
func (c Class) Land() bool { return !c.Sea }
// RateMYr is the uplift rate in metres a year, which is the unit the solve works in.
func (c Class) RateMYr() float64 { return c.UpliftMmYr / 1000 }
// PlainFloorMYr is the uplift rate at the waterline in metres a year, never above the class's own rate.
func (c Class) PlainFloorMYr() float64 {
floor := c.CoastalFloorMmYr
if floor <= 0 {
floor = defaultCoastalFloorMmYr
}
if floor > c.UpliftMmYr {
floor = c.UpliftMmYr
}
return floor / 1000
}
// MassifFloorMYr is the plain's uplift rate in metres a year, or the class rate when this class has no
// massif and is therefore one rate all over.
func (c Class) MassifFloorMYr() float64 {
if c.Massif == nil {
return c.RateMYr()
}
return c.Massif.FloorMmYr / 1000
}
// MassifFraction is how much of this class stands above the midpoint between its floor and its rate. Zero
// means no massif field is built for it at all.
func (c Class) MassifFraction() float64 {
if c.Massif == nil {
return 0
}
return c.Massif.Fraction
}
// LithMix is how much of the planet's rock field this class takes, with "not set" read as all of it.
func (c Class) LithMix() float64 {
if c.LithologyMix == nil {
return 1
}
return *c.LithologyMix
}
// ThrowM is this class's fault throw range, or zeroes when it has no faults.
func (c Class) ThrowM() [2]float64 {
if c.Faults == nil {
return [2]float64{}
}
return c.Faults.ThrowM
}
// K is KMult with the zero value read as 1.
func (c Class) K() float64 {
if c.KMult == 0 {
return 1
}
return c.KMult
}
// Legend is a template's colours and their meanings. It lives beside the image as JSON so that tuning a
// world is a text edit and a rerun rather than a repaint.
type Legend struct {
// Image is the painted map, relative to the legend file unless it is absolute.
Image string `json:"image"`
// WarnDistance is how far, in RGB, a pixel may sit from the nearest class before the run says so.
// Every pixel is always assigned to its nearest class - there is no unclassified - so this is the
// only thing that catches a colour the legend forgot. Zero means the default.
WarnDistance float64 `json:"warn_distance"`
Classes []Class `json:"classes"`
edge []int // per class: the resolved EdgeClass index, or -1
}
// defaultCoastalFloorMmYr is a plain, and it is 0.02 rather than the 0.06 it was first written as because
// 0.06 is not one. The old number came from reading internal/stats' "plain below 0.1 mm/yr" as a description
// of terrain; it is not, it is a reporting bucket calibrated for the procedural path's intraplate rates. What
// decides how ground reads is the divide angle, and at an 8 m cell and K 5e-5 that is tan(angle) = 2.5 * the
// rate in mm/yr: 0.06 is an 8.5 degree hillslope on every divide, which is hill country, and 0.02 is 2.9
// degrees, which is a coastal plain. See `terrain plan`, which prints the angle and what it reads as.
const defaultCoastalFloorMmYr = 0.02
// MaxMassifFraction is the largest share of a class that may stand above the midpoint. See the ramp in
// internal/uplift: it opens at 1 - 1.5*fraction in probability, so above two thirds it would run off the
// bottom of the distribution and the number would stop meaning what it says.
const MaxMassifFraction = 0.6
// DefaultWarnDistance is generous on purpose. A JPEG bleeds several units of each channel across a
// boundary and a hand-mixed colour is rarely the one in the legend to the unit; a class the legend has
// never heard of is usually tens of units away from everything.
const DefaultWarnDistance = 60
// Load reads a legend from JSON.
func Load(path string) (*Legend, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
l, err := Parse(data)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return l, nil
}
// Parse reads a legend from JSON already in memory.
//
// Unknown fields are refused, which is unusual for this project and deliberate here: a legend is a table of
// numbers an author edits by hand, and a misspelt key that is silently ignored is a class quietly running on
// the default rather than on what they wrote. Keys beginning with an underscore are the exception, because
// that is how every manifest in this repository carries its commentary.
func Parse(data []byte) (*Legend, error) {
clean, err := field.StripJSONComments(data)
if err != nil {
return nil, err
}
var l Legend
dec := json.NewDecoder(bytes.NewReader(clean))
dec.DisallowUnknownFields()
if err := dec.Decode(&l); err != nil {
return nil, err
}
if err := l.resolve(); err != nil {
return nil, err
}
return &l, nil
}
// Index is the class with this name, or -1.
func (l *Legend) Index(name string) int {
for i := range l.Classes {
if l.Classes[i].Name == name {
return i
}
}
return -1
}
// EdgeIndex is the class a stroke at the map edge becomes, or -1 when it has none.
func (l *Legend) EdgeIndex(i int) int { return l.edge[i] }
// FirstSea is the index of the first sea class, or -1. It is the default fill for the polar pad: those rows
// are synthetic ocean that exists only so a cap touching the top of the painted map has a shore to drain to,
// and they are discarded before anything is written out.
func (l *Legend) FirstSea() int {
for i := range l.Classes {
if l.Classes[i].Sea {
return i
}
}
return -1
}
// HasCraters reports whether any class is stamped as an impact.
func (l *Legend) HasCraters() bool {
for i := range l.Classes {
if l.Classes[i].Crater != nil {
return true
}
}
return false
}
// HasCoastalPlains reports whether any class puts its range inland.
func (l *Legend) HasCoastalPlains() bool {
for i := range l.Classes {
if l.Classes[i].CoastalPlainKm > 0 {
return true
}
}
return false
}
// HasFaults reports whether any class asks for fault traces. When none does, the planet's grain field is
// never built and no trace is ever drawn, so a legend that does not ask for them pays nothing.
func (l *Legend) HasFaults() bool {
for i := range l.Classes {
if l.Classes[i].Faults != nil {
return true
}
}
return false
}
// HasLithology reports whether any class lets the rock field through.
func (l *Legend) HasLithology() bool {
for i := range l.Classes {
if l.Classes[i].Land() && l.Classes[i].LithMix() > 0 {
return true
}
}
return false
}
// HasMassifs reports whether any class breaks into plain and upland. When none does, the planet's upland
// fabric is never built and never sampled, so a legend that does not ask for it pays nothing.
func (l *Legend) HasMassifs() bool {
for i := range l.Classes {
if l.Classes[i].MassifFraction() > 0 {
return true
}
}
return false
}
// resolve fills the derived fields and refuses a legend that cannot mean anything.
func (l *Legend) resolve() error {
if len(l.Classes) == 0 {
return fmt.Errorf("legend has no classes")
}
if len(l.Classes) > 255 {
return fmt.Errorf("legend has %d classes; the raster holds 255", len(l.Classes))
}
if l.WarnDistance <= 0 {
l.WarnDistance = DefaultWarnDistance
}
seen := make(map[string]int, len(l.Classes))
byRGB := make(map[[3]int]string, len(l.Classes))
nonStroke, painted := 0, 0
for i := range l.Classes {
c := &l.Classes[i]
if c.Name == "" {
return fmt.Errorf("class %d has no name", i)
}
if c.Derived && c.Stroke {
return fmt.Errorf("class %q is both derived and a stroke; a derived class is never painted, "+
"so there is nothing of it to dissolve", c.Name)
}
if j, dup := seen[c.Name]; dup {
return fmt.Errorf("classes %d and %d are both named %q", j, i, c.Name)
}
seen[c.Name] = i
for k, v := range c.RGB {
if v < 0 || v > 255 {
return fmt.Errorf("class %q: rgb[%d] is %d, outside 0..255", c.Name, k, v)
}
}
if !c.Derived {
if other, dup := byRGB[c.RGB]; dup {
return fmt.Errorf("classes %q and %q share the colour %v; nothing could tell them apart",
other, c.Name, c.RGB)
}
byRGB[c.RGB] = c.Name
painted++
}
if c.Sea {
if c.DepthM < 0 {
return fmt.Errorf("class %q: depth_m is %.1f; it is metres below sea level, so positive",
c.Name, c.DepthM)
}
if c.UpliftMmYr != 0 || c.KMult != 0 {
return fmt.Errorf("class %q is sea but carries uplift or erodibility; the solve holds "+
"every sea cell at base level and would never read them", c.Name)
}
if c.Crater != nil || c.CoastalPlainKm != 0 || c.Snow || c.Detail != nil || c.Massif != nil ||
c.Faults != nil || c.LithologyMix != nil {
return fmt.Errorf("class %q is sea but carries a land property (crater, coastal plain, snow, "+
"massif, faults, lithology or detail); the solve holds every sea cell at base level and "+
"would never read them", c.Name)
}
} else {
if c.UpliftMmYr < 0 {
return fmt.Errorf("class %q: uplift_mm_yr is %.3f; subsidence is not modelled",
c.Name, c.UpliftMmYr)
}
if c.KMult < 0 {
return fmt.Errorf("class %q: k_mult is %.3f", c.Name, c.KMult)
}
if c.DepthM != 0 {
return fmt.Errorf("class %q is land but carries depth_m", c.Name)
}
if c.CoastalPlainKm < 0 {
return fmt.Errorf("class %q: coastal_plain_km is %v", c.Name, c.CoastalPlainKm)
}
if d := c.Detail; d != nil {
if d.DropletsPerCell < 0 {
return fmt.Errorf("class %q: detail.droplets_per_cell is %v", c.Name, d.DropletsPerCell)
}
if d.StrataContrast < 0 || d.StrataContrast > 1 {
return fmt.Errorf("class %q: detail.strata_contrast is %v, outside 0..1",
c.Name, d.StrataContrast)
}
if a := d.AmplitudeM; a != nil && (a[0] < 0 || a[1] < a[0]) {
return fmt.Errorf("class %q: detail.amplitude_m is %v", c.Name, *a)
}
}
if ms := c.Massif; ms != nil {
if ms.FloorMmYr < 0 {
return fmt.Errorf("class %q: massif.floor_mm_yr is %.4f; subsidence is not modelled",
c.Name, ms.FloorMmYr)
}
if ms.FloorMmYr >= c.UpliftMmYr {
return fmt.Errorf("class %q: massif.floor_mm_yr is %.4f and uplift_mm_yr is %.4f; the "+
"floor is the plain between the massifs, so it has to be below the rate they reach",
c.Name, ms.FloorMmYr, c.UpliftMmYr)
}
// Above two thirds the ramp would start below the bottom of the distribution and the
// fraction would stop meaning what it says. A class that is two thirds upland is not a
// plain with hills in it anyway; paint it as its own colour.
if ms.Fraction <= 0 || ms.Fraction > MaxMassifFraction {
return fmt.Errorf("class %q: massif.fraction is %.3f; it is the share of this class "+
"standing above the midpoint and must be over 0 and at most %.2f",
c.Name, ms.Fraction, MaxMassifFraction)
}
}
if c.LithologyMix != nil && (*c.LithologyMix < 0 || *c.LithologyMix > 1) {
return fmt.Errorf("class %q: lithology_mix is %v, outside 0..1; it is the share of the "+
"planet's rock field this class takes", c.Name, *c.LithologyMix)
}
if fa := c.Faults; fa != nil {
if fa.Per1000Km2 <= 0 {
return fmt.Errorf("class %q: faults.per_1000km2 is %v; leave the block out to have no "+
"faults rather than asking for none", c.Name, fa.Per1000Km2)
}
if fa.LengthKm[0] <= 0 || fa.LengthKm[1] < fa.LengthKm[0] {
return fmt.Errorf("class %q: faults.length_km is %v; it is a low-to-high range in "+
"kilometres", c.Name, fa.LengthKm)
}
if fa.ThrowM[0] <= 0 || fa.ThrowM[1] < fa.ThrowM[0] {
return fmt.Errorf("class %q: faults.throw_m is %v; it is a low-to-high range of total "+
"displacement over the run, in metres", c.Name, fa.ThrowM)
}
}
if cr := c.Crater; cr != nil {
if cr.FloorM >= cr.RimM {
return fmt.Errorf("class %q: a crater's floor (%.0f m) must be below its rim (%.0f m)",
c.Name, cr.FloorM, cr.RimM)
}
if cr.RimAt <= 0 || cr.RimAt >= cr.WallAt || cr.WallAt > 1 {
return fmt.Errorf("class %q: a crater needs 0 < rim_at < wall_at <= 1, got %.2f and %.2f",
c.Name, cr.RimAt, cr.WallAt)
}
}
}
if !c.Stroke {
nonStroke++
}
}
if nonStroke == 0 {
return fmt.Errorf("every class is a stroke; there is nothing for them to dissolve into")
}
if painted == 0 {
return fmt.Errorf("every class is derived; nothing in the legend can match a painted pixel")
}
l.edge = make([]int, len(l.Classes))
for i := range l.Classes {
l.edge[i] = -1
c := &l.Classes[i]
if c.EdgeClass == "" {
continue
}
if !c.Stroke {
return fmt.Errorf("class %q sets edge_class but is not a stroke; only a stroke is rewritten "+
"at the map edge", c.Name)
}
j := l.Index(c.EdgeClass)
if j < 0 {
return fmt.Errorf("class %q: edge_class %q is not a class", c.Name, c.EdgeClass)
}
if l.Classes[j].Stroke {
return fmt.Errorf("class %q: edge_class %q is itself a stroke", c.Name, c.EdgeClass)
}
l.edge[i] = j
}
return nil
}