// 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 }