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 }