This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
@@ -0,0 +1,489 @@
package plates
import (
"math"
"sort"
"testing"
"salty/terrain/internal/world"
)
// testPlanet is a small cylinder with the same shape of arithmetic as a real one: a whole number of columns
// and a noise period that divides the circumference.
func testPlanet(t *testing.T) world.Planet {
t.Helper()
p, err := world.New(40000, 8, 100, 50, 0, 40000)
if err != nil {
t.Fatalf("planet: %v", err)
}
return p
}
// handModel builds a Model with a raster set by the caller, so a test can ask about a boundary in a known
// place. Nothing here goes through Build: the point is to control the partition rather than the seed.
func handModel(t *testing.T, p world.Planet, gw, gh int, cell []int16, ps []Plate) *Model {
t.Helper()
return &Model{
P: p, Cfg: Default(), Plates: ps,
GW: gw, GH: gh, GCellM: p.CircumferenceM() / float64(gw),
Cell: cell,
}
}
// stripes paints two vertical bands: plate 1 from column lo up to hi, plate 0 everywhere else.
//
// That is **two** contacts, not one, and it is worth saying why every test here is written in pairs. A
// cylinder cut into two strips has a margin at each end of each strip, and under a pure translation the
// plates are closing at one of them and opening at the other by exactly the same amount. There is no way to
// arrange two plates on a cylinder that only collide. The invariant is the test.
func stripes(gw, gh, lo, hi int) []int16 {
cell := make([]int16, gw*gh)
for gy := range gh {
for gx := range gw {
if gx >= lo && gx < hi {
cell[gy*gw+gx] = 1
}
}
}
return cell
}
// rollX moves every column east by n, wrapping. Rolling the map is the whole seam test: the cylinder has no
// preferred meridian, so a partition and the same partition rolled must produce the same tectonics.
func rollX(cell []int16, gw, gh, n int) []int16 {
out := make([]int16, len(cell))
for gy := range gh {
for gx := range gw {
out[gy*gw+((gx+n)%gw)] = cell[gy*gw+gx]
}
}
return out
}
// closingPair drives plate 0 east and plate 1 west, with no spin.
func closingPair() []Plate {
return []Plate{
{ID: 0, TransXM: 0.02, Continental: true, AreaCells: 1},
{ID: 1, TransXM: -0.02, Continental: true, AreaCells: 1},
}
}
// meanClosing is a boundary's average closing rate, in metres a year.
func meanClosing(b Boundary) float64 {
if len(b.V) == 0 {
return 0
}
total := 0.0
for _, v := range b.V {
total += v.ClosingMYr
}
return total / float64(len(b.V))
}
// sortedMeans is every boundary's mean closing rate, in order: the signature of a whole planet's tectonics,
// independent of which order the boundaries happened to be found in.
func sortedMeans(bs []Boundary) []float64 {
out := make([]float64, len(bs))
for i, b := range bs {
out[i] = meanClosing(b)
}
sort.Float64s(out)
return out
}
func kinds(bs []Boundary) []string {
out := make([]string, len(bs))
for i, b := range bs {
out[i] = b.Dominant().String()
}
sort.Strings(out)
return out
}
func TestABoundaryAcrossTheSeamIsOneBoundary(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
m := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair())
bs := m.buildBoundaries()
if len(bs) != 2 {
t.Fatalf("two vertical contacts on a cylinder, got %d boundaries", len(bs))
}
// Roll the partition so one contact sits exactly on the meridian. If the seam were special the boundary
// through it would come back cut in half - two chains of half the length - or with a whole circumference
// of jump in the middle of it.
rolled := handModel(t, p, gw, gh, rollX(m.Cell, gw, gh, 50), closingPair())
rbs := rolled.buildBoundaries()
if len(rbs) != 2 {
t.Fatalf("after rolling the map onto the seam: %d boundaries, want 2", len(rbs))
}
for _, b := range rbs {
for i := 0; i+1 < len(b.V); i++ {
d := math.Hypot(b.V[i+1].XM-b.V[i].XM, b.V[i+1].YM-b.V[i].YM)
if d > maxGapCells*m.GCellM*1.01 {
t.Fatalf("a %.0f m step between neighbouring vertices: X was wrapped, not unwrapped", d)
}
}
if got, want := b.LengthM(), bs[0].LengthM(); math.Abs(got-want) > m.GCellM {
t.Errorf("rolled boundary is %.0f m, unrolled %.0f m", got, want)
}
}
// The tectonics have to be the same planet, not just the same shape: the rolled map's margins close at
// the same rates as the unrolled map's.
before, after := sortedMeans(bs), sortedMeans(rbs)
for i := range before {
if math.Abs(before[i]-after[i]) > 1e-9 {
t.Errorf("closing rate %d is %.6g before the roll and %.6g after", i, before[i], after[i])
}
}
circ := p.CircumferenceM()
crossed := false
for _, b := range rbs {
for _, v := range b.V {
if v.XM < 0 || v.XM >= circ {
crossed = true
}
}
}
if !crossed {
t.Error("no vertex outside 0..circumference, so nothing was unwrapped and the roll tested nothing")
}
}
func TestClosingIsTheSameWhicheverPlateIsCalledA(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
forward := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair())
// Swap which stripe belongs to which plate, and swap the motions with it. Physically nothing has moved:
// the same two materials are being driven together at the same margin. Every closing rate must therefore
// come back identical, because the normal flips and the relative velocity flips with it.
swapped := stripes(gw, gh, 50, 150)
for i := range swapped {
swapped[i] = 1 - swapped[i]
}
reverse := handModel(t, p, gw, gh, swapped, []Plate{
{ID: 0, TransXM: -0.02, Continental: true, AreaCells: 1},
{ID: 1, TransXM: 0.02, Continental: true, AreaCells: 1},
})
fm, rm := sortedMeans(forward.buildBoundaries()), sortedMeans(reverse.buildBoundaries())
if len(fm) != len(rm) {
t.Fatalf("%d boundaries one way round and %d the other", len(fm), len(rm))
}
for i := range fm {
if math.Abs(fm[i]-rm[i]) > 1e-9 {
t.Errorf("closing rate %d is %.6g one way round and %.6g the other; the sign convention is not "+
"symmetric", i, fm[i], rm[i])
}
}
}
func TestOneMarginClosesAndTheOtherOpens(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
bs := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair()).buildBoundaries()
if len(bs) != 2 {
t.Fatalf("got %d boundaries, want 2", len(bs))
}
means := sortedMeans(bs)
if means[0] >= 0 || means[1] <= 0 {
t.Fatalf("closing rates %.4g and %.4g; two strips on a cylinder give one of each", means[0], means[1])
}
// Equal and opposite, because a pure translation is the same relative velocity at both margins and the
// only thing that differs is which way the normal points.
if math.Abs(means[0]+means[1]) > 1e-9 {
t.Errorf("closing rates %.6g and %.6g are not equal and opposite", means[0], means[1])
}
if got := math.Abs(means[1]); math.Abs(got-0.04) > 1e-9 {
t.Errorf("plates at 2 cm/yr each close at %.4g m/yr; 0.04 is the sum of the two speeds", got)
}
}
func TestContinentsCollideAndOceansSubduct(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
cell := stripes(gw, gh, 50, 150)
both := handModel(t, p, gw, gh, cell, closingPair()).buildBoundaries()
if got, want := kinds(both), []string{"collision", "rift"}; !sameStrings(got, want) {
t.Errorf("two continental plates give %v, want %v", got, want)
}
// The same geometry with one side oceanic: the closing margin is a subduction zone and the arc belongs
// to the continent, because continental crust is too buoyant to go down.
ps := closingPair()
ps[1].Continental = false
oceanic := handModel(t, p, gw, gh, cell, ps).buildBoundaries()
if got, want := kinds(oceanic), []string{"ridge", "subduction"}; !sameStrings(got, want) {
t.Errorf("continent against ocean gives %v, want %v", got, want)
}
for _, b := range oceanic {
for _, v := range b.V {
if v.Kind == Subduction && v.Over != 0 {
t.Fatalf("the overriding plate is %d, but plate 1 is the oceanic one", v.Over)
}
if v.Kind != Subduction && v.Over != -1 {
t.Fatalf("a %q vertex carries an overriding plate", v.Kind)
}
}
}
}
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestMotionAlongTheLineIsATransform(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
// Vertical contacts, both plates sliding north and south: the relative motion is entirely along the line,
// so neither margin closes or opens.
m := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), []Plate{
{ID: 0, TransYM: 0.02, Continental: true, AreaCells: 1},
{ID: 1, TransYM: -0.02, Continental: true, AreaCells: 1},
})
for _, b := range m.buildBoundaries() {
if got := b.Dominant(); got != Transform {
t.Errorf("plates sliding past each other give %q, want %q", got, Transform)
}
if got := math.Abs(meanClosing(b)); got > 1e-9 {
t.Errorf("a transform margin closes at %.3g m/yr", got)
}
}
}
func TestKindOf(t *testing.T) {
oblique := 60 * math.Pi / 180
cases := []struct {
name string
closing, slip float64
bothContinental bool
want Kind
}{
{"head-on continental", 1, 0, true, Collision},
{"head-on with an ocean", 1, 0, false, Subduction},
{"opening continental", -1, 0, true, Rift},
{"opening with an ocean", -1, 0, false, Ridge},
{"pure slip", 0, 1, true, Transform},
{"oblique but still closing", 1, 1.5, true, Collision},
{"slip has taken over", 1, 2, true, Transform},
}
for _, c := range cases {
if got := kindOf(c.closing, c.slip, oblique, c.bothContinental); got != c.want {
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
}
}
}
func TestSpinMakesAMarginChangeAlongItsLength(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
cell := stripes(gw, gh, 50, 150)
// Pure translation: the relative velocity is the same everywhere, so a straight margin closes at one rate
// from end to end. That uniformity is exactly what the in-plane pole exists to break.
flat := handModel(t, p, gw, gh, cell, closingPair()).buildBoundaries()
if spread := worstSpread(flat); spread > 1e-9 {
t.Errorf("without spin a margin varies by %.3g m/yr along its own length; it should not", spread)
}
// The same plates with one rotating about a pole off to the side close at one end and slide at the other.
spun := closingPair()
spun[0].PoleXM, spun[0].PoleYM = 0, 0
spun[0].OmegaRadYr = 2e-6
spinning := handModel(t, p, gw, gh, cell, spun).buildBoundaries()
if spread := worstSpread(spinning); spread < 1e-3 {
t.Errorf("with spin a margin varies by only %.3g m/yr; the rotation is not reaching the boundary",
spread)
}
}
// worstSpread is the largest range of closing rates found *within* a single boundary. Within, not across:
// two margins of the same pair legitimately differ, and measuring across them would report that difference
// as variation along a line.
func worstSpread(bs []Boundary) float64 {
worst := 0.0
for _, b := range bs {
lo, hi := math.Inf(1), math.Inf(-1)
for _, v := range b.V {
lo = math.Min(lo, v.ClosingMYr)
hi = math.Max(hi, v.ClosingMYr)
}
if !math.IsInf(lo, 1) && hi-lo > worst {
worst = hi - lo
}
}
return worst
}
func TestVelocityIsContinuousAcrossTheSeam(t *testing.T) {
p := testPlanet(t)
circ := p.CircumferenceM()
// A plate whose pole sits just east of the meridian. Measured without wrapping, the lever arm a metre
// west of the seam would be a whole circumference long, and the plate would spin the wrong way there.
pl := Plate{ID: 0, PoleXM: 10, PoleYM: 0, OmegaRadYr: 1e-6, TransXM: 0.01}
ax, ay := pl.VelocityAt(p, circ-1, 0)
bx, by := pl.VelocityAt(p, 1, 0)
acrossSeam := math.Hypot(ax-bx, ay-by)
// The same two-metre gap in open map, away from the meridian: the seam must cost nothing extra.
cx, cy := pl.VelocityAt(p, circ/2-1, 0)
dx, dy := pl.VelocityAt(p, circ/2+1, 0)
elsewhere := math.Hypot(cx-dx, cy-dy)
if math.Abs(acrossSeam-elsewhere) > 1e-12 {
t.Errorf("velocity changes by %.3g m/yr over two metres at the meridian and %.3g m/yr over two "+
"metres anywhere else", acrossSeam, elsewhere)
}
// And the failure this guards against is enormous, not subtle: an unwrapped lever arm would be a whole
// circumference and give a jump of omega*circ.
if acrossSeam > pl.OmegaRadYr*circ/100 {
t.Errorf("velocity jumps by %.3g m/yr at the meridian; the lever arm was not wrapped", acrossSeam)
}
}
func TestBuildCoversThePlanetAndReadsTheLandMask(t *testing.T) {
p := testPlanet(t)
cfg := Default()
cfg.Count = 6
allSea, err := Build(p, 7, cfg, func(xM, yM float64) bool { return false })
if err != nil {
t.Fatalf("build: %v", err)
}
total := 0
for _, pl := range allSea.Plates {
total += pl.AreaCells
if pl.Continental {
t.Errorf("plate %d is continental on a planet with no land", pl.ID)
}
}
if total != allSea.GW*allSea.GH {
t.Errorf("plates cover %d cells of %d; the partition has holes", total, allSea.GW*allSea.GH)
}
if len(allSea.Boundaries) == 0 {
t.Fatal("six plates and no boundaries between them")
}
for _, b := range allSea.Boundaries {
if b.A >= b.B {
t.Errorf("boundary pair (%d, %d) is not ordered", b.A, b.B)
}
for _, v := range b.V {
if v.Kind == Collision || v.Kind == Rift {
t.Errorf("a %q on a planet with no continental plate at all", v.Kind)
}
}
}
allLand, err := Build(p, 7, cfg, func(xM, yM float64) bool { return true })
if err != nil {
t.Fatalf("build: %v", err)
}
for _, pl := range allLand.Plates {
if !pl.Continental {
t.Errorf("plate %d is oceanic on a planet that is all land", pl.ID)
}
}
for _, b := range allLand.Boundaries {
for _, v := range b.V {
if v.Kind == Subduction || v.Kind == Ridge {
t.Errorf("a %q with no oceanic plate to make it", v.Kind)
}
}
}
}
func TestTheSameSeedGivesTheSamePlanet(t *testing.T) {
p := testPlanet(t)
cfg := Default()
land := func(xM, yM float64) bool { return yM > 5000 && yM < 12000 }
a, err := Build(p, 9342, cfg, land)
if err != nil {
t.Fatalf("build: %v", err)
}
b, err := Build(p, 9342, cfg, land)
if err != nil {
t.Fatalf("build: %v", err)
}
if len(a.Boundaries) != len(b.Boundaries) {
t.Fatalf("%d boundaries then %d; the set is not deterministic", len(a.Boundaries), len(b.Boundaries))
}
for i := range a.Boundaries {
if a.Boundaries[i].A != b.Boundaries[i].A || a.Boundaries[i].B != b.Boundaries[i].B {
t.Fatalf("boundary %d is a different pair on the second run", i)
}
if math.Abs(a.Boundaries[i].LengthM()-b.Boundaries[i].LengthM()) > 1e-9 {
t.Fatalf("boundary %d is a different length on the second run", i)
}
}
}
func TestTheWarpBendsMarginsAndTooMuchOfItBreaksThem(t *testing.T) {
p := testPlanet(t)
// Sinuosity - the line's own length over the distance between its ends - is what "a margin is not a
// ruled line" means as a number. A weighted Voronoi edge is a circular arc even at no warp, so the
// baseline is a little over 1 rather than exactly 1.
measure := func(warp float64) (sinuosity float64, boundaries int) {
cfg := Default()
cfg.Count = 7
cfg.WarpFraction = warp
m, err := Build(p, 3630, cfg, func(xM, yM float64) bool { return false })
if err != nil {
t.Fatalf("warp %.2f: %v", warp, err)
}
total, n := 0.0, 0
for _, b := range m.Boundaries {
if len(b.V) < 10 {
continue
}
last := b.V[len(b.V)-1]
if straight := math.Hypot(last.XM-b.V[0].XM, last.YM-b.V[0].YM); straight > 0 {
total += b.LengthM() / straight
n++
}
}
if n == 0 {
return 0, len(m.Boundaries)
}
return total / float64(n), len(m.Boundaries)
}
straight, straightCount := measure(0)
warped, warpedCount := measure(Default().WarpFraction)
if straight > 1.05 {
t.Errorf("an unwarped partition already has a sinuosity of %.3f; it should be close to a polygon net",
straight)
}
if warped <= straight*1.03 {
t.Errorf("the warp takes sinuosity from %.3f to %.3f, which is no bend at all", straight, warped)
}
// Past about half a plate spacing the displacement folds back on itself and the partition grows islands
// of one plate inside another, which the tracer faithfully chains into extra rings. The count is the
// symptom, and this is the bound warpFineGain and the default are set under.
_, tooMuch := measure(0.5)
if tooMuch <= warpedCount {
t.Skipf("no fragmentation at warp 0.5 on this seed (%d boundaries against %d); the bound still holds "+
"but this seed does not show it", tooMuch, warpedCount)
}
if warpedCount != straightCount {
t.Errorf("the default warp changed the boundary count from %d to %d; it should bend margins, not "+
"create them", straightCount, warpedCount)
}
}