Tooling
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
package detail
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
const testCellM = 2.0
|
||||
|
||||
// coastalCfg is the manifest's own block, so the tests fail when a default moves rather than measuring a copy
|
||||
// of it that nothing ships.
|
||||
func coastalCfg() (manifest.CoastDetail, manifest.Coast) {
|
||||
m := manifest.Defaults()
|
||||
return m.Pipeline.CoastDetail, m.Pipeline.Coast
|
||||
}
|
||||
|
||||
func coastPlanet(w, h int) world.Planet {
|
||||
return world.Planet{CellM: testCellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * testCellM}
|
||||
}
|
||||
|
||||
// straightCoast is a world cut in half: land to the left of shoreM, sea to the right. The land rises to backM
|
||||
// over one surf reach and then holds, so the backshore window the pass measures in is exactly backM and the
|
||||
// beach-or-cliff decision in a test is the number the test set.
|
||||
//
|
||||
// A straight coast rather than an island on purpose: the profile is then one dimensional, so "what did the
|
||||
// pass do" is a column that can be read off and compared against the arithmetic it is meant to be.
|
||||
func straightCoast(w, h int, shoreM, backM, reachM, seaDepthM float64) (*field.Field, []bool) {
|
||||
f := field.New(w, h, testCellM)
|
||||
land := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
inland := shoreM - float64(x)*testCellM
|
||||
if inland >= 0 {
|
||||
land[i] = true
|
||||
t := inland / reachM
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
f.Data[i] = float32(backM * t * t * (3 - 2*t))
|
||||
} else {
|
||||
f.Data[i] = float32(-seaDepthM)
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, land
|
||||
}
|
||||
|
||||
func runCoastal(t *testing.T, f *field.Field, land []bool, p world.Planet, x0, y0 int, backM float64) CoastalStats {
|
||||
t.Helper()
|
||||
cfg, surf := coastalCfg()
|
||||
return RunCoastal(f, land, CoastalParams{
|
||||
Cfg: cfg, Surf: surf, Seed: 7,
|
||||
Frame: world.Frame{P: p, X0: x0, Y0: y0, W: f.W, H: f.H},
|
||||
PeriodM: 1000, SeaLevelM: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Rule 1, for this pass: everything is keyed on absolute world position - the crenulation lattice through
|
||||
// noise.WorldUV, the distance through a transform whose seeds are the same cells - so a window cut out of a
|
||||
// bigger world and run on its own comes back bit-identical inside its margin.
|
||||
//
|
||||
// This is the test for the mistake the rule exists for: a noise field indexed by grid index instead of world
|
||||
// position looks perfect on any one tile and puts a seam down every tile boundary. Measured by breaking it -
|
||||
// passing a zero origin to WorldUV moves the interior by up to 3.6 m.
|
||||
//
|
||||
// It is *not* the test for the margin being big enough; the coast here is in the middle of the window, so the
|
||||
// answer would be the same with no margin at all. TestThePassFitsInsideTheTileMargin is that one.
|
||||
func TestATileInteriorIsWhatOneWholeRunWouldHaveGiven(t *testing.T) {
|
||||
const w, h = 512, 192
|
||||
p := coastPlanet(w, h)
|
||||
whole, land := straightCoast(w, h, 420, 40, 110, 6)
|
||||
runCoastal(t, whole, land, p, 0, 0, 40)
|
||||
|
||||
// The same world, cut out with a margin and run on its own. 130 cells is 260 m, which is past the pass's
|
||||
// own outer limit of two surf reaches.
|
||||
const margin = 130
|
||||
const cx0, cw = 160, 192
|
||||
cut := field.New(cw+2*margin, h, testCellM)
|
||||
cutLand := make([]bool, len(cut.Data))
|
||||
src, srcLand := straightCoast(w, h, 420, 40, 110, 6)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < cut.W; x++ {
|
||||
sx := cx0 - margin + x
|
||||
cut.Data[y*cut.W+x] = src.Data[y*w+sx]
|
||||
cutLand[y*cut.W+x] = srcLand[y*w+sx]
|
||||
}
|
||||
}
|
||||
runCoastal(t, cut, cutLand, p, cx0-margin, 0, 40)
|
||||
|
||||
var worst float64
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < cw; x++ {
|
||||
a := whole.Data[y*w+cx0+x]
|
||||
b := cut.Data[y*cut.W+margin+x]
|
||||
if d := math.Abs(float64(a) - float64(b)); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
}
|
||||
if worst != 0 {
|
||||
t.Fatalf("a tile's interior differs from the whole run by up to %g m; every hash and lattice in this "+
|
||||
"pass is supposed to be keyed on world position", worst)
|
||||
}
|
||||
}
|
||||
|
||||
// The cliff branch only cuts, so it owes an apron. This is the one hard conservation statement in the pass:
|
||||
// what comes off the face is what lands at its foot, per stretch of shore rather than per tile, so the debris
|
||||
// under a cliff is that cliff's debris.
|
||||
func TestTheScreeIsExactlyWhatTheCliffLost(t *testing.T) {
|
||||
const w, h = 320, 128
|
||||
p := coastPlanet(w, h)
|
||||
f, land := straightCoast(w, h, 400, 60, 110, 6)
|
||||
st := runCoastal(t, f, land, p, 0, 0, 60)
|
||||
|
||||
if st.CutM3 <= 0 {
|
||||
t.Fatalf("a 60 m backshore cut nothing off its face; cliff fraction %.2f", st.CliffFrac)
|
||||
}
|
||||
if st.CliffFrac < 0.99 {
|
||||
t.Fatalf("a 60 m backshore is %.0f%% cliff, not a cliff coast", st.CliffFrac*100)
|
||||
}
|
||||
// Float32 heights, so the tolerance is the accumulation of a few million of them rather than zero.
|
||||
if rel := math.Abs(st.ScreeM3-st.CutM3) / st.CutM3; rel > 1e-9 {
|
||||
t.Fatalf("the face lost %.3f m3 and the apron gained %.3f m3, a relative gap of %g",
|
||||
st.CutM3, st.ScreeM3, rel)
|
||||
}
|
||||
}
|
||||
|
||||
// A beach coast and a cliff coast are the same code with one number changed, and the number is the height of
|
||||
// the land behind the shore. This checks the two come out as different landforms rather than as the same one
|
||||
// scaled: a berm above the waterline on the beach, and no berm at all on the cliff.
|
||||
func TestTheBackshoreDecidesBetweenABeachAndACliff(t *testing.T) {
|
||||
const w, h = 320, 96
|
||||
p := coastPlanet(w, h)
|
||||
cfg, surf := coastalCfg()
|
||||
|
||||
// The swash zone: the strip just inland of the waterline. A berm is ground *standing* above the water
|
||||
// there, so the measurement is a height and not a change - the first version of this measured how much
|
||||
// the pass raised the ground and read 6 m on a beach, all of it the foreshore being filled up from the
|
||||
// flat sea floor the fixture starts with. What was being measured was the fixture.
|
||||
crest := func(f *field.Field) float64 {
|
||||
var top float64
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
inland := 400 - float64(x)*testCellM
|
||||
if inland < 0 || inland > float64(cfg.BermBackM) {
|
||||
continue
|
||||
}
|
||||
if v := float64(f.Data[y*w+x]); v > top {
|
||||
top = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
beach, beachLand := straightCoast(w, h, 400, 3, 110, 6)
|
||||
beachStats := runCoastal(t, beach, beachLand, p, 0, 0, 3)
|
||||
cliff, cliffLand := straightCoast(w, h, 400, 60, 110, 6)
|
||||
cliffStats := runCoastal(t, cliff, cliffLand, p, 0, 0, 60)
|
||||
|
||||
if beachStats.CliffFrac > 0.01 {
|
||||
t.Errorf("a 3 m backshore came out %.0f%% cliff", beachStats.CliffFrac*100)
|
||||
}
|
||||
if cliffStats.CliffFrac < 0.99 {
|
||||
t.Errorf("a 60 m backshore came out only %.0f%% cliff", cliffStats.CliffFrac*100)
|
||||
}
|
||||
|
||||
// With no exposure field every shore is treated as fully exposed, so the berm stands at the manifest's
|
||||
// full height.
|
||||
gotBerm := crest(beach)
|
||||
if want := surf.BermM; gotBerm < want*0.8 || gotBerm > want*1.2 {
|
||||
t.Errorf("the beach's swash zone tops out at %.2f m; a berm should stand about %.2f", gotBerm, want)
|
||||
}
|
||||
// The cliff coast has a shore platform there instead, which runs up at the platform grade and nothing
|
||||
// more: a cliff does not get a berm, it gets the rock the surf planed.
|
||||
gotPlatform := crest(cliff)
|
||||
if want := surf.PlatformGrade * cfg.BermBackM; gotPlatform > want*1.5 {
|
||||
t.Errorf("the cliff's swash zone tops out at %.2f m; the platform should reach about %.2f",
|
||||
gotPlatform, want)
|
||||
}
|
||||
if gotPlatform >= gotBerm {
|
||||
t.Errorf("the cliff coast (%.2f m) stands as high in the swash zone as the beach (%.2f m); the two "+
|
||||
"branches are not producing different landforms", gotPlatform, gotBerm)
|
||||
}
|
||||
}
|
||||
|
||||
// The claim that lets the pass run per tile at all: it never reaches further from the waterline than the tile
|
||||
// margin, so a tile's margin holds everything its interior needed.
|
||||
//
|
||||
// The margin is the droplets' - three lifetimes, 244 m at the defaults - and this pass has to fit inside a
|
||||
// number that was measured for something else. Two surf reaches is its own hard limit, and it is a limit
|
||||
// rather than a consequence: past it a cell has no stretch of shore to belong to at all.
|
||||
//
|
||||
// The test asserts both ends. Past the margin, nothing may move; and something must move a good way out, or
|
||||
// the test would pass just as well on a pass that did nothing.
|
||||
func TestThePassFitsInsideTheTileMargin(t *testing.T) {
|
||||
const w, h = 512, 96
|
||||
p := coastPlanet(w, h)
|
||||
m := manifest.Defaults()
|
||||
marginM := float64(MarginCells(m.Pipeline.Particle)) * testCellM
|
||||
|
||||
for _, backM := range []float64{3, 40, 300, 600} {
|
||||
f, land := straightCoast(w, h, 500, backM, 110, 6)
|
||||
before := f.Clone()
|
||||
runCoastal(t, f, land, p, 0, 0, backM)
|
||||
|
||||
var reachedM float64
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if f.Data[i] == before.Data[i] {
|
||||
continue
|
||||
}
|
||||
if d := math.Abs(500 - float64(x)*testCellM); d > reachedM {
|
||||
reachedM = d
|
||||
}
|
||||
}
|
||||
}
|
||||
if reachedM > marginM {
|
||||
t.Errorf("backshore %.0f m: the pass reached %.0f m from the waterline, past the %.0f m tile "+
|
||||
"margin it has to fit inside", backM, reachedM, marginM)
|
||||
}
|
||||
if reachedM < 40 {
|
||||
t.Errorf("backshore %.0f m: the pass only reached %.0f m, which is not a shore profile",
|
||||
backM, reachedM)
|
||||
}
|
||||
t.Logf("backshore %3.0f m: reached %3.0f m of the %.0f m margin", backM, reachedM, marginM)
|
||||
}
|
||||
}
|
||||
|
||||
// Dean's profile is the one piece of published geomorphology in this pass, so it is worth checking that what
|
||||
// comes out is actually it rather than something that merely slopes the right way. Away from the crenulation
|
||||
// and inside the full-weight strip, the depth under water must be A*x^(2/3).
|
||||
func TestTheForeshoreIsDeansProfile(t *testing.T) {
|
||||
const w, h = 320, 64
|
||||
p := coastPlanet(w, h)
|
||||
cfg, surf := coastalCfg()
|
||||
|
||||
// Shallow water on purpose. A beach may lay at most BeachFillM of sediment on what is already there, so a
|
||||
// fixture with a deep flat floor would measure the cap rather than the curve - which is what the first
|
||||
// version of this did, at 40 m, and it read a flat profile 3 m above the floor. At 3 m the equilibrium
|
||||
// curve sits above the floor by less than the cap everywhere it is sampled.
|
||||
f, land := straightCoast(w, h, 300, 3, 110, 3)
|
||||
runCoastal(t, f, land, p, 0, 0, 3)
|
||||
|
||||
// One row, and the crenulation read off the pass's own noise by inverting the profile at a known depth
|
||||
// would be circular - so instead the check is against the *shape*: the ratio of depths at two offsets
|
||||
// must be (x1/x2)^(2/3) whatever the crenulation shifted them by, and that is what is asserted.
|
||||
y := h / 2
|
||||
depthAt := func(offsetM float64) float64 {
|
||||
x := int((300 + offsetM) / testCellM)
|
||||
return -float64(f.Data[y*w+x])
|
||||
}
|
||||
d1, d2 := depthAt(20), depthAt(45)
|
||||
if d1 <= 0 || d2 <= d1 {
|
||||
t.Fatalf("the foreshore is not going down: %.2f m at 20 m out, %.2f m at 45 m", d1, d2)
|
||||
}
|
||||
// Solve for the shift the crenulation applied, then check A.
|
||||
// d1 = A*(20+s)^(2/3), d2 = A*(45+s)^(2/3)
|
||||
var best, bestErr = 0.0, math.Inf(1)
|
||||
for s := -cfg.CrenulationM; s <= cfg.CrenulationM; s += 0.01 {
|
||||
want := math.Pow((45+s)/(20+s), 2.0/3.0)
|
||||
if e := math.Abs(d2/d1 - want); e < bestErr {
|
||||
best, bestErr = s, e
|
||||
}
|
||||
}
|
||||
if bestErr > 0.02 {
|
||||
t.Fatalf("the two depths %.3f and %.3f are not in a 2/3-power ratio at any crenulation inside "+
|
||||
"+/-%.0f m (best miss %.3f)", d1, d2, cfg.CrenulationM, bestErr)
|
||||
}
|
||||
gotA := d1 / math.Pow(20+best, 2.0/3.0)
|
||||
if math.Abs(gotA-cfg.DeanA) > 0.01 {
|
||||
t.Fatalf("Dean's A came out %.3f against the manifest's %.3f (crenulation %.2f m)",
|
||||
gotA, cfg.DeanA, best)
|
||||
}
|
||||
_ = surf
|
||||
}
|
||||
|
||||
// speckledCoast is a coastal plain: land rising at one in a hundred, with a little roughness on it. That is
|
||||
// enough to make the land mask a forty-metre band of speckle rather than a line, which is what a real one is
|
||||
// - measured on region 11 of the first painted planet, where the shore wandered eighteen cells between rows
|
||||
// three apart and a row crossed sea level three times.
|
||||
func speckledCoast(w, h int, shoreM, grade, roughM float64) (*field.Field, []bool) {
|
||||
f := field.New(w, h, testCellM)
|
||||
land := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
inland := shoreM - float64(x)*testCellM
|
||||
// A hash of the cell, so the roughness is the same every run and has no structure in it.
|
||||
k := uint32(x*374761393+y*668265263) * 2246822519
|
||||
k ^= k >> 13
|
||||
u := float64(k%10007)/10007.0 - 0.5
|
||||
v := grade*inland + roughM*u
|
||||
f.Data[i] = float32(v)
|
||||
land[i] = v > 0
|
||||
}
|
||||
}
|
||||
return f, land
|
||||
}
|
||||
|
||||
// What a coastal plain does to a shoreline, and the reason the signed distance is smoothed before the profile
|
||||
// is measured from it.
|
||||
//
|
||||
// The pass rebuilds the surface as a monotonic function of that distance, so its output crosses sea level
|
||||
// once along any line across the shore however ragged the input was. Without the smoothing it instead builds
|
||||
// a separate berm on every island in the speckle, which is what the first run of the pass did: a string of
|
||||
// beads down the whole coast.
|
||||
func TestACoastalPlainComesOutWithOneShorelineAndNotABeadedOne(t *testing.T) {
|
||||
const w, h = 320, 128
|
||||
p := coastPlanet(w, h)
|
||||
f, land := speckledCoast(w, h, 400, 0.01, 0.30)
|
||||
|
||||
crossings := func(g *field.Field) float64 {
|
||||
total := 0
|
||||
for y := 0; y < h; y++ {
|
||||
n := 0
|
||||
for x := 1; x < w; x++ {
|
||||
a, b := g.Data[y*w+x-1], g.Data[y*w+x]
|
||||
if (a <= 0) != (b <= 0) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return float64(total) / float64(h)
|
||||
}
|
||||
|
||||
before := crossings(f)
|
||||
if before < 3 {
|
||||
t.Fatalf("the fixture is not speckled: %.1f sea-level crossings a row", before)
|
||||
}
|
||||
runCoastal(t, f, land, p, 0, 0, 4)
|
||||
after := crossings(f)
|
||||
if after > 1.05 {
|
||||
t.Errorf("the shore came out with %.2f sea-level crossings a row (%.1f before); a shoreline crosses "+
|
||||
"once, and more than that is a bead on the beach for every island in the mask", after, before)
|
||||
}
|
||||
t.Logf("sea-level crossings a row: %.1f before, %.2f after", before, after)
|
||||
}
|
||||
|
||||
// A beach is a veneer of sediment and not a landform that fills a fjord.
|
||||
//
|
||||
// The equilibrium profile is a target *depth*, so on a shore with forty metres of water a hundred metres off
|
||||
// it - a drowned valley, which is an ordinary thing on a real coast - an uncapped beach branch invents
|
||||
// thirty-seven metres of sand to bring the floor up to the curve. Capped, the beach lays a few metres on
|
||||
// whatever is there and runs out where the water gets deep, which is what a steep-to shore is.
|
||||
func TestABeachDoesNotFillADrownedValley(t *testing.T) {
|
||||
const w, h = 320, 96
|
||||
p := coastPlanet(w, h)
|
||||
cfg, _ := coastalCfg()
|
||||
|
||||
f, land := straightCoast(w, h, 400, 3, 110, 40)
|
||||
before := f.Clone()
|
||||
runCoastal(t, f, land, p, 0, 0, 3)
|
||||
|
||||
var worst float64
|
||||
for i := range f.Data {
|
||||
if d := float64(f.Data[i]) - float64(before.Data[i]); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
if worst > cfg.BeachFillM+0.01 {
|
||||
t.Fatalf("the beach laid %.2f m of sediment where the cap is %.2f; a shore with deep water close in "+
|
||||
"is a steep-to shore, not a bay to be filled", worst, cfg.BeachFillM)
|
||||
}
|
||||
if worst < cfg.BeachFillM*0.5 {
|
||||
t.Fatalf("the beach laid only %.2f m; the fixture is meant to press against the %.2f m cap",
|
||||
worst, cfg.BeachFillM)
|
||||
}
|
||||
}
|
||||
|
||||
// The pass is off when the manifest says so, and off means nothing at all rather than a cheaper version of
|
||||
// itself. Worth a test because it is the switch somebody reaches for when a coast looks wrong, and a switch
|
||||
// that half works is worse than no switch.
|
||||
func TestTheSwitchTurnsItOff(t *testing.T) {
|
||||
const w, h = 128, 64
|
||||
p := coastPlanet(w, h)
|
||||
f, land := straightCoast(w, h, 150, 40, 110, 6)
|
||||
before := f.Clone()
|
||||
|
||||
cfg, surf := coastalCfg()
|
||||
cfg.Enabled = false
|
||||
st := RunCoastal(f, land, CoastalParams{
|
||||
Cfg: cfg, Surf: surf, Seed: 7,
|
||||
Frame: world.Frame{P: p, X0: 0, Y0: 0, W: w, H: h},
|
||||
PeriodM: 1000, SeaLevelM: 0,
|
||||
})
|
||||
if st.ShoreCells != 0 {
|
||||
t.Errorf("a disabled pass reported %d shore cells", st.ShoreCells)
|
||||
}
|
||||
for i := range f.Data {
|
||||
if f.Data[i] != before.Data[i] {
|
||||
t.Fatalf("a disabled pass moved cell %d from %g to %g", i, before.Data[i], f.Data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user