Tooling
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package plates
|
||||
|
||||
import "math"
|
||||
|
||||
// Belt is how faulted a margin's surroundings are: the deformation zone around a boundary, and how densely
|
||||
// it is broken.
|
||||
//
|
||||
// It lives beside the plates rather than beside the faults because it describes a *boundary*, not a fault.
|
||||
// How wide the ground is that a margin deforms is a property of what that margin is doing - a continental
|
||||
// collision takes up its convergence across a belt a thousand kilometres wide and a mid-ocean ridge across an
|
||||
// axis a few tens wide - and the traces are a consequence of that width, not the other way round. What
|
||||
// internal/uplift's belt_faults.go does with these numbers, and the fault map they were read off, is
|
||||
// documented there.
|
||||
type Belt struct {
|
||||
// ZoneKm is the deformation half-width of a *collision* margin closing at ReferenceCmYr, in kilometres.
|
||||
// Every other kind of margin is a fraction of it, and every margin scales with its own rate.
|
||||
ZoneKm float64 `json:"zone_km"`
|
||||
|
||||
// ReferenceCmYr is the rate ZoneKm is quoted at. Earth's big collisions run 2 to 5 cm/yr.
|
||||
ReferenceCmYr float64 `json:"reference_cm_yr"`
|
||||
|
||||
// Per1000Km2 is the trace density over the *zone*, not over the planet: a belt is as faulted as a belt is
|
||||
// wherever it happens to run, and the ground away from one is not lightly faulted, it is unfaulted.
|
||||
Per1000Km2 float64 `json:"per_1000km2"`
|
||||
|
||||
// ThrowM is the total displacement over the whole run, low to high, before the closing rate scales it -
|
||||
// the height of the scarp the fault would build if nothing eroded it.
|
||||
ThrowM [2]float64 `json:"throw_m"`
|
||||
|
||||
// LengthKm is how long a trace is, low to high, before the local zone width scales it. A wide belt
|
||||
// carries long faults and a narrow one cannot.
|
||||
LengthKm [2]float64 `json:"length_km"`
|
||||
|
||||
// StrikeSpreadDeg is how far a trace may wander off the belt's local tangent. Small on purpose: a swarm
|
||||
// being sub-parallel is the thing that makes it read as a swarm.
|
||||
//
|
||||
// ConjugateFraction is the share of traces drawn on the second, crossing direction, and ConjugateDeg is
|
||||
// the angle between the two sets. One direction alone reads as corduroy.
|
||||
//
|
||||
// The first two are pointers for the reason Config.SpinFraction is: JSON cannot tell an absent number
|
||||
// from a zero one, and both of these have a real meaning at zero - perfectly parallel traces, and no
|
||||
// second set. Read as the same thing, a block that simply did not mention them silently turned them off,
|
||||
// which is how the first painted planet came out with no conjugate set at all. Absent takes the default;
|
||||
// an explicit 0 means none.
|
||||
StrikeSpreadDeg *float64 `json:"strike_spread_deg"`
|
||||
ConjugateFraction *float64 `json:"conjugate_fraction"`
|
||||
ConjugateDeg float64 `json:"conjugate_deg"`
|
||||
}
|
||||
|
||||
// DefaultStrikeSpreadDeg and DefaultConjugateFraction are what a belt that does not mention them gets.
|
||||
const (
|
||||
DefaultStrikeSpreadDeg = 11.0
|
||||
DefaultConjugateFraction = 0.22
|
||||
)
|
||||
|
||||
// Spread is the configured strike spread, or the default when the block said nothing.
|
||||
func (b Belt) Spread() float64 {
|
||||
if b.StrikeSpreadDeg == nil {
|
||||
return DefaultStrikeSpreadDeg
|
||||
}
|
||||
return math.Max(0, *b.StrikeSpreadDeg)
|
||||
}
|
||||
|
||||
// Conjugate is the configured share of crossing traces, or the default when the block said nothing.
|
||||
func (b Belt) Conjugate() float64 {
|
||||
if b.ConjugateFraction == nil {
|
||||
return DefaultConjugateFraction
|
||||
}
|
||||
return math.Min(1, math.Max(0, *b.ConjugateFraction))
|
||||
}
|
||||
|
||||
// DefaultBelt is what a planet that asks for belt faults but says nothing else gets.
|
||||
func DefaultBelt() Belt {
|
||||
return Belt{
|
||||
ZoneKm: 6,
|
||||
ReferenceCmYr: 4,
|
||||
Per1000Km2: 90,
|
||||
ThrowM: [2]float64{80, 420},
|
||||
LengthKm: [2]float64{4, 16},
|
||||
ConjugateDeg: 32,
|
||||
// StrikeSpreadDeg and ConjugateFraction stay nil: their defaults live in Spread and Conjugate, so
|
||||
// that an explicit zero can mean none.
|
||||
}
|
||||
}
|
||||
|
||||
// Wanted reports whether this asks for anything. A zero Belt is a planet whose margins are not faulted, which
|
||||
// is what every painted planet had before this existed.
|
||||
func (b Belt) Wanted() bool {
|
||||
return b.ZoneKm > 0 && b.Per1000Km2 > 0 && b.LengthKm[1] > 0 && b.ThrowM[1] > 0
|
||||
}
|
||||
|
||||
// WithDefaults fills in the fields that have a sensible value when left out. ZoneKm, Per1000Km2, ThrowM and
|
||||
// LengthKm are deliberately not among them: those four are the feature, and defaulting them would turn
|
||||
// leaving the block out into switching the feature on.
|
||||
func (b Belt) WithDefaults() Belt {
|
||||
d := DefaultBelt()
|
||||
if b.ReferenceCmYr <= 0 {
|
||||
b.ReferenceCmYr = d.ReferenceCmYr
|
||||
}
|
||||
if b.ConjugateDeg <= 0 {
|
||||
b.ConjugateDeg = d.ConjugateDeg
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package plates
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// What a boundary does, which is the whole point of the package: "two plates hit each other" is one of these
|
||||
// five and the other four are what happens when they do something else.
|
||||
//
|
||||
// The kind is per *vertex*, not per boundary. A margin whose plates are rotating as well as translating
|
||||
// closes at one end and slides at the other - that is why the pole is in the map plane at all - so a single
|
||||
// label for the whole line would throw away the thing the model was built to produce.
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
// Transform: the relative motion is along the line rather than across it. Little uplift, a strike-slip
|
||||
// fault, and a restraining bend that pops a range up where the line curves into the motion.
|
||||
Transform Kind = iota
|
||||
|
||||
// Collision: convergent, both sides continental. Neither can subduct, so the crust thickens and the
|
||||
// result is a wide doubly-vergent belt - the thing an author means when they paint a mountain range.
|
||||
Collision
|
||||
|
||||
// Subduction: convergent with at least one oceanic side. The ocean floor goes under, and the uplift is
|
||||
// an arc on the *overriding* plate, set back from the trench rather than centred on the line.
|
||||
Subduction
|
||||
|
||||
// Rift: divergent, both sides continental. The axis drops and the shoulders stand up - the East African
|
||||
// pattern, and the one kind of boundary that lowers ground rather than raising it.
|
||||
Rift
|
||||
|
||||
// Ridge: divergent with an oceanic side. A bathymetric ridge under water; on land it is a rift that has
|
||||
// already opened.
|
||||
Ridge
|
||||
)
|
||||
|
||||
func (k Kind) String() string {
|
||||
switch k {
|
||||
case Collision:
|
||||
return "collision"
|
||||
case Subduction:
|
||||
return "subduction"
|
||||
case Rift:
|
||||
return "rift"
|
||||
case Ridge:
|
||||
return "ridge"
|
||||
default:
|
||||
return "transform"
|
||||
}
|
||||
}
|
||||
|
||||
// Convergent reports whether this kind is two plates closing on each other.
|
||||
func (k Kind) Convergent() bool { return k == Collision || k == Subduction }
|
||||
|
||||
// Divergent reports whether this kind is two plates separating.
|
||||
func (k Kind) Divergent() bool { return k == Rift || k == Ridge }
|
||||
|
||||
// Vertex is one point on a boundary and everything a later pass reads off it.
|
||||
type Vertex struct {
|
||||
XM float64 `json:"x_m"`
|
||||
YM float64 `json:"y_m"`
|
||||
|
||||
// NX, NY is the unit normal, pointing out of plate A and into plate B. Every sign in this package is
|
||||
// measured against it, so "which side goes up" has one definition rather than one per consumer.
|
||||
NX float64 `json:"nx"`
|
||||
NY float64 `json:"ny"`
|
||||
|
||||
// ClosingMYr is the relative velocity's component along the normal, in metres a year: positive closing,
|
||||
// negative opening. This is the number an uplift rate is a function of - "when two plates hit each other
|
||||
// they create mountains" is this field and nothing else.
|
||||
ClosingMYr float64 `json:"closing_m_yr"`
|
||||
|
||||
// SlipMYr is the component along the line, signed in the polyline's own direction.
|
||||
SlipMYr float64 `json:"slip_m_yr"`
|
||||
|
||||
Kind Kind `json:"kind"`
|
||||
|
||||
// Over is the overriding plate at a subduction margin - the side the arc is built on - and -1 anywhere
|
||||
// else.
|
||||
Over int `json:"over"`
|
||||
}
|
||||
|
||||
// Boundary is one continuous stretch of contact between two plates.
|
||||
//
|
||||
// X is **unwrapped**, exactly as uplift.FaultTrace is and for exactly the same reason: a boundary that
|
||||
// crosses the seam has X running past the circumference or below zero rather than jumping, so every segment
|
||||
// is a straight line between neighbouring points and no consumer has to special-case the meridian.
|
||||
type Boundary struct {
|
||||
A int `json:"a"`
|
||||
B int `json:"b"`
|
||||
|
||||
V []Vertex `json:"vertices"`
|
||||
}
|
||||
|
||||
// LengthM is how long the boundary is, following the line.
|
||||
func (b Boundary) LengthM() float64 {
|
||||
total := 0.0
|
||||
for i := 0; i+1 < len(b.V); i++ {
|
||||
total += math.Hypot(b.V[i+1].XM-b.V[i].XM, b.V[i+1].YM-b.V[i].YM)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// Dominant is the kind most of this boundary's length is, which is the one word to print for it.
|
||||
func (b Boundary) Dominant() Kind {
|
||||
var byKind [5]float64
|
||||
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)
|
||||
byKind[b.V[i].Kind] += d
|
||||
}
|
||||
best, bestK := -1.0, Transform
|
||||
for k, d := range byKind {
|
||||
if d > best {
|
||||
best, bestK = d, Kind(k)
|
||||
}
|
||||
}
|
||||
return bestK
|
||||
}
|
||||
|
||||
// LengthByKind totals the planet's boundary length in each kind, in metres: the summary a run prints and the
|
||||
// one number that says whether a seed produced a world with mountains in it.
|
||||
func LengthByKind(bs []Boundary) [5]float64 {
|
||||
var out [5]float64
|
||||
for _, b := range bs {
|
||||
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)
|
||||
out[b.V[i].Kind] += d
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sample is one crossing of the boundary on the tectonic grid: the midpoint of two adjacent cells that
|
||||
// belong to different plates.
|
||||
type sample struct {
|
||||
xM, yM float64
|
||||
// dx, dy is the step from the plate-A cell towards the plate-B cell, which is what fixes the normal's
|
||||
// sign once the chain has a tangent to make it perpendicular to.
|
||||
dx, dy float64
|
||||
a, b int
|
||||
}
|
||||
|
||||
// minChainSamples is how short a chain is allowed to be before it is dropped. Triple junctions leave stubs
|
||||
// of two or three cells that are a corner of the partition rather than a margin, and a stub cannot be given
|
||||
// a meaningful tangent.
|
||||
const minChainSamples = 6
|
||||
|
||||
// maxGapCells is how far apart two samples may be and still be the same line. Along a straight run they are
|
||||
// one cell apart and on a staircase 0.71, so 1.6 chains both without reaching a parallel strand.
|
||||
const maxGapCells = 1.6
|
||||
|
||||
// smoothPasses is how many times the chained polyline is averaged with its own neighbours.
|
||||
//
|
||||
// It is not cosmetic. A chain straight off the grid is a staircase, so its tangent alternates between two
|
||||
// axis-aligned directions from vertex to vertex - and since the normal is the tangent's perpendicular and
|
||||
// every classification is a dot product with the normal, an unsmoothed margin flickers between convergent
|
||||
// and transform along its whole length. Two passes of a three-tap average cost a fraction of a grid cell in
|
||||
// position and give a tangent that means something.
|
||||
const smoothPasses = 2
|
||||
|
||||
// buildBoundaries finds every stretch of contact between two plates and says what each one is doing.
|
||||
func (m *Model) buildBoundaries() []Boundary {
|
||||
groups := m.collect()
|
||||
|
||||
// Sorted by pair, so the set is in the same order on every run: a planet's tectonics must not depend on
|
||||
// Go's map iteration order, or two runs of the same seed would write different meta.json files.
|
||||
keys := make([][2]int, 0, len(groups))
|
||||
for k := range groups {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i][0] != keys[j][0] {
|
||||
return keys[i][0] < keys[j][0]
|
||||
}
|
||||
return keys[i][1] < keys[j][1]
|
||||
})
|
||||
|
||||
circ := m.P.CircumferenceM()
|
||||
maxGap := maxGapCells * m.GCellM
|
||||
|
||||
var out []Boundary
|
||||
for _, k := range keys {
|
||||
for _, chain := range chainSamples(groups[k], circ, maxGap) {
|
||||
b := m.classify(k[0], k[1], chain, circ)
|
||||
if len(b.V) >= minChainSamples {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collect walks the tectonic grid once and records every cell edge whose two sides belong to different
|
||||
// plates.
|
||||
//
|
||||
// East and south only. Testing all four neighbours would record each edge twice, and a chain built from
|
||||
// duplicated points walks on the spot.
|
||||
func (m *Model) collect() map[[2]int][]sample {
|
||||
half := m.GCellM / 2
|
||||
out := make(map[[2]int][]sample)
|
||||
add := func(a, b int, xM, yM, dx, dy float64) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
key := [2]int{a, b}
|
||||
if a > b {
|
||||
key = [2]int{b, a}
|
||||
dx, dy = -dx, -dy
|
||||
}
|
||||
out[key] = append(out[key], sample{xM: xM, yM: yM, dx: dx, dy: dy, a: key[0], b: key[1]})
|
||||
}
|
||||
|
||||
for gy := 0; gy < m.GH; gy++ {
|
||||
row := gy * m.GW
|
||||
for gx := 0; gx < m.GW; gx++ {
|
||||
here := int(m.Cell[row+gx])
|
||||
east := int(m.Cell[m.GridIdx(gx+1, gy)])
|
||||
add(here, east, m.GridXM(gx)+half, m.GridYM(gy), 1, 0)
|
||||
if gy+1 < m.GH {
|
||||
south := int(m.Cell[m.GridIdx(gx, gy+1)])
|
||||
add(here, south, m.GridXM(gx), m.GridYM(gy)+half, 0, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chainSamples orders a pair's scattered crossings into one or more polylines.
|
||||
//
|
||||
// A greedy nearest-unused walk rather than a proper contour tracer. The set it is given is one cell wide by
|
||||
// construction, so the nearest unused neighbour is the next point along the line in every case except a
|
||||
// triple junction, where the walk takes one branch and the other becomes a chain of its own - which is the
|
||||
// right answer, because two plates meeting a third meet it on two different margins.
|
||||
//
|
||||
// O(n squared) on purpose. n is a few hundred, because the tectonic grid is a quarter of a kilometre and a
|
||||
// boundary is a few tens of kilometres; a spatial index here would be more code than the thing it indexes.
|
||||
func chainSamples(ss []sample, circ, maxGap float64) [][]sample {
|
||||
used := make([]bool, len(ss))
|
||||
var out [][]sample
|
||||
for {
|
||||
seed := pickEnd(ss, used, circ, maxGap)
|
||||
if seed < 0 {
|
||||
break
|
||||
}
|
||||
used[seed] = true
|
||||
fwd := walk(ss, used, seed, circ, maxGap)
|
||||
back := walk(ss, used, seed, circ, maxGap)
|
||||
|
||||
chain := make([]sample, 0, len(fwd)+len(back)+1)
|
||||
for i := len(back) - 1; i >= 0; i-- {
|
||||
chain = append(chain, ss[back[i]])
|
||||
}
|
||||
chain = append(chain, ss[seed])
|
||||
for _, i := range fwd {
|
||||
chain = append(chain, ss[i])
|
||||
}
|
||||
if len(chain) >= minChainSamples {
|
||||
out = append(out, chain)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pickEnd chooses where to start a chain: a sample with at most one unused neighbour, which is an end of the
|
||||
// line. Starting in the middle would give two half-chains walked in opposite directions and joined at a
|
||||
// point, which is the same line with a kink in the tangent at its centre.
|
||||
func pickEnd(ss []sample, used []bool, circ, maxGap float64) int {
|
||||
best, bestDeg := -1, 1<<30
|
||||
for i := range ss {
|
||||
if used[i] {
|
||||
continue
|
||||
}
|
||||
deg := 0
|
||||
for j := range ss {
|
||||
if i == j || used[j] {
|
||||
continue
|
||||
}
|
||||
if dist(ss[i], ss[j], circ) <= maxGap {
|
||||
deg++
|
||||
}
|
||||
}
|
||||
if deg <= 1 {
|
||||
return i
|
||||
}
|
||||
if deg < bestDeg {
|
||||
best, bestDeg = i, deg
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// walk steps from a sample to its nearest unused neighbour until there is none in reach.
|
||||
func walk(ss []sample, used []bool, from int, circ, maxGap float64) []int {
|
||||
var out []int
|
||||
cur := from
|
||||
for {
|
||||
best, bestD := -1, maxGap
|
||||
for j := range ss {
|
||||
if used[j] {
|
||||
continue
|
||||
}
|
||||
if d := dist(ss[cur], ss[j], circ); d <= bestD {
|
||||
best, bestD = j, d
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return out
|
||||
}
|
||||
used[best] = true
|
||||
out = append(out, best)
|
||||
cur = best
|
||||
}
|
||||
}
|
||||
|
||||
func dist(a, b sample, circ float64) float64 {
|
||||
return math.Hypot(wrapDelta(a.xM-b.xM, circ), a.yM-b.yM)
|
||||
}
|
||||
|
||||
// classify turns a chain of crossings into a boundary: unwrapped, smoothed, and with the relative motion
|
||||
// resolved into a closing rate and a slip rate at every vertex.
|
||||
func (m *Model) classify(a, b int, chain []sample, circ float64) Boundary {
|
||||
xs := make([]float64, len(chain))
|
||||
ys := make([]float64, len(chain))
|
||||
xs[0], ys[0] = chain[0].xM, chain[0].yM
|
||||
// Unwrap as the chain is copied: each point is put within half a circumference of the one before it, so
|
||||
// a margin crossing the seam comes out as a straight run of increasing X rather than a jump.
|
||||
for i := 1; i < len(chain); i++ {
|
||||
xs[i] = xs[i-1] + wrapDelta(chain[i].xM-xs[i-1], circ)
|
||||
ys[i] = chain[i].yM
|
||||
}
|
||||
smooth(xs, ys)
|
||||
|
||||
obliqueRad := m.Cfg.ObliqueDeg * math.Pi / 180
|
||||
over := m.overriding(a, b)
|
||||
|
||||
out := Boundary{A: a, B: b, V: make([]Vertex, len(chain))}
|
||||
for i := range chain {
|
||||
tx, ty := tangent(xs, ys, i)
|
||||
// The normal is the tangent's perpendicular, and the crossing itself says which of the two
|
||||
// perpendiculars points into plate B.
|
||||
nx, ny := -ty, tx
|
||||
if nx*chain[i].dx+ny*chain[i].dy < 0 {
|
||||
nx, ny = ty, -tx
|
||||
}
|
||||
|
||||
vax, vay := m.Plates[a].VelocityAt(m.P, xs[i], ys[i])
|
||||
vbx, vby := m.Plates[b].VelocityAt(m.P, xs[i], ys[i])
|
||||
rx, ry := vax-vbx, vay-vby
|
||||
|
||||
closing := rx*nx + ry*ny
|
||||
slip := rx*tx + ry*ty
|
||||
|
||||
out.V[i] = Vertex{
|
||||
XM: xs[i], YM: ys[i], NX: nx, NY: ny,
|
||||
ClosingMYr: closing, SlipMYr: slip,
|
||||
Kind: kindOf(closing, slip, obliqueRad,
|
||||
m.Plates[a].Continental && m.Plates[b].Continental),
|
||||
Over: -1,
|
||||
}
|
||||
if out.V[i].Kind == Subduction {
|
||||
out.V[i].Over = over
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// kindOf is the classification itself, and it is one comparison: is the relative motion more across the line
|
||||
// or more along it, and if across, which way.
|
||||
func kindOf(closing, slip, obliqueRad float64, bothContinental bool) Kind {
|
||||
if math.Atan2(math.Abs(slip), math.Abs(closing)) > obliqueRad {
|
||||
return Transform
|
||||
}
|
||||
if closing > 0 {
|
||||
if bothContinental {
|
||||
return Collision
|
||||
}
|
||||
return Subduction
|
||||
}
|
||||
if bothContinental {
|
||||
return Rift
|
||||
}
|
||||
return Ridge
|
||||
}
|
||||
|
||||
// overriding is which of two plates ends up on top when they converge.
|
||||
//
|
||||
// The continental one, when exactly one is: continental crust is too buoyant to go down, which is why the
|
||||
// Andes are on South America and not on the Nazca plate. When both sides are oceanic it is the larger, as a
|
||||
// stand-in for the older and therefore colder and denser slab being the one that sinks.
|
||||
func (m *Model) overriding(a, b int) int {
|
||||
ca, cb := m.Plates[a].Continental, m.Plates[b].Continental
|
||||
switch {
|
||||
case ca && !cb:
|
||||
return a
|
||||
case cb && !ca:
|
||||
return b
|
||||
case m.Plates[a].AreaCells >= m.Plates[b].AreaCells:
|
||||
return a
|
||||
default:
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
// tangent is the local direction of the line, as a unit vector, from a central difference.
|
||||
func tangent(xs, ys []float64, i int) (tx, ty float64) {
|
||||
lo, hi := i-1, i+1
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
if hi >= len(xs) {
|
||||
hi = len(xs) - 1
|
||||
}
|
||||
tx, ty = xs[hi]-xs[lo], ys[hi]-ys[lo]
|
||||
if d := math.Hypot(tx, ty); d > 0 {
|
||||
return tx / d, ty / d
|
||||
}
|
||||
return 1, 0
|
||||
}
|
||||
|
||||
// smooth averages the polyline with its own neighbours, in place, with the ends pinned. See smoothPasses for
|
||||
// why an unsmoothed chain is unusable rather than merely ugly.
|
||||
func smooth(xs, ys []float64) {
|
||||
if len(xs) < 3 {
|
||||
return
|
||||
}
|
||||
bx := make([]float64, len(xs))
|
||||
by := make([]float64, len(ys))
|
||||
for pass := 0; pass < smoothPasses; pass++ {
|
||||
copy(bx, xs)
|
||||
copy(by, ys)
|
||||
for i := 1; i < len(xs)-1; i++ {
|
||||
xs[i] = (bx[i-1] + 2*bx[i] + bx[i+1]) / 4
|
||||
ys[i] = (by[i-1] + 2*by[i] + by[i+1]) / 4
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package plates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// The painted tectonic layer: a third painting beside the template and the overlay, where a colour is a
|
||||
// plate and the legend says how that plate is moving.
|
||||
//
|
||||
// **You paint the cause, not the conclusion.** A colour does not say "there is a collision here" - it says
|
||||
// "this piece of lithosphere is moving north-east at three centimetres a year", and where two of them meet,
|
||||
// what happens is worked out from the two motions and the shape of the contact. That is the whole reason to
|
||||
// paint plates rather than to paint boundary lines: a drawn line has to be told what it is, while a contact
|
||||
// between two painted plates *becomes* a collision, a transform or a rift by itself, and changes character
|
||||
// along its own length wherever it turns relative to the motion. The Alpide belt is a collision at the
|
||||
// Himalaya and a strike-slip fault through Anatolia for exactly that reason, and no author should have to
|
||||
// hand-annotate it.
|
||||
//
|
||||
// It also means the tracer needs no new code. Build's weighted Voronoi and this both produce the same thing -
|
||||
// a plate id at every cell of the tectonic grid - and everything downstream reads that.
|
||||
//
|
||||
// **Registration is by extent, not by pixel.** The layer is stretched over the painted map's own rectangle,
|
||||
// so it does not have to be the template's size. Paint plates at a quarter of it if you like: the tectonic
|
||||
// grid is a few hundred metres a cell and a plate is tens of kilometres across, so detail below that is
|
||||
// detail nothing will ever read. The polar pad has no painting under it and takes the nearest painted row,
|
||||
// which is right - a plate does not stop at the top of the author's canvas.
|
||||
|
||||
// PaintLegend is what the colours on a tectonic layer mean.
|
||||
type PaintLegend struct {
|
||||
// Comment is the legend's own note to whoever opens it next. Propose writes the conventions into it,
|
||||
// because "which way does heading 90 point" is the first thing an author needs and the last thing they
|
||||
// should have to find in a source file.
|
||||
Comment string `json:"_comment,omitempty"`
|
||||
|
||||
// Image is the layer's file name, resolved beside the legend. The manifest may name one instead.
|
||||
Image string `json:"image"`
|
||||
|
||||
// WarnDistance is how far, in RGB, a sampled pixel may sit from the nearest plate before the run says so.
|
||||
// It exists for the same reason the class template's does: a JPEG bleeds several units of each channel
|
||||
// across a painted edge, and a silent mismatch is a plate boundary in the wrong place.
|
||||
WarnDistance float64 `json:"warn_distance"`
|
||||
|
||||
Plates []PaintPlate `json:"plates"`
|
||||
}
|
||||
|
||||
// PaintPlate is one painted plate: a colour, and how that piece of lithosphere is moving.
|
||||
type PaintPlate struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
// RGB is the colour on the layer. Every sampled pixel becomes the *nearest* plate in RGB, because on a
|
||||
// tectonic layer every pixel has to be some plate - the same rule the class template uses, and the
|
||||
// opposite of the overlay's, where most of the image is deliberately nothing.
|
||||
RGB [3]int `json:"rgb"`
|
||||
|
||||
// SpeedCmYr and HeadingDeg are the plate's drift. The heading is a compass bearing over the map: 0 points
|
||||
// at the top of the image, 90 to the right, 180 to the bottom. Earth's plates run 1 to 10 cm/yr, and what
|
||||
// matters at a margin is the *difference* between two of these, so two plates both drifting east at 4 are
|
||||
// a boundary doing nothing at all.
|
||||
SpeedCmYr float64 `json:"speed_cm_yr"`
|
||||
HeadingDeg float64 `json:"heading_deg"`
|
||||
|
||||
// SpinDegMyr turns the plate about its own centre, in degrees per million years, positive clockwise on
|
||||
// the map.
|
||||
//
|
||||
// It is worth setting on at least one plate. A planet of plates that only drift has margins that are the
|
||||
// same all the way along, because the relative velocity is then one constant vector and the only thing
|
||||
// that varies is where the contact happens to point. A little spin is what makes one end of a margin
|
||||
// collide while the other slides - which is the Anatolia case, and the most useful thing a tectonic map
|
||||
// can give a fault set.
|
||||
SpinDegMyr float64 `json:"spin_deg_myr"`
|
||||
|
||||
// Continental overrides what the painting says. Left out - which is the usual case - a plate is
|
||||
// continental when enough of its painted area is land, so the template decides and the two paintings
|
||||
// cannot contradict each other. Set it when they should: an oceanic plate carrying a chain of islands, or
|
||||
// a continental fragment currently underwater.
|
||||
Continental *bool `json:"continental,omitempty"`
|
||||
}
|
||||
|
||||
// rgbOneLine finds an indented colour triple so MarshalLegend can put it back on one line.
|
||||
var rgbOneLine = regexp.MustCompile(`"rgb": \[\s*(\d+),\s*(\d+),\s*(\d+)\s*\]`)
|
||||
|
||||
// MarshalLegend writes a legend as JSON somebody will want to edit.
|
||||
//
|
||||
// json.MarshalIndent puts every colour on five lines, because Indent reformats every array whatever a custom
|
||||
// marshaller does, and a seven-plate legend then runs to ninety lines of mostly punctuation. Putting the
|
||||
// triples back on one line each is cosmetic and it is worth the ten lines: this file is meant to be opened
|
||||
// and changed by hand, beside the painting, and a legend nobody can read at a glance is a legend nobody
|
||||
// keeps in step with the picture.
|
||||
func MarshalLegend(lg *PaintLegend) ([]byte, error) {
|
||||
data, err := json.MarshalIndent(lg, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(rgbOneLine.ReplaceAll(data, []byte(`"rgb": [$1, $2, $3]`)), '\n'), nil
|
||||
}
|
||||
|
||||
// LoadPaintLegend reads a tectonic layer's legend.
|
||||
func LoadPaintLegend(path string) (*PaintLegend, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lg PaintLegend
|
||||
if err := json.Unmarshal(data, &lg); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if err := lg.validate(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lg, nil
|
||||
}
|
||||
|
||||
func (l *PaintLegend) validate(path string) error {
|
||||
if len(l.Plates) < 2 {
|
||||
return fmt.Errorf("%s: %d plate(s); a planet in one plate has no boundaries", path, len(l.Plates))
|
||||
}
|
||||
if l.WarnDistance <= 0 {
|
||||
l.WarnDistance = 60
|
||||
}
|
||||
seen := map[[3]int]string{}
|
||||
for i := range l.Plates {
|
||||
p := &l.Plates[i]
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("%s: plate %d has no name", path, i)
|
||||
}
|
||||
for c := range 3 {
|
||||
if p.RGB[c] < 0 || p.RGB[c] > 255 {
|
||||
return fmt.Errorf("%s: plate %q has rgb %v", path, p.Name, p.RGB)
|
||||
}
|
||||
}
|
||||
if prev, dup := seen[p.RGB]; dup {
|
||||
return fmt.Errorf("%s: plates %q and %q are both rgb %v; a colour is one plate",
|
||||
path, prev, p.Name, p.RGB)
|
||||
}
|
||||
seen[p.RGB] = p.Name
|
||||
if p.SpeedCmYr < 0 {
|
||||
return fmt.Errorf("%s: plate %q moves at %v cm/yr; speed is a magnitude and the heading is "+
|
||||
"where it points", path, p.Name, p.SpeedCmYr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PaintMatch is how well the painting matched the legend, reported the way the class template's match is: a
|
||||
// layer whose colours have drifted is a tectonic model quietly built on the wrong plates.
|
||||
type PaintMatch struct {
|
||||
Cells int `json:"cells"`
|
||||
Far int `json:"far"`
|
||||
MaxDistance float64 `json:"max_distance"`
|
||||
}
|
||||
|
||||
// FromPainting builds a tectonic model from a painted layer instead of from a seed.
|
||||
//
|
||||
// px is the layer decoded to RGB triples, pw by ph. Decoding happens in the caller so that this package keeps
|
||||
// knowing nothing about files or image formats - the same reason land is a callback.
|
||||
func FromPainting(p world.Planet, cfg Config, lg *PaintLegend, px []uint8, pw, ph int,
|
||||
land func(xM, yM float64) bool) (*Model, PaintMatch, error) {
|
||||
|
||||
var match PaintMatch
|
||||
if lg == nil || len(lg.Plates) < 2 {
|
||||
return nil, match, fmt.Errorf("a tectonic layer needs at least two plates")
|
||||
}
|
||||
if pw <= 0 || ph <= 0 || len(px) < pw*ph*3 {
|
||||
return nil, match, fmt.Errorf("the tectonic layer is %dx%d with %d bytes", pw, ph, len(px))
|
||||
}
|
||||
|
||||
cfg = cfg.withDefaults()
|
||||
circ := p.CircumferenceM()
|
||||
gw := int(circ/cfg.ResolutionM + 0.5)
|
||||
if gw < 8 {
|
||||
gw = 8
|
||||
}
|
||||
gcell := circ / float64(gw)
|
||||
gh := int(float64(p.H)*p.CellM/gcell + 0.5)
|
||||
if gh < 2 {
|
||||
gh = 2
|
||||
}
|
||||
|
||||
m := &Model{P: p, Cfg: cfg, GW: gw, GH: gh, GCellM: gcell, Cell: make([]int16, gw*gh)}
|
||||
m.Plates = make([]Plate, len(lg.Plates))
|
||||
for i := range m.Plates {
|
||||
m.Plates[i] = Plate{ID: i, Weight: 1}
|
||||
}
|
||||
|
||||
heightM := p.HeightM()
|
||||
for gy := range gh {
|
||||
yM := m.GridYM(gy)
|
||||
// The painted map covers 0..heightM; the polar pad above and below it takes the nearest painted row.
|
||||
v := clamp01(yM / heightM)
|
||||
py := int(v * float64(ph-1))
|
||||
row := gy * gw
|
||||
for gx := range gw {
|
||||
xM := m.GridXM(gx)
|
||||
pxi := int(xM / circ * float64(pw))
|
||||
if pxi >= pw {
|
||||
pxi = pw - 1
|
||||
}
|
||||
o := (py*pw + pxi) * 3
|
||||
id, dist := nearestPlate(lg.Plates, px[o], px[o+1], px[o+2])
|
||||
m.Cell[row+gx] = int16(id)
|
||||
|
||||
match.Cells++
|
||||
if dist > lg.WarnDistance {
|
||||
match.Far++
|
||||
}
|
||||
if dist > match.MaxDistance {
|
||||
match.MaxDistance = dist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range m.Plates {
|
||||
pl := &m.Plates[i]
|
||||
src := lg.Plates[i]
|
||||
// Compass bearing over the map: 0 points at the top of the image, which is -Y, and 90 to the right.
|
||||
speed := src.SpeedCmYr / 100
|
||||
bearing := src.HeadingDeg * math.Pi / 180
|
||||
pl.TransXM = speed * math.Sin(bearing)
|
||||
pl.TransYM = -speed * math.Cos(bearing)
|
||||
// Positive spin is clockwise on the map: with Y running down the image, v = T + omega x r sends the
|
||||
// point east of the centre southwards. The centre itself comes from measure, below.
|
||||
pl.OmegaRadYr = src.SpinDegMyr * math.Pi / 180 / 1e6
|
||||
}
|
||||
|
||||
// measure fills in the area, the land fraction and the centre of each plate - and the centre is the pole
|
||||
// every one of them turns about, so nothing has a usable velocity field until this has run.
|
||||
m.measure(land)
|
||||
for i := range m.Plates {
|
||||
// A painted plate has no Voronoi site. Its centre of area is the only position it has, and it is what
|
||||
// the map and the reports point at.
|
||||
m.Plates[i].SiteXM = m.Plates[i].CentroidXM
|
||||
m.Plates[i].SiteYM = m.Plates[i].CentroidYM
|
||||
}
|
||||
// The painting has the last word where it asks for one, after measure has read the template's land.
|
||||
for i := range m.Plates {
|
||||
if c := lg.Plates[i].Continental; c != nil {
|
||||
m.Plates[i].Continental = *c
|
||||
}
|
||||
}
|
||||
|
||||
m.Boundaries = m.buildBoundaries()
|
||||
return m, match, nil
|
||||
}
|
||||
|
||||
// nearestPlate is the legend entry closest to a colour, and how far away it was.
|
||||
//
|
||||
// Nearest rather than exact, and unlike the overlay there is no "no plate" answer: every pixel of a tectonic
|
||||
// layer is some piece of lithosphere, so a colour that matches nothing is a painting mistake to report rather
|
||||
// than a hole to leave. WarnDistance is what reports it.
|
||||
func nearestPlate(ps []PaintPlate, r, g, b uint8) (id int, dist float64) {
|
||||
best, bestID := math.Inf(1), 0
|
||||
for i := range ps {
|
||||
dr := float64(int(r) - ps[i].RGB[0])
|
||||
dg := float64(int(g) - ps[i].RGB[1])
|
||||
db := float64(int(b) - ps[i].RGB[2])
|
||||
if d := dr*dr + dg*dg + db*db; d < best {
|
||||
best, bestID = d, i
|
||||
}
|
||||
}
|
||||
return bestID, math.Sqrt(best)
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Propose turns a generated model into a painting and a legend to start from.
|
||||
//
|
||||
// An author should not face a blank canvas for this. Seven plates with plausible motions is a minute's work
|
||||
// for the Voronoi and an afternoon's by hand, and what an author actually wants to do is move two of them and
|
||||
// change a heading - which is editing, not authoring from nothing.
|
||||
//
|
||||
// The returned pixels are the layer at the given width, and the legend has one entry per plate carrying the
|
||||
// motion the generator drew. Writing them out is the caller's job.
|
||||
func (m *Model) Propose(width int) (px []uint8, w, h int, lg *PaintLegend) {
|
||||
if width < 64 {
|
||||
width = 64
|
||||
}
|
||||
h = int(float64(width) * m.P.HeightM() / m.P.CircumferenceM())
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
w = width
|
||||
|
||||
lg = &PaintLegend{
|
||||
Comment: "A painted tectonic layer: one colour per plate, and how that plate is moving. " +
|
||||
"heading_deg is a compass bearing over the map - 0 points at the top of the image, 90 to the " +
|
||||
"right, 180 to the bottom. speed_cm_yr is drift; what happens at a margin is the difference " +
|
||||
"between the two plates either side of it, so two plates drifting the same way are a boundary " +
|
||||
"doing nothing. spin_deg_myr turns a plate about its own centre, positive clockwise, and it is " +
|
||||
"worth setting on at least one: without it every margin is the same all the way along, and " +
|
||||
"with it one end collides while the other slides. Paint the plates, not the mountains - where " +
|
||||
"two of these meet, the collision, the belt and its faults are worked out from the motions. " +
|
||||
"Repaint the blobs freely; only the colours have to keep matching this file.",
|
||||
WarnDistance: 60,
|
||||
Plates: make([]PaintPlate, len(m.Plates)),
|
||||
}
|
||||
colours := make([][3]uint8, len(m.Plates))
|
||||
for i := range m.Plates {
|
||||
pl := &m.Plates[i]
|
||||
// Hues walked by the golden ratio, so that neighbouring ids are not neighbouring colours and an
|
||||
// author can tell two touching plates apart at a glance.
|
||||
c := hsvBytes(math.Mod(float64(i)*0.61803398875, 1)*360, 0.62, 0.86)
|
||||
colours[i] = c
|
||||
|
||||
speed := math.Hypot(pl.TransXM, pl.TransYM) * 100 // m/yr to cm/yr
|
||||
// Back to a compass bearing: 0 at the top of the image, 90 to the right.
|
||||
bearing := math.Atan2(pl.TransXM, -pl.TransYM) * 180 / math.Pi
|
||||
if bearing < 0 {
|
||||
bearing += 360
|
||||
}
|
||||
lg.Plates[i] = PaintPlate{
|
||||
Name: fmt.Sprintf("plate_%d", i),
|
||||
RGB: [3]int{int(c[0]), int(c[1]), int(c[2])},
|
||||
SpeedCmYr: math.Round(speed*10) / 10,
|
||||
HeadingDeg: math.Round(bearing),
|
||||
SpinDegMyr: math.Round(pl.OmegaRadYr*180/math.Pi*1e6*100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
px = make([]uint8, w*h*3)
|
||||
for y := range h {
|
||||
yM := m.P.HeightM() * (float64(y) + 0.5) / float64(h)
|
||||
for x := range w {
|
||||
xM := m.P.CircumferenceM() * (float64(x) + 0.5) / float64(w)
|
||||
c := colours[m.PlateAt(xM, yM)]
|
||||
o := (y*w + x) * 3
|
||||
px[o], px[o+1], px[o+2] = c[0], c[1], c[2]
|
||||
}
|
||||
}
|
||||
return px, w, h, lg
|
||||
}
|
||||
|
||||
// hsvBytes is a hue in degrees, saturation and value in 0..1, as an RGB triple.
|
||||
func hsvBytes(hue, sat, val float64) [3]uint8 {
|
||||
hue = math.Mod(math.Mod(hue, 360)+360, 360) / 60
|
||||
i := math.Floor(hue)
|
||||
f := hue - i
|
||||
p := val * (1 - sat)
|
||||
q := val * (1 - sat*f)
|
||||
t := val * (1 - sat*(1-f))
|
||||
var r, g, b float64
|
||||
switch int(i) % 6 {
|
||||
case 0:
|
||||
r, g, b = val, t, p
|
||||
case 1:
|
||||
r, g, b = q, val, p
|
||||
case 2:
|
||||
r, g, b = p, val, t
|
||||
case 3:
|
||||
r, g, b = p, q, val
|
||||
case 4:
|
||||
r, g, b = t, p, val
|
||||
default:
|
||||
r, g, b = val, p, q
|
||||
}
|
||||
return [3]uint8{byte(r*255 + 0.5), byte(g*255 + 0.5), byte(b*255 + 0.5)}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package plates
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A painted layer and its legend, built by hand: two plates split at a quarter and three quarters of the way
|
||||
// round, driven into each other along X with no spin.
|
||||
func paintedStripes(w, h int) ([]uint8, *PaintLegend) {
|
||||
lg := &PaintLegend{
|
||||
WarnDistance: 60,
|
||||
Plates: []PaintPlate{
|
||||
{Name: "west", RGB: [3]int{200, 60, 60}, SpeedCmYr: 2, HeadingDeg: 90}, // due east
|
||||
{Name: "east", RGB: [3]int{60, 60, 200}, SpeedCmYr: 2, HeadingDeg: 270}, // due west
|
||||
},
|
||||
}
|
||||
px := make([]uint8, w*h*3)
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
id := 0
|
||||
if x >= w/4 && x < 3*w/4 {
|
||||
id = 1
|
||||
}
|
||||
o := (y*w + x) * 3
|
||||
c := lg.Plates[id].RGB
|
||||
px[o], px[o+1], px[o+2] = uint8(c[0]), uint8(c[1]), uint8(c[2])
|
||||
}
|
||||
}
|
||||
return px, lg
|
||||
}
|
||||
|
||||
func TestAPaintedLayerBecomesAPlanet(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
px, lg := paintedStripes(400, 200)
|
||||
|
||||
m, match, err := FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
|
||||
if err != nil {
|
||||
t.Fatalf("from painting: %v", err)
|
||||
}
|
||||
if match.Far != 0 {
|
||||
t.Errorf("%d of %d sampled cells did not match a plate colour", match.Far, match.Cells)
|
||||
}
|
||||
if len(m.Plates) != 2 {
|
||||
t.Fatalf("%d plates from a two-colour legend", len(m.Plates))
|
||||
}
|
||||
if len(m.Boundaries) != 2 {
|
||||
t.Fatalf("%d boundaries; two stripes on a cylinder make two contacts", len(m.Boundaries))
|
||||
}
|
||||
|
||||
// The same invariant the generated path has: with a pure translation one margin closes and the other
|
||||
// opens, by the same amount. Two plates at 2 cm/yr closing head-on give 4 cm/yr.
|
||||
means := sortedMeans(m.Boundaries)
|
||||
if means[0] >= 0 || means[1] <= 0 {
|
||||
t.Fatalf("closing rates %.4g and %.4g; one of each is the only arrangement possible", means[0], means[1])
|
||||
}
|
||||
if got := math.Abs(means[1]); math.Abs(got-0.04) > 1e-3 {
|
||||
t.Errorf("painted plates at 2 cm/yr each close at %.4g m/yr, want 0.04", got)
|
||||
}
|
||||
// Both painted as land, so both are continental and the closing margin is a collision.
|
||||
if got, want := kinds(m.Boundaries), []string{"collision", "rift"}; !sameStrings(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func allLandAt(xM, yM float64) bool { return true }
|
||||
|
||||
func TestAHeadingIsACompassBearing(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
px, lg := paintedStripes(400, 200)
|
||||
// 0 points at the top of the image, which is -Y; 90 to the right, which is +X.
|
||||
lg.Plates[0].HeadingDeg = 0
|
||||
lg.Plates[1].HeadingDeg = 90
|
||||
|
||||
m, _, err := FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
|
||||
if err != nil {
|
||||
t.Fatalf("from painting: %v", err)
|
||||
}
|
||||
north, east := m.Plates[0], m.Plates[1]
|
||||
if math.Abs(north.TransXM) > 1e-9 || north.TransYM >= 0 {
|
||||
t.Errorf("heading 0 gives (%.4g, %.4g); it should point at the top of the map",
|
||||
north.TransXM, north.TransYM)
|
||||
}
|
||||
if math.Abs(east.TransYM) > 1e-9 || east.TransXM <= 0 {
|
||||
t.Errorf("heading 90 gives (%.4g, %.4g); it should point to the right of the map",
|
||||
east.TransXM, east.TransYM)
|
||||
}
|
||||
if got := math.Hypot(east.TransXM, east.TransYM); math.Abs(got-0.02) > 1e-9 {
|
||||
t.Errorf("2 cm/yr came out as %.4g m/yr", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePaintingCanOverruleTheLandMask(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
px, lg := paintedStripes(400, 200)
|
||||
|
||||
// Every cell is land, so both plates are continental and the closing margin is a collision.
|
||||
m, _, err := FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
|
||||
if err != nil {
|
||||
t.Fatalf("from painting: %v", err)
|
||||
}
|
||||
if !m.Plates[0].Continental || !m.Plates[1].Continental {
|
||||
t.Fatal("a planet of land has an oceanic plate on it")
|
||||
}
|
||||
|
||||
// The legend says otherwise about one of them, and a legend that bothers to say so wins.
|
||||
oceanic := false
|
||||
lg.Plates[1].Continental = &oceanic
|
||||
m, _, err = FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
|
||||
if err != nil {
|
||||
t.Fatalf("from painting: %v", err)
|
||||
}
|
||||
if m.Plates[1].Continental {
|
||||
t.Error("the legend called plate 1 oceanic and the land mask overruled it")
|
||||
}
|
||||
// And the consequence is the point of the override: the same margin is now a subduction zone.
|
||||
if got, want := kinds(m.Boundaries), []string{"ridge", "subduction"}; !sameStrings(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACentroidIsMeasuredTheShortWayRound(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
const w, h = 400, 200
|
||||
lg := &PaintLegend{
|
||||
WarnDistance: 60,
|
||||
Plates: []PaintPlate{
|
||||
{Name: "seam", RGB: [3]int{200, 60, 60}, SpeedCmYr: 2, HeadingDeg: 90},
|
||||
{Name: "rest", RGB: [3]int{60, 60, 200}, SpeedCmYr: 2, HeadingDeg: 270},
|
||||
},
|
||||
}
|
||||
// Plate 0 is painted across the meridian: the left eighth and the right eighth of the image. Its centre
|
||||
// is the seam, and an arithmetic mean of those columns would put it on the far side of the planet - and
|
||||
// with it the pole it spins about.
|
||||
px := make([]uint8, w*h*3)
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
id := 1
|
||||
if x < w/8 || x >= 7*w/8 {
|
||||
id = 0
|
||||
}
|
||||
o := (y*w + x) * 3
|
||||
c := lg.Plates[id].RGB
|
||||
px[o], px[o+1], px[o+2] = uint8(c[0]), uint8(c[1]), uint8(c[2])
|
||||
}
|
||||
}
|
||||
|
||||
m, _, err := FromPainting(p, Default(), lg, px, w, h, allLandAt)
|
||||
if err != nil {
|
||||
t.Fatalf("from painting: %v", err)
|
||||
}
|
||||
circ := p.CircumferenceM()
|
||||
got := m.Plates[0].SiteXM
|
||||
// Near the meridian, measured the short way round: either just above 0 or just below the circumference.
|
||||
if d := math.Abs(wrapDelta(got, circ)); d > circ/16 {
|
||||
t.Errorf("the seam-straddling plate's centre is at %.0f m of %.0f; it should be near the meridian, "+
|
||||
"and the arithmetic mean would have put it near %.0f", got, circ, circ/2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProposalReadsBackAsTheSamePlanet(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
cfg := Default()
|
||||
cfg.Count = 6
|
||||
land := func(xM, yM float64) bool { return yM > 4000 && yM < 14000 }
|
||||
|
||||
made, err := Build(p, 3630, cfg, land)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
// The round trip is what makes Propose worth having: what it writes has to be a layer that comes back as
|
||||
// the planet it was written from, or an author's first edit starts from something that was never true.
|
||||
px, w, h, lg := made.Propose(1600)
|
||||
read, match, err := FromPainting(p, cfg, lg, px, w, h, land)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if match.Far != 0 {
|
||||
t.Errorf("%d of %d cells of its own proposal did not match its own legend", match.Far, match.Cells)
|
||||
}
|
||||
if len(read.Plates) != len(made.Plates) {
|
||||
t.Fatalf("%d plates written, %d read back", len(made.Plates), len(read.Plates))
|
||||
}
|
||||
|
||||
// The motions survive the trip through the legend's cm/yr and degrees. Rounded when written - a tenth of
|
||||
// a cm/yr and a whole degree - so the tolerance is the rounding, not a fudge.
|
||||
for i := range made.Plates {
|
||||
a, b := made.Plates[i], read.Plates[i]
|
||||
if d := math.Hypot(a.TransXM-b.TransXM, a.TransYM-b.TransYM); d > 0.0006 {
|
||||
t.Errorf("plate %d drifts %.5g m/yr differently after the round trip", i, d)
|
||||
}
|
||||
if a.Continental != b.Continental {
|
||||
t.Errorf("plate %d was %v continental and reads back %v", i, a.Continental, b.Continental)
|
||||
}
|
||||
}
|
||||
|
||||
// And the tectonics: the same margins doing the same things. Not vertex-for-vertex - the proposal is a
|
||||
// raster at 1600 px and the model was traced at the tectonic grid - but the same boundaries by count and
|
||||
// by what each one is.
|
||||
if len(read.Boundaries) != len(made.Boundaries) {
|
||||
t.Errorf("%d boundaries written, %d read back", len(made.Boundaries), len(read.Boundaries))
|
||||
}
|
||||
if got, want := kinds(read.Boundaries), kinds(made.Boundaries); !sameStrings(got, want) {
|
||||
t.Errorf("margins read back as %v, were %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALegendThatCannotBeAPlanetIsRefused(t *testing.T) {
|
||||
one := &PaintLegend{Plates: []PaintPlate{{Name: "only", RGB: [3]int{1, 2, 3}}}}
|
||||
if err := one.validate("test"); err == nil {
|
||||
t.Error("a planet in one plate was accepted; it has no boundaries")
|
||||
}
|
||||
dup := &PaintLegend{Plates: []PaintPlate{
|
||||
{Name: "a", RGB: [3]int{1, 2, 3}},
|
||||
{Name: "b", RGB: [3]int{1, 2, 3}},
|
||||
}}
|
||||
if err := dup.validate("test"); err == nil {
|
||||
t.Error("two plates sharing a colour were accepted; a colour is one plate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
// Package plates is the tectonics a painted planet does not draw: which rigid pieces the lithosphere is in,
|
||||
// how they move, and therefore where they are colliding.
|
||||
//
|
||||
// It exists because of one row in Docs/Terrain.md's pass table. Pass 1 writes `uplift` *and* `boundaries`,
|
||||
// and pass 3 - faults - reads `boundaries`. D-53 dropped passes 1 to 4 on the painted path, because a
|
||||
// painted template is already a statement about where the ranges are. What went out with them was the
|
||||
// boundary set, and `uplift/painted_faults.go` substituted a noise grain field for it - a field that has
|
||||
// never been told where a belt is. That substitution is visible in `map_uplift.png`: cyan traces striking
|
||||
// across the bright belts at angles unrelated to them, the densest set sitting in a lowland, and several
|
||||
// walking out over open ocean.
|
||||
//
|
||||
// The correction is not a better grain field. A range and its faults are not two things, one decorating the
|
||||
// other: they are both consequences of the same convergence, and the line they are consequences of is the
|
||||
// plate boundary. So the boundary is what gets built first, and the uplift and the faults are both read off
|
||||
// it.
|
||||
//
|
||||
// **The plates live on the cylinder, not on a sphere.** A real plate moves by rotating about an Euler pole
|
||||
// through the centre of the planet, and the velocity that produces varies along a boundary - which is the
|
||||
// reason one margin is a head-on collision at one end and a strike-slip fault at the other. That variation
|
||||
// is worth having; the sphere is not. Every other pass here measures distance in flat metres on a cylinder
|
||||
// of fixed circumference with an 8 m cell that never varies (D-48), so a pass that believed in a sphere
|
||||
// would be the only one whose distances disagreed with the solve's, and its velocities would converge at
|
||||
// poles nothing else knows are there. The compromise keeps the property and drops the geometry: a plate's
|
||||
// motion is a translation plus a rotation about a pole **in the map plane**,
|
||||
//
|
||||
// v(x) = T + omega x (x - pole)
|
||||
//
|
||||
// which is the two-dimensional analogue and varies along a boundary for the same reason.
|
||||
//
|
||||
// **What the painting still owns.** Whether a plate is continental is read from the land mask rather than
|
||||
// drawn from the seed: a plate covering the author's continent *is* a continental plate. That is the one
|
||||
// place the painting feeds the model rather than competing with it, and it is what makes an ocean-continent
|
||||
// margin land where an author would expect a subduction zone.
|
||||
//
|
||||
// **The tectonic grid is its own, and coarse.** The partition is rasterised at a few hundred metres rather
|
||||
// than at the 8 m geology cell. A plate boundary belt is tens of kilometres wide and the finest thing read
|
||||
// off the line is a fault trace, so a quarter-kilometre lattice is already finer than anything downstream
|
||||
// can use, and it makes the whole pass a few million operations instead of a few hundred million. What
|
||||
// leaves this package is polylines in **world metres**, which is the same form `uplift.FaultTrace` already
|
||||
// travels in and for the same reason: a region filters the planet's set to what reaches its own frame, so a
|
||||
// boundary crossing a region edge is one boundary and two decompositions agree.
|
||||
package plates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Pass indices for this package's seeded streams. They sit above the detail passes' 40s so that adding one
|
||||
// here cannot reshuffle any existing field.
|
||||
const (
|
||||
srcSites = 50
|
||||
srcMotion = 51
|
||||
srcWarp = 52
|
||||
)
|
||||
|
||||
// Config is what the manifest asks for. Every zero field takes a default from withDefaults.
|
||||
type Config struct {
|
||||
// Layer and Legend are the painted tectonic layer: an image where a colour is a plate, and a legend
|
||||
// saying how each one moves. Naming them is what turns the plates from something the seed invents into
|
||||
// something an author draws, and it is the intended way to use this package - see paint.go.
|
||||
//
|
||||
// With no layer the plates come from Count and the seed, which is a Voronoi partition that knows nothing
|
||||
// about where the continents are. That mode's real job is Propose: it writes a first painting, which the
|
||||
// author then edits.
|
||||
Layer string `json:"layer"`
|
||||
Legend string `json:"legend"`
|
||||
|
||||
// Count is how many plates the lithosphere is in. Earth has seven or eight majors and a couple of dozen
|
||||
// minors; what matters here is that a boundary has to have room to be a mountain belt, so the useful
|
||||
// range on a hundred-kilometre planet is single digits.
|
||||
Count int `json:"count"`
|
||||
|
||||
// SizeSpread is the ratio between the largest and smallest plate weight. The partition is a
|
||||
// multiplicatively weighted Voronoi, so a heavier site claims ground further away: 1 makes every plate
|
||||
// the same size, which is the one thing real plates never are.
|
||||
SizeSpread float64 `json:"size_spread"`
|
||||
|
||||
// VelocityCmYr is how fast a plate moves, low to high. Earth runs 1 to 10; the number that matters
|
||||
// downstream is the *relative* speed across a boundary, which is a difference of two of these.
|
||||
VelocityCmYr [2]float64 `json:"velocity_cm_yr"`
|
||||
|
||||
// SpinFraction is how much of a plate's speed is rotation about its own centre rather than translation.
|
||||
// Zero makes every margin uniform along its length, which is the defect the in-plane pole exists to
|
||||
// avoid; one makes the plate a pinwheel. A third of it is enough to turn a collision into a transform
|
||||
// over a few tens of kilometres.
|
||||
//
|
||||
// A pointer because zero is a real answer here and so is "say nothing". JSON cannot tell an absent
|
||||
// number from a zero one, and a plain float64 read them as the same thing - which is how a proposal came
|
||||
// out with every plate's spin at zero and every margin uniform, the one defect this field exists to
|
||||
// prevent. Absent takes the default; an explicit 0 means none.
|
||||
SpinFraction *float64 `json:"spin_fraction"`
|
||||
|
||||
// WarpFraction is how far a boundary wanders from the straight Voronoi edge, as a fraction of the mean
|
||||
// plate spacing. Without it the partition is a polygon net and every margin is a ruled line.
|
||||
//
|
||||
// It is applied over two octaves, and that is not decoration either: one octave at the plate wavelength
|
||||
// gives a margin one long shallow bend, which at planet scale is still a ruled line with a kink in it.
|
||||
// The second octave at a third of the wavelength is what puts a promontory and a re-entrant into a
|
||||
// margin, and those are where a collision belt gets its along-strike segmentation from.
|
||||
WarpFraction float64 `json:"warp_fraction"`
|
||||
|
||||
// ResolutionM is the tectonic grid's cell. See the package comment: coarse on purpose.
|
||||
ResolutionM float64 `json:"resolution_m"`
|
||||
|
||||
// ContinentalFraction is the share of a plate's painted area that has to be land before it counts as
|
||||
// continental. Well below a half, because a continental plate carries a shelf and a passive margin as
|
||||
// well as its continent.
|
||||
ContinentalFraction float64 `json:"continental_fraction"`
|
||||
|
||||
// ObliqueDeg is where a margin stops being convergent or divergent and becomes transform: the angle
|
||||
// between the relative velocity and the boundary normal, past which the strike-slip component is the
|
||||
// one in charge. 60 degrees means a margin stays convergent until the slip is over 1.7 times the
|
||||
// closing.
|
||||
ObliqueDeg float64 `json:"oblique_deg"`
|
||||
|
||||
// Faults is the deformation zone around every margin: how wide it is and how densely it is broken. A
|
||||
// zero block means the margins carry no faults of their own, which is what every painted planet had
|
||||
// before it existed - the legend's per-class `faults` blocks are a separate, and now secondary, set.
|
||||
Faults Belt `json:"faults"`
|
||||
}
|
||||
|
||||
// Default is the configuration a manifest that says nothing gets.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Count: 7,
|
||||
SizeSpread: 1.7,
|
||||
VelocityCmYr: [2]float64{1, 6},
|
||||
SpinFraction: nil, // see Spin(); the default lives there so that an explicit 0 can mean none
|
||||
WarpFraction: 0.34,
|
||||
ResolutionM: 250,
|
||||
ContinentalFraction: 0.18,
|
||||
ObliqueDeg: 60,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultSpinFraction is what a config that does not mention spin gets. It is not zero on purpose: a planet
|
||||
// of plates that only drift has margins identical along their whole length, and the along-strike change from
|
||||
// collision to transform is the most useful thing the model gives a fault set.
|
||||
const DefaultSpinFraction = 0.35
|
||||
|
||||
// Spin is the configured spin fraction, or the default when the manifest said nothing. An explicit zero is
|
||||
// honoured and means no rotation at all.
|
||||
func (c Config) Spin() float64 {
|
||||
if c.SpinFraction == nil {
|
||||
return DefaultSpinFraction
|
||||
}
|
||||
if *c.SpinFraction < 0 {
|
||||
return 0
|
||||
}
|
||||
return *c.SpinFraction
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
d := Default()
|
||||
if c.Count <= 0 {
|
||||
c.Count = d.Count
|
||||
}
|
||||
if c.SizeSpread < 1 {
|
||||
c.SizeSpread = d.SizeSpread
|
||||
}
|
||||
if c.VelocityCmYr[1] <= 0 {
|
||||
c.VelocityCmYr = d.VelocityCmYr
|
||||
}
|
||||
if c.VelocityCmYr[0] < 0 {
|
||||
c.VelocityCmYr[0] = 0
|
||||
}
|
||||
if c.WarpFraction < 0 {
|
||||
c.WarpFraction = d.WarpFraction
|
||||
}
|
||||
if c.ResolutionM <= 0 {
|
||||
c.ResolutionM = d.ResolutionM
|
||||
}
|
||||
if c.ContinentalFraction <= 0 {
|
||||
c.ContinentalFraction = d.ContinentalFraction
|
||||
}
|
||||
if c.ObliqueDeg <= 0 || c.ObliqueDeg >= 90 {
|
||||
c.ObliqueDeg = d.ObliqueDeg
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Plate is one rigid piece of the lithosphere.
|
||||
type Plate struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
// SiteXM, SiteYM is the Voronoi site in world metres, and Weight is what makes plates different sizes.
|
||||
SiteXM float64 `json:"site_x_m"`
|
||||
SiteYM float64 `json:"site_y_m"`
|
||||
Weight float64 `json:"weight"`
|
||||
|
||||
// CentroidXM, CentroidYM is the plate's centre of area, measured the short way round the cylinder. It is
|
||||
// where the plate turns about, and it is the one position a painted plate has - a painting has no site.
|
||||
CentroidXM float64 `json:"centroid_x_m"`
|
||||
CentroidYM float64 `json:"centroid_y_m"`
|
||||
|
||||
// The motion, in metres a year: a translation plus a rotation about a pole in the map plane. The pole is
|
||||
// always the centroid; it is stored rather than derived so that VelocityAt needs nothing but the plate.
|
||||
TransXM float64 `json:"trans_x_m_yr"`
|
||||
TransYM float64 `json:"trans_y_m_yr"`
|
||||
PoleXM float64 `json:"pole_x_m"`
|
||||
PoleYM float64 `json:"pole_y_m"`
|
||||
OmegaRadYr float64 `json:"omega_rad_yr"`
|
||||
|
||||
// Continental is read from the painting rather than drawn from the seed: see the package comment.
|
||||
Continental bool `json:"continental"`
|
||||
LandFraction float64 `json:"land_fraction"`
|
||||
|
||||
// AreaCells is the plate's size on the tectonic grid, which is what decides who overrides whom when two
|
||||
// oceanic plates converge.
|
||||
AreaCells int `json:"area_cells"`
|
||||
}
|
||||
|
||||
// VelocityAt is the plate's motion at a world point, in metres a year.
|
||||
//
|
||||
// The lever arm is measured the short way round the cylinder. Without that a plate whose pole sits just east
|
||||
// of the seam would spin the wrong way for every point just west of it, and the boundary running through the
|
||||
// seam would be classified as convergent on one side and divergent on the other - the one bug this whole
|
||||
// coordinate system exists to make impossible.
|
||||
func (pl Plate) VelocityAt(p world.Planet, xM, yM float64) (vx, vy float64) {
|
||||
rx := wrapDelta(xM-pl.PoleXM, p.CircumferenceM())
|
||||
ry := yM - pl.PoleYM
|
||||
return pl.TransXM - pl.OmegaRadYr*ry, pl.TransYM + pl.OmegaRadYr*rx
|
||||
}
|
||||
|
||||
// SpeedMYr is how fast the plate is going at its own site, which is the number worth printing.
|
||||
func (pl Plate) SpeedMYr(p world.Planet) float64 {
|
||||
vx, vy := pl.VelocityAt(p, pl.SiteXM, pl.SiteYM)
|
||||
return math.Hypot(vx, vy)
|
||||
}
|
||||
|
||||
// Model is a planet's tectonics: the plates, the grid they were rasterised on, and the boundaries between
|
||||
// them.
|
||||
type Model struct {
|
||||
P world.Planet `json:"-"`
|
||||
Cfg Config `json:"config"`
|
||||
|
||||
Plates []Plate `json:"plates"`
|
||||
|
||||
// The tectonic grid. GCellM is derived rather than taken: it is the circumference divided by a whole
|
||||
// number of columns, so the grid wraps exactly and a boundary crossing the seam is an ordinary one.
|
||||
GW int `json:"-"`
|
||||
GH int `json:"-"`
|
||||
GCellM float64 `json:"grid_cell_m"`
|
||||
|
||||
// Cell is the plate id at every tectonic cell, row-major, X cyclic.
|
||||
Cell []int16 `json:"-"`
|
||||
|
||||
// Boundaries is the whole planet's set, in world metres.
|
||||
Boundaries []Boundary `json:"boundaries"`
|
||||
}
|
||||
|
||||
// GridXM and GridYM are the world position of a tectonic cell's centre. Y runs from the top of the polar
|
||||
// pad, so a grid row and a planet row mean the same place.
|
||||
func (m *Model) GridXM(gx int) float64 { return (float64(gx) + 0.5) * m.GCellM }
|
||||
func (m *Model) GridYM(gy int) float64 { return m.P.YM(0) + (float64(gy)+0.5)*m.GCellM }
|
||||
|
||||
// GridIdx wraps X and clamps Y, the same way world.Planet.Idx does.
|
||||
func (m *Model) GridIdx(gx, gy int) int {
|
||||
gx = ((gx % m.GW) + m.GW) % m.GW
|
||||
if gy < 0 {
|
||||
gy = 0
|
||||
} else if gy >= m.GH {
|
||||
gy = m.GH - 1
|
||||
}
|
||||
return gy*m.GW + gx
|
||||
}
|
||||
|
||||
// PlateAt is which plate owns a world position.
|
||||
func (m *Model) PlateAt(xM, yM float64) int {
|
||||
gx := int(math.Floor(xM / m.GCellM))
|
||||
gy := int(math.Floor((yM - m.P.YM(0)) / m.GCellM))
|
||||
return int(m.Cell[m.GridIdx(gx, gy)])
|
||||
}
|
||||
|
||||
// Build draws a planet's plates and the boundaries between them, once, deterministically from the seed.
|
||||
//
|
||||
// land reports whether a world position is painted land. It is a callback rather than a raster so that this
|
||||
// package knows nothing about templates: what it needs from the painting is one bit, and asking for it this
|
||||
// way also means the caller decides how the land mask is sampled.
|
||||
func Build(p world.Planet, seed int64, cfg Config, land func(xM, yM float64) bool) (*Model, error) {
|
||||
cfg = cfg.withDefaults()
|
||||
if cfg.Count < 2 {
|
||||
return nil, fmt.Errorf("a planet in %d plate(s) has no boundaries", cfg.Count)
|
||||
}
|
||||
|
||||
circ := p.CircumferenceM()
|
||||
gw := int(circ/cfg.ResolutionM + 0.5)
|
||||
if gw < cfg.Count*4 {
|
||||
gw = cfg.Count * 4
|
||||
}
|
||||
gcell := circ / float64(gw)
|
||||
gh := int(float64(p.H)*p.CellM/gcell + 0.5)
|
||||
if gh < 2 {
|
||||
gh = 2
|
||||
}
|
||||
|
||||
m := &Model{P: p, Cfg: cfg, GW: gw, GH: gh, GCellM: gcell}
|
||||
m.Plates = placeSites(p, seed, cfg, gh, gcell)
|
||||
giveMotion(p, seed, cfg, m.Plates)
|
||||
m.Cell = partition(m, seed)
|
||||
m.measure(land)
|
||||
m.Boundaries = m.buildBoundaries()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// warpFineGain is how strong the second warp octave is against the first. Held well below a half: past that
|
||||
// the displacement folds back on itself and the partition grows islands of one plate inside another, which
|
||||
// is a nonsense the boundary tracer would faithfully chain into a ring.
|
||||
const warpFineGain = 0.4
|
||||
|
||||
// spacingM is the mean distance between neighbouring sites: the length every other length in this package is
|
||||
// a fraction of.
|
||||
func spacingM(circ, heightM float64, count int) float64 {
|
||||
return math.Sqrt(circ * heightM / float64(count))
|
||||
}
|
||||
|
||||
// placeSites scatters the plate centres, refusing any that lands on top of another.
|
||||
//
|
||||
// Rejection rather than relaxation. Lloyd's algorithm would give an even, hexagonal net, which is a worse
|
||||
// answer than this one: plates are not even, and the interesting boundary geometry - a small plate wedged
|
||||
// between two large ones, a long thin one - comes from exactly the irregularity relaxation removes. The
|
||||
// minimum separation is only there to stop two sites coinciding, which produces a sliver no boundary tracer
|
||||
// can chain.
|
||||
func placeSites(p world.Planet, seed int64, cfg Config, gh int, gcell float64) []Plate {
|
||||
s := noise.NewSource(seed, srcSites)
|
||||
circ := p.CircumferenceM()
|
||||
heightM := float64(gh) * gcell
|
||||
top := p.YM(0)
|
||||
minSep := 0.5 * spacingM(circ, heightM, cfg.Count)
|
||||
|
||||
out := make([]Plate, 0, cfg.Count)
|
||||
for len(out) < cfg.Count {
|
||||
for try := 0; ; try++ {
|
||||
x := s.Float() * circ
|
||||
y := top + s.Float()*heightM
|
||||
// After enough refusals the separation is the thing that is wrong, not the draw, so it is given
|
||||
// up rather than looped on for ever - a count near the area's limit can have no valid position
|
||||
// left at all.
|
||||
if try < 64 && tooClose(out, p, x, y, minSep) {
|
||||
continue
|
||||
}
|
||||
out = append(out, Plate{
|
||||
ID: len(out),
|
||||
SiteXM: x,
|
||||
SiteYM: y,
|
||||
Weight: 1 + (cfg.SizeSpread-1)*s.Float(),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tooClose(out []Plate, p world.Planet, x, y, minSep float64) bool {
|
||||
circ := p.CircumferenceM()
|
||||
for i := range out {
|
||||
dx := wrapDelta(x-out[i].SiteXM, circ)
|
||||
dy := y - out[i].SiteYM
|
||||
if math.Hypot(dx, dy) < minSep {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// giveMotion draws each plate's translation and its spin.
|
||||
//
|
||||
// **Every plate turns about its own centre of area**, which measure fills in once the partition exists. An
|
||||
// earlier version put the pole a plate-width off to one side, on the reasoning that a pole at the middle
|
||||
// would cancel symmetrically and leave the margins as uniform as a pure translation does. That reasoning is
|
||||
// wrong: the relative velocity at a contact is
|
||||
//
|
||||
// (T_a - T_b) + omega_a x (x - c_a) - omega_b x (x - c_b)
|
||||
//
|
||||
// which varies along the contact for any pole at all, and a pole at the centre puts the *largest* rotational
|
||||
// contribution out at the margins, where it is wanted. The offset pole bought nothing and cost something
|
||||
// real: it cannot be written into a painted legend, where an author says "this plate is also turning
|
||||
// clockwise" and means about its own middle. A proposal therefore did not read back as the planet it was
|
||||
// proposed from, which is what TestAProposalReadsBackAsTheSamePlanet caught.
|
||||
func giveMotion(p world.Planet, seed int64, cfg Config, ps []Plate) {
|
||||
s := noise.NewSource(seed, srcMotion)
|
||||
circ := p.CircumferenceM()
|
||||
spacing := spacingM(circ, p.HeightM(), cfg.Count)
|
||||
for i := range ps {
|
||||
speed := s.Range(cfg.VelocityCmYr[0], cfg.VelocityCmYr[1]) / 100 // cm/yr to m/yr
|
||||
dir := s.Float() * 2 * math.Pi
|
||||
ps[i].TransXM = math.Cos(dir) * speed
|
||||
ps[i].TransYM = math.Sin(dir) * speed
|
||||
|
||||
// The spin is set so that the rotational speed one spacing from the pole is SpinFraction of the
|
||||
// translation speed: the fraction means the same thing whatever the planet's size.
|
||||
sign := 1.0
|
||||
if s.Float() < 0.5 {
|
||||
sign = -1
|
||||
}
|
||||
if spacing > 0 {
|
||||
ps[i].OmegaRadYr = sign * cfg.Spin() * speed / spacing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// partition rasterises the plates onto the tectonic grid.
|
||||
//
|
||||
// A multiplicatively weighted Voronoi - nearest site by distance/weight - through a warped query point. The
|
||||
// warp is what stops the result being a polygon net: it is sampled from a lattice in world coordinates, so
|
||||
// two decompositions of the same planet warp the same point the same way, and its wavelength is deliberately
|
||||
// long compared with the plate spacing, because a boundary that wiggled at a ten-kilometre wavelength would
|
||||
// be a coastline rather than a plate margin.
|
||||
func partition(m *Model, seed int64) []int16 {
|
||||
p := m.P
|
||||
circ := p.CircumferenceM()
|
||||
spacing := spacingM(circ, p.HeightM(), m.Cfg.Count)
|
||||
|
||||
ws := noise.NewSource(seed, srcWarp)
|
||||
// Two octaves. Both cell counts are whole numbers of the noise period, because noise.Lattice.Sample wraps
|
||||
// modulo its own count and anything else is a discontinuity down one meridian.
|
||||
coarse := int(p.NoisePeriodM/spacing + 0.5)
|
||||
if coarse < 1 {
|
||||
coarse = 1
|
||||
}
|
||||
fine := coarse * 3
|
||||
wx := noise.NewLattice(coarse, ws)
|
||||
wy := noise.NewLattice(coarse, ws)
|
||||
fx := noise.NewLattice(fine, ws)
|
||||
fy := noise.NewLattice(fine, ws)
|
||||
amp := m.Cfg.WarpFraction * spacing
|
||||
|
||||
out := make([]int16, m.GW*m.GH)
|
||||
for gy := 0; gy < m.GH; gy++ {
|
||||
yM := m.GridYM(gy)
|
||||
v := yM / p.NoisePeriodM * float64(coarse)
|
||||
fv := yM / p.NoisePeriodM * float64(fine)
|
||||
row := gy * m.GW
|
||||
for gx := 0; gx < m.GW; gx++ {
|
||||
xM := m.GridXM(gx)
|
||||
u := xM / p.NoisePeriodM * float64(coarse)
|
||||
fu := xM / p.NoisePeriodM * float64(fine)
|
||||
qx := xM + ((float64(wx.Sample(u, v))*2-1)+(float64(fx.Sample(fu, fv))*2-1)*warpFineGain)*amp
|
||||
qy := yM + ((float64(wy.Sample(u, v))*2-1)+(float64(fy.Sample(fu, fv))*2-1)*warpFineGain)*amp
|
||||
|
||||
best, bestID := math.Inf(1), 0
|
||||
for i := range m.Plates {
|
||||
pl := &m.Plates[i]
|
||||
dx := wrapDelta(qx-pl.SiteXM, circ)
|
||||
dy := qy - pl.SiteYM
|
||||
d := math.Hypot(dx, dy) / pl.Weight
|
||||
if d < best {
|
||||
best, bestID = d, pl.ID
|
||||
}
|
||||
}
|
||||
out[row+gx] = int16(bestID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// measure walks the partition once and fills in everything that can only be known after it exists: each
|
||||
// plate's area, how much of it the author painted as land, and where its centre is.
|
||||
//
|
||||
// Painted rows only, for the land fraction. The polar pad is synthetic ocean that no class was ever painted
|
||||
// on, so counting it would drag every plate that reaches a pole towards oceanic for a reason that is
|
||||
// scaffolding rather than geography. The area and the centre do count the pad, because a plate really does
|
||||
// extend over it.
|
||||
//
|
||||
// **The centre is a circular mean in X.** A plate painted or drawn across the meridian has cells at both ends
|
||||
// of the raster, and an arithmetic mean of those columns puts its centre on the far side of the planet - and
|
||||
// with it the pole the whole plate rotates about, which would make its velocity field nonsense and every
|
||||
// margin around it wrong. This is the same rule as world.WrapX and Plate.VelocityAt's lever arm: the short
|
||||
// way round is the only way round.
|
||||
func (m *Model) measure(land func(xM, yM float64) bool) {
|
||||
n := len(m.Plates)
|
||||
landCells := make([]int, n)
|
||||
paintedCells := make([]int, n)
|
||||
sumSin := make([]float64, n)
|
||||
sumCos := make([]float64, n)
|
||||
sumY := make([]float64, n)
|
||||
|
||||
circ := m.P.CircumferenceM()
|
||||
for gy := range m.GH {
|
||||
yM := m.GridYM(gy)
|
||||
painted := yM >= 0 && yM < m.P.HeightM()
|
||||
row := gy * m.GW
|
||||
for gx := range m.GW {
|
||||
id := int(m.Cell[row+gx])
|
||||
pl := &m.Plates[id]
|
||||
pl.AreaCells++
|
||||
|
||||
xM := m.GridXM(gx)
|
||||
ang := 2 * math.Pi * xM / circ
|
||||
sumSin[id] += math.Sin(ang)
|
||||
sumCos[id] += math.Cos(ang)
|
||||
sumY[id] += yM
|
||||
|
||||
if !painted {
|
||||
continue
|
||||
}
|
||||
paintedCells[id]++
|
||||
if land != nil && land(xM, yM) {
|
||||
landCells[id]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range m.Plates {
|
||||
pl := &m.Plates[i]
|
||||
if paintedCells[i] > 0 {
|
||||
pl.LandFraction = float64(landCells[i]) / float64(paintedCells[i])
|
||||
}
|
||||
pl.Continental = pl.LandFraction >= m.Cfg.ContinentalFraction
|
||||
if pl.AreaCells > 0 {
|
||||
pl.CentroidXM, pl.CentroidYM = centroid(sumSin[i], sumCos[i], sumY[i], pl.AreaCells, circ)
|
||||
}
|
||||
// Every plate turns about its own centre of area. See giveMotion for why it is not somewhere else.
|
||||
pl.PoleXM, pl.PoleYM = pl.CentroidXM, pl.CentroidYM
|
||||
}
|
||||
}
|
||||
|
||||
// centroid turns the accumulated sums into a position, taking X the short way round the cylinder.
|
||||
func centroid(sumSin, sumCos, sumY float64, cells int, circ float64) (xM, yM float64) {
|
||||
ang := math.Atan2(sumSin, sumCos)
|
||||
if ang < 0 {
|
||||
ang += 2 * math.Pi
|
||||
}
|
||||
return ang / (2 * math.Pi) * circ, sumY / float64(cells)
|
||||
}
|
||||
|
||||
// wrapDelta brings a difference in X into -circ/2 .. +circ/2: the short way round the cylinder.
|
||||
func wrapDelta(d, circ float64) float64 {
|
||||
if circ <= 0 {
|
||||
return d
|
||||
}
|
||||
d = math.Mod(d, circ)
|
||||
if d > circ/2 {
|
||||
d -= circ
|
||||
} else if d < -circ/2 {
|
||||
d += circ
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user