483 lines
20 KiB
Go
483 lines
20 KiB
Go
// Package uplift builds what the fluvial solve integrates: a rock uplift rate field in metres per year, an
|
|
// erodibility field, a continent mask that says where the sea is, and a small initial relief to break the
|
|
// symmetry.
|
|
//
|
|
// This is heightmap_noise.generate_metres turned inside out (D-47). The numpy version produced the terrain:
|
|
// ranges to 2600 m, foothills, plains, and erosion was applied to it afterwards as decoration. Here the same
|
|
// shapes produce an uplift *rate*, and the terrain is whatever the stream-power solve makes of it. The
|
|
// percentile thresholding is kept exactly, because it is what makes the result seed-independent.
|
|
//
|
|
// The coast is kept (D-48). Sea level is the base level on every ocean cell, which is a far better-posed
|
|
// boundary for the solve than a single outlet edge and removes the artificial divide a one-outlet map has
|
|
// along three of its sides. This package decides *where* the coastline runs and nothing else about it; what
|
|
// the shoreline and the sea floor then look like belongs to package coast, which runs after the solve.
|
|
//
|
|
// # Why the plains need their own uplift, and not a flat one
|
|
//
|
|
// The first version gave the whole intraplate interior one uniform rate, 0.2 mm/yr against a convergent
|
|
// 5 mm/yr. That produced a table-flat green void with a polygonal river network scribbled across it, and the
|
|
// network was an artifact rather than drainage. Two reasons, and they are the same reason twice:
|
|
//
|
|
// - Steady state is S = U/(K*A^m). On a plain A is large and U was tiny, so S collapsed to nothing.
|
|
// - Uniform uplift over a wide area produces no *divides*. With no divides there is no drainage to find,
|
|
// so the router fell back on the only gradient present, which was the priority-flood's millimetre of
|
|
// epsilon, and drew the flood's own traversal geometry as rivers.
|
|
//
|
|
// Real intraplate regions are not uniform. They warp gently over tens of kilometres into swells and sags, and
|
|
// that warping is what puts divides on a plain. So the intraplate rate is modulated by a long-wavelength
|
|
// field.
|
|
//
|
|
// # And why the rate itself must stay low
|
|
//
|
|
// The first attempt at the above did the right thing and then overdid it: the plains were lifted to
|
|
// 0.25-0.9 mm/yr, on the reasoning that a higher rate sustains more relief against K. It does, but relief is
|
|
// not the quantity that was in trouble. Steady state is S = U/(K*A^m), and with no critical area that holds
|
|
// down to a single cell, so every divide stands at A = cell² whatever else is true of it. At the defaults
|
|
// that made 0.25 mm/yr a 32 degree hillslope and 0.9 mm/yr one past the angle of repose — so the repose clamp,
|
|
// which is meant to be a mountain process, became the surface of the whole continent. Measured: 81 % of the
|
|
// land fell in the >0.5 mm/yr class and the plains held 1 %.
|
|
//
|
|
// The lesson is that U and S are not two knobs. For n = 1, U alone fixes the hillslope angle at a given A,
|
|
// and the only things that make a plain flat are a low U or a large A. So the intraplate rate is an order of
|
|
// magnitude lower than it was and the *variation* carries the divides, which is what it was for. The
|
|
// mountain-to-plain ratio is 30-fold and up, which is what real ones are.
|
|
//
|
|
// # Where the land ends does not decide how fast it is rising
|
|
//
|
|
// The uplift rate used to be multiplied by the continent mask, which is a smoothstep, so it tapered to zero
|
|
// across the shore. That made every coastline on the map the lowest-uplift ground on the map, by construction
|
|
// and whatever the tectonics said — and since steady state is S = U/(K*A^m), ground with no uplift grades to
|
|
// no slope, so every coast was a plain. It is why the coastal pass measured a mean sea cliff of two metres
|
|
// while working perfectly: there was nothing anywhere on the map for the surf to cut into.
|
|
//
|
|
// The two questions are not the same question. The mask answers "is this cell sea", which is a yes or a no and
|
|
// is what the solve needs for its base level. The uplift field answers "how fast is this rock rising", and a
|
|
// range that happens to run out to the water is rising at range rates right up to the waterline — which is
|
|
// what Big Sur, the Norwegian west coast and the Great Australian Bight all are. So the mask is thresholded
|
|
// rather than multiplied, and whether a given coast is a cliff or a plain is now decided by where the range
|
|
// band falls relative to the coastline, which is exactly the sort of thing that should be decided by the
|
|
// tectonics and not by a smoothstep.
|
|
package uplift
|
|
|
|
import (
|
|
"math"
|
|
|
|
"salty/terrain/internal/field"
|
|
"salty/terrain/internal/manifest"
|
|
"salty/terrain/internal/noise"
|
|
)
|
|
|
|
type Result struct {
|
|
Rate *field.Field // rock uplift, metres per year
|
|
Height *field.Field // initial relief, metres; ocean cells sit at sea level and stay there for the solve
|
|
Land *field.Field // continent mask, 0 at sea, 1 inland
|
|
K *field.Field // erodibility multiplier from the lithology pass, around 1
|
|
Base []bool // cells fixed at base level: the ocean
|
|
|
|
Faults []Fault
|
|
}
|
|
|
|
// Fault is a recorded trace, kept for meta.json and for whatever later wants to draw one.
|
|
type Fault struct {
|
|
Points [][2]float64 `json:"points"` // map coordinates, 0..1
|
|
ThrowM float64 `json:"throw_m"`
|
|
Major bool `json:"major"`
|
|
Reverse bool `json:"reverse"` // which side goes up
|
|
}
|
|
|
|
// seaThreshold is where the continent mask stops being land. The mask is a smoothstep, so it has a ramp, and
|
|
// this is the one place that ramp is turned into the yes-or-no answer the solve needs: a cell is either an
|
|
// ocean cell held at base level or it is not.
|
|
//
|
|
// It is a named constant rather than a literal in two loops because the rate field and the height field have
|
|
// to agree about it exactly. If they ever disagreed, a cell would be uplifted and then pinned at base level,
|
|
// or held at sea level while taking no uplift, and neither would be visible in anything a run prints.
|
|
const seaThreshold = 0.02
|
|
|
|
// Pass indices for the seeded sources. Fixed and never reordered: a pass keeps its own stream so that
|
|
// inserting a later pass does not reshuffle the ones before it (cross-cutting rule 12).
|
|
const (
|
|
srcContinent = 1
|
|
srcWarp = 2
|
|
srcBand = 3
|
|
srcRidges = 4
|
|
srcCrests = 5
|
|
srcRelief = 6
|
|
srcSwell = 7
|
|
srcFaults = 8
|
|
srcLithology = 9
|
|
)
|
|
|
|
// Build produces the geology-grid inputs at size x size.
|
|
func Build(size int, cellM float64, m *manifest.Manifest) *Result {
|
|
seed := m.Source.Seed
|
|
cfg := m.Pipeline
|
|
sideM := float64(size-1) * cellM
|
|
u, v := noise.Identity(size)
|
|
|
|
// A low-frequency warp bends everything that follows, so ridges curve and ranges are not blobs.
|
|
ws := noise.NewSource(seed, srcWarp)
|
|
wx := noise.FBM(size, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
|
|
wy := noise.FBM(size, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
|
|
|
|
land := continentMask(size, u, v, wx, wy, seed, cfg.Continent)
|
|
|
|
// Ranges: an elongated, warped band says where they run, stretched across its grain so they come as long
|
|
// chains rather than patches. Thresholded by percentile, not by value, which is the whole trick.
|
|
bs := noise.NewSource(seed, srcBand)
|
|
angle := bs.Range(0, math.Pi)
|
|
cos, sin := math.Cos(angle), math.Sin(angle)
|
|
bu := field.NewLike(u)
|
|
bv := field.NewLike(v)
|
|
for i := range bu.Data {
|
|
x := float64(u.Data[i]) - 0.5
|
|
y := float64(v.Data[i]) - 0.5
|
|
along := x*cos + y*sin
|
|
across := -x*sin + y*cos
|
|
bu.Data[i] = float32(0.5 + along*0.7 + float64(wx.Data[i]-0.5)*0.32)
|
|
bv.Data[i] = float32(0.5 + across*2.2 + float64(wy.Data[i]-0.5)*0.32)
|
|
}
|
|
band := noise.FBMAt(bu, bv, bs, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
|
|
rangeMask := percentileMask(band, cfg.Plates.LowUpliftFraction.Hi()*100, 86)
|
|
|
|
// The regional swell: long-wavelength warping of the intraplate interior, which is what puts divides on
|
|
// a plain. Without it the lowlands have no drainage of their own and the flood's epsilon decides where
|
|
// the water goes.
|
|
ss := noise.NewSource(seed, srcSwell)
|
|
swu, swv := noise.Warp(u, v, wx, wy, 0.10)
|
|
swell := noise.FBMAt(swu, swv, ss, noise.Params{BaseCells: 2, Octaves: 4, Gain: 0.5})
|
|
swell.Normalise()
|
|
|
|
// Rock uplift in metres per year.
|
|
intraLo := cfg.Plates.IntraplateMmYr / 1000
|
|
intraHi := cfg.Plates.IntraplateSwellMmYr / 1000
|
|
if intraHi < intraLo {
|
|
intraHi = intraLo
|
|
}
|
|
convergent := cfg.Plates.ConvergentMmYr.Hi() / 1000
|
|
rate := field.New(size, size, cellM)
|
|
for i := range rate.Data {
|
|
if land.Data[i] <= seaThreshold {
|
|
// Ocean. The rate is zeroed for the sake of map_uplift and the statistics; the solve does not
|
|
// need it, because a base cell is fixed and StreamPower skips it before it reads the rate.
|
|
rate.Data[i] = 0
|
|
continue
|
|
}
|
|
base := intraLo + (intraHi-intraLo)*float64(swell.Data[i])
|
|
rate.Data[i] = float32(base + (convergent-base)*float64(rangeMask.Data[i]))
|
|
}
|
|
|
|
faults := buildFaults(rate, u, v, seed, angle, sideM, cfg.Faults, cfg.Plates.ConvergentMmYr.Hi()/1000,
|
|
float64(cfg.Fluvial.Steps)*cfg.Fluvial.DtYr)
|
|
|
|
// Lithology: a plan-view erodibility field. It is what stops every ridge in a range looking like every
|
|
// other ridge, because a hard band resists and a soft one is cut away.
|
|
k := lithology(size, cellM, u, v, wx, wy, seed, cfg.Lithology)
|
|
|
|
// Initial relief: small on purpose. The spec says 50-150 m x normalised uplift and it means it; the
|
|
// solve is what produces relief, and handing it 2600 m of ridged noise means it spends its whole run
|
|
// tearing that down instead of carving.
|
|
rs := noise.NewSource(seed, srcRidges)
|
|
wu, wv := noise.Warp(u, v, wx, wy, 0.16)
|
|
ridges := noise.FBMAt(wu, wv, rs, noise.Params{BaseCells: 5, Octaves: 6, Gain: 0.42, Ridged: true})
|
|
ridges.Normalise()
|
|
cs := noise.NewSource(seed, srcCrests)
|
|
cu, cv := noise.Warp(u, v, wx, wy, 0.224) // the stronger warp the crest lines need
|
|
crests := noise.CellularEdges(cu, cv, cs, 14, 0.95)
|
|
|
|
ps := noise.NewSource(seed, srcRelief)
|
|
plains := noise.FBM(size, ps, noise.Params{BaseCells: 6, Octaves: 4, Gain: 0.45})
|
|
|
|
ampLo := cfg.Relief.AmplitudeM.Lo()
|
|
ampHi := cfg.Relief.AmplitudeM.Hi()
|
|
crestW := cfg.Relief.CrestWeight
|
|
maxRate := convergent
|
|
if maxRate <= 0 {
|
|
maxRate = 1
|
|
}
|
|
height := field.New(size, size, cellM)
|
|
base := make([]bool, size*size)
|
|
for i := range height.Data {
|
|
if land.Data[i] <= seaThreshold {
|
|
// Ocean: base level, fixed, never eroded, never uplifted, and held at sea level for the whole
|
|
// solve. The sea floor is not laid here and deliberately not laid *yet* — a coastal cell drains
|
|
// into an ocean cell, and if that cell already sat at -180 m the solver would cut the river down
|
|
// to -180 m, because that is the base level it was handed. The first run with a coast eroded the
|
|
// land to 174 m below sea level for exactly that reason. Package coast lays the sea floor after
|
|
// the solve, where it also has the relief it needs to decide how wide the shelf is.
|
|
height.Data[i] = float32(m.SeaLevelM)
|
|
base[i] = true
|
|
continue
|
|
}
|
|
norm := float64(rate.Data[i]) / maxRate // normalised uplift, 0..1
|
|
amp := ampLo + (ampHi-ampLo)*norm
|
|
shape := (1-crestW)*float64(ridges.Data[i]) + crestW*float64(crests.Data[i])
|
|
height.Data[i] = float32(m.SeaLevelM + 20 + amp*shape + float64(plains.Data[i])*8)
|
|
}
|
|
|
|
return &Result{Rate: rate, Height: height, Land: land, K: k, Base: base, Faults: faults}
|
|
}
|
|
|
|
// percentileMask thresholds a field between two percentiles and smoothsteps between them, so the fraction of
|
|
// the map it covers is the same whatever the seed.
|
|
func percentileMask(f *field.Field, loPct, hiPct float64) *field.Field {
|
|
lo := f.Percentile(loPct)
|
|
hi := f.Percentile(hiPct)
|
|
span := float64(hi - lo)
|
|
if span < 1e-6 {
|
|
span = 1e-6
|
|
}
|
|
out := field.NewLike(f)
|
|
for i, b := range f.Data {
|
|
t := (float64(b) - float64(lo)) / span
|
|
if t < 0 {
|
|
t = 0
|
|
} else if t > 1 {
|
|
t = 1
|
|
}
|
|
out.Data[i] = float32(noise.Smoothstep(t))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// continentMask is the coast. A radial falloff with noise added to the radius gives a disc with a wobbly
|
|
// edge, which is what the first version did and what it looked like. Instead a warped multi-octave field is
|
|
// biased radially and then thresholded at the percentile that yields the wanted land fraction: the coastline
|
|
// gets bays, peninsulas and offshore deeps, and the land area is still the same whatever the seed.
|
|
func continentMask(size int, u, v, wx, wy *field.Field, seed int64, cfg manifest.Continent) *field.Field {
|
|
out := field.New(size, size, 1)
|
|
if !cfg.Enabled {
|
|
out.Fill(1)
|
|
return out
|
|
}
|
|
s := noise.NewSource(seed, srcContinent)
|
|
cx := 0.5 + (s.Float()-0.5)*0.12
|
|
cy := 0.5 + (s.Float()-0.5)*0.12
|
|
|
|
// Strong domain warp, so the shape is not obviously built from a circle.
|
|
//
|
|
// The octave count is the coastline's own detail and it is a manifest key because it is the one number
|
|
// that decides whether the continent has a coast or an outline: five octaves over this map is a 450 m
|
|
// finest feature, which is a smooth blob, and everything downstream that asks "is this stretch sheltered"
|
|
// then answers "no" everywhere. Gain stays at 0.5 rather than the 0.42 the *relief* noise uses, because
|
|
// this field is thresholded at a percentile rather than read as a height, so a steep spectrum costs
|
|
// nothing here and is what makes the shoreline crenellate.
|
|
cu, cv := noise.Warp(u, v, wx, wy, cfg.CoastWarp)
|
|
octaves := cfg.OutlineOctaves
|
|
if octaves < 1 {
|
|
octaves = 5
|
|
}
|
|
gain := cfg.OutlineGain
|
|
if gain <= 0 {
|
|
gain = 0.5
|
|
}
|
|
shape := noise.FBMAt(cu, cv, s, noise.Params{BaseCells: 2, Octaves: octaves, Gain: gain})
|
|
|
|
// The radial term only biases the field towards the middle; it does not define the edge.
|
|
score := field.New(size, size, 1)
|
|
for i := range score.Data {
|
|
x := float64(u.Data[i]) - cx
|
|
y := float64(v.Data[i]) - cy
|
|
radius := math.Hypot(x*1.05, y*0.95) / cfg.Radius
|
|
score.Data[i] = float32(float64(shape.Data[i]) - cfg.RadialBias*radius*radius)
|
|
}
|
|
|
|
// The threshold that yields the wanted land fraction, read off the distribution.
|
|
seaPct := (1 - cfg.LandFraction) * 100
|
|
lo := score.Percentile(seaPct)
|
|
hi := score.Percentile(math.Min(99.9, seaPct+cfg.ShoreWidthPct))
|
|
span := float64(hi - lo)
|
|
if span < 1e-6 {
|
|
span = 1e-6
|
|
}
|
|
for i, sc := range score.Data {
|
|
t := (float64(sc) - float64(lo)) / span
|
|
if t < 0 {
|
|
t = 0
|
|
} else if t > 1 {
|
|
t = 1
|
|
}
|
|
out.Data[i] = float32(noise.Smoothstep(t))
|
|
}
|
|
|
|
// The last few percent of the map is forced to sea, so land never touches the edge.
|
|
//
|
|
// This is not cosmetic. A border cell is an outlet: it takes no uplift, is never eroded, and the repose
|
|
// clamp will not lower it either, so any land that reaches the edge is frozen at whatever height the
|
|
// initial relief gave it while the interior erodes away beneath it. The result is a rim of untouched
|
|
// terrain standing over a hundred metres above its neighbour — which is exactly what the repose test
|
|
// found when it reported a 66 degree slope on a map whose angle of repose was 22. Percentile
|
|
// thresholding picks the lowest fraction of the *score* and has no reason to put it at the edges, so the
|
|
// margin has to be imposed.
|
|
const marginFrac = 0.04
|
|
for i := range out.Data {
|
|
x := float64(i%size) / float64(size-1)
|
|
y := float64(i/size) / float64(size-1)
|
|
d := math.Min(math.Min(x, 1-x), math.Min(y, 1-y))
|
|
if d < marginFrac {
|
|
out.Data[i] *= float32(noise.Smoothstep(math.Max(0, d/marginFrac)))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// lithology is the spec's 4.3: low-frequency noise thresholded into a few rock types, each with its own
|
|
// erodibility. Plan view, and orthogonal to the strata model that scales the particle pass by depth.
|
|
func lithology(size int, cellM float64, u, v, wx, wy *field.Field, seed int64, cfg manifest.Lithology) *field.Field {
|
|
out := field.New(size, size, cellM)
|
|
if cfg.Types <= 1 || len(cfg.KMultipliers) == 0 {
|
|
out.Fill(1)
|
|
return out
|
|
}
|
|
s := noise.NewSource(seed, srcLithology)
|
|
lu, lv := noise.Warp(u, v, wx, wy, 0.18)
|
|
f := noise.FBMAt(lu, lv, s, noise.Params{BaseCells: 3, Octaves: 4, Gain: 0.5})
|
|
|
|
n := cfg.Types
|
|
if n > len(cfg.KMultipliers) {
|
|
n = len(cfg.KMultipliers)
|
|
}
|
|
// Equal-area bands, so every rock type actually appears whatever the seed.
|
|
edges := make([]float32, n-1)
|
|
for i := 1; i < n; i++ {
|
|
edges[i-1] = f.Percentile(float64(i) / float64(n) * 100)
|
|
}
|
|
for i, val := range f.Data {
|
|
t := 0
|
|
for t < len(edges) && val > edges[t] {
|
|
t++
|
|
}
|
|
out.Data[i] = float32(cfg.KMultipliers[t])
|
|
}
|
|
// Softened, so a boundary is a transition rather than a wall the solver carves into a cliff.
|
|
return out.Blur(2)
|
|
}
|
|
|
|
// buildFaults perturbs the uplift field across a set of traces. Normal faults are applied as an uplift-rate
|
|
// *difference* across the trace, steep on one side and gentle on the other, and erosion then carves the
|
|
// escarpment; that is the spec's 4.2 and it is why a fault reads as landscape rather than as a drawn line.
|
|
//
|
|
// Orientation follows the range grain rather than being random, because a fault set that ignores the
|
|
// structure it belongs to looks like scratches.
|
|
func buildFaults(rate, u, v *field.Field, seed int64, grainAngle, sideM float64,
|
|
cfg manifest.Faults, convergent, runYears float64) []Fault {
|
|
s := noise.NewSource(seed, srcFaults)
|
|
if runYears <= 0 {
|
|
runYears = 1.5e6
|
|
}
|
|
|
|
nMajor := int(cfg.Major.Pick(s.Float()) + 0.5)
|
|
nMinor := int(cfg.Minor.Pick(s.Float()) + 0.5)
|
|
faults := make([]Fault, 0, nMajor+nMinor)
|
|
|
|
for i := 0; i < nMajor+nMinor; i++ {
|
|
major := i < nMajor
|
|
lengthM := cfg.LengthKm.Pick(s.Float()) * 1000
|
|
if major {
|
|
lengthM = math.Max(lengthM, cfg.LengthKm.Hi()*1000*0.6)
|
|
}
|
|
throw := cfg.ThrowMinorM.Pick(s.Float())
|
|
if major {
|
|
throw = cfg.ThrowMajorM.Pick(s.Float())
|
|
}
|
|
// Parallel to the grain, with a little scatter: never a random orientation.
|
|
a := grainAngle + (s.Float()-0.5)*0.6
|
|
cx, cy := s.Float(), s.Float()
|
|
half := lengthM / sideM / 2
|
|
|
|
// A polyline, gently curved by low-frequency wander rather than a straight segment.
|
|
const segs = 8
|
|
pts := make([][2]float64, segs+1)
|
|
wander := (s.Float() - 0.5) * 0.5
|
|
for j := 0; j <= segs; j++ {
|
|
t := float64(j)/segs*2 - 1 // -1..1
|
|
off := wander * (1 - t*t) // zero at the tips, largest in the middle
|
|
px := cx + math.Cos(a)*half*t - math.Sin(a)*half*off
|
|
py := cy + math.Sin(a)*half*t + math.Cos(a)*half*off
|
|
pts[j] = [2]float64{px, py}
|
|
}
|
|
faults = append(faults, Fault{Points: pts, ThrowM: throw, Major: major, Reverse: s.Float() < 0.5})
|
|
}
|
|
|
|
// Throw is a total displacement over the run, so it becomes a rate the solve can integrate.
|
|
steepM := 200.0
|
|
gentleM := 2000.0
|
|
field.Rows(rate.H, func(y0, y1 int) {
|
|
for y := y0; y < y1; y++ {
|
|
for x := 0; x < rate.W; x++ {
|
|
i := y*rate.W + x
|
|
px := float64(u.Data[i])
|
|
py := float64(v.Data[i])
|
|
var delta float64
|
|
for _, f := range faults {
|
|
d, inside := signedDistance(px, py, f.Points)
|
|
if !inside {
|
|
continue
|
|
}
|
|
dm := d * sideM
|
|
sign := 1.0
|
|
if f.Reverse {
|
|
sign = -1
|
|
}
|
|
// Steep side falls off fast, gentle side slowly: an asymmetric block, not a ridge.
|
|
var w float64
|
|
if dm*sign >= 0 {
|
|
w = math.Exp(-math.Abs(dm) / steepM)
|
|
} else {
|
|
w = -math.Exp(-math.Abs(dm) / gentleM)
|
|
}
|
|
delta += f.ThrowM / runYears * w
|
|
}
|
|
if delta != 0 {
|
|
r := float64(rate.Data[i]) + delta
|
|
if r < 0 {
|
|
r = 0
|
|
}
|
|
if r > convergent*1.6 {
|
|
r = convergent * 1.6
|
|
}
|
|
rate.Data[i] = float32(r)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
return faults
|
|
}
|
|
|
|
// signedDistance is the perpendicular distance from a point to a polyline, signed by which side it falls on,
|
|
// in map units. inside is false beyond the ends, where a fault has no effect.
|
|
func signedDistance(px, py float64, pts [][2]float64) (float64, bool) {
|
|
best := math.Inf(1)
|
|
sign := 1.0
|
|
found := false
|
|
for j := 0; j+1 < len(pts); j++ {
|
|
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-12 {
|
|
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
|
|
}
|
|
found = true
|
|
projx, projy := ax+t*dx, ay+t*dy
|
|
d := math.Hypot(px-projx, py-projy)
|
|
if d < best {
|
|
best = d
|
|
// Cross product decides the side.
|
|
if (px-ax)*dy-(py-ay)*dx < 0 {
|
|
sign = -1
|
|
} else {
|
|
sign = 1
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
return 0, false
|
|
}
|
|
return best * sign, true
|
|
}
|