Tooling
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user