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