Files
UnrealPrototyping/Tools/Terrain/internal/uplift/belt_faults_test.go
T
2026-09-25 17:02:24 +03:00

241 lines
7.7 KiB
Go

package uplift
import (
"math"
"testing"
"salty/terrain/internal/plates"
"salty/terrain/internal/world"
)
// A straight north-south margin down the middle of a planet, closing head-on. Everything a belt fault is
// supposed to do is measurable against a line whose direction is known: the traces should run along it, sit
// near it, and face away from it.
func straightMargin(t *testing.T, p world.Planet) []plates.Boundary {
t.Helper()
const n = 200
xM := p.CircumferenceM() / 2
v := make([]plates.Vertex, n)
for i := range v {
v[i] = plates.Vertex{
XM: xM,
YM: p.HeightM() * float64(i) / float64(n-1),
NX: 1, // the margin runs north-south, so its normal points east
NY: 0,
ClosingMYr: 0.04,
Kind: plates.Collision,
Over: -1,
}
}
return []plates.Boundary{{A: 0, B: 1, V: v}}
}
func beltPlanet(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
}
func testBelt() plates.Belt {
b := plates.DefaultBelt()
b.ZoneKm = 3
b.Per1000Km2 = 400
none := 0.0
b.ConjugateFraction = &none // measured separately; the main set has to be parallel on its own
return b
}
func allLand(xM, yM float64) bool { return true }
func TestBeltFaultsRunAlongTheMargin(t *testing.T) {
p := beltPlanet(t)
fs := BuildBeltFaults(p, 7, testBelt(), straightMargin(t, p), allLand)
if len(fs) < 20 {
t.Fatalf("%d traces; not enough to measure anything", len(fs))
}
// The margin runs north-south, so every trace should too. Measured as the angle between the trace's own
// end-to-end direction and the line, folded into 0..90 because a fault has no head or tail.
worst, total := 0.0, 0.0
for _, f := range fs {
a, b := f.PointsM[0], f.PointsM[len(f.PointsM)-1]
deg := foldedAngleDeg(math.Atan2(b[1]-a[1], b[0]-a[0]), math.Pi/2)
total += deg
if deg > worst {
worst = deg
}
}
mean := total / float64(len(fs))
// The configured spread is 11 degrees, and the walk wanders on top of it. A mean much above that would
// mean the strike is not coming from the boundary at all, which is the defect this whole file exists for.
if mean > 20 {
t.Errorf("traces average %.1f degrees off the margin; they are not following it", mean)
}
if worst > 55 {
t.Errorf("a trace is %.1f degrees off the margin; nothing should be near perpendicular to it", worst)
}
}
// foldedAngleDeg is the angle between two directions, in degrees, folded into 0..90: a line at 170 degrees
// and one at 10 are twenty degrees apart, not a hundred and sixty.
func foldedAngleDeg(a, b float64) float64 {
d := math.Abs(a-b) * 180 / math.Pi
d = math.Mod(d, 180)
if d > 90 {
d = 180 - d
}
return d
}
func TestBeltFaultsStayInTheDeformationZone(t *testing.T) {
p := beltPlanet(t)
cfg := testBelt()
fs := BuildBeltFaults(p, 7, cfg, straightMargin(t, p), allLand)
if len(fs) == 0 {
t.Fatal("no traces")
}
xM := p.CircumferenceM() / 2
// The zone half-width here is the configured width times the collision multiplier times the rate scale.
// A *fault's* centre is placed inside it, but a trace's need not be: a fault over twelve kilometres is
// broken into en-echelon segments staggered up to 0.06 of its length across strike, which is the whole
// point of the stepping. So the bound on a segment centre is the zone plus that stagger, and the bound on
// any point of it is a further half-length beyond that.
half := cfg.ZoneKm * 1000 * beltWidth[plates.Collision] * math.Sqrt(0.04/(cfg.ReferenceCmYr/100))
longest := cfg.LengthKm[1] * 1000 * 2.2
centreBound := half + 0.06*longest
anyBound := centreBound + longest
far := 0
inZone := 0
for _, f := range fs {
mid := f.PointsM[len(f.PointsM)/2]
d := math.Abs(mid[0] - xM)
if d > centreBound {
far++
}
if d <= half {
inZone++
}
for _, pt := range f.PointsM {
if math.Abs(pt[0]-xM) > anyBound {
t.Fatalf("a trace reaches %.0f m from the margin; the zone, the stagger and a trace is %.0f m",
math.Abs(pt[0]-xM), anyBound)
}
}
}
if far > 0 {
t.Errorf("%d of %d trace centres sit outside the deformation zone and its en-echelon stagger",
far, len(fs))
}
// And they should be *concentrated* near the line rather than spread evenly across the zone: that is what
// beltFalloff is for, and what the reference map shows.
near := 0
for _, f := range fs {
if math.Abs(f.PointsM[len(f.PointsM)/2][0]-xM) < half/2 {
near++
}
}
if float64(near)/float64(inZone) < 0.55 {
t.Errorf("only %d of %d traces are in the inner half of the zone; the falloff is not biting",
near, inZone)
}
}
func TestBeltFaultsVergeAwayFromTheMargin(t *testing.T) {
p := beltPlanet(t)
cfg := testBelt()
// Short faults only. Vergence is decided per *fault*, from which side of the line it was placed on, and
// then every en-echelon segment of it inherits that - correctly, since the segments are one fault. Keeping
// every fault under enEchelonM means one trace per placement, so the side a trace sits on and the side it
// was placed on are the same thing and the property can be measured at all.
cfg.LengthKm = [2]float64{2, 4}
fs := BuildBeltFaults(p, 7, cfg, straightMargin(t, p), allLand)
if len(fs) < 20 {
t.Fatalf("%d traces; not enough to measure anything", len(fs))
}
xM := p.CircumferenceM() / 2
wrong := 0
for _, f := range fs {
mid := f.PointsM[len(f.PointsM)/2]
// A doubly-vergent belt faces outwards on both flanks, so the two sides must disagree about which
// block goes up. Which flank got which sign does not matter; that they are consistent within a flank
// does, because the alternative is the coin flip a class fault set has to use.
if (mid[0] > xM) != f.Reverse {
wrong++
}
}
if wrong != 0 && wrong != len(fs) {
t.Errorf("%d of %d traces disagree with their own flank about vergence; a belt is doubly vergent, "+
"not randomly vergent", min(wrong, len(fs)-wrong), len(fs))
}
}
func TestBeltFaultsNeedLand(t *testing.T) {
p := beltPlanet(t)
m := straightMargin(t, p)
if got := BuildBeltFaults(p, 7, testBelt(), m, func(xM, yM float64) bool { return false }); len(got) != 0 {
t.Errorf("%d traces on a planet with no land", len(got))
}
// A coast down one side of the margin: every trace must be mostly on the land side.
xM := p.CircumferenceM() / 2
half := func(x, y float64) bool { return x < xM }
fs := BuildBeltFaults(p, 7, testBelt(), m, half)
if len(fs) == 0 {
t.Fatal("no traces on a half-land planet")
}
for _, f := range fs {
on := 0
for _, pt := range f.PointsM {
if half(pt[0], pt[1]) {
on++
}
}
if share := float64(on) / float64(len(f.PointsM)); share < 0.3 {
t.Errorf("a trace is only %.0f%% on land; the span test should have refused it", share*100)
}
}
}
func TestABeltWithNoNumbersAsksForNothing(t *testing.T) {
p := beltPlanet(t)
if got := BuildBeltFaults(p, 7, plates.Belt{}, straightMargin(t, p), allLand); got != nil {
t.Errorf("%d traces from an empty config; leaving the block out must leave the feature off", len(got))
}
}
func TestAFasterMarginDeformsAWiderBelt(t *testing.T) {
p := beltPlanet(t)
cfg := testBelt()
spread := func(closing float64) float64 {
bs := straightMargin(t, p)
for i := range bs[0].V {
bs[0].V[i].ClosingMYr = closing
}
fs := BuildBeltFaults(p, 7, cfg, bs, allLand)
if len(fs) == 0 {
t.Fatalf("no traces at %.3g m/yr", closing)
}
xM := p.CircumferenceM() / 2
worst := 0.0
for _, f := range fs {
mid := f.PointsM[len(f.PointsM)/2]
if d := math.Abs(mid[0] - xM); d > worst {
worst = d
}
}
return worst
}
slow, fast := spread(0.01), spread(0.08)
if fast <= slow*1.4 {
t.Errorf("a margin closing eight times faster deforms a belt %.0f m wide against %.0f m; the zone is "+
"not scaling with the rate", fast, slow)
}
}