Tooling
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user