Tooling
This commit is contained in:
@@ -8,52 +8,7 @@ import (
|
||||
"salty/terrain/internal/manifest"
|
||||
)
|
||||
|
||||
// TestEdtMatchesBruteForce is the one test the whole package rests on. Everything else is written in terms of
|
||||
// "how far is this cell from the waterline and which stretch does it belong to", so a distance transform that
|
||||
// is subtly wrong would not fail loudly, it would put the shelf break in slightly the wrong place everywhere.
|
||||
// Felzenszwalb's transform is exact, so the comparison is against an exhaustive search and the tolerance is
|
||||
// float32 rounding, not a percentage.
|
||||
func TestEdtMatchesBruteForce(t *testing.T) {
|
||||
const w, h = 41, 37
|
||||
seed := uint32(99)
|
||||
seeds := make([]bool, w*h)
|
||||
for i := range seeds {
|
||||
seed = seed*1664525 + 1013904223
|
||||
seeds[i] = seed>>20&7 == 0
|
||||
}
|
||||
seeds[0] = true // guarantee at least one
|
||||
|
||||
d2, near := edt(seeds, w, h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
best := math.Inf(1)
|
||||
for sy := 0; sy < h; sy++ {
|
||||
for sx := 0; sx < w; sx++ {
|
||||
if !seeds[sy*w+sx] {
|
||||
continue
|
||||
}
|
||||
dx, dy := float64(x-sx), float64(y-sy)
|
||||
if d := dx*dx + dy*dy; d < best {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
}
|
||||
i := y*w + x
|
||||
if math.Abs(float64(d2[i])-best) > 1e-3 {
|
||||
t.Fatalf("cell (%d,%d): d2 %g, brute force %g", x, y, d2[i], best)
|
||||
}
|
||||
// The feature index must be a seed, and it must be one at exactly that distance.
|
||||
n := int(near[i])
|
||||
if n < 0 || !seeds[n] {
|
||||
t.Fatalf("cell (%d,%d): nearest %d is not a seed", x, y, n)
|
||||
}
|
||||
dx, dy := float64(x-n%w), float64(y-n/w)
|
||||
if math.Abs(dx*dx+dy*dy-best) > 1e-3 {
|
||||
t.Fatalf("cell (%d,%d): nearest seed %d is at %g, not %g", x, y, n, dx*dx+dy*dy, best)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The exact distance transform this pass is built on is tested in internal/dt, where it now lives.
|
||||
|
||||
// TestSignedDistanceIsMetresEitherWay checks the sign convention and the unit on a straight coast, where the
|
||||
// answer is arithmetic. The cells asked about are named explicitly: the map's own border is forced to sea by
|
||||
@@ -312,7 +267,7 @@ func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
|
||||
before += float64(f.Data[y*64+x])
|
||||
}
|
||||
}
|
||||
out := boxBlur(f, 5, 3)
|
||||
out := boxBlur(f, 5, 3, false)
|
||||
var after float64
|
||||
for _, v := range out.Data {
|
||||
after += float64(v)
|
||||
@@ -323,7 +278,7 @@ func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
|
||||
|
||||
one := field.New(64, 64, 1)
|
||||
one.Data[32*64+32] = 1
|
||||
k := boxBlur(one, 5, 3)
|
||||
k := boxBlur(one, 5, 3, false)
|
||||
for d := 1; d <= 16; d++ {
|
||||
l, r := k.Data[32*64+32-d], k.Data[32*64+32+d]
|
||||
if math.Abs(float64(l-r)) > 1e-7 {
|
||||
@@ -349,3 +304,231 @@ func TestDisabledIsThePreCoastBehaviour(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the cylinder ------------------------------------------------------------------------------------
|
||||
//
|
||||
// A planet is measured once, whole, so every march, every ray and every running sum in this pass has to cross
|
||||
// the seam. The twins below are the flat-grid tests' questions asked again on a cylinder, and the shape of
|
||||
// each one is the same: build a world, build the *same* world rotated half a turn, and require the answer to
|
||||
// follow the ground rather than the grid. A pass that stops at column zero passes every flat test there is.
|
||||
|
||||
// rotate shifts a grid half a turn in X. On a cylinder that is not a change to the world at all, so anything
|
||||
// this pass measures has to come out rotated with it and not otherwise different.
|
||||
func rotate(f *field.Field, sea []bool, by int) (*field.Field, []bool) {
|
||||
w, h := f.W, f.H
|
||||
g := field.New(w, h, f.CellM)
|
||||
s := make([]bool, len(sea))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
src := y*w + x
|
||||
dst := y*w + (x+by)%w
|
||||
g.Data[dst] = f.Data[src]
|
||||
s[dst] = sea[src]
|
||||
}
|
||||
}
|
||||
return g, s
|
||||
}
|
||||
|
||||
// islandFixture is a round island on an otherwise open ocean, centred where the caller asks. Put the centre at
|
||||
// x=0 and it straddles the seam.
|
||||
func islandFixture(w, h, cx, cy, radius int, cellM, heightM float64) (*field.Field, []bool) {
|
||||
f := field.New(w, h, cellM)
|
||||
sea := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
dx := x - cx
|
||||
if dx > w/2 {
|
||||
dx -= w
|
||||
} else if dx < -w/2 {
|
||||
dx += w
|
||||
}
|
||||
dy := y - cy
|
||||
if dx*dx+dy*dy <= radius*radius {
|
||||
f.Data[i] = float32(heightM)
|
||||
} else {
|
||||
sea[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, sea
|
||||
}
|
||||
|
||||
// The whole pass, twice, on the same island in two places. Everything it produces has to be the same world
|
||||
// rotated - which is the one assertion that catches a march, a ray or a running sum stopping at the seam,
|
||||
// because on a flat grid the two would differ and nobody would know which was right.
|
||||
func TestTheWholePassIsRotationInvariantOnACylinder(t *testing.T) {
|
||||
const w, h, r = 256, 96, 22
|
||||
const cellM = 40.0
|
||||
cfg := testCfg()
|
||||
|
||||
// Away from the seam.
|
||||
a, aSea := islandFixture(w, h, w/2, h/2, r, cellM, 60)
|
||||
ra := Build(Input{Height: a, Sea: aSea, SeaLevelM: 0, BreakM: 30, AbyssM: 180,
|
||||
WrapX: true, Seed: 7, Cfg: cfg})
|
||||
// The same island astride it, which is the same island.
|
||||
b, bSea := islandFixture(w, h, 0, h/2, r, cellM, 60)
|
||||
rb := Build(Input{Height: b, Sea: bSea, SeaLevelM: 0, BreakM: 30, AbyssM: 180,
|
||||
WrapX: true, Seed: 7, Cfg: cfg})
|
||||
|
||||
want, _ := rotate(a, aSea, w/2) // a rotated to sit where b does
|
||||
worst, at := 0.0, -1
|
||||
for i := range want.Data {
|
||||
if d := math.Abs(float64(want.Data[i] - b.Data[i])); d > worst {
|
||||
worst, at = d, i
|
||||
}
|
||||
}
|
||||
// Exactly zero when everything wraps, measured: the same island in two places is the same arithmetic in a
|
||||
// different order, and the order happens not to matter here. The tolerance is set just under what each
|
||||
// broken piece actually costs rather than at a comfortable round number - forcing the ray march flat gives
|
||||
// 0.224 m, forcing the box blur flat gives 7.6e-5 m, and a tolerance loose enough to pass the second is a
|
||||
// test that does not cover the running sums it claims to.
|
||||
if worst > 2e-5 {
|
||||
t.Errorf("the same island at the seam and away from it differ by %g m at cell %d (%d,%d); "+
|
||||
"something in the pass stops at column zero", worst, at, at%w, at/w)
|
||||
}
|
||||
|
||||
// And the accounting follows the ground too.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
a, b float64
|
||||
tolRel float64
|
||||
}{
|
||||
{"shoreline", ra.Stats.ShorelineKm, rb.Stats.ShorelineKm, 1e-9},
|
||||
{"surf cut", ra.Stats.CutM3, rb.Stats.CutM3, 1e-3},
|
||||
{"laid", ra.Stats.LaidM3, rb.Stats.LaidM3, 1e-3},
|
||||
{"shelf share", ra.Stats.ShelfPctSea, rb.Stats.ShelfPctSea, 1e-6},
|
||||
{"exposure p50", ra.Stats.ExposureP50, rb.Stats.ExposureP50, 1e-6},
|
||||
} {
|
||||
if c.a == 0 && c.b == 0 {
|
||||
t.Errorf("%s is zero in both runs; this comparison measured nothing", c.name)
|
||||
continue
|
||||
}
|
||||
if rel := math.Abs(c.a-c.b) / math.Max(math.Abs(c.a), 1e-12); rel > c.tolRel {
|
||||
t.Errorf("%s: %.6g at the seam against %.6g away from it", c.name, c.b, c.a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The flat grid must not have changed. A cylinder is opt-in, and every template drawn before it existed was
|
||||
// drawn against the old behaviour.
|
||||
func TestAFlatGridIsUnchangedByTheCylinderOption(t *testing.T) {
|
||||
const w, h, split = 200, 40, 120
|
||||
f1, sea1 := coastFixture(w, h, split, 8, 5)
|
||||
r1 := Build(Input{Height: f1, Sea: sea1, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: testCfg()})
|
||||
|
||||
// Land at both ends and water in the middle: on a flat grid the two coasts are unrelated, on a cylinder
|
||||
// they are one landmass. The flat answer has to be the flat answer.
|
||||
if r1.Geometry.WrapX {
|
||||
t.Fatal("a caller that asked for nothing got a cylinder")
|
||||
}
|
||||
f2, sea2 := coastFixture(w, h, split, 8, 5)
|
||||
r2 := Build(Input{Height: f2, Sea: sea2, SeaLevelM: 0, BreakM: 30, AbyssM: 180, WrapX: false,
|
||||
Seed: 7, Cfg: testCfg()})
|
||||
for i := range f1.Data {
|
||||
if f1.Data[i] != f2.Data[i] {
|
||||
t.Fatalf("cell %d differs between two flat runs", i)
|
||||
}
|
||||
}
|
||||
_ = r2
|
||||
}
|
||||
|
||||
// The drift kernel on a cylinder: still mass-preserving, still symmetric, and now symmetric *across the seam*
|
||||
// as well. The deposition balance rests on K(i,j) = K(j,i), and a row pass that truncated at column zero
|
||||
// would break it exactly where a coast crosses the meridian.
|
||||
func TestBoxBlurWrapsWithoutLosingMass(t *testing.T) {
|
||||
const w, h = 64, 64
|
||||
f := field.New(w, h, 1)
|
||||
// Support astride the seam, which on a flat grid would run off both ends.
|
||||
var before float64
|
||||
for y := 20; y < 44; y++ {
|
||||
for _, x := range []int{w - 3, w - 2, w - 1, 0, 1, 2} {
|
||||
f.Data[y*w+x] = 1
|
||||
before++
|
||||
}
|
||||
}
|
||||
out := boxBlur(f, 5, 3, true)
|
||||
var after float64
|
||||
for _, v := range out.Data {
|
||||
after += float64(v)
|
||||
}
|
||||
if rel := math.Abs(after-before) / before; rel > 1e-4 {
|
||||
t.Errorf("wrapping moved the total from %.4f to %.4f (%.4f%%)", before, after, rel*100)
|
||||
}
|
||||
// And the flat kernel would have lost some of it, which is what says this test measures the wrap.
|
||||
flat := boxBlur(f, 5, 3, false)
|
||||
var flatSum float64
|
||||
for _, v := range flat.Data {
|
||||
flatSum += float64(v)
|
||||
}
|
||||
if flatSum >= before*0.999 {
|
||||
t.Error("the flat kernel kept everything too; move the support onto the seam")
|
||||
}
|
||||
|
||||
one := field.New(w, h, 1)
|
||||
one.Data[32*w+0] = 1 // a single grain exactly on the seam
|
||||
k := boxBlur(one, 5, 3, true)
|
||||
for d := 1; d <= 16; d++ {
|
||||
l, r := k.Data[32*w+wrapCol(-d, w)], k.Data[32*w+wrapCol(d, w)]
|
||||
if math.Abs(float64(l-r)) > 1e-7 {
|
||||
t.Fatalf("the wrapped kernel is not symmetric at offset %d: %g against %g", d, l, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A per-cell abyss is what lets a derived shelf meet a *painted* ocean floor. Without it the slope runs down
|
||||
// to one global depth and steps to whatever the painting said, which on a planet whose sea classes carry
|
||||
// 20, 120 and 512 m is a cliff at the shelf break in every strait.
|
||||
func TestThePerCellAbyssIsWhereTheSlopeEnds(t *testing.T) {
|
||||
// A tall coast, so the shelf comes out at its narrowest (600 m) and the 3.2 km of ocean has room for the
|
||||
// 1.6 km of continental slope behind it. On a low coast the shelf is 3 km wide and the slope never
|
||||
// finishes, which is correct behaviour and would read here as a failure.
|
||||
const w, h, split = 700, 24, 400
|
||||
const cellM = 8.0
|
||||
f, sea := coastFixture(w, h, split, cellM, 400)
|
||||
abyss := make([]float32, w*h)
|
||||
for i := range abyss {
|
||||
abyss[i] = 400 // deeper than the 180 m a global AbyssM would give
|
||||
}
|
||||
cfg := shelfOnlyCfg()
|
||||
cfg.RoughnessM = 0
|
||||
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Abyss: abyss,
|
||||
Seed: 7, Cfg: cfg})
|
||||
|
||||
// The far end of the ocean, well past shelf plus slope, has to be at the painted depth and not at AbyssM.
|
||||
deepest := 0.0
|
||||
for y := 0; y < h; y++ {
|
||||
if d := -float64(f.Data[y*w+0]); d > deepest {
|
||||
deepest = d
|
||||
}
|
||||
}
|
||||
if math.Abs(deepest-400) > 1 {
|
||||
t.Errorf("the sea floor bottoms out at %.1f m; the painted abyss is 400 m", deepest)
|
||||
}
|
||||
}
|
||||
|
||||
// The separable coverage has to be the field it replaced, exactly. It is an optimisation of a divisor, and an
|
||||
// optimisation of a divisor that is only nearly right moves every smoothed value on the map.
|
||||
func TestTheSeparableCoverageIsTheFieldItReplaced(t *testing.T) {
|
||||
for _, wrapX := range []bool{false, true} {
|
||||
for _, radius := range []int{1, 4, 11, 40, 97} { // including radii past the grid, where the coast pass really runs
|
||||
for _, passes := range []int{1, 2, 3} {
|
||||
const w, h = 37, 29
|
||||
ones := field.New(w, h, 1)
|
||||
ones.Fill(1)
|
||||
want := boxBlur(ones, radius, passes, wrapX)
|
||||
cx := boxCover(w, radius, passes, wrapX)
|
||||
cy := boxCover(h, radius, passes, false)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
got := cx[x] * cy[y]
|
||||
if d := math.Abs(got - float64(want.Data[y*w+x])); d > 1e-6 {
|
||||
t.Fatalf("wrap=%v r=%d p=%d at (%d,%d): %.8f against the blurred field's %.8f",
|
||||
wrapX, radius, passes, x, y, got, want.Data[y*w+x])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user