Tooling
This commit is contained in:
@@ -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