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

819 lines
26 KiB
Go

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)
}
}
}
}