819 lines
26 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|