Tooling
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
package uplift
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Faults on a painted planet: the structure inside a range that the painting cannot draw.
|
||||
//
|
||||
// A painted class is one rate over every cell of a colour, and a massif block breaks that into plain and
|
||||
// upland. Neither can produce the thing a real range has running through it - an escarpment, a block tilted
|
||||
// against its neighbour, a valley that is straight for fifteen kilometres because it is following a break in
|
||||
// the rock. That is a fault, and a fault is not a shape to paint: it is a *difference in uplift rate across a
|
||||
// line*, steep on one side and gentle on the other, which erosion then carves into a scarp. Paint it as
|
||||
// terrain and the solve erodes it away; apply it as a rate and it maintains itself, which is the same
|
||||
// argument as "paint the uplift, never the height" one scale down.
|
||||
//
|
||||
// The procedural path has had this since the beginning and none of it could be carried over as it stood,
|
||||
// for two reasons that are by now familiar.
|
||||
//
|
||||
// **Placement is in world metres, not map fractions.** `uplift.Build` draws a trace centre as two calls to
|
||||
// `s.Float()`, which are fractions of the grid it happens to be filling. On a decomposed planet that is a
|
||||
// different place in every region. Here the whole set is drawn once for the planet, in metres east of the
|
||||
// seam and metres south of the top painted row, and a region filters it to the traces that reach into its
|
||||
// own frame - so a fault that crosses a region boundary is one fault, and two different decompositions of the
|
||||
// same planet produce the same escarpment.
|
||||
//
|
||||
// **And a trace is placed in the ground the author painted for it.** A class carries `faults`, so an author
|
||||
// says *this range is faulted and that plain is not*, which is both the control they want and what is
|
||||
// actually true of the world: faults belong to orogens. The influence is not restricted to the class,
|
||||
// because a range-front fault runs along the edge of a range by definition and its scarp faces the lowland.
|
||||
//
|
||||
// The four defects recorded against the procedural version in Terrain-Next 4.A2 are fixed here rather than
|
||||
// carried, because writing a new implementation with a known fault list is cheaper than porting one and
|
||||
// fixing it afterwards:
|
||||
//
|
||||
// - The trace is a walk with a *perturbed heading* rather than one 8-point parabola, so the distance field
|
||||
// around it has no polygonal contours.
|
||||
// - The throw is **tapered to zero over the last sixth at each tip** rather than stopping dead where the
|
||||
// last segment ends, which is what made a fault cut abruptly across a summit.
|
||||
// - A long fault is broken into overlapping en-echelon segments, which is how long faults actually step.
|
||||
// - And nothing is clamped to a fraction of a global rate. The procedural version flattens its strongest
|
||||
// throws with `if r > convergent*1.6`, which turns exactly the faults that matter most into plateaus.
|
||||
// The ceiling here is the one with a physical meaning - the rate at which a divide reaches the angle of
|
||||
// repose - and the count of cells that reach it is reported rather than hidden.
|
||||
|
||||
const (
|
||||
srcPaintFaults = 27
|
||||
srcPaintGrain = 28
|
||||
)
|
||||
|
||||
// The escarpment's shape, in metres, and the reason it is that shape (D-62).
|
||||
//
|
||||
// The first version of this made the rate difference a *step*: the whole throw on one side of the trace and
|
||||
// the whole throw negated on the other, one cell apart, the positive side decaying to nothing over six
|
||||
// hundred metres and the negative side over six kilometres. Measured on Bake_013 that is a wall of two
|
||||
// throws across a single 8 m cell, standing in a welt six hundred metres wide. Two things follow, and both
|
||||
// are visible in a hillshade before any number is taken.
|
||||
//
|
||||
// **A step in the rate is a painted cliff.** "Paint the uplift, never the height" is a claim about what a
|
||||
// solve can undo, and a discontinuity in the rate field is precisely what it cannot: the surface has
|
||||
// nowhere to put the difference but into a scarp at the angle of repose, so the trace comes out as a
|
||||
// facetted line at any throw, and turning throw_m down only lowers the same artefact.
|
||||
//
|
||||
// **And six hundred metres is narrower than one hillslope.** Bake_013's drainage density is 0.45 channels
|
||||
// per kilometre, so a divide sits about 1.1 km from its channel. Nothing can dissect a block six hundred
|
||||
// metres wide - there is no drainage area at that width for stream power to work with, and hillslope
|
||||
// diffusion only smooths what is already there - so the uplift profile is *printed* onto the surface
|
||||
// rather than eroded into a landform. That is why every fault in that bake reads as a smooth ruled ridge
|
||||
// running through terrain dissected everywhere else: it is the one part of the map erosion never touched.
|
||||
//
|
||||
// So the profile is antisymmetric, continuous through the trace, and kilometres wide on both flanks.
|
||||
//
|
||||
// - faultRampM is how far the rate takes to cross from the hanging wall to the footwall - about one
|
||||
// hillslope length, which makes the mountain front the sharpest thing this landscape can express
|
||||
// without it being a cliff nobody solved for.
|
||||
// - faultFootwallM and faultHangingM are how far each flank reaches. Several hillslope lengths, so a
|
||||
// drainage network fits on the block and cuts it into spurs and valleys, which is what a range front
|
||||
// is and what an extruded cross-section will never be.
|
||||
//
|
||||
// Neither width scales with the trace's length, and that is deliberate twice over. Physically, the width
|
||||
// of flexural footwall uplift is set by how the crust bends rather than by the fault in it, so a short
|
||||
// fault on the same lithosphere makes a *lower* range, not a narrower one - which is what a throw does
|
||||
// here already. And practically, the floor is the one that matters: a flank has to be wide enough for a
|
||||
// drainage network whatever the trace is, and scaling it down for the planet's shortest traces - 1.7 km
|
||||
// on the shipped template against a 6.4 km median - would put the printing artefact straight back on
|
||||
// exactly those. `length_km` says how far a fault runs along strike; it does not say how wide a belt it
|
||||
// deforms.
|
||||
//
|
||||
// The anomaly is therefore zero *on the trace itself*, which is also the honest reading: a rate difference
|
||||
// across a line says one side rises relative to the other, and at the line the two average to the regional
|
||||
// rate. The old profile asserted +throw and -throw at the same point.
|
||||
const (
|
||||
faultRampM = 900.0
|
||||
faultFootwallM = 6000.0
|
||||
faultHangingM = 4000.0
|
||||
)
|
||||
|
||||
// faultReachM is where the influence is cut off.
|
||||
//
|
||||
// The flank envelope reaches zero *with zero gradient* at its own width, so unlike the old
|
||||
// exponential-minus-a-floor there is nothing to subtract and no step at the box edge to hide: the cut-off
|
||||
// is the support of the function rather than a truncation of it.
|
||||
const faultReachM = faultFootwallM
|
||||
|
||||
// faultShape is the unnormalised profile at a signed distance from the trace, positive on the upthrown
|
||||
// side: an odd saturating ramp across the line, times a flank envelope.
|
||||
//
|
||||
// The ramp is d/sqrt(R*R+d*d) rather than tanh and the envelope is (1-u*u)^2 rather than an exponential,
|
||||
// because this runs at every cell of every fault's box - a few hundred million times on a planet - and
|
||||
// neither transcendental buys anything over the algebraic pair.
|
||||
func faultShape(d float64) float64 {
|
||||
w := faultFootwallM
|
||||
if d < 0 {
|
||||
w = faultHangingM
|
||||
}
|
||||
u := math.Abs(d) / w
|
||||
if u >= 1 {
|
||||
return 0
|
||||
}
|
||||
e := 1 - u*u
|
||||
return d / math.Sqrt(faultRampM*faultRampM+d*d) * e * e
|
||||
}
|
||||
|
||||
// faultNorm scales the profile so the whole step across a fault - the footwall crest less the hanging wall
|
||||
// trough - is exactly the throw the author asked for, which is what the word means: the vertical
|
||||
// displacement across the fault. The old profile put a full throw on each side and so built two.
|
||||
//
|
||||
// Measured over the profile rather than written down, so that changing a width above cannot silently
|
||||
// change what throw_m means.
|
||||
var faultNorm = func() float64 {
|
||||
up, down := 0.0, 0.0
|
||||
for d := 1.0; d < faultReachM; d++ {
|
||||
if v := faultShape(d); v > up {
|
||||
up = v
|
||||
}
|
||||
if v := -faultShape(-d); v > down {
|
||||
down = v
|
||||
}
|
||||
}
|
||||
return up + down
|
||||
}()
|
||||
|
||||
// faultWeight is the escarpment profile, scaled so the crest-to-trough step across it is one throw.
|
||||
func faultWeight(d float64) float64 { return faultShape(d) / faultNorm }
|
||||
|
||||
// faultStackBonus is how much more than its strongest single fault a whole stack of them may build,
|
||||
// and it is the answer to the defect D-62 caused (D-63).
|
||||
//
|
||||
// Faults are rasterised with `+=`, which was harmless while a fault reached six hundred metres: two of
|
||||
// them almost never met. At six kilometres they meet constantly, and a fault set is *sub-parallel by
|
||||
// construction* - traces within one cell of the orientation grain share a strike, and a belt fault takes
|
||||
// its strike from the plate margin - so where they meet they are all pushing the same way. Measured on
|
||||
// Bake_018's region 11, twenty-two kilometres across with thirteen traces at strikes spanning fourteen
|
||||
// degrees: **75 % of the faulted ground had two or more faults on it**, the sum was a median 1.77 times
|
||||
// the largest single contribution there and up to 4.46 times, and the landmass came out at 221 m against
|
||||
// 75 m for the same ground before D-62. Thirteen per cent of it asked for more uplift than the repose
|
||||
// ceiling allows on its own, so the hard clamp downstream fired on 160 289 cells - 4.1 % of the region,
|
||||
// against 0.15 % over the whole planet before - and a hard clamp makes plateaus.
|
||||
//
|
||||
// The knee is the **largest single contribution at that cell**, not the largest throw in the set. A
|
||||
// planet-wide throw would not bite: the biggest throw on this template is 744 m while the biggest single
|
||||
// contribution anywhere in region 11 is 209 m, because a fault's own taper and falloff have already
|
||||
// reduced it by the time it reaches anywhere. Keyed per cell, one fault passes through untouched and only
|
||||
// the stacking is bent.
|
||||
const faultStackBonus = 0.6
|
||||
|
||||
// softStack combines the summed anomaly at a cell with the largest single contribution there.
|
||||
//
|
||||
// Below the knee it is the identity, so a cell reached by one fault gets exactly what that fault asked
|
||||
// for and D-62's calibration - the step across a fault is its throw - is unchanged. Above it the excess
|
||||
// is bent through a tanh onto an asymptote of (1+faultStackBonus) times the knee, so a belt still stands
|
||||
// higher than an unfaulted one, which is the point of a belt, but five parallel faults cannot deliver
|
||||
// five throws. Odd in `sum`, so a stack of hanging walls is bounded on the same terms.
|
||||
//
|
||||
// It is continuous: `peak` is a max of continuous functions and the join at the knee has gradient 1 on
|
||||
// both sides. And it is frame-independent, which is what keeps the decomposition honest - `sum` and
|
||||
// `peak` at a cell depend only on the faults within reach of it, and a fault too far away to be in a
|
||||
// frame's box contributes nothing to either.
|
||||
func softStack(sum, peak float64) float64 {
|
||||
if peak <= 0 {
|
||||
return 0
|
||||
}
|
||||
a := math.Abs(sum)
|
||||
if a <= peak {
|
||||
return sum
|
||||
}
|
||||
head := faultStackBonus * peak
|
||||
v := peak + head*math.Tanh((a-peak)/head)
|
||||
if sum < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// enEchelonM is the length past which a fault is drawn as overlapping segments instead of one line, and
|
||||
// enEchelonSpan is how long each segment is as a fraction of the parent.
|
||||
const (
|
||||
enEchelonM = 12000.0
|
||||
enEchelonSpan = 0.55
|
||||
)
|
||||
|
||||
// FaultSpec is what one painted class asks for. A class with no spec has no faults.
|
||||
type FaultSpec struct {
|
||||
// Per1000Km2 is how many traces to place in every thousand square kilometres of this class. It is a
|
||||
// density rather than a count because a class covers whatever an author painted, and a count would mean
|
||||
// something different on every template.
|
||||
Per1000Km2 float64
|
||||
|
||||
// ThrowM is the whole step across the fault over the run, low to high: the footwall crest less the
|
||||
// hanging wall trough, which is what the word throw means. It becomes a rate - the solve integrates it
|
||||
// for `steps * dt_yr` years - so what an author picks is how much higher the upthrown side would stand
|
||||
// than the downthrown one if erosion never touched either. Before D-62 the profile put a whole throw on
|
||||
// each flank and so built two of them, and a legend written against that asks for half what it did.
|
||||
ThrowM [2]float64
|
||||
|
||||
// LengthKm is how long a trace is, low to high.
|
||||
LengthKm [2]float64
|
||||
}
|
||||
|
||||
// Wanted reports whether this spec asks for anything.
|
||||
func (s FaultSpec) Wanted() bool {
|
||||
return s.Per1000Km2 > 0 && s.LengthKm[1] > 0 && s.ThrowM[1] > 0
|
||||
}
|
||||
|
||||
// FaultTrace is one fault, in world metres.
|
||||
//
|
||||
// X is **unwrapped**: a trace that crosses the seam has X running past the circumference or below zero rather
|
||||
// than jumping, so that every segment of it is a straight line between neighbouring points and no consumer
|
||||
// has to special-case the meridian. Whoever draws or tests it wraps by the circumference.
|
||||
type FaultTrace struct {
|
||||
PointsM [][2]float64 `json:"points_m"`
|
||||
ThrowM float64 `json:"throw_m"`
|
||||
LengthM float64 `json:"length_m"`
|
||||
Class int `json:"class"`
|
||||
|
||||
// Reverse flips which side goes up. Half of them do, drawn from the same stream, because a fault set in
|
||||
// which every block tilts the same way reads as corduroy.
|
||||
Reverse bool `json:"reverse"`
|
||||
}
|
||||
|
||||
// BuildFaults draws the planet's whole fault set, once, deterministically from the seed.
|
||||
//
|
||||
// candidates[c] is a strided sample of the planet cells belonging to class c: the list a trace centre is
|
||||
// drawn from, so a fault lands in the ground its class was painted on. Sampled rather than enumerated because
|
||||
// the full list for a class covering a seventh of a 76-million-cell planet is ten million entries, and the
|
||||
// only thing asked of it is a uniform draw. areaCells is the class's *exact* cell count, which the projection
|
||||
// already counted, so the density is not estimated from the sample.
|
||||
//
|
||||
// grainKm is the wavelength of the orientation field. Faults within one of its cells come out sub-parallel
|
||||
// and the set swings gradually across the world, which is what a fault set looks like and what a single
|
||||
// global strike angle - the procedural path's `grainAngle` - does not: Terrain-Next 4.A3 records that one
|
||||
// running as straight corduroy across a whole map.
|
||||
func BuildFaults(p world.Planet, seed int64, grainKm float64, specs []FaultSpec,
|
||||
candidates [][]int32, areaCells []int) []FaultTrace {
|
||||
|
||||
if grainKm <= 0 {
|
||||
return nil
|
||||
}
|
||||
any := false
|
||||
for _, s := range specs {
|
||||
any = any || s.Wanted()
|
||||
}
|
||||
if !any {
|
||||
return nil
|
||||
}
|
||||
|
||||
grainCells := int(p.NoisePeriodM/(grainKm*1000) + 0.5)
|
||||
if grainCells < 1 {
|
||||
grainCells = 1
|
||||
}
|
||||
gs := noise.NewSource(seed, srcPaintGrain)
|
||||
// Two lattices read as a vector rather than one read as an angle. A value lattice runs 0..1 and an angle
|
||||
// taken straight from it jumps by a whole turn wherever it crosses its own wrap, which would put a hard
|
||||
// seam through the fault set along a contour nobody can see. atan2 of two fields is continuous.
|
||||
gx := noise.NewLattice(grainCells, gs)
|
||||
gy := noise.NewLattice(grainCells, gs)
|
||||
// The bend lattice is finer, so a trace curves within the province its strike came from.
|
||||
bend := noise.NewLattice(grainCells*4, gs)
|
||||
|
||||
s := noise.NewSource(seed, srcPaintFaults)
|
||||
cellArea := p.CellM * p.CellM
|
||||
|
||||
var out []FaultTrace
|
||||
for c := range specs {
|
||||
spec := specs[c]
|
||||
if !spec.Wanted() || c >= len(candidates) || len(candidates[c]) == 0 || c >= len(areaCells) {
|
||||
continue
|
||||
}
|
||||
areaKm2 := float64(areaCells[c]) * cellArea / 1e6
|
||||
want := spec.Per1000Km2 * areaKm2 / 1000
|
||||
// Stochastic rounding, so a class too small for one whole fault still gets one sometimes and the
|
||||
// density means what it says when averaged over a world rather than being floored to zero.
|
||||
n := int(want)
|
||||
if s.Float() < want-float64(n) {
|
||||
n++
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
cell := candidates[c][s.IntN(len(candidates[c]))]
|
||||
cx := float64(int(cell)%p.W) * p.CellM
|
||||
cy := p.YM(int(cell) / p.W)
|
||||
lengthM := spec.LengthKm[0] + (spec.LengthKm[1]-spec.LengthKm[0])*s.Float()
|
||||
lengthM *= 1000
|
||||
throw := spec.ThrowM[0] + (spec.ThrowM[1]-spec.ThrowM[0])*s.Float()
|
||||
reverse := s.Float() < 0.5
|
||||
a := strikeAt(p, gx, gy, grainCells, cx, cy)
|
||||
out = append(out, traceSet(p, bend, grainCells, s, cx, cy, a, lengthM, throw, reverse, c)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// traceSet turns one drawn fault into the one or more traces it is actually made of.
|
||||
//
|
||||
// The strike is an argument rather than something this function looks up. Where a fault points is the whole
|
||||
// difference between the two placements that call it: a class fault takes its angle from a noise grain, and a
|
||||
// belt fault takes it from the plate boundary it belongs to. Everything below - the en-echelon step, the
|
||||
// walk, the taper - is the same fault either way.
|
||||
func traceSet(p world.Planet, bend *noise.Lattice, grainCells int, s *noise.Source,
|
||||
cx, cy, a, lengthM, throw float64, reverse bool, class int) []FaultTrace {
|
||||
|
||||
if lengthM <= enEchelonM {
|
||||
return []FaultTrace{walkTrace(p, bend, grainCells, cx, cy, a, lengthM, throw, reverse, class)}
|
||||
}
|
||||
// A long fault steps. Two or three overlapping segments, each a little over half the parent's length,
|
||||
// staggered along strike and offset across it - which is what a long fault does in the ground and is also
|
||||
// the difference between a fifteen-kilometre ruled line and something that reads as structure.
|
||||
n := 2
|
||||
if s.Float() < 0.5 {
|
||||
n = 3
|
||||
}
|
||||
segLen := lengthM * enEchelonSpan
|
||||
out := make([]FaultTrace, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
// Centres spread along the parent, from -0.5 to +0.5 of its length.
|
||||
t := (float64(i)/float64(n-1) - 0.5) * (lengthM - segLen)
|
||||
lateral := (s.Float() - 0.5) * 0.12 * lengthM
|
||||
sx := cx + math.Cos(a)*t - math.Sin(a)*lateral
|
||||
sy := cy + math.Sin(a)*t + math.Cos(a)*lateral
|
||||
out = append(out, walkTrace(p, bend, grainCells, sx, sy, a, segLen, throw, reverse, class))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// strikeAt is the fault grain's direction at a world position, as an angle.
|
||||
func strikeAt(p world.Planet, gx, gy *noise.Lattice, cells int, xM, yM float64) float64 {
|
||||
u := xM / p.NoisePeriodM * float64(cells)
|
||||
v := yM / p.NoisePeriodM * float64(cells)
|
||||
return math.Atan2(float64(gy.Sample(u, v))-0.5, float64(gx.Sample(u, v))-0.5)
|
||||
}
|
||||
|
||||
// walkTrace steps outward from the centre in both directions, turning a little each step.
|
||||
//
|
||||
// A walk rather than a formula. The procedural path draws one parabola through eight points, and a distance
|
||||
// field built from eight long straight segments has visibly polygonal contours - which is the first of the
|
||||
// four things Terrain-Next 4.A2 lists against it. Short steps with a heading that wanders make the same
|
||||
// gentle curve with none of that.
|
||||
func walkTrace(p world.Planet, bend *noise.Lattice, grainCells int,
|
||||
cx, cy, a, lengthM, throw float64, reverse bool, class int) FaultTrace {
|
||||
|
||||
segs := int(lengthM / 500)
|
||||
if segs < 8 {
|
||||
segs = 8
|
||||
}
|
||||
if segs > 64 {
|
||||
segs = 64
|
||||
}
|
||||
if segs%2 == 1 {
|
||||
segs++ // even, so the centre is a point rather than the middle of a segment
|
||||
}
|
||||
step := lengthM / float64(segs)
|
||||
// The heading turns by at most this much per step. Correlated through the lattice, so consecutive steps
|
||||
// see nearly the same value and the walk integrates into a smooth arc rather than a jitter.
|
||||
const maxTurn = 0.12
|
||||
|
||||
pts := make([][2]float64, segs+1)
|
||||
mid := segs / 2
|
||||
pts[mid] = [2]float64{cx, cy}
|
||||
for dir := -1; dir <= 1; dir += 2 {
|
||||
x, y, ang := cx, cy, a
|
||||
if dir < 0 {
|
||||
ang += math.Pi
|
||||
}
|
||||
for k := 1; k <= mid; k++ {
|
||||
u := x / p.NoisePeriodM * float64(grainCells*4)
|
||||
v := y / p.NoisePeriodM * float64(grainCells*4)
|
||||
ang += (float64(bend.Sample(u, v))*2 - 1) * maxTurn * float64(dir)
|
||||
x += math.Cos(ang) * step
|
||||
y += math.Sin(ang) * step
|
||||
pts[mid+dir*k] = [2]float64{x, y}
|
||||
}
|
||||
}
|
||||
return FaultTrace{PointsM: pts, ThrowM: throw, LengthM: lengthM, Class: class, Reverse: reverse}
|
||||
}
|
||||
|
||||
// FaultDelta is the uplift-rate change the fault set contributes over one frame, in metres a year, or nil
|
||||
// when none of them reaches it.
|
||||
//
|
||||
// Rasterised per fault into its own box rather than per cell over every fault: the set is planet-wide, so a
|
||||
// per-cell loop over all of it would be the whole planet's faults tested at every cell of every region. A
|
||||
// fault's box is its trace plus faultReachM on all sides, clipped to the frame, and inside it each cell tests
|
||||
// only the segments whose own boxes contain it.
|
||||
func FaultDelta(f world.Frame, faults []FaultTrace, runYears float64) []float32 {
|
||||
if len(faults) == 0 || runYears <= 0 {
|
||||
return nil
|
||||
}
|
||||
cellM := f.P.CellM
|
||||
circ := f.P.CircumferenceM()
|
||||
x0M, y0M := f.OriginXM(), f.OriginYM()
|
||||
x1M, y1M := x0M+float64(f.W)*cellM, y0M+float64(f.H)*cellM
|
||||
|
||||
// peak is the largest single contribution at each cell, which is the knee softStack bends the sum
|
||||
// over. Carried alongside rather than derived afterwards because a second pass over every fault would
|
||||
// cost exactly what the first one did.
|
||||
var out, peak []float32
|
||||
for fi := range faults {
|
||||
ft := &faults[fi]
|
||||
if len(ft.PointsM) < 2 {
|
||||
continue
|
||||
}
|
||||
// Shift the trace by whole turns of the planet so it sits nearest this frame. X is unwrapped in the
|
||||
// stored trace, so this is the one place the seam is dealt with, once per fault instead of per cell.
|
||||
pts := shiftToFrame(ft.PointsM, (x0M+x1M)/2, circ)
|
||||
|
||||
lo, hi := traceBounds(pts)
|
||||
if lo[0]-faultReachM > x1M || hi[0]+faultReachM < x0M ||
|
||||
lo[1]-faultReachM > y1M || hi[1]+faultReachM < y0M {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = make([]float32, f.W*f.H)
|
||||
peak = make([]float32, f.W*f.H)
|
||||
}
|
||||
cxa := clampInt(int((lo[0]-faultReachM-x0M)/cellM), 0, f.W-1)
|
||||
cxb := clampInt(int((hi[0]+faultReachM-x0M)/cellM)+1, 0, f.W-1)
|
||||
cya := clampInt(int((lo[1]-faultReachM-y0M)/cellM), 0, f.H-1)
|
||||
cyb := clampInt(int((hi[1]+faultReachM-y0M)/cellM)+1, 0, f.H-1)
|
||||
|
||||
sign := 1.0
|
||||
if ft.Reverse {
|
||||
sign = -1
|
||||
}
|
||||
rate := ft.ThrowM / runYears
|
||||
// One box per segment, computed here rather than inside the cell loop. The rejection below runs for
|
||||
// every segment at every cell in the fault's box - a few hundred million times on a large region -
|
||||
// and recomputing four min/max per test was most of what the pass cost.
|
||||
boxes := segmentBoxes(pts)
|
||||
for y := cya; y <= cyb; y++ {
|
||||
py := y0M + float64(y)*cellM
|
||||
row := y * f.W
|
||||
for x := cxa; x <= cxb; x++ {
|
||||
px := x0M + float64(x)*cellM
|
||||
d, along, ok := nearestOnTrace(px, py, pts, boxes)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v := float32(rate * faultWeight(d*sign) * tipTaper(along))
|
||||
out[row+x] += v
|
||||
if v < 0 {
|
||||
v = -v
|
||||
}
|
||||
if v > peak[row+x] {
|
||||
peak[row+x] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bend the stacking. Unconditional whenever anything was rasterised, including when a single trace
|
||||
// reached this frame: skipping it there would make a cell's value depend on which frame it was asked
|
||||
// about, which is the one thing FaultDelta is not allowed to do.
|
||||
for i := range out {
|
||||
if peak[i] > 0 {
|
||||
out[i] = float32(softStack(float64(out[i]), float64(peak[i])))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tipTaper is how much of its throw a fault carries at a fraction along its length.
|
||||
//
|
||||
// It was a ramp over the last sixth at each end with a flat top over the middle two thirds. That dies out
|
||||
// at the tips, which is what it was written for, and leaves the cross-section above *extruded* unchanged
|
||||
// along two thirds of every trace - which is the other half of why a fault reads as a ruled line. An
|
||||
// extrusion has no along-strike structure, so erosion has no reason to head a valley in one place rather
|
||||
// than another and the ridge stays as smooth as the function that drew it.
|
||||
//
|
||||
// A real fault carries most of its displacement near the middle and none at either tip, in a profile
|
||||
// somewhere between elliptical and a linear taper. This is that: an ellipse in q, lifted by q*(2-q) so the
|
||||
// middle four fifths keeps a body rather than coming to a point. Polynomial on purpose - it is evaluated
|
||||
// at every cell of every fault's box.
|
||||
func tipTaper(along float64) float64 {
|
||||
if along <= 0 || along >= 1 {
|
||||
return 0
|
||||
}
|
||||
q := 4 * along * (1 - along)
|
||||
return q * (2 - q)
|
||||
}
|
||||
|
||||
// nearestOnTrace is the signed perpendicular distance to a polyline and how far along it the nearest point
|
||||
// sits, 0 at one tip and 1 at the other. ok is false beyond the ends, where a fault has no effect.
|
||||
func nearestOnTrace(px, py float64, pts [][2]float64, boxes [][4]float64) (dist, along float64, ok bool) {
|
||||
best := math.Inf(1)
|
||||
sign := 1.0
|
||||
at := 0.0
|
||||
n := len(pts) - 1
|
||||
for j := 0; j < n; j++ {
|
||||
// Cheap rejection first: this loop runs for every cell in the fault's box, and on most of them every
|
||||
// segment misses.
|
||||
b := &boxes[j]
|
||||
if px < b[0] || px > b[2] || py < b[1] || py > b[3] {
|
||||
continue
|
||||
}
|
||||
ax, ay := pts[j][0], pts[j][1]
|
||||
bx, by := pts[j+1][0], pts[j+1][1]
|
||||
dx, dy := bx-ax, by-ay
|
||||
l2 := dx*dx + dy*dy
|
||||
if l2 < 1e-9 {
|
||||
continue
|
||||
}
|
||||
t := ((px-ax)*dx + (py-ay)*dy) / l2
|
||||
if t < 0 || t > 1 {
|
||||
continue // beyond this segment; a neighbouring one may still claim the point
|
||||
}
|
||||
projx, projy := ax+t*dx, ay+t*dy
|
||||
d := math.Hypot(px-projx, py-projy)
|
||||
if d < best {
|
||||
best = d
|
||||
at = (float64(j) + t) / float64(n)
|
||||
if (px-ax)*dy-(py-ay)*dx < 0 {
|
||||
sign = -1
|
||||
} else {
|
||||
sign = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if math.IsInf(best, 1) {
|
||||
return 0, 0, false
|
||||
}
|
||||
return best * sign, at, true
|
||||
}
|
||||
|
||||
// segmentBoxes is each segment's own box, grown by the reach: the rejection test in nearestOnTrace.
|
||||
func segmentBoxes(pts [][2]float64) [][4]float64 {
|
||||
out := make([][4]float64, len(pts)-1)
|
||||
for j := range out {
|
||||
ax, ay := pts[j][0], pts[j][1]
|
||||
bx, by := pts[j+1][0], pts[j+1][1]
|
||||
out[j] = [4]float64{
|
||||
math.Min(ax, bx) - faultReachM, math.Min(ay, by) - faultReachM,
|
||||
math.Max(ax, bx) + faultReachM, math.Max(ay, by) + faultReachM,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shiftToFrame moves a trace by whole circumferences so its middle is nearest a given longitude.
|
||||
func shiftToFrame(pts [][2]float64, centreXM, circ float64) [][2]float64 {
|
||||
mid := pts[len(pts)/2][0]
|
||||
k := math.Round((centreXM - mid) / circ)
|
||||
if k == 0 {
|
||||
return pts
|
||||
}
|
||||
out := make([][2]float64, len(pts))
|
||||
for i, p := range pts {
|
||||
out[i] = [2]float64{p[0] + k*circ, p[1]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func traceBounds(pts [][2]float64) (lo, hi [2]float64) {
|
||||
lo = [2]float64{math.Inf(1), math.Inf(1)}
|
||||
hi = [2]float64{math.Inf(-1), math.Inf(-1)}
|
||||
for _, p := range pts {
|
||||
lo[0] = math.Min(lo[0], p[0])
|
||||
lo[1] = math.Min(lo[1], p[1])
|
||||
hi[0] = math.Max(hi[0], p[0])
|
||||
hi[1] = math.Max(hi[1], p[1])
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user