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
+330
View File
@@ -0,0 +1,330 @@
package template
import (
"fmt"
"math"
"salty/terrain/internal/field"
)
// Raster is a class index per pixel, row-major. X wraps; Y does not.
type Raster struct {
W, H int
Class []uint8
}
// At reads a pixel, wrapping X and clamping Y, which is the convention every cylindrical map in this
// tree follows: the left and right edges are the same meridian, the top and bottom are the poles.
func (r *Raster) At(x, y int) uint8 {
x = ((x % r.W) + r.W) % r.W
if y < 0 {
y = 0
} else if y >= r.H {
y = r.H - 1
}
return r.Class[y*r.W+x]
}
// Match is what the classifier saw, and it is the first thing to read when a template comes out wrong.
//
// Every pixel is assigned to its nearest class, so a colour the legend has never heard of does not fail
// the run - it quietly becomes whatever it happens to be closest to. Far and MaxDist are what make that
// visible.
type Match struct {
Total int
Counts []int // per class
Far int // pixels further than the legend's WarnDistance from every class
MaxDist float64
MaxAt [2]int // where the worst one was
// The wrap: how well the painting's left and right edges agree. They are the same meridian, so a
// template that does not wrap produces a real discontinuity down one line of the world and there is no
// way to see it by looking at the picture - the two edges are as far apart on screen as they can be.
WrapRows int // rows compared
WrapDiffer int // rows where the two edges classify differently
WrapLandSea int // rows where one edge is land and the other water: the visible kind
WrapFarEdge int // pixels in the first or last two columns that no class is near
}
func (m Match) String() string {
return fmt.Sprintf("%d px, %d further than the warn distance from any class (worst %.0f at %d,%d)",
m.Total, m.Far, m.MaxDist, m.MaxAt[0], m.MaxAt[1])
}
// WrapReport is the one-line verdict on whether the painting is a cylinder.
func (m Match) WrapReport() string {
if m.WrapRows == 0 {
return "wrap not measured"
}
return fmt.Sprintf("the edges disagree on %d of %d rows (%.1f%%), %d of them land against water; "+
"%d px in the outermost columns match no class",
m.WrapDiffer, m.WrapRows, 100*float64(m.WrapDiffer)/float64(m.WrapRows), m.WrapLandSea, m.WrapFarEdge)
}
// measureWrap compares the first and last columns, which are the same meridian.
func (l *Legend) measureWrap(px []uint8, w, h int, r *Raster, m *Match) {
if w < 2 {
return
}
m.WrapRows = h
warn2 := l.WarnDistance * l.WarnDistance
for y := 0; y < h; y++ {
a := r.Class[y*w]
b := r.Class[y*w+w-1]
if a != b {
m.WrapDiffer++
if l.Classes[a].Sea != l.Classes[b].Sea {
m.WrapLandSea++
}
}
// The outermost columns are where a lossy encoder leaves its halo, and a halo on the seam is a
// stripe of the wrong class down the one line of the world where it cannot be hidden.
for _, x := range [4]int{0, 1, w - 2, w - 1} {
o := (y*w + x) * 3
if float64(l.nearestDist2(int(px[o]), int(px[o+1]), int(px[o+2]))) > warn2 {
m.WrapFarEdge++
}
}
}
}
// nearestDist2 is the squared RGB distance to the closest painted class.
func (l *Legend) nearestDist2(r, g, b int) int {
best := 1 << 30
for ci := range l.Classes {
c := &l.Classes[ci]
if c.Derived {
continue
}
dr, dg, db := r-c.RGB[0], g-c.RGB[1], b-c.RGB[2]
if d := dr*dr + dg*dg + db*db; d < best {
best = d
}
}
return best
}
// Classify assigns every pixel to the nearest class in RGB.
//
// Nearest rather than within-a-tolerance, so the result is total: there is no unclassified pixel to
// decide what to do with later, and a stray artefact - a JPEG ringing overshoot, the one black pixel in
// the left column of the template this was written for - lands on something sensible instead of
// punching a hole in the world. The Match report is what says it happened.
func (l *Legend) Classify(px []uint8, w, h int) (*Raster, Match) {
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
// Reduction into pre-allocated indexed slots, never a channel drain: the result must not depend on
// which goroutine finished first (cross-cutting rule 12).
partial := make([]Match, field.BandCount(h))
for i := range partial {
partial[i].Counts = make([]int, len(l.Classes))
}
warn2 := l.WarnDistance * l.WarnDistance
field.RowsIndexed(h, func(band, y0, y1 int) {
p := &partial[band]
for y := y0; y < y1; y++ {
for x := 0; x < w; x++ {
o := (y*w + x) * 3
cr, cg, cb := int(px[o]), int(px[o+1]), int(px[o+2])
best, bestD := -1, 1<<30
for ci := range l.Classes {
c := &l.Classes[ci]
if c.Derived {
continue // never painted, so never matched
}
dr := cr - c.RGB[0]
dg := cg - c.RGB[1]
db := cb - c.RGB[2]
d := dr*dr + dg*dg + db*db
if d < bestD {
bestD, best = d, ci
}
}
r.Class[y*w+x] = uint8(best)
p.Total++
p.Counts[best]++
if float64(bestD) > warn2 {
p.Far++
}
if float64(bestD) > p.MaxDist {
p.MaxDist = float64(bestD)
p.MaxAt = [2]int{x, y}
}
}
}
})
out := Match{Counts: make([]int, len(l.Classes))}
out.MaxAt = [2]int{-1, -1}
for i := range partial {
p := &partial[i]
out.Total += p.Total
out.Far += p.Far
for c, n := range p.Counts {
out.Counts[c] += n
}
// The tie-break keeps the report itself independent of GOMAXPROCS, which changes how many bands
// there are: without it two pixels at the same distance could be reported in either order.
if p.Total > 0 && (p.MaxDist > out.MaxDist ||
(p.MaxDist == out.MaxDist && earlier(p.MaxAt, out.MaxAt))) {
out.MaxDist = p.MaxDist
out.MaxAt = p.MaxAt
}
}
out.MaxDist = math.Sqrt(out.MaxDist) // kept squared through the loops; reported as a distance
l.measureWrap(px, w, h, r, &out)
return r, out
}
func earlier(a, b [2]int) bool {
if b[1] < 0 {
return true
}
if a[1] != b[1] {
return a[1] < b[1]
}
return a[0] < b[0]
}
// DissolveStrokes removes the decoration an artist drew and leaves only classes that mean something.
//
// Two rules, in this order:
//
// 1. A stroke region that touches the top or bottom row of the map is not a stroke. It becomes the
// class its edge_class names. This is what tells a polar ice cap from the white outline drawn
// around every island when both are painted the same white, and it is the whole reason the rule
// exists.
// 2. Every remaining stroke pixel takes the class of the nearest pixel that is not a stroke, measured
// outwards from all of them at once. A ring sitting between land and water is therefore split down
// its middle rather than given wholly to one side, which is the only answer that does not move the
// coastline by the width of the artist's brush.
//
// Returns how many pixels each rule rewrote.
func (r *Raster) DissolveStrokes(l *Legend) (edge, dissolved int) {
stroke := make([]bool, len(l.Classes))
any := false
for i := range l.Classes {
stroke[i] = l.Classes[i].Stroke
any = any || stroke[i]
}
if !any {
return 0, 0
}
edge = r.rewriteEdgeStrokes(l, stroke)
// Rule 2. Seed from every non-stroke pixel that touches a stroke pixel, then walk outwards through
// stroke pixels only. Seeds are pushed in raster order and the queue is FIFO, so the result does not
// depend on anything but the image.
queue := make([]int32, 0, 1<<16)
filled := make([]bool, len(r.Class))
for y := 0; y < r.H; y++ {
for x := 0; x < r.W; x++ {
i := y*r.W + x
if stroke[r.Class[i]] {
continue
}
if r.hasStrokeNeighbour(x, y, stroke) {
queue = append(queue, int32(i))
filled[i] = true
}
}
}
for head := 0; head < len(queue); head++ {
i := int(queue[head])
c := r.Class[i]
x, y := i%r.W, i/r.W
for _, n := range r.neighbours(x, y) {
if n < 0 || filled[n] || !stroke[r.Class[n]] {
continue
}
r.Class[n] = c
filled[n] = true
dissolved++
queue = append(queue, int32(n))
}
}
return edge, dissolved
}
// rewriteEdgeStrokes applies rule 1: flood each stroke class inwards from the poles.
func (r *Raster) rewriteEdgeStrokes(l *Legend, stroke []bool) int {
n := 0
stack := make([]int32, 0, 1<<16)
for ci := range l.Classes {
if !stroke[ci] {
continue
}
to := l.EdgeIndex(ci)
if to < 0 {
continue
}
want := uint8(ci)
become := uint8(to)
stack = stack[:0]
push := func(x, y int) {
i := y*r.W + x
if r.Class[i] == want {
r.Class[i] = become
n++
stack = append(stack, int32(i))
}
}
for x := 0; x < r.W; x++ {
push(x, 0)
push(x, r.H-1)
}
for len(stack) > 0 {
i := int(stack[len(stack)-1])
stack = stack[:len(stack)-1]
x, y := i%r.W, i/r.W
for _, m := range r.neighbours(x, y) {
if m >= 0 && r.Class[m] == want {
r.Class[m] = become
n++
stack = append(stack, int32(m))
}
}
}
}
return n
}
// neighbours is the eight-connected neighbourhood with X wrapped and Y bounded. -1 means off the map,
// which only ever happens past a pole.
func (r *Raster) neighbours(x, y int) [8]int {
var out [8]int
k := 0
for dy := -1; dy <= 1; dy++ {
ny := y + dy
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
if ny < 0 || ny >= r.H {
out[k] = -1
k++
continue
}
nx := x + dx
if nx < 0 {
nx += r.W
} else if nx >= r.W {
nx -= r.W
}
out[k] = ny*r.W + nx
k++
}
}
return out
}
func (r *Raster) hasStrokeNeighbour(x, y int, stroke []bool) bool {
for _, n := range r.neighbours(x, y) {
if n >= 0 && stroke[r.Class[n]] {
return true
}
}
return false
}
+244
View File
@@ -0,0 +1,244 @@
package template
import (
"math"
"salty/terrain/internal/dt"
"salty/terrain/internal/field"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// A painted coastline is a drawn line, and a coastline is not a drawn line.
//
// This is the Richardson paradox with a brush in it. An author draws a shore as a smooth curve, because that
// is what a hand and a bezier tool produce; a real coast has bays inside bays inside bays and the length you
// measure depends on the ruler you measure it with. Projected straight, the painting's own smoothness
// survives all the way to the heightmap, and the result reads as exactly what it is - a shape somebody drew -
// however good the erosion downstream is. `internal/coast` does this job on the square canvas, where the
// outline is noise to begin with; the painted planet had a manifest key for it, `coast_jitter_px`, which
// until now nothing anywhere read.
//
// **It is a mask on the waterline, not a warp of the painting.** That distinction was measured rather than
// reasoned. Displacing the point each cell asks the painting about - a domain warp - was tried first and it
// cannot cut a bay: a smooth warp of a smooth boundary is another smooth boundary, just wigglier, and at an
// amplitude large enough to fold it back on itself it drags every inland class boundary the same distance.
// What produces bays and headlands is thresholding a *signed distance field*: how far is this cell from the
// waterline, add fractal noise to that distance in metres, and ask again which side of zero it is on. Land
// juts out where the noise is positive and the sea reaches in where it is negative, at every scale the
// octaves cover, and nothing away from the shore moves at all.
//
// Two things follow from doing it this way, and both are the reason to:
//
// - A cell that changes sides needs a class, and the distance transform already knows which one: it
// returns the nearest seed cell as well as the distance to it, so new land takes the class of the land
// it grew from and new sea takes the class of the water that came in. Sea eaten out of a shore becomes
// the surf that was lying against it rather than deep ocean.
// - Small islands have to survive. An islet thirty pixels across, under a noise field whose wavelength is
// four hundred, sees very nearly a constant - so it either sits still or vanishes whole, and vanishing
// whole is how an archipelago disappears between two runs. The amplitude is therefore capped per cell at
// a fraction of the widest land within reach of it, which is a sliding maximum of the land distance.
// A continent sees the full amplitude; an islet gets nibbled instead of deleted.
// Pass indices for the coast mask's noise, above the painted uplift path's 20..25 so neither can reshuffle
// the other.
const (
srcCoastMask = 30
)
// islandGuard is how much of the widest land within reach the mask may eat. Two thirds leaves an islet
// recognisably itself while still giving it a ragged edge; at 1 it can take the whole thing.
const islandGuard = 0.66
// Coast is how the painted waterline is roughened before the painting is projected.
type Coast struct {
// AmplitudePx is the furthest, in template pixels, that the shoreline may move. Zero switches the whole
// thing off and the painting is used exactly as drawn.
AmplitudePx float64
// WavelengthPx is the coarsest octave: the width of the biggest bay it can cut. Octaves halve from
// there, so the finest detail is this over 2^(Octaves-1). Bays come out about this wide and up to
// AmplitudePx deep, so the ratio of the two is what decides whether the coast reads as a rough line or
// as a fjord coast.
WavelengthPx float64
// Octaves and Gain are the fractal structure. A gain near 0.5 makes each scale about as prominent as the
// last, which is the property a real coastline has and a single wobble does not.
Octaves int
Gain float64
// Scale is a per-pixel multiplier on AmplitudePx at the raster's own resolution, from the annotation
// layer's coast_jitter marks. Nil is one everywhere, which is every world before D-57.
//
// It is what makes a hand-drawn coastline hold. The roughening exists because a drawn shore is smooth and
// a real one is not, which is true of a shore nobody thought about and false of one somebody traced off a
// map on purpose; a zero here pins that stretch exactly as painted while the rest of the world is still
// roughened. Above one chews harder, which is the same knob pointed the other way - a fjord coast wants
// more than the planet's own amplitude, not less.
//
// **A negative entry means the pixel carries no instruction**, which is not the same as one. A mark is a
// stroke an author drew along a coastline and it lands on whichever side of the waterline their hand was
// on; if an unmarked cell took the default, a stroke painted on the land would leave the water beside it
// free to march inland anyway and the coast would move regardless. So an uninstructed cell takes the
// instruction from the nearest cell on the other side of the waterline, which the distance transform
// below has already found for a different reason. Painting either side is then enough, and painting over
// the line - which is what a brush does - is enough twice over.
Scale []float32
Seed int64
}
// Amount reports whether this mask does anything.
func (c Coast) Amount() bool {
return c.AmplitudePx > 0 && c.Octaves > 0 && c.WavelengthPx > 0 && c.Gain > 0
}
// maxScale is the largest multiplier any mark asks for, and at least 1. Uninstructed entries are negative and
// do not count; an unmarked world has no Scale at all and gets 1.
func (c Coast) maxScale() float64 {
m := 1.0
for _, v := range c.Scale {
if float64(v) > m {
m = float64(v)
}
}
return m
}
// RoughenCoast returns the painting with its waterline displaced by fractal noise. The receiver is not
// modified: a caller that wants both keeps both, which is what the studio's preview does.
//
// It runs at the paint's own resolution rather than the planet's. That is a third of the cells, the mask's
// scales are quoted in template pixels anyway, and the thing being roughened is the painting - so a template
// re-exported at a different size is the one case where the coast moves, and that is already true of every
// other thing the painting decides.
func (r *Raster) RoughenCoast(l *Legend, p world.Planet, c Coast) *Raster {
if !c.Amount() || r.W == 0 || r.H == 0 {
return r
}
sea := make([]bool, len(l.Classes))
for i := range l.Classes {
sea[i] = l.Classes[i].Sea
}
isSea := make([]bool, len(r.Class))
anySea, anyLand := false, false
for i, cl := range r.Class {
isSea[i] = sea[cl]
if isSea[i] {
anySea = true
} else {
anyLand = true
}
}
if !anySea || !anyLand {
return r // nothing to move: the painting is all one or all the other
}
// Signed distance to the waterline, positive on land, in template pixels, plus the index of the nearest
// cell on the other side - which is where a cell that changes sides gets its class from.
signed := make([]float32, len(r.Class))
other := make([]int32, len(r.Class))
// Seeded on land: for every sea cell, how far to land and which land cell.
d2, near := dt.Transform(invert(isSea), r.W, r.H, true)
for i := range signed {
if isSea[i] {
signed[i] = -float32(math.Sqrt(float64(d2[i])))
other[i] = near[i]
}
}
// Seeded on sea: for every land cell, how far to water and which water cell. Released in turn so the
// two transforms are never both alive - at 29 million pixels each one is a quarter of a gigabyte.
d2, near = dt.Transform(isSea, r.W, r.H, true)
for i := range signed {
if !isSea[i] {
signed[i] = float32(math.Sqrt(float64(d2[i])))
other[i] = near[i]
}
}
d2, near = nil, nil
// How wide the land is near each cell, so an islet cannot be eaten whole. Only land contributes, so a
// lone islet reports its own half-width and not the open water around it.
landOnly := field.New(r.W, r.H, 1)
for i := range signed {
if signed[i] > 0 {
landOnly.Data[i] = signed[i]
}
}
// The window is the furthest the shore could move *anywhere*, which is no longer the plain amplitude: a
// mark asking for more than the planet's own can reach past it, and a guard measured over too small a
// window would under-report how wide the land is and let an islet inside such a mark be eaten whole -
// the one failure this guard exists to stop.
reach := int(c.AmplitudePx*c.maxScale() + 0.5)
widest := field.SlidingMax(landOnly, reach, true)
// The noise, on world coordinates so it wraps at the seam and two runs of the same world agree.
// noise.Lattice wraps modulo its cell count, so the lattice has to be a whole number of cells in the
// noise period; the wavelength is quoted in template pixels and converts through the paint's own scale.
metresPerPx := p.CircumferenceM() / float64(r.W)
cells := int(p.NoisePeriodM/(c.WavelengthPx*metresPerPx) + 0.5)
if cells < 1 {
cells = 1
}
u, v := noise.WorldUV(r.W, r.H, metresPerPx, 0, 0, p.NoisePeriodM)
n := noise.FBMAt(u, v, noise.NewSource(c.Seed, srcCoastMask),
noise.Params{BaseCells: cells, Octaves: c.Octaves, Gain: c.Gain})
// Stretched to its own full range before it is used, so the amplitude means what it says. An fBm stack
// is normalised by the sum of its octave amplitudes, which is the value it would take if every octave
// agreed at once - they never do, so the realised spread is far narrower than 0..1 and a nominal 48 px
// was moving the shore about ten. The same trap as the massif fabric's threshold, and the same fix:
// measure the distribution rather than assume it. Here it is one pass of min/max over the whole painting
// - legitimate because the mask runs once on the whole map and not per region, so there is no second
// caller to disagree with.
n.Normalise()
out := &Raster{W: r.W, H: r.H, Class: make([]uint8, len(r.Class))}
copy(out.Class, r.Class)
field.Rows(r.H, func(y0, y1 int) {
for i := y0 * r.W; i < y1*r.W; i++ {
amp := c.AmplitudePx
if c.Scale != nil {
sc := c.Scale[i]
if sc < 0 {
// Uninstructed: take the instruction from the far side of the waterline. See Coast.Scale.
if j := other[i]; j >= 0 {
sc = c.Scale[j]
}
}
if sc >= 0 {
amp *= float64(sc)
}
}
if g := islandGuard * float64(widest.Data[i]); g < amp {
amp = g
}
if amp <= 0 {
continue
}
d := float64(signed[i]) + amp*(float64(n.Data[i])*2-1)
nowLand := d > 0
if nowLand == !isSea[i] {
continue // this cell did not change sides
}
// It did. Take the class of the nearest cell on the side it has joined, which the transform
// already found: land grows out of the land beside it, and water comes in as the water that
// was lying against the shore.
if j := other[i]; j >= 0 {
out.Class[i] = r.Class[j]
}
}
})
return out
}
func invert(b []bool) []bool {
out := make([]bool, len(b))
for i, v := range b {
out[i] = !v
}
return out
}
@@ -0,0 +1,88 @@
package template
import "salty/terrain/internal/field"
// Despeckle removes the hairline of a class nobody painted that appears along a boundary between two others.
//
// It exists because of a measured failure, and the arithmetic is worth keeping because it will happen to
// anybody who exports a lossy image. Classification gives every pixel its nearest class in RGB, and a codec
// blends across every boundary it finds. On the first template the antialiased edge between `surf`
// (221,238,238) and `lowland` (153,204,102) comes out at about (186,219,174), whose distance to `desert`
// (238,221,153) is **53.8** against **77.9** to either of the colours it was actually mixed from. So every
// temperate coast on the map gained a one-pixel ribbon of desert - 1607 pixels of it nowhere near the real
// desert - and it was invisible until the coast mask made those strays the nearest *land* to a stretch of
// open water and handed their class to every cell it turned into shore. An eleven-pixel band of desert
// appeared along a green continent, and the mask was blamed for it first.
//
// **The test is spatial, and it has to be.** The obvious fix is colorimetric - notice that the pixel lies on
// the line between two class colours and give it to the nearer one - and it was built, measured and thrown
// away, because it cannot work in general and this legend is the proof: `shelf` (153,204,221) sits 10 units
// from the line between `ocean` and `surf`, so a real shelf pixel with a little codec noise on it and a
// genuine ocean/surf blend are the same point in colour space. That rule reclassified 943 000 painted shelf
// pixels. What actually distinguishes a stray is *where* it is: a class nobody painted here occupies a line
// one pixel wide with two other classes on either side of it, and no painted feature at 12.9 m a pixel is
// one pixel wide - the one thing that was, the decorative stroke, has a pass of its own.
//
// Hence a five by five window rather than three by three. A one-pixel ribbon running through the middle of a
// 3x3 holds three of its nine cells and the two classes it divides hold three each, so nothing has a
// majority and the rule cannot fire; over 5x5 the ribbon holds five of twenty-five against ten and ten, which
// is the signature being looked for. A feature two pixels wide already holds ten and is left alone.
func (r *Raster) Despeckle() int {
if r.W < despeckleWindow || r.H < despeckleWindow {
return 0
}
out := make([]uint8, len(r.Class))
copy(out, r.Class)
const rad = despeckleWindow / 2
counts := make([]int32, field.BandCount(r.H)*256)
changed := make([]int, field.BandCount(r.H))
field.RowsIndexed(r.H, func(band, y0, y1 int) {
c := counts[band*256 : band*256+256]
for y := y0; y < y1; y++ {
for x := 0; x < r.W; x++ {
self := r.Class[y*r.W+x]
for dy := -rad; dy <= rad; dy++ {
for dx := -rad; dx <= rad; dx++ {
c[r.At(x+dx, y+dy)]++
}
}
best, bestN := self, int32(0)
for i := range c {
if c[i] > bestN || (c[i] == bestN && uint8(i) < best) {
best, bestN = uint8(i), c[i]
}
}
selfN := c[self]
for i := range c {
c[i] = 0
}
if selfN <= despeckleThin && bestN >= despeckleMajority && best != self {
out[y*r.W+x] = best
changed[band]++
}
}
}
})
total := 0
for _, n := range changed {
total += n
}
r.Class = out
return total
}
const (
// despeckleWindow is five: see the note above on why three is too small to see a ribbon at all.
despeckleWindow = 5
// despeckleThin is how little of its own window a class may hold and still be called a stray. Five of
// twenty-five is a line one pixel wide straight through the middle; a feature two pixels wide holds ten.
despeckleThin = 5
// despeckleMajority is how much of the window the replacement has to hold. Eight of twenty-five means
// there is something clearly there to join, so a pixel in genuinely mixed country is left as it is.
despeckleMajority = 8
)
+154
View File
@@ -0,0 +1,154 @@
// Package template reads a painted world map and turns it into the fields the geology solve needs.
//
// The map is an image; the legend beside it says what each colour means. The image is cylindrical: X
// wraps, Y does not, so the left and right edges are the same meridian and the top and bottom rows are
// the poles. Nothing here knows how big the world is - that is the planet package's job. This package
// only answers "what did the author paint here".
//
// The one rule that governs the whole design is Docs/Terrain-Next.md 3.2: paint the uplift, never the
// height. A painted heightmap is handed to a solver that erodes it into something else and throws away
// the drainage network, which is the reason the generator exists. So a class carries an uplift rate and
// an erodibility, and the solve makes the terrain.
package template
import (
"bufio"
"fmt"
"image"
"os"
// Registered for image.Decode. JPEG is here because the first template anyone painted was a JPEG;
// PNG is what a template should be, because JPEG bleeds colour across every class boundary and the
// classifier then has to clean up after it.
_ "image/jpeg"
_ "image/png"
)
// DecodeRGB reads an image and returns tightly packed 8-bit RGB, three bytes a pixel, row-major.
//
// field.ReadHeightmap cannot be used for this and deliberately is not extended: it decodes PNG only, and
// its fallback branch collapses colour to luma, which is right for a DEM and destroys a painted map -
// two different classes can share a luma and here several nearly do.
func DecodeRGB(path string) (px []uint8, w, h int, err error) {
px, _, w, h, err = decode(path, false)
return px, w, h, err
}
// DecodeRGBA is DecodeRGB with the alpha channel kept alongside.
//
// It exists for the annotation layer and only for it. A class template is opaque by definition - every pixel
// is some class - so throwing alpha away there costs nothing. An overlay is the opposite: it is a transparent
// sheet with strokes on it, most of it is nothing, and "nothing" is exactly what alpha records. An image with
// no alpha comes back fully opaque, which is the right reading of a flattened export.
func DecodeRGBA(path string) (px, alpha []uint8, w, h int, err error) {
return decode(path, true)
}
func decode(path string, wantAlpha bool) (px, alpha []uint8, w, h int, err error) {
f, err := os.Open(path)
if err != nil {
return nil, nil, 0, 0, err
}
defer f.Close()
img, _, err := image.Decode(bufio.NewReaderSize(f, 1<<20))
if err != nil {
return nil, nil, 0, 0, fmt.Errorf("%s: %w", path, err)
}
b := img.Bounds()
w, h = b.Dx(), b.Dy()
if w <= 0 || h <= 0 {
return nil, nil, 0, 0, fmt.Errorf("%s: empty image", path)
}
px = make([]uint8, w*h*3)
if wantAlpha {
alpha = make([]uint8, w*h)
for i := range alpha {
alpha[i] = 255
}
}
// The fast paths matter: a 7738x3761 template is 29 million pixels, and going through the At()
// interface for every one of them costs seconds rather than milliseconds.
switch src := img.(type) {
case *image.RGBA:
// Premultiplied: the RGB bytes are already scaled by alpha, so a half-transparent red reads as a
// darker red. Nothing here un-multiplies it, because every consumer that cares about alpha treats a
// non-opaque pixel as blank and never looks at its colour.
for y := 0; y < h; y++ {
row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride:]
off := (b.Min.X - src.Rect.Min.X) * 4
for x := 0; x < w; x++ {
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = row[off+x*4], row[off+x*4+1], row[off+x*4+2]
if alpha != nil {
alpha[y*w+x] = row[off+x*4+3]
}
}
}
case *image.NRGBA:
for y := 0; y < h; y++ {
row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride:]
off := (b.Min.X - src.Rect.Min.X) * 4
for x := 0; x < w; x++ {
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = row[off+x*4], row[off+x*4+1], row[off+x*4+2]
if alpha != nil {
alpha[y*w+x] = row[off+x*4+3]
}
}
}
case *image.YCbCr:
// What image/jpeg returns.
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
yi := src.YOffset(b.Min.X+x, b.Min.Y+y)
ci := src.COffset(b.Min.X+x, b.Min.Y+y)
r, g, bl := ycbcrToRGB(src.Y[yi], src.Cb[ci], src.Cr[ci])
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = r, g, bl
}
}
default:
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, bl, a := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = uint8(r>>8), uint8(g>>8), uint8(bl>>8)
if alpha != nil {
alpha[y*w+x] = uint8(a >> 8)
}
}
}
}
return px, alpha, w, h, nil
}
// ycbcrToRGB is image/color's conversion, inlined so the YCbCr path does not allocate a color.Color per
// pixel. Same arithmetic, same rounding.
func ycbcrToRGB(y, cb, cr uint8) (uint8, uint8, uint8) {
yy := int32(y) * 0x10101
cb1 := int32(cb) - 128
cr1 := int32(cr) - 128
r := yy + 91881*cr1
if uint32(r)&0xff000000 == 0 {
r >>= 16
} else {
r = ^(r >> 31) & 0xffff >> 8
}
g := yy - 22554*cb1 - 46802*cr1
if uint32(g)&0xff000000 == 0 {
g >>= 16
} else {
g = ^(g >> 31) & 0xffff >> 8
}
b := yy + 116130*cb1
if uint32(b)&0xff000000 == 0 {
b >>= 16
} else {
b = ^(b >> 31) & 0xffff >> 8
}
return uint8(r), uint8(g), uint8(b)
}
+568
View File
@@ -0,0 +1,568 @@
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
}
+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
}
@@ -0,0 +1,818 @@
package template
import (
"math"
"strings"
"testing"
"salty/terrain/internal/world"
)
const goodLegend = `{
"image": "x.png",
"classes": [
{ "name": "ocean", "rgb": [0, 0, 255], "sea": true, "depth_m": 500 },
{ "name": "land", "rgb": [0, 255, 0], "uplift_mm_yr": 0.5, "k_mult": 2 },
{ "name": "ice", "rgb": [200, 200, 200], "uplift_mm_yr": 0.05 },
{ "name": "white", "rgb": [255, 255, 255], "stroke": true, "edge_class": "ice" },
{ "name": "outline", "rgb": [255, 0, 255], "stroke": true }
]
}`
func mustLegend(t *testing.T, src string) *Legend {
t.Helper()
l, err := Parse([]byte(src))
if err != nil {
t.Fatalf("Parse: %v", err)
}
return l
}
func TestLegendResolves(t *testing.T) {
l := mustLegend(t, goodLegend)
if l.WarnDistance != DefaultWarnDistance {
t.Errorf("WarnDistance = %v, want the default %v", l.WarnDistance, DefaultWarnDistance)
}
if got := l.Index("land"); got != 1 {
t.Errorf("Index(land) = %d, want 1", got)
}
if got := l.EdgeIndex(3); got != 2 {
t.Errorf("EdgeIndex(white) = %d, want 2 (ice)", got)
}
if got := l.EdgeIndex(1); got != -1 {
t.Errorf("EdgeIndex(land) = %d, want -1", got)
}
if got := l.Classes[1].K(); got != 2 {
t.Errorf("land K = %v, want 2", got)
}
if got := l.Classes[2].K(); got != 1 {
t.Errorf("ice K = %v, want 1 (zero reads as one)", got)
}
if got := l.Classes[1].RateMYr(); got != 0.0005 {
t.Errorf("land rate = %v m/yr, want 0.0005", got)
}
}
func TestLegendRefusesTheImpossible(t *testing.T) {
cases := []struct{ name, src, want string }{
{"no classes", `{"classes":[]}`, "no classes"},
{"duplicate colour", `{"classes":[
{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[1,2,3]}]}`, "share the colour"},
{"duplicate name", `{"classes":[
{"name":"a","rgb":[1,2,3]},{"name":"a","rgb":[4,5,6]}]}`, "both named"},
{"sea with uplift", `{"classes":[
{"name":"a","rgb":[1,2,3],"sea":true,"uplift_mm_yr":1}]}`, "would never read them"},
{"land with depth", `{"classes":[
{"name":"a","rgb":[1,2,3],"depth_m":10}]}`, "carries depth_m"},
{"negative depth", `{"classes":[
{"name":"a","rgb":[1,2,3],"sea":true,"depth_m":-10}]}`, "so positive"},
{"edge on a non-stroke", `{"classes":[
{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[4,5,6],"edge_class":"a"}]}`, "is not a stroke"},
{"edge names nothing", `{"classes":[
{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[4,5,6],"stroke":true,"edge_class":"z"}]}`,
"is not a class"},
{"everything is a stroke", `{"classes":[
{"name":"a","rgb":[1,2,3],"stroke":true}]}`, "nothing for them to dissolve into"},
{"rgb out of range", `{"classes":[{"name":"a","rgb":[1,2,300]}]}`, "outside 0..255"},
{"sea with a massif", `{"classes":[
{"name":"a","rgb":[1,2,3],"sea":true,"massif":{"floor_mm_yr":0.01,"fraction":0.2}}]}`,
"carries a land property"},
{"massif floor at or above the rate", `{"classes":[
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":0.08,"fraction":0.2}}]}`,
"below the rate they reach"},
{"negative massif floor", `{"classes":[
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":-0.01,"fraction":0.2}}]}`,
"subsidence is not modelled"},
{"massif fraction of nothing", `{"classes":[
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":0.01,"fraction":0}}]}`,
"must be over 0"},
{"massif fraction past the ramp", `{"classes":[
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":0.01,"fraction":0.8}}]}`,
"must be over 0"},
{"misspelt massif key", `{"classes":[
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor":0.01,"fraction":0.2}}]}`,
"unknown field"},
{"sea with faults", `{"classes":[
{"name":"a","rgb":[1,2,3],"sea":true,"faults":{"per_1000km2":5,"throw_m":[100,200],
"length_km":[4,8]}}]}`, "carries a land property"},
{"sea with a lithology mix", `{"classes":[
{"name":"a","rgb":[1,2,3],"sea":true,"lithology_mix":0.5}]}`, "carries a land property"},
{"a fault block asking for nothing", `{"classes":[
{"name":"a","rgb":[1,2,3],"faults":{"per_1000km2":0,"throw_m":[100,200],
"length_km":[4,8]}}]}`, "leave the block out"},
{"fault length the wrong way round", `{"classes":[
{"name":"a","rgb":[1,2,3],"faults":{"per_1000km2":5,"throw_m":[100,200],
"length_km":[8,4]}}]}`, "low-to-high range in kilometres"},
{"fault throw the wrong way round", `{"classes":[
{"name":"a","rgb":[1,2,3],"faults":{"per_1000km2":5,"throw_m":[200,100],
"length_km":[4,8]}}]}`, "low-to-high range of total"},
{"lithology mix past one", `{"classes":[
{"name":"a","rgb":[1,2,3],"lithology_mix":1.5}]}`, "outside 0..1"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := Parse([]byte(c.src))
if err == nil {
t.Fatalf("accepted %s", c.name)
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("error %q does not mention %q", err, c.want)
}
})
}
}
// build an RGB buffer from a small picture written as one rune per pixel.
func picture(t *testing.T, l *Legend, rows []string) ([]uint8, int, int) {
t.Helper()
h := len(rows)
w := len(rows[0])
px := make([]uint8, w*h*3)
for y, row := range rows {
if len(row) != w {
t.Fatalf("row %d is %d wide, want %d", y, len(row), w)
}
for x, r := range row {
var ci int
switch r {
case 'o':
ci = l.Index("ocean")
case 'L':
ci = l.Index("land")
case 'i':
ci = l.Index("ice")
case 'W':
ci = l.Index("white")
case 'X':
ci = l.Index("outline")
case '?':
ci = -1
default:
t.Fatalf("unknown pixel %q", r)
}
o := (y*w + x) * 3
if ci < 0 {
px[o], px[o+1], px[o+2] = 0, 0, 0 // the stray black pixel a real template had
continue
}
c := l.Classes[ci]
px[o], px[o+1], px[o+2] = uint8(c.RGB[0]), uint8(c.RGB[1]), uint8(c.RGB[2])
}
}
return px, w, h
}
func render(l *Legend, r *Raster) []string {
sym := map[string]rune{"ocean": 'o', "land": 'L', "ice": 'i', "white": 'W', "outline": 'X'}
out := make([]string, r.H)
for y := 0; y < r.H; y++ {
var b strings.Builder
for x := 0; x < r.W; x++ {
b.WriteRune(sym[l.Classes[r.Class[y*r.W+x]].Name])
}
out[y] = b.String()
}
return out
}
func TestClassifyIsTotalAndReportsTheStrays(t *testing.T) {
l := mustLegend(t, goodLegend)
px, w, h := picture(t, l, []string{
"ooLL",
"oo?L",
})
r, m := l.Classify(px, w, h)
if m.Total != 8 {
t.Errorf("Total = %d, want 8", m.Total)
}
// Black is nearest to ocean here, and nothing is unclassified - but it must be reported as far.
if m.Far != 1 {
t.Errorf("Far = %d, want 1: the black pixel", m.Far)
}
if m.MaxAt != [2]int{2, 1} {
t.Errorf("MaxAt = %v, want the black pixel at 2,1", m.MaxAt)
}
if m.MaxDist < 100 {
t.Errorf("MaxDist = %.1f, want it large", m.MaxDist)
}
if got := render(l, r)[0]; got != "ooLL" {
t.Errorf("row 0 = %q", got)
}
if n := m.Counts[l.Index("land")]; n != 3 {
t.Errorf("land count = %d, want 3", n)
}
}
func TestWhiteAtThePoleIsIceAndWhiteAroundAnIslandIsNot(t *testing.T) {
l := mustLegend(t, goodLegend)
px, w, h := picture(t, l, []string{
"WWWW", // the cap: touches row 0, so it is ice
"WWWW",
"oooo",
"oWWo", // an island's outline: touches nothing, so it dissolves
"oWLo",
"oooo",
})
r, _ := l.Classify(px, w, h)
edge, dissolved := r.DissolveStrokes(l)
if edge != 8 {
t.Errorf("edge rewrites = %d, want 8", edge)
}
if dissolved != 3 {
t.Errorf("dissolved = %d, want 3", dissolved)
}
got := render(l, r)
want := []string{"iiii", "iiii", "oooo", "oooo", "ooLo", "oooo"}
for y := range want {
if got[y] != want[y] {
t.Errorf("row %d = %q, want %q (whole picture %v)", y, got[y], want[y], got)
}
}
}
// A stroke lying between land and water is split down the middle. Giving it wholly to one side would
// move the coastline by the width of the artist's brush, which on a real template is hundreds of metres.
func TestStrokeSplitsDownItsMiddle(t *testing.T) {
l := mustLegend(t, goodLegend)
px, w, h := picture(t, l, []string{
"LLL",
"LLL",
"WWW",
"WWW",
"ooo",
"ooo",
})
r, _ := l.Classify(px, w, h)
if _, n := r.DissolveStrokes(l); n != 6 {
t.Errorf("dissolved = %d, want 6", n)
}
got := render(l, r)
want := []string{"LLL", "LLL", "LLL", "ooo", "ooo", "ooo"}
for y := range want {
if got[y] != want[y] {
t.Errorf("row %d = %q, want %q (whole picture %v)", y, got[y], want[y], got)
}
}
}
// The map is a cylinder: a stroke on the left edge is reached by land on the right edge.
func TestDissolveWrapsInX(t *testing.T) {
l := mustLegend(t, goodLegend)
// One row, and a stroke class with no edge_class so the polar rescue never applies. The stroke at
// x=0 has land only at x=5, on the far side of the seam; if X did not wrap it would take the ocean
// in the middle instead.
px, w, h := picture(t, l, []string{"XXoXXL"})
r, _ := l.Classify(px, w, h)
r.DissolveStrokes(l)
got := render(l, r)
want := []string{"LoooLL"}
for y := range want {
if got[y] != want[y] {
t.Errorf("row %d = %q, want %q", y, got[y], want[y])
}
}
}
func TestRasterAtWrapsXAndClampsY(t *testing.T) {
r := &Raster{W: 3, H: 2, Class: []uint8{1, 2, 3, 4, 5, 6}}
if got := r.At(-1, 0); got != 3 {
t.Errorf("At(-1,0) = %d, want 3", got)
}
if got := r.At(3, 0); got != 1 {
t.Errorf("At(3,0) = %d, want 1", got)
}
if got := r.At(0, -1); got != 1 {
t.Errorf("At(0,-1) = %d, want 1 (clamped to the pole)", got)
}
if got := r.At(0, 9); got != 4 {
t.Errorf("At(0,9) = %d, want 4", got)
}
}
func TestProjectIsNearestNeighbourAndPadsThePoles(t *testing.T) {
l := mustLegend(t, goodLegend)
// A 4x2 paint: land on the right half, ocean on the left.
px, w, h := picture(t, l, []string{
"ooLL",
"ooLL",
})
r, _ := l.Classify(px, w, h)
// 8 columns of 10 m is an 80 m circumference; the paint's 4:2 aspect gives 4 painted rows, plus 1 of
// pad at each end.
p, err := world.New(80, 10, w, h, 1, 80)
if err != nil {
t.Fatal(err)
}
if p.W != 8 || p.PaintH() != 4 || p.H != 6 {
t.Fatalf("planet is %dx%d with %d painted rows, want 8x6 with 4", p.W, p.H, p.PaintH())
}
m := r.Project(p, l, l.Index("ocean"))
for x := 0; x < p.W; x++ {
if !m.Sea[0*p.W+x] || !m.Sea[(p.H-1)*p.W+x] {
t.Fatalf("pad row is not sea at column %d", x)
}
}
// Every painted row upsamples the same way: four ocean cells then four land cells, and no third class
// has been invented in between.
for y := p.PadY; y < p.H-p.PadY; y++ {
for x := 0; x < p.W; x++ {
wantSea := x < 4
if m.Sea[y*p.W+x] != wantSea {
t.Fatalf("cell (%d,%d): sea = %v, want %v", x, y, m.Sea[y*p.W+x], wantSea)
}
name := l.Classes[m.Class[y*p.W+x]].Name
if name != "ocean" && name != "land" {
t.Fatalf("cell (%d,%d) is %q; projection invented a class", x, y, name)
}
}
}
perClass, land, total := m.Counts()
if total != p.W*p.PaintH() {
t.Errorf("Counts total = %d, want %d (the pad is not part of the world)", total, p.W*p.PaintH())
}
if land != 16 {
t.Errorf("land = %d, want 16", land)
}
if perClass[l.Index("land")] != 16 {
t.Errorf("land class count = %d, want 16", perClass[l.Index("land")])
}
}
func TestPerClassTables(t *testing.T) {
l := mustLegend(t, goodLegend)
rates := l.Rates()
if got := rates[l.Index("land")]; got != 0.0005 {
t.Errorf("land rate = %v, want 0.0005 m/yr", got)
}
if got := rates[l.Index("ocean")]; got != 0 {
t.Errorf("ocean rate = %v, want 0", got)
}
ks := l.Erodibilities()
if got := ks[l.Index("land")]; got != 2 {
t.Errorf("land K = %v, want 2", got)
}
if got := ks[l.Index("ocean")]; got != 1 {
t.Errorf("ocean K = %v, want 1: a zero would be carried into a division", got)
}
if got := l.Depths()[l.Index("ocean")]; got != 500 {
t.Errorf("ocean depth = %v, want 500", got)
}
}
// A class with no massif block is one rate all over, and the tables have to say so in the way internal/uplift
// reads them: a zero fraction, which is what switches the fabric off, and a floor that is the class's own rate
// so that nothing can read a plain out of a class that never asked for one.
func TestAClassWithNoMassifIsOneRateAllOver(t *testing.T) {
l := mustLegend(t, goodLegend)
floor, fraction := l.Massifs()
i := l.Index("land")
if fraction[i] != 0 {
t.Errorf("fraction = %v, want 0 for a class with no massif block", fraction[i])
}
if got := floor[i]; got != 0.0005 {
t.Errorf("floor = %v, want the class rate 0.0005 m/yr", got)
}
if l.HasMassifs() {
t.Error("HasMassifs is true for a legend with no massif block anywhere")
}
}
// And a class that asks for one reports the numbers the fabric is cut with.
func TestAMassifClassReportsItsFloorAndFraction(t *testing.T) {
l := mustLegend(t, `{"classes":[
{"name":"ocean","rgb":[0,0,255],"sea":true,"depth_m":500},
{"name":"land","rgb":[0,255,0],"uplift_mm_yr":0.08,
"massif":{"floor_mm_yr":0.012,"fraction":0.16}}]}`)
if !l.HasMassifs() {
t.Fatal("HasMassifs is false for a legend that has one")
}
floor, fraction := l.Massifs()
i := l.Index("land")
if got, want := float64(floor[i]), 0.000012; math.Abs(got-want) > 1e-12 {
t.Errorf("floor = %v m/yr, want %v", got, want)
}
if fraction[i] != 0.16 {
t.Errorf("fraction = %v, want 0.16", fraction[i])
}
// Sea classes carry neither, and the fraction has to be zero rather than inherited: a sea cell is held at
// base level for the whole run and a fabric there would be a field nobody reads.
if j := l.Index("ocean"); floor[j] != 0 || fraction[j] != 0 {
t.Errorf("ocean carries floor %v fraction %v, want both zero", floor[j], fraction[j])
}
}
// White is drawn twice on a hand-painted world map: the polar caps and the stroke around every island. Only
// one class can own that colour, and it has to be the stroke - so what the caps become is a class with no
// colour of its own.
func TestADerivedClassIsNeverMatched(t *testing.T) {
const src = `{"classes":[
{"name":"sea","rgb":[0,0,255],"sea":true},
{"name":"ice","derived":true,"uplift_mm_yr":0.05},
{"name":"white","rgb":[238,238,238],"stroke":true,"edge_class":"ice"}
]}`
l := mustLegend(t, src)
// A pixel near white must become the stroke, not the derived ice, however close ice's zero colour is.
px := []uint8{236, 236, 236}
r, m := l.Classify(px, 1, 1)
if got := l.Classes[r.Class[0]].Name; got != "white" {
t.Errorf("a near-white pixel classified as %q, want the painted stroke", got)
}
if m.Counts[l.Index("ice")] != 0 {
t.Error("the derived class matched a pixel")
}
}
// A derived class may carry a colour, and it is display only: the diagnostic maps need something to draw it
// with, and without one the polar caps came out as black holes in map_class.png.
func TestADerivedClassColourIsDisplayOnly(t *testing.T) {
l := mustLegend(t, `{"classes":[
{"name":"sea","rgb":[0,0,255],"sea":true},
{"name":"ice","derived":true,"rgb":[250,250,250]},
{"name":"white","rgb":[238,238,238],"stroke":true,"edge_class":"ice"}
]}`)
// 245,245,245 is nearer to ice's display colour than to the painted stroke, and must still be the stroke.
r, _ := l.Classify([]uint8{245, 245, 245}, 1, 1)
if got := l.Classes[r.Class[0]].Name; got != "white" {
t.Errorf("classified as %q, want the painted stroke: a derived colour must not match", got)
}
}
func TestLegendRefusesAllDerived(t *testing.T) {
_, err := Parse([]byte(`{"classes":[{"name":"a","derived":true}]}`))
if err == nil || !strings.Contains(err.Error(), "every class is derived") {
t.Fatalf("error = %v, want a refusal", err)
}
}
// The mask is opt-in: zero amplitude has to leave the painting exactly as drawn, because every template
// written before it existed was drawn against that contract.
func TestNoCoastMaskLeavesThePaintingExactly(t *testing.T) {
p := testCylinder(t, 512, 288)
l := mustLegend(t, goodLegend)
r := stripeRaster(512, 288, l)
out := r.RoughenCoast(l, p, Coast{AmplitudePx: 0, WavelengthPx: 64, Octaves: 4, Gain: 0.5})
for i := range r.Class {
if out.Class[i] != r.Class[i] {
t.Fatalf("pixel %d changed with the mask switched off", i)
}
}
}
// What it is for: a ruled painted coastline has to come back with bays in it. Measured as the spread of the
// waterline's row along the map - zero for a drawn line, tens of pixels for a coast.
func TestTheCoastMaskCutsBaysIntoARuledShore(t *testing.T) {
p := testCylinder(t, 1024, 512)
l := mustLegend(t, goodLegend)
r := stripeRaster(1024, 512, l) // land above the halfway row, ocean below
if lo, hi := shoreSpread(r, l); hi-lo != 0 {
t.Fatalf("the painted shore is not ruled: rows %d..%d; the test would measure nothing", lo, hi)
}
out := r.RoughenCoast(l, p, Coast{
AmplitudePx: 48, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7,
})
lo, hi := shoreSpread(out, l)
if hi-lo < 20 {
t.Errorf("the roughened shore spans %d rows (%d..%d); the mask is barely moving it", hi-lo+1, lo, hi)
}
// And it must stay a coastline rather than dissolving into speckle: the land has to remain one run down
// every column, not a scatter of pixels.
if runs := columnRuns(out, l, 1024/2); runs > 3 {
t.Errorf("a column crosses the waterline %d times; the mask is dissolving the shore, not shaping it",
runs)
}
}
// An archipelago has to survive. Under a wavelength far wider than an islet the noise is very nearly a
// constant across it, so without the guard the whole islet steps to the wrong side of zero at once and a
// scatter of islands disappears between two runs.
func TestSmallIslandsAreNibbledRatherThanDeleted(t *testing.T) {
p := testCylinder(t, 1024, 512)
l := mustLegend(t, goodLegend)
land := uint8(l.Index("land"))
sea := uint8(l.Index("ocean"))
r := &Raster{W: 1024, H: 512, Class: make([]uint8, 1024*512)}
for i := range r.Class {
r.Class[i] = sea
}
// Twelve islets of radius 8, well apart, none of them anywhere near the amplitude in size.
centres := [][2]int{}
for k := 0; k < 12; k++ {
centres = append(centres, [2]int{60 + k*80, 200 + (k%3)*90})
}
for _, c := range centres {
for dy := -8; dy <= 8; dy++ {
for dx := -8; dx <= 8; dx++ {
if dx*dx+dy*dy <= 64 {
r.Class[(c[1]+dy)*r.W+c[0]+dx] = land
}
}
}
}
out := r.RoughenCoast(l, p, Coast{
AmplitudePx: 64, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7,
})
gone := 0
for _, c := range centres {
alive := false
for dy := -20; dy <= 20 && !alive; dy++ {
for dx := -20; dx <= 20; dx++ {
x, y := c[0]+dx, c[1]+dy
if x < 0 || y < 0 || x >= out.W || y >= out.H {
continue
}
if out.Class[y*out.W+x] == land {
alive = true
break
}
}
}
if !alive {
gone++
}
}
if gone > 0 {
t.Errorf("%d of %d islets were erased by a mask four times their radius; the island guard is not "+
"holding", gone, len(centres))
}
}
// The seam is the one place a coastline can break invisibly, because the map's two edges are as far apart on
// screen as they can be. The mask is world-indexed and its distance transform wraps, so a shore crossing the
// seam has to come out continuous.
func TestTheCoastMaskWrapsAtTheSeam(t *testing.T) {
p := testCylinder(t, 1024, 512)
l := mustLegend(t, goodLegend)
r := stripeRaster(1024, 512, l)
out := r.RoughenCoast(l, p, Coast{
AmplitudePx: 48, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7,
})
// The waterline's row in the first column and in the last must be within a pixel or two of each other,
// exactly as two adjacent columns anywhere inside the map are.
rowAt := func(x int) int {
for y := 0; y < out.H; y++ {
if l.Classes[out.Class[y*out.W+x]].Sea {
return y
}
}
return -1
}
seam := rowAt(0) - rowAt(out.W-1)
if seam < 0 {
seam = -seam
}
worst := 0
for x := 1; x < out.W; x++ {
d := rowAt(x) - rowAt(x-1)
if d < 0 {
d = -d
}
if d > worst {
worst = d
}
}
if seam > worst {
t.Errorf("the shore steps %d rows across the seam against %d anywhere inside the map", seam, worst)
}
}
func testCylinder(t *testing.T, w, h int) world.Planet {
t.Helper()
p := world.Planet{CellM: 8, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * 8}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
return p
}
// shoreSpread is the lowest and highest row at which a column first meets water.
func shoreSpread(r *Raster, l *Legend) (lo, hi int) {
lo, hi = 1<<30, -1
for x := 0; x < r.W; x++ {
for y := 0; y < r.H; y++ {
if l.Classes[r.Class[y*r.W+x]].Sea {
if y < lo {
lo = y
}
if y > hi {
hi = y
}
break
}
}
}
return lo, hi
}
// columnRuns counts how many times a column crosses the waterline.
func columnRuns(r *Raster, l *Legend, x int) int {
n := 0
prev := l.Classes[r.Class[x]].Sea
for y := 1; y < r.H; y++ {
cur := l.Classes[r.Class[y*r.W+x]].Sea
if cur != prev {
n++
prev = cur
}
}
return n
}
// stripeRaster is a painting with one ruled coastline: land in the top half, ocean in the bottom.
func stripeRaster(w, h int, l *Legend) *Raster {
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
land := uint8(l.Index("land"))
sea := uint8(l.Index("ocean"))
for y := 0; y < h; y++ {
c := land
if y >= h/2 {
c = sea
}
for x := 0; x < w; x++ {
r.Class[y*w+x] = c
}
}
return r
}
// The failure this exists for, built in miniature: a one-pixel ribbon of a class nobody painted, lying along
// the boundary between the two it is a blend of. On the real template that ribbon was `desert` along every
// temperate coast, because the JPEG's blend of surf and lowland is nearer to desert than to either parent.
func TestDespeckleRemovesAHairlineBetweenTwoClasses(t *testing.T) {
l := mustLegend(t, goodLegend)
const w, h = 64, 64
land := uint8(l.Index("land"))
sea := uint8(l.Index("ocean"))
ice := uint8(l.Index("ice")) // standing in for the class nobody painted
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := land
if y > h/2 {
c = sea
}
if y == h/2 {
c = ice // the hairline, one pixel wide, all the way across
}
r.Class[y*w+x] = c
}
}
n := r.Despeckle()
if n == 0 {
t.Fatal("nothing was despeckled; the hairline is still there")
}
for x := 0; x < w; x++ {
if got := r.Class[(h/2)*w+x]; got == ice {
t.Fatalf("column %d of the hairline survived as %q", x, l.Classes[got].Name)
}
}
// And it must have joined one of its neighbours rather than becoming something else again.
for x := 0; x < w; x++ {
if got := r.Class[(h/2)*w+x]; got != land && got != sea {
t.Fatalf("column %d became %q, which is neither side of the boundary", x, l.Classes[got].Name)
}
}
}
// The other half of the contract, and the one that keeps the rule honest: a band two pixels wide is
// something an author drew, and it has to survive untouched. Without this the threshold could be raised
// until it ate the map.
func TestDespeckleLeavesARealBandAlone(t *testing.T) {
l := mustLegend(t, goodLegend)
const w, h = 64, 64
land := uint8(l.Index("land"))
sea := uint8(l.Index("ocean"))
ice := uint8(l.Index("ice"))
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := land
if y > h/2+1 {
c = sea
}
if y == h/2 || y == h/2+1 {
c = ice // two pixels wide: a painted shoreline band, not a codec artefact
}
r.Class[y*w+x] = c
}
}
before := append([]uint8(nil), r.Class...)
r.Despeckle()
for i := range before {
if before[i] != r.Class[i] {
t.Fatalf("pixel %d changed; a two-pixel band is a feature and must survive", i)
}
}
}
// The mask can be masked, which is the whole of D-57's contribution to the coastline: a shore somebody drew
// on purpose stays where they drew it while the rest of the world is still roughened.
func TestTheCoastMaskIsMaskedByTheOverlay(t *testing.T) {
const w, h = 1024, 512
p := testCylinder(t, w, h)
l := mustLegend(t, goodLegend)
r := stripeRaster(w, h, l) // land above the halfway row, ocean below
cfg := Coast{AmplitudePx: 48, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7}
free := r.RoughenCoast(l, p, cfg)
// Pin the left half and say nothing about the right. "Say nothing" is -1, not 1: an unmarked cell takes
// its instruction from the far side of the waterline, and that is what makes a stroke on one side enough.
scale := make([]float32, w*h)
for i := range scale {
scale[i] = -1
}
for y := 0; y < h; y++ {
for x := 0; x < w/2; x++ {
scale[y*w+x] = 0
}
}
cfg.Scale = scale
masked := r.RoughenCoast(l, p, cfg)
// The pinned half is the painting, exactly.
for y := 0; y < h; y++ {
for x := 0; x < w/2; x++ {
if masked.Class[y*w+x] != r.Class[y*w+x] {
t.Fatalf("pixel (%d,%d) moved inside a pinned stretch", x, y)
}
}
}
// And the half that said nothing is still roughened, or the test above proves nothing.
moved := 0
for y := 0; y < h; y++ {
for x := w / 2; x < w; x++ {
if masked.Class[y*w+x] != r.Class[y*w+x] {
moved++
}
}
}
if moved == 0 {
t.Fatal("nothing moved in the unmarked half; the mask is switching the whole pass off")
}
// The unmarked half must be exactly what it was with no mask at all - the noise is a function of world
// position, so pinning one stretch cannot move another.
for y := 0; y < h; y++ {
for x := w/2 + int(cfg.AmplitudePx) + 2; x < w; x++ {
if masked.Class[y*w+x] != free.Class[y*w+x] {
t.Fatalf("pixel (%d,%d) differs from the unmasked run; pinning one stretch moved another",
x, y)
}
}
}
}
// Painting only the water is enough, and so is painting only the land. A mark is a brush stroke along a
// coastline and it lands on whichever side the author's hand was on; if an unmarked cell took the default
// amplitude, the other side would march across the line anyway and the coast would move regardless.
func TestPinningOneSideOfTheWaterlineIsEnough(t *testing.T) {
const w, h = 512, 256
p := testCylinder(t, w, h)
l := mustLegend(t, goodLegend)
r := stripeRaster(w, h, l)
cfg := Coast{AmplitudePx: 24, WavelengthPx: 128, Octaves: 4, Gain: 0.55, Seed: 3}
// Everything that could move is within the amplitude of the halfway row, so the two cases below pin the
// same stretch of shore from opposite sides.
landOnly := make([]float32, w*h)
seaOnly := make([]float32, w*h)
for i := range landOnly {
landOnly[i], seaOnly[i] = -1, -1
}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
if y < h/2 {
landOnly[y*w+x] = 0
} else {
seaOnly[y*w+x] = 0
}
}
}
for _, c := range []struct {
name string
scale []float32
}{{"the land side", landOnly}, {"the sea side", seaOnly}} {
cfg.Scale = c.scale
out := r.RoughenCoast(l, p, cfg)
for i := range r.Class {
if out.Class[i] != r.Class[i] {
t.Fatalf("painting %s only did not hold the shore: pixel %d moved", c.name, i)
}
}
}
}