535 lines
20 KiB
Go
535 lines
20 KiB
Go
package coast
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
|
|
"salty/terrain/internal/field"
|
|
"salty/terrain/internal/manifest"
|
|
)
|
|
|
|
// 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
|
|
// the continent mask in a real run, and a test that read the border back would be measuring the boundary
|
|
// condition rather than the transform.
|
|
func TestSignedDistanceIsMetresEitherWay(t *testing.T) {
|
|
const w, h = 60, 20
|
|
const cellM = 8.0
|
|
sea := make([]bool, w*h)
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
sea[y*w+x] = x < 30
|
|
}
|
|
}
|
|
g := Measure(sea, w, h, cellM)
|
|
|
|
y := h / 2
|
|
for _, c := range []struct {
|
|
x int
|
|
want float64
|
|
}{{29, -cellM}, {30, cellM}, {33, 4 * cellM}, {26, -4 * cellM}} {
|
|
if got := float64(g.Dist.Data[y*w+c.x]); math.Abs(got-c.want) > 1e-3 {
|
|
t.Errorf("x=%d: distance %.3f m, want %.3f m", c.x, got, c.want)
|
|
}
|
|
}
|
|
// The waterline is the sea side of the boundary, one column of it.
|
|
for _, i := range g.Waterline {
|
|
if x := int(i) % w; x != 29 {
|
|
t.Fatalf("waterline cell at x=%d, want 29", x)
|
|
}
|
|
}
|
|
if len(g.Waterline) != h {
|
|
t.Errorf("%d waterline cells, want %d", len(g.Waterline), h)
|
|
}
|
|
// A straight coast of h cells has h boundary edges.
|
|
if want := float64(h) * cellM; math.Abs(g.ShoreM-want) > 1e-6 {
|
|
t.Errorf("shoreline %.1f m, want %.1f m", g.ShoreM, want)
|
|
}
|
|
}
|
|
|
|
// coastFixture is a straight coast: sea to the left of x=split, a plateau at heightM to the right.
|
|
func coastFixture(w, h, split 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
|
|
if x < split {
|
|
sea[i] = true
|
|
f.Data[i] = 0 // held at sea level by the solve; the pass overwrites it
|
|
} else {
|
|
f.Data[i] = float32(heightM)
|
|
}
|
|
}
|
|
}
|
|
return f, sea
|
|
}
|
|
|
|
func testCfg() manifest.Coast {
|
|
c := manifest.Defaults().Pipeline.Coast
|
|
c.RoughnessM = 0 // the profile tests are about the profile, not about the noise on it
|
|
return c
|
|
}
|
|
|
|
// shelfOnlyCfg silences the surf, which silences the sediment with it: no cut means no supply, and no supply
|
|
// means the sea floor is the shelf profile and nothing else. Without this the two shelf tests are also testing
|
|
// the beach the deposition step builds over the top of it, which is a different question and has its own test.
|
|
func shelfOnlyCfg() manifest.Coast {
|
|
c := testCfg()
|
|
c.SurfReachM = 0
|
|
c.RiverM3PerKm2 = 0
|
|
return c
|
|
}
|
|
|
|
// TestShelfDeepensAwayFromTheShore is the sea floor's shape: monotone down from the waterline, through the
|
|
// break, to the abyssal floor, and never above sea level.
|
|
//
|
|
// The coast in this fixture stands 5 m above the water, so the shelf comes out at its widest — 3 km of shelf
|
|
// and 1.6 km of slope — and the map is made wide enough to hold both. That matters: on a narrower map the
|
|
// abyssal floor is simply never reached, which is correct behaviour and would read as a failed test.
|
|
func TestShelfDeepensAwayFromTheShore(t *testing.T) {
|
|
const w, h, split = 1000, 40, 600
|
|
const cellM = 8.0
|
|
cfg := shelfOnlyCfg()
|
|
f, sea := coastFixture(w, h, split, cellM, 5)
|
|
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: cfg})
|
|
|
|
y := h / 2
|
|
prev := 0.0
|
|
for x := split - 1; x >= 1; x-- {
|
|
z := float64(f.Data[y*w+x])
|
|
if z > 0 {
|
|
t.Fatalf("x=%d: sea floor at %.2f m, above sea level", x, z)
|
|
}
|
|
if x < split-1 && z > prev+1e-4 {
|
|
t.Fatalf("x=%d: sea floor rose from %.2f to %.2f m going offshore", x, prev, z)
|
|
}
|
|
prev = z
|
|
}
|
|
// Past the shelf and the slope together, 4.6 km out, is the abyssal floor.
|
|
if z := float64(f.Data[y*w+2]); math.Abs(z+180) > 1 {
|
|
t.Errorf("the far sea floor is at %.1f m, want -180 m", z)
|
|
}
|
|
// And the break is where it was asked for: just inside the shelf width, the depth is the break depth.
|
|
shelfCells := int(cfg.ShelfKm.Hi()*1000/cellM) - 2
|
|
if z := float64(f.Data[y*w+split-1-shelfCells]); math.Abs(z+30) > 2 {
|
|
t.Errorf("the shelf break is at %.1f m, want -30 m", z)
|
|
}
|
|
}
|
|
|
|
// TestShelfIsNarrowerOffAMountain is the one behaviour that makes the shelf width worth deriving rather than
|
|
// setting: the same manifest gives a wide shelf off a plain and a narrow one off a range.
|
|
func TestShelfIsNarrowerOffAMountain(t *testing.T) {
|
|
const w, h, split = 700, 60, 400
|
|
const cellM = 8.0
|
|
depthAt := func(backshoreM float64, x int) float64 {
|
|
f, sea := coastFixture(w, h, split, cellM, backshoreM)
|
|
in := Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: shelfOnlyCfg()}
|
|
Build(in)
|
|
return float64(f.Data[(h/2)*w+x])
|
|
}
|
|
// One kilometre offshore: on a plain coast that is still shelf, on a mountain coast it is past the break.
|
|
const probe = 400 - 125
|
|
plain := depthAt(20, probe)
|
|
mountain := depthAt(600, probe)
|
|
if !(mountain < plain-20) {
|
|
t.Errorf("1 km offshore: %.1f m off a 20 m coast, %.1f m off a 600 m coast; "+
|
|
"the mountain coast should be far deeper", plain, mountain)
|
|
}
|
|
}
|
|
|
|
// TestSurfCutsACliffNotARamp is the shape the surf is for. A ramp would satisfy "the land is lower near the
|
|
// water" just as well, and it is not what a coast looks like, so the test asks for both halves: a nearly flat
|
|
// platform at the water and a step at the back of it.
|
|
func TestSurfCutsACliffNotARamp(t *testing.T) {
|
|
const w, h, split = 700, 60, 400
|
|
const cellM, plateau = 8.0, 120.0
|
|
f, sea := coastFixture(w, h, split, cellM, plateau)
|
|
cfg := testCfg()
|
|
in := Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: cfg}
|
|
Build(in)
|
|
|
|
y := h / 2
|
|
// The platform: the first five cells inland, 8 to 40 m from the water.
|
|
for x := split; x < split+5; x++ {
|
|
if z := float64(f.Data[y*w+x]); z > 8 {
|
|
t.Errorf("x=%d (%.0f m inland): %.1f m, want a platform near sea level",
|
|
x, float64(x-split+1)*cellM, z)
|
|
}
|
|
}
|
|
// The land beyond twice the reach is untouched.
|
|
far := split + int(2*cfg.SurfReachM/cellM)
|
|
if z := float64(f.Data[y*w+far]); math.Abs(z-plateau) > 1e-3 {
|
|
t.Errorf("%.0f m inland: %.1f m, want the plateau at %.0f m", 2*cfg.SurfReachM, z, plateau)
|
|
}
|
|
// The cliff: somewhere in the strip there is a step of at least a third of the plateau in one cell.
|
|
biggest := 0.0
|
|
for x := split; x < far; x++ {
|
|
if d := float64(f.Data[y*w+x+1] - f.Data[y*w+x]); d > biggest {
|
|
biggest = d
|
|
}
|
|
}
|
|
if biggest < plateau/3 {
|
|
t.Errorf("the biggest step in the surf strip is %.1f m over %.0f m; a %0.f m plateau should leave a "+
|
|
"cliff, not a ramp", biggest, cellM, plateau)
|
|
}
|
|
}
|
|
|
|
// bayFixture is a straight coast with a semicircular bay bitten out of it, which is the smallest shape that
|
|
// has both an exposed stretch and a sheltered one.
|
|
func bayFixture(w, h, split, radius int, cellM, heightM float64) (*field.Field, []bool) {
|
|
f, sea := coastFixture(w, h, split, cellM, heightM)
|
|
cx, cy := split, h/2
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
dx, dy := float64(x-cx), float64(y-cy)
|
|
if math.Hypot(dx, dy) < float64(radius) {
|
|
i := y*w + x
|
|
sea[i] = true
|
|
f.Data[i] = 0
|
|
}
|
|
}
|
|
}
|
|
return f, sea
|
|
}
|
|
|
|
// TestSedimentBudgetBalances is an accounting identity, and it is worth asserting because the deposition step
|
|
// is the only place in the generator where material is moved from one place to another rather than created or
|
|
// destroyed by a law. Everything cut, plus everything the rivers deliver, is either laid down or reported as
|
|
// unplaced; nothing evaporates.
|
|
func TestSedimentBudgetBalances(t *testing.T) {
|
|
const w, h, split = 400, 400, 250
|
|
const cellM = 8.0
|
|
f, sea := bayFixture(w, h, split, 90, cellM, 90)
|
|
res := Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: testCfg()})
|
|
s := res.Stats
|
|
|
|
in := s.CutM3 + s.RiverM3
|
|
out := s.LaidM3 + s.UnplacedM3
|
|
if in <= 0 {
|
|
t.Fatalf("the surf cut nothing: there is no budget to balance")
|
|
}
|
|
if rel := math.Abs(out-in) / in; rel > 0.02 {
|
|
t.Errorf("cut %.0f m3 + rivers %.0f m3 = %.0f, but laid %.0f + unplaced %.0f = %.0f (%.1f%% out)",
|
|
s.CutM3, s.RiverM3, in, s.LaidM3, s.UnplacedM3, out, rel*100)
|
|
}
|
|
if s.LaidM3 <= 0 {
|
|
t.Errorf("nothing was laid down at all; a bay should collect sediment")
|
|
}
|
|
}
|
|
|
|
// TestSedimentPrefersTheBay is the behaviour the fetch field exists to produce. Without it the surf would cut
|
|
// a headland and lay the debris straight back down on the headland, which is the one thing a coast never does.
|
|
func TestSedimentPrefersTheBay(t *testing.T) {
|
|
const w, h, split, radius = 400, 400, 250, 90
|
|
const cellM = 8.0
|
|
f, sea := bayFixture(w, h, split, radius, cellM, 90)
|
|
res := Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: testCfg()})
|
|
|
|
// Two windows of sea cells: the back of the bay, and open water the same distance offshore from the
|
|
// straight coast well clear of it.
|
|
var bay, open float64
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
i := y*w + x
|
|
if !sea[i] || res.Change.Data[i] <= 0 {
|
|
continue
|
|
}
|
|
inBay := math.Hypot(float64(x-split), float64(y-h/2)) < float64(radius)
|
|
farFromBay := math.Abs(float64(y-h/2)) > float64(radius)*1.6
|
|
if inBay {
|
|
bay += float64(res.Change.Data[i])
|
|
} else if farFromBay && x > split-40 {
|
|
open += float64(res.Change.Data[i])
|
|
}
|
|
}
|
|
}
|
|
if !(bay > open*2) {
|
|
t.Errorf("sediment laid: %.0f m in the bay against %.0f m on the open coast; "+
|
|
"shelter is not steering deposition", bay, open)
|
|
}
|
|
}
|
|
|
|
// TestBoxBlurIsMassPreservingAndSymmetric guards the drift kernel, which the deposition mass balance rests on.
|
|
// Support well inside the grid must come through with its total intact, and a single grain must spread to a
|
|
// kernel that is the same either side of where it started — that symmetry is what makes "what i gives j" equal
|
|
// "what j gives i", and it is what the zero padding is there to protect.
|
|
func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
|
|
f := field.New(64, 64, 1)
|
|
seed := uint32(5)
|
|
var before float64
|
|
for y := 20; y < 44; y++ { // support clear of the border by more than the kernel
|
|
for x := 20; x < 44; x++ {
|
|
seed = seed*1664525 + 1013904223
|
|
f.Data[y*64+x] = float32(seed>>16&255) / 255
|
|
before += float64(f.Data[y*64+x])
|
|
}
|
|
}
|
|
out := boxBlur(f, 5, 3, false)
|
|
var after float64
|
|
for _, v := range out.Data {
|
|
after += float64(v)
|
|
}
|
|
if rel := math.Abs(after-before) / before; rel > 1e-4 {
|
|
t.Errorf("the kernel moved the total from %.4f to %.4f (%.4f%%)", before, after, rel*100)
|
|
}
|
|
|
|
one := field.New(64, 64, 1)
|
|
one.Data[32*64+32] = 1
|
|
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 {
|
|
t.Fatalf("the kernel is not symmetric at offset %d: %g against %g", d, l, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDisabledIsThePreCoastBehaviour keeps the escape hatch honest: --no-coast has to give the flat sea floor
|
|
// the generator had before this pass, not a half-applied version of it.
|
|
func TestDisabledIsThePreCoastBehaviour(t *testing.T) {
|
|
const w, h, split = 200, 40, 120
|
|
f, sea := coastFixture(w, h, split, 8, 100)
|
|
cfg := testCfg()
|
|
cfg.Enabled = false
|
|
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: cfg})
|
|
for i := range sea {
|
|
if sea[i] && math.Abs(float64(f.Data[i])+180) > 1e-3 {
|
|
t.Fatalf("cell %d: %.2f m, want a flat floor at -180 m", i, f.Data[i])
|
|
}
|
|
if !sea[i] && math.Abs(float64(f.Data[i])-100) > 1e-3 {
|
|
t.Fatalf("cell %d: land at %.2f m, want it untouched at 100 m", i, f.Data[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|