438 lines
14 KiB
Go
438 lines
14 KiB
Go
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
|
|
}
|
|
}
|
|
}
|