Tooling
This commit is contained in:
@@ -16,14 +16,20 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"salty/terrain/internal/coast"
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/planet"
|
||||
"salty/terrain/internal/plates"
|
||||
"salty/terrain/internal/stats"
|
||||
"salty/terrain/internal/studio"
|
||||
"salty/terrain/internal/thermal"
|
||||
"salty/terrain/internal/uplift"
|
||||
)
|
||||
@@ -39,6 +45,36 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "terrain:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "plan":
|
||||
if err := planCmd(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "terrain:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "bake":
|
||||
if err := bakeCmd(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "terrain:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "tiles":
|
||||
if err := tilesCmd(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "terrain:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "overlay":
|
||||
if err := overlayCmd(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "terrain:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "studio":
|
||||
if err := studioCmd(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "terrain:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "palette":
|
||||
if err := paletteCmd(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "terrain:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
default:
|
||||
@@ -51,16 +87,71 @@ func main() {
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `terrain - the world's heightmap generator (Docs/Terrain.md)
|
||||
|
||||
terrain generate [flags]
|
||||
terrain generate [flags] the square canvas, from a seed
|
||||
terrain plan [flags] a painted planet: read the template, cut it into regions, solve nothing
|
||||
terrain bake [flags] a painted planet: solve every region and composite the world
|
||||
terrain tiles [flags] the detail passes over a bake, a batch of tiles at a time
|
||||
terrain palette PATH write the default preview palette out, to copy and change
|
||||
|
||||
--manifest PATH default RawContent/World/World.json, found by walking up from the working directory
|
||||
--seed N override the noise seed for this run
|
||||
--size N run the geology grid at N instead of the manifest's, for iterating
|
||||
--stage NAME stop after a stage: uplift, fluvial (default: the last one built)
|
||||
--steps N override the fluvial step count
|
||||
--mfd P multiple-flow exponent for drainage area; 0 reverts to D8's single receiver
|
||||
--smooth-passes N post-solve edge-preserving smooth; 0 is off (the default)
|
||||
--out DIR where the PNGs go (default: beside the manifest, or Preview/ for a --size run)
|
||||
--quiet only the summary
|
||||
--no-coast skip the coastal pass: a flat sea floor and an unworked shoreline
|
||||
|
||||
plan:
|
||||
--manifest PATH a manifest with a planet block; default RawContent/World/Planet.json
|
||||
--out DIR where the maps go (default: beside the manifest, in Plan/)
|
||||
--map-size N width in pixels of the maps it writes (default 2400)
|
||||
--margin-km F override planet.ocean_margin_km for this run
|
||||
--massif-km F override planet.massif_wavelength_km for this run
|
||||
--coast-jitter F override the outline jitter amplitude, template px; 0 projects the painting as drawn
|
||||
--coast-wavelength F --coast-octaves N --coast-gain F the rest of the outline jitter
|
||||
--quiet only the tables
|
||||
|
||||
bake:
|
||||
--manifest PATH default RawContent/World/Planet.json
|
||||
--out DIR where the maps go (default: the next free Bake_NNN beside the manifest)
|
||||
--only 3,11 solve only these regions, for iterating on one landmass
|
||||
--steps N override the fluvial step count
|
||||
--mfd P multiple-flow exponent for drainage area; 0 reverts to D8's single receiver
|
||||
--smooth-passes N post-solve edge-preserving smooth; 0 is off (the default)
|
||||
--jobs N how many regions to solve at once (default 3)
|
||||
--map-size N width in pixels of the preview and data maps (default 3000)
|
||||
--margin-km F override planet.ocean_margin_km for this run
|
||||
--massif-km F override planet.massif_wavelength_km for this run
|
||||
--coast-jitter F override the outline jitter amplitude, template px; 0 projects the painting as drawn
|
||||
--coast-wavelength F --coast-octaves N --coast-gain F the rest of the outline jitter
|
||||
--quiet only the summary
|
||||
|
||||
overlay:
|
||||
--manifest PATH default RawContent/World/Planet.json
|
||||
--bake DIR the bake to read the terrain from (default: the newest Bake_NNN beside the manifest)
|
||||
--out PATH where the sheet goes (default: the next Map_NNN.overlay.png beside the template)
|
||||
--replace start from a blank sheet instead of filling in around what is painted
|
||||
--no-save write nothing and only say what it would place
|
||||
--seed N override source.seed for this run
|
||||
--quiet only the summary
|
||||
|
||||
studio:
|
||||
--manifest PATH default RawContent/World/Planet.json
|
||||
--addr HOST:PORT where to listen (default 127.0.0.1:8099)
|
||||
|
||||
tiles:
|
||||
--manifest PATH default RawContent/World/Planet.json
|
||||
--bake DIR where planet_height.png is (default: the newest Bake_NNN beside the manifest)
|
||||
--out DIR where the tiles go (default: <bake>/tiles)
|
||||
--only x0,y0,x1,y1 a rectangle of tile indices; the default is all of them
|
||||
--prefix NAME the tile file stem (default Planet)
|
||||
--jobs N how many tiles to bake at once (default 4)
|
||||
--no-detail write the geology upsampled and nothing else: is it the solve or the detail passes?
|
||||
--no-shore skip the coastal detail pass: is it the shore pass or what it was handed?
|
||||
--quiet only the summary
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -93,6 +184,9 @@ func generate(args []string) error {
|
||||
criticalSlope := fs.Float64("critical-slope", -1, "override Sc in the nonlinear hillslope law, degrees; 0 reverts to linear diffusion and the in-loop clamp")
|
||||
slopeCap := fs.Float64("slope-cap", 0, "override where the nonlinear flux stops stiffening, as a fraction of Sc")
|
||||
hillslopeSub := fs.Int("hillslope-substeps", 0, "override the nonlinear hillslope sub-step budget")
|
||||
mfd := fs.Float64("mfd", -1, "override the multiple-flow exponent for drainage area; 0 reverts to D8's single receiver")
|
||||
smoothPasses := fs.Int("smooth-passes", -1, "override the post-solve edge-preserving smooth; 0 is off")
|
||||
smoothSlopeRef := fs.Float64("smooth-slope-ref", 0, "override the slope the smooth preserves, rise over run")
|
||||
mapSize := fs.Int("map-size", 1400, "side, in pixels, of the false-colour data maps")
|
||||
outlineOctaves := fs.Int("outline-octaves", 0, "override how much detail the coastline outline has")
|
||||
outlineGain := fs.Float64("outline-gain", 0, "override the coastline outline's octave gain: how crenellated it is")
|
||||
@@ -100,6 +194,7 @@ func generate(args []string) error {
|
||||
surfReach := fs.Float64("surf-reach", 0, "override how far inland the surf planes on open coast, m")
|
||||
cutFraction := fs.Float64("cut-fraction", 0, "override how completely the surf planes the shore platform, 0..1")
|
||||
shelfKm := fs.Float64("shelf-km", 0, "override the widest continental shelf, km")
|
||||
breakM := fs.Float64("break-m", 0, "override the depth at the shelf break, m")
|
||||
driftM := fs.Float64("drift", 0, "override how far sediment is carried along the shore, m")
|
||||
riverSediment := fs.Float64("river-sediment", -1, "override the river load per km2 of catchment, m3; 0 disables deltas")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -129,6 +224,15 @@ func generate(args []string) error {
|
||||
if *diffusion > 0 {
|
||||
m.Pipeline.Fluvial.DiffusionM2Yr = *diffusion
|
||||
}
|
||||
if *mfd >= 0 {
|
||||
m.Pipeline.Fluvial.MFDExponent = *mfd
|
||||
}
|
||||
if *smoothPasses >= 0 {
|
||||
m.Pipeline.Smooth.Passes = *smoothPasses
|
||||
}
|
||||
if *smoothSlopeRef > 0 {
|
||||
m.Pipeline.Smooth.SlopeRef = *smoothSlopeRef
|
||||
}
|
||||
if *talusDeg > 0 {
|
||||
m.Pipeline.Thermal.TalusDeg = *talusDeg
|
||||
}
|
||||
@@ -186,6 +290,9 @@ func generate(args []string) error {
|
||||
if *shelfKm > 0 {
|
||||
m.Pipeline.Coast.ShelfKm[1] = *shelfKm
|
||||
}
|
||||
if *breakM > 0 {
|
||||
m.Pipeline.Coast.BreakM = *breakM
|
||||
}
|
||||
if *driftM > 0 {
|
||||
m.Pipeline.Coast.DriftM = *driftM
|
||||
}
|
||||
@@ -258,6 +365,7 @@ func generate(args []string) error {
|
||||
CriticalSlope: thermal.TalusFromDegrees(m.Pipeline.Fluvial.CriticalSlopeDeg),
|
||||
SlopeCap: m.Pipeline.Fluvial.SlopeCap,
|
||||
MaxHillslopeSub: m.Pipeline.Fluvial.MaxHillslopeSub,
|
||||
MFDExponent: m.Pipeline.Fluvial.MFDExponent,
|
||||
}
|
||||
hillslope := fmt.Sprintf("linear D %.3f m2/yr, repose clamp every %d steps", p.Diffusion, m.Pipeline.Thermal.Every)
|
||||
if p.CriticalSlope > 0 {
|
||||
@@ -267,6 +375,11 @@ func generate(args []string) error {
|
||||
log("fluvial %d steps of %.0f yr (%.1f Myr), K %.1e, m %.2f, n %.2f, fill every %d",
|
||||
p.Steps, p.DtYr, float64(p.Steps)*p.DtYr/1e6, p.K, p.M, p.N, p.FillEvery)
|
||||
log("hillslope: %s", hillslope)
|
||||
if p.MFDExponent > 0 {
|
||||
log("drainage area: multiple-flow, exponent %.2f", p.MFDExponent)
|
||||
} else {
|
||||
log("drainage area: D8 single receiver")
|
||||
}
|
||||
grid = fluvial.NewGrid(geoSize, geoSize, geoCell, up.Base)
|
||||
grid.SetSeed(m.Source.Seed) // the flat-routing jitter; see internal/fluvial/jitter.go
|
||||
// Size the flood's bucket queue to the elevation the run can actually reach: the manifest's range,
|
||||
@@ -283,6 +396,18 @@ func generate(args []string) error {
|
||||
log(" %3.0f%% step %d/%d height %.0f..%.0f m eta %s", pct, step, total, lo, hi, eta.Round(time.Second))
|
||||
})
|
||||
log("fluvial done [%s]", since(solveStart))
|
||||
|
||||
// The edge-preserving pass, before the coast so the shore is worked on the surface that ships. Land
|
||||
// is "above sea level and not flagged as ocean"; the coastal pass owns everything else.
|
||||
if sm := m.Pipeline.Smooth; sm.Passes > 0 {
|
||||
land := make([]bool, len(h.Data))
|
||||
for i := range land {
|
||||
land[i] = h.Data[i] > float32(m.SeaLevelM) && (up.Base == nil || !up.Base[i])
|
||||
}
|
||||
smoothStart := time.Now()
|
||||
field.SmoothEdgePreserving(h.Data, h.W, h.H, h.CellM, land, sm.Passes, sm.SlopeRef, grid.Scratch())
|
||||
log("smooth: %d edge-preserving passes, slope ref %.2f [%s]", sm.Passes, sm.SlopeRef, since(smoothStart))
|
||||
}
|
||||
}
|
||||
|
||||
// The coast, last, on the terrain the solve produced: the sea floor, the surf and the sediment it moves.
|
||||
@@ -295,12 +420,14 @@ func generate(args []string) error {
|
||||
}
|
||||
cs := coast.Build(coast.Input{
|
||||
Height: h, Sea: up.Base, SeaLevelM: m.SeaLevelM,
|
||||
BreakM: -m.Pipeline.Continent.SeaFloorM.Hi(), AbyssM: -m.Pipeline.Continent.SeaFloorM.Lo(),
|
||||
BreakM: m.ShelfBreakM(), AbyssM: -m.Pipeline.Continent.SeaFloorM.Lo(),
|
||||
Flow: flow, Seed: m.Source.Seed, Cfg: m.Pipeline.Coast,
|
||||
})
|
||||
if m.Pipeline.Coast.Enabled {
|
||||
log("coast: shelf %.1f..%.1f km, surf reach %.0f m, drift %.0f m, %d fetch rays to %.0f m [%s]",
|
||||
m.Pipeline.Coast.ShelfKm.Lo(), m.Pipeline.Coast.ShelfKm.Hi(), m.Pipeline.Coast.SurfReachM,
|
||||
log("coast: shelf %.1f..%.1f km to a break at %.0f m, surf reach %.0f m, drift %.0f m, "+
|
||||
"%d fetch rays to %.0f m [%s]",
|
||||
m.Pipeline.Coast.ShelfKm.Lo(), m.Pipeline.Coast.ShelfKm.Hi(), m.ShelfBreakM(),
|
||||
m.Pipeline.Coast.SurfReachM,
|
||||
m.Pipeline.Coast.DriftM, m.Pipeline.Coast.FetchDirections, m.Pipeline.Coast.FetchRangeM,
|
||||
since(coastStart))
|
||||
}
|
||||
@@ -310,25 +437,24 @@ func generate(args []string) error {
|
||||
// statistics, the preview and the data maps use is the one the coast pass finished with.
|
||||
sea := cs.Sea
|
||||
land := invert(sea)
|
||||
landFrac = fractionTrue(land)
|
||||
hLo, hHi = h.MinMax()
|
||||
landLo, landHi := minMaxWhere(h.Data, land)
|
||||
rep := stats.Report{
|
||||
LandFraction: landFrac,
|
||||
ClipFraction: m.ClipFraction(h.Data),
|
||||
MinM: float64(hLo), MaxM: float64(hHi), ReliefM: float64(hHi - hLo),
|
||||
LandMinM: landLo, LandMaxM: landHi, LandReliefM: landHi - landLo,
|
||||
Slopes: stats.ComputeSlopes(h, land),
|
||||
Hypsometry: stats.ComputeHypsometry(h, land),
|
||||
Buckets: stats.UpliftBuckets(h, up.Rate.Data, land, m.Pipeline.Thermal.TalusDeg, *reliefWindowM),
|
||||
}
|
||||
// One accumulator over one grid. The square canvas is a single piece, so this is the degenerate case of
|
||||
// what a planet does with twenty - and it is the same code, which is what makes a number measured here
|
||||
// comparable with the same number measured on a bake.
|
||||
stats.SetExpected(m.Pipeline.Fluvial.M, m.Pipeline.Fluvial.N)
|
||||
acc := stats.New(stats.Options{
|
||||
ElevMin: m.ElevationM.Min, ElevMax: m.ElevationM.Max,
|
||||
TalusDeg: m.Pipeline.Thermal.TalusDeg, ReliefWindowM: *reliefWindowM,
|
||||
ChannelM2: *channelKm2 * 1e6, // the incoming spec's channel definition is 1 km²
|
||||
K: m.Pipeline.Fluvial.K, M: m.Pipeline.Fluvial.M, N: m.Pipeline.Fluvial.N,
|
||||
})
|
||||
sin := stats.Input{H: h, Land: land, UpliftMYr: up.Rate.Data, KLocal: kField}
|
||||
if grid != nil {
|
||||
threshold := *channelKm2 * 1e6 // the incoming spec's channel definition is 1 km²
|
||||
stats.SetExpected(m.Pipeline.Fluvial.M, m.Pipeline.Fluvial.N)
|
||||
rep.SlopeArea = stats.ComputeSlopeArea(h, grid.Area, grid.Receiver, grid.Length, land,
|
||||
up.Rate.Data, kField, m.Pipeline.Fluvial.K, m.Pipeline.Fluvial.N, threshold)
|
||||
rep.DrainageDensity = stats.DrainageDensity(grid.Area, land, geoCell, threshold)
|
||||
sin.Area, sin.Receiver, sin.Length = grid.Area, grid.Receiver, grid.Length
|
||||
}
|
||||
acc.Add(sin)
|
||||
acc.AddExtent(h.Data, land, m.ClipCells(h.Data))
|
||||
rep := acc.Report(geoCell)
|
||||
landFrac = rep.LandFraction
|
||||
fmt.Println(rep.Summary())
|
||||
if m.Pipeline.Coast.Enabled {
|
||||
fmt.Println()
|
||||
@@ -353,7 +479,7 @@ func generate(args []string) error {
|
||||
copy(flow.Data, grid.Area)
|
||||
pv.Flow = flow
|
||||
}
|
||||
if err := field.WritePreview(filepath.Join(outDir, "preview.png"), h, pv); err != nil {
|
||||
if _, err := field.WritePreview(filepath.Join(outDir, "preview.png"), h, pv); err != nil {
|
||||
return err
|
||||
}
|
||||
// A detail crop as well, always. The whole continent at 1500 px cannot show whether the lowlands read as
|
||||
@@ -363,7 +489,7 @@ func generate(args []string) error {
|
||||
detail.Size = 1400
|
||||
detail.RiverKm2 = 0.15
|
||||
detail.Exaggeration = 2.0
|
||||
if err := field.WritePreview(filepath.Join(outDir, "preview_detail.png"), h, detail); err != nil {
|
||||
if _, err := field.WritePreview(filepath.Join(outDir, "preview_detail.png"), h, detail); err != nil {
|
||||
return err
|
||||
}
|
||||
// The geology-grid height, so a preview run has something to look at. The full-resolution height belongs
|
||||
@@ -466,7 +592,32 @@ func since(t time.Time) string { return time.Since(t).Round(time.Millisecond).St
|
||||
|
||||
// findManifest walks up from the working directory, so the command works from anywhere in the repository
|
||||
// rather than only from the root.
|
||||
func findManifest(explicit string) (string, error) {
|
||||
func findManifest(explicit string) (string, error) { return findNamedManifest(explicit, "World.json") }
|
||||
|
||||
// studioCmd serves the painting tool. It is the one command that does not finish: it holds the template in
|
||||
// memory and waits for a browser.
|
||||
func studioCmd(args []string) error {
|
||||
fs := flag.NewFlagSet("studio", flag.ExitOnError)
|
||||
manifestPath := fs.String("manifest", "", "path to a manifest with a planet block")
|
||||
addr := fs.String("addr", "127.0.0.1:8099", "address to listen on")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := findNamedManifest(*manifestPath, "Planet.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log := func(format string, a ...any) { fmt.Printf(format+"\n", a...) }
|
||||
srv, err := studio.New(path, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srv.Close()
|
||||
fmt.Printf("terrain studio %s\n", path)
|
||||
return srv.Listen(*addr)
|
||||
}
|
||||
|
||||
func findNamedManifest(explicit, name string) (string, error) {
|
||||
if explicit != "" {
|
||||
return explicit, nil
|
||||
}
|
||||
@@ -475,7 +626,7 @@ func findManifest(explicit string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
candidate := filepath.Join(dir, "RawContent", "World", "World.json")
|
||||
candidate := filepath.Join(dir, "RawContent", "World", name)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
@@ -485,7 +636,617 @@ func findManifest(explicit string) (string, error) {
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return "", fmt.Errorf("no RawContent/World/World.json above %q; pass --manifest", mustWd())
|
||||
return "", fmt.Errorf("no RawContent/World/%s above %q; pass --manifest", name, mustWd())
|
||||
}
|
||||
|
||||
// planCmd reads a painted template and reports what baking it would involve, without eroding anything.
|
||||
//
|
||||
// It is the cheap half of the loop and it is deliberately a separate command rather than a flag on the bake:
|
||||
// the two decisions that can waste an hour - how the legend read the painting, and how the planet was cut
|
||||
// into regions - are both settled before the first erosion step, and both are pictures.
|
||||
func planCmd(args []string) error {
|
||||
fs := flag.NewFlagSet("plan", flag.ExitOnError)
|
||||
manifestPath := fs.String("manifest", "", "path to a manifest with a planet block")
|
||||
out := fs.String("out", "", "where the maps go")
|
||||
mapSize := fs.Int("map-size", 2400, "width in pixels of the maps")
|
||||
marginKm := fs.Float64("margin-km", 0, "override the ocean margin, km")
|
||||
massifKm := fs.Float64("massif-km", 0, "override the upland fabric's wavelength, km")
|
||||
coastJitter := fs.Float64("coast-jitter", -1, "override the outline jitter amplitude, template px; 0 is off")
|
||||
coastWave := fs.Float64("coast-wavelength", 0, "override the outline jitter's coarsest octave, template px")
|
||||
coastOct := fs.Int("coast-octaves", 0, "override the outline jitter's octave count")
|
||||
coastGain := fs.Float64("coast-gain", 0, "override the outline jitter's octave gain")
|
||||
plateCount := fs.Int("plates", 0, "override how many plates the lithosphere is in; 0 keeps the manifest's, which is off unless it says otherwise")
|
||||
proposePlates := fs.Bool("propose-plates", false, "write plates_proposal.png and .json: a tectonic layer to open, edit and point planet.plates.layer at")
|
||||
beltKm := fs.Float64("belt-km", 0, "override the deformation half-width around a plate margin, km; the zone the belt faults are placed in")
|
||||
beltDensity := fs.Float64("belt-density", 0, "override the belt fault density, traces per 1000 km2 of deformation zone")
|
||||
seed := fs.Int64("seed", 0, "re-roll everything the painting does not fix: the massifs, the rock, the faults and the coastline detail")
|
||||
quiet := fs.Bool("quiet", false, "only the tables")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := findNamedManifest(*manifestPath, "Planet.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := manifest.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applySeed(fs, seed, m)
|
||||
if !m.IsPlanet() {
|
||||
return fmt.Errorf("%s has no planet block; `terrain generate` is the command for the square canvas", path)
|
||||
}
|
||||
if *massifKm > 0 {
|
||||
m.Planet.MassifWavelengthKm = *massifKm
|
||||
}
|
||||
if *coastJitter >= 0 {
|
||||
m.Planet.CoastJitterPx = *coastJitter
|
||||
}
|
||||
if *coastWave > 0 {
|
||||
m.Planet.CoastJitterWavelengthPx = *coastWave
|
||||
}
|
||||
if *coastOct > 0 {
|
||||
m.Planet.CoastJitterOctaves = *coastOct
|
||||
}
|
||||
if *coastGain > 0 {
|
||||
m.Planet.CoastJitterGain = *coastGain
|
||||
}
|
||||
if *plateCount > 0 {
|
||||
m.Planet.Plates.Count = *plateCount
|
||||
}
|
||||
if *beltKm > 0 || *beltDensity > 0 {
|
||||
// Either flag switches the belt set on, so the other one has to come from somewhere: the defaults,
|
||||
// rather than zero, which would be "on but asking for nothing".
|
||||
b := m.Planet.Plates.Faults
|
||||
if !b.Wanted() {
|
||||
b = plates.DefaultBelt()
|
||||
}
|
||||
if *beltKm > 0 {
|
||||
b.ZoneKm = *beltKm
|
||||
}
|
||||
if *beltDensity > 0 {
|
||||
b.Per1000Km2 = *beltDensity
|
||||
}
|
||||
m.Planet.Plates.Faults = b
|
||||
}
|
||||
if *marginKm > 0 {
|
||||
m.Planet.OceanMarginKm = *marginKm
|
||||
if err := m.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
outDir := *out
|
||||
if outDir == "" {
|
||||
outDir = filepath.Join(filepath.Dir(path), "Plan")
|
||||
}
|
||||
|
||||
log := func(format string, a ...any) { fmt.Printf(format+"\n", a...) }
|
||||
if *quiet {
|
||||
log = func(string, ...any) {}
|
||||
}
|
||||
fmt.Printf("terrain plan %s -> %s\n", path, outDir)
|
||||
|
||||
in, err := planet.Plan(m, outDir, *mapSize, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Report().Print(os.Stdout)
|
||||
fmt.Printf(" wrote %s and plan.json to %s\n", strings.Join(in.MapNames(), ", "), outDir)
|
||||
if *proposePlates {
|
||||
if in.Plates == nil {
|
||||
return fmt.Errorf("--propose-plates needs a tectonic model to propose from; give it --plates N " +
|
||||
"or a planet.plates.count")
|
||||
}
|
||||
if err := planet.WritePlateProposal(outDir, in, *mapSize); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" wrote plates_proposal.png and plates_proposal.json: edit them, then point\n" +
|
||||
" planet.plates.layer and planet.plates.legend at them\n")
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
// bakeCmd solves a painted planet. Run it detached: a full bake is an hour, and a tool timeout that kills it
|
||||
// part way leaves nothing useful behind.
|
||||
func bakeCmd(args []string) error {
|
||||
fs := flag.NewFlagSet("bake", flag.ExitOnError)
|
||||
manifestPath := fs.String("manifest", "", "path to a manifest with a planet block")
|
||||
out := fs.String("out", "", "output directory")
|
||||
only := fs.String("only", "", "solve only these region ids, comma separated")
|
||||
steps := fs.Int("steps", 0, "override the fluvial step count")
|
||||
mapSize := fs.Int("map-size", 3000, "width in pixels of the preview and data maps")
|
||||
jobs := fs.Int("jobs", 3, "how many regions to solve at once")
|
||||
marginKm := fs.Float64("margin-km", 0, "override the ocean margin, km")
|
||||
massifKm := fs.Float64("massif-km", 0, "override the upland fabric's wavelength, km")
|
||||
coastJitter := fs.Float64("coast-jitter", -1, "override the outline jitter amplitude, template px; 0 is off")
|
||||
coastWave := fs.Float64("coast-wavelength", 0, "override the outline jitter's coarsest octave, template px")
|
||||
coastOct := fs.Int("coast-octaves", 0, "override the outline jitter's octave count")
|
||||
coastGain := fs.Float64("coast-gain", 0, "override the outline jitter's octave gain")
|
||||
breakM := fs.Float64("break-m", 0, "override the depth at the shelf break, m")
|
||||
shelfKm := fs.Float64("shelf-km", 0, "override the widest continental shelf, km")
|
||||
slopeKm := fs.Float64("slope-km", 0, "override how far the continental slope runs to the abyss, km")
|
||||
seed := fs.Int64("seed", 0, "re-roll everything the painting does not fix")
|
||||
mfd := fs.Float64("mfd", -1, "override the multiple-flow exponent for drainage area; 0 reverts to D8's single receiver")
|
||||
smoothPasses := fs.Int("smooth-passes", -1, "override the post-solve edge-preserving smooth; 0 is off")
|
||||
smoothSlopeRef := fs.Float64("smooth-slope-ref", 0, "override the slope the smooth preserves, rise over run")
|
||||
quiet := fs.Bool("quiet", false, "only the summary")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := findNamedManifest(*manifestPath, "Planet.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := manifest.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applySeed(fs, seed, m)
|
||||
if !m.IsPlanet() {
|
||||
return fmt.Errorf("%s has no planet block; `terrain generate` is the command for the square canvas", path)
|
||||
}
|
||||
if *massifKm > 0 {
|
||||
m.Planet.MassifWavelengthKm = *massifKm
|
||||
}
|
||||
if *coastJitter >= 0 {
|
||||
m.Planet.CoastJitterPx = *coastJitter
|
||||
}
|
||||
if *coastWave > 0 {
|
||||
m.Planet.CoastJitterWavelengthPx = *coastWave
|
||||
}
|
||||
if *coastOct > 0 {
|
||||
m.Planet.CoastJitterOctaves = *coastOct
|
||||
}
|
||||
if *coastGain > 0 {
|
||||
m.Planet.CoastJitterGain = *coastGain
|
||||
}
|
||||
if *breakM > 0 {
|
||||
m.Pipeline.Coast.BreakM = *breakM
|
||||
}
|
||||
if *shelfKm > 0 {
|
||||
m.Pipeline.Coast.ShelfKm[1] = *shelfKm
|
||||
}
|
||||
if *slopeKm > 0 {
|
||||
m.Pipeline.Coast.SlopeKm = *slopeKm
|
||||
}
|
||||
if *mfd >= 0 {
|
||||
m.Pipeline.Fluvial.MFDExponent = *mfd
|
||||
}
|
||||
if *smoothPasses >= 0 {
|
||||
m.Pipeline.Smooth.Passes = *smoothPasses
|
||||
}
|
||||
if *smoothSlopeRef > 0 {
|
||||
m.Pipeline.Smooth.SlopeRef = *smoothSlopeRef
|
||||
}
|
||||
if *marginKm > 0 {
|
||||
m.Planet.OceanMarginKm = *marginKm
|
||||
if err := m.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ids, err := parseIDs(*only)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outDir := *out
|
||||
if outDir == "" {
|
||||
outDir = planet.NextBakeDir(filepath.Dir(path))
|
||||
}
|
||||
log := func(format string, a ...any) { fmt.Printf(format+"\n", a...) }
|
||||
if *quiet {
|
||||
log = func(string, ...any) {}
|
||||
}
|
||||
fmt.Printf("terrain bake %s -> %s (GOMAXPROCS %d)\n", path, outDir, runtime.GOMAXPROCS(0))
|
||||
|
||||
in, err := planet.Prepare(m, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := planet.Bake(in, planet.BakeOptions{Only: ids, Steps: *steps, Jobs: *jobs, Log: log})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := res.Write(outDir, *mapSize, log); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Print("\n" + res.Summary())
|
||||
fmt.Printf(" wrote %s\n\n", outDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// overlayCmd proposes an annotation layer from a finished bake.
|
||||
//
|
||||
// The overlay starts blank and stays blank until somebody paints it, which is right for a layer whose whole
|
||||
// purpose is authorial - but it means every forest, town and road begins as a guess about terrain the author
|
||||
// cannot see. This reads a bake and fills the blanks: woodland where trees would grow, settlements where the
|
||||
// rivers, the flat ground and the coast agree, and the least-cost roads between them.
|
||||
//
|
||||
// **It never touches a painted pixel.** The sheet on disk is loaded first and generation fills around it, so
|
||||
// running this against a half-painted overlay adds to it rather than replacing it, and running it twice is
|
||||
// safe. --replace is the explicit way to throw the last generation away.
|
||||
func overlayCmd(args []string) error {
|
||||
fs := flag.NewFlagSet("overlay", flag.ExitOnError)
|
||||
manifestPath := fs.String("manifest", "", "path to a manifest with a planet block")
|
||||
bakeDir := fs.String("bake", "", "the bake to read the terrain from")
|
||||
out := fs.String("out", "", "where the generated sheet goes")
|
||||
replace := fs.Bool("replace", false, "ignore the overlay on disk instead of filling in around it")
|
||||
noSave := fs.Bool("no-save", false, "say what would be placed and write nothing")
|
||||
seed := fs.Int64("seed", 0, "override source.seed for this run")
|
||||
quiet := fs.Bool("quiet", false, "only the summary")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := findNamedManifest(*manifestPath, "Planet.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := manifest.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applySeed(fs, seed, m)
|
||||
if !m.IsPlanet() {
|
||||
return fmt.Errorf("%s has no planet block", path)
|
||||
}
|
||||
dir := *bakeDir
|
||||
if dir == "" {
|
||||
dir = latestBakeDir(filepath.Dir(path))
|
||||
if dir == "" {
|
||||
return fmt.Errorf("no %sNNN directory beside %s; the overlay is generated from a baked world, "+
|
||||
"so run `terrain bake` first, or pass --bake", bakePrefix, path)
|
||||
}
|
||||
}
|
||||
|
||||
log := func(format string, a ...any) { fmt.Printf(format+"\n", a...) }
|
||||
if *quiet {
|
||||
log = func(string, ...any) {}
|
||||
}
|
||||
fmt.Printf("terrain overlay %s\n", dir)
|
||||
|
||||
in, err := planet.Prepare(m, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ras, rep, err := planet.GenerateOverlay(planet.OverlayGenOptions{
|
||||
In: in, BakeDir: dir, Replace: *replace, Log: log,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
for _, line := range planet.OverlaySummary(rep, ras.W, ras.H) {
|
||||
fmt.Println(line)
|
||||
}
|
||||
if *noSave {
|
||||
fmt.Printf("\nwrote nothing (--no-save)\n\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
dest := *out
|
||||
if dest == "" {
|
||||
// Beside the template and versioned the same way the studio versions its saves, so a generated sheet
|
||||
// never destroys the one before it: the interesting question is almost always "what did that change".
|
||||
base := m.OverlayPath()
|
||||
if base == "" {
|
||||
base = strings.TrimSuffix(m.TemplatePath(), filepath.Ext(m.TemplatePath())) + ".overlay.png"
|
||||
}
|
||||
dest, err = nextOverlayVersion(base)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
px, alpha := in.Overlay.Encode(ras)
|
||||
if err := field.WriteRGBA(dest, ras.W, ras.H, px, alpha, png.DefaultCompression); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("wrote %s\n", dest)
|
||||
|
||||
// Point the manifest at it, so the next plan, bake and studio all read what was just written. Patched as
|
||||
// text, like every other write to these files, so the commentary survives.
|
||||
rel, err := filepath.Rel(filepath.Dir(path), dest)
|
||||
if err != nil {
|
||||
rel = dest
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if err := repointOverlay(path, rel); err != nil {
|
||||
return fmt.Errorf("the sheet was written but %s could not be repointed at it: %w", path, err)
|
||||
}
|
||||
fmt.Printf(" planet.overlay -> %s\n\n", rel)
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextOverlayVersion is the first <stem>_NNN.overlay.png beside a path that does not exist yet. It matches
|
||||
// the studio's numbering so the two write into the same series.
|
||||
func nextOverlayVersion(src string) (string, error) {
|
||||
dir := filepath.Dir(src)
|
||||
base := filepath.Base(src)
|
||||
stem := strings.TrimSuffix(base, ".overlay.png")
|
||||
if stem == base {
|
||||
stem = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
stem = regexp.MustCompile(`_[0-9]{3}$`).ReplaceAllString(stem, "")
|
||||
for n := 1; n < 1000; n++ {
|
||||
p := filepath.Join(dir, fmt.Sprintf("%s_%03d.overlay.png", stem, n))
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
return p, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s_001 through _999 all exist; tidy some up", stem)
|
||||
}
|
||||
|
||||
// repointOverlay sets planet.overlay in the manifest text without disturbing anything else in the file.
|
||||
func repointOverlay(manifestPath, rel string) error {
|
||||
raw, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := string(raw)
|
||||
key := regexp.MustCompile(`("overlay"\s*:\s*)"[^"]*"`)
|
||||
if key.MatchString(text) {
|
||||
text = key.ReplaceAllString(text, `${1}"`+rel+`"`)
|
||||
} else {
|
||||
// No key yet: add one beside the legend it belongs with, which is where an author would look for it.
|
||||
anchor := regexp.MustCompile(`("overlay_legend"\s*:\s*"[^"]*")`)
|
||||
if !anchor.MatchString(text) {
|
||||
return fmt.Errorf("neither planet.overlay nor planet.overlay_legend is in the file")
|
||||
}
|
||||
text = anchor.ReplaceAllString(text, `"overlay": "`+rel+`",\n ${1}`)
|
||||
}
|
||||
return os.WriteFile(manifestPath, []byte(text), 0o644)
|
||||
}
|
||||
|
||||
// tilesCmd runs the detail passes over a geology bake, in batches.
|
||||
//
|
||||
// It reads the bake's heightmap from disk rather than solving anything, which is the point: the geology is
|
||||
// hours and a tile is seconds, so the ground somebody actually wants to stand on can be baked first.
|
||||
func tilesCmd(args []string) error {
|
||||
fs := flag.NewFlagSet("tiles", flag.ExitOnError)
|
||||
manifestPath := fs.String("manifest", "", "path to a manifest with a planet block")
|
||||
bakeDir := fs.String("bake", "", "where planet_height.png is")
|
||||
out := fs.String("out", "", "where the tiles go")
|
||||
only := fs.String("only", "", "a rectangle of tile indices: x0,y0,x1,y1")
|
||||
prefix := fs.String("prefix", "Planet", "the tile file stem")
|
||||
jobs := fs.Int("jobs", 4, "how many tiles to bake at once")
|
||||
noDetail := fs.Bool("no-detail", false, "write the geology upsampled and nothing else, as a diagnostic")
|
||||
noShore := fs.Bool("no-shore", false, "skip the coastal detail pass, as a diagnostic")
|
||||
seed := fs.Int64("seed", 0, "the seed the bake was made with; it has to match")
|
||||
quiet := fs.Bool("quiet", false, "only the summary")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := findNamedManifest(*manifestPath, "Planet.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := manifest.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applySeed(fs, seed, m)
|
||||
if !m.IsPlanet() {
|
||||
return fmt.Errorf("%s has no planet block", path)
|
||||
}
|
||||
dir := *bakeDir
|
||||
if dir == "" {
|
||||
dir = latestBakeDir(filepath.Dir(path))
|
||||
if dir == "" {
|
||||
return fmt.Errorf("no %sNNN directory beside %s; run `terrain bake` first, or pass --bake",
|
||||
bakePrefix, path)
|
||||
}
|
||||
}
|
||||
outDir := *out
|
||||
if outDir == "" {
|
||||
outDir = filepath.Join(dir, "tiles")
|
||||
}
|
||||
|
||||
log := func(format string, a ...any) { fmt.Printf(format+"\n", a...) }
|
||||
if *quiet {
|
||||
log = func(string, ...any) {}
|
||||
}
|
||||
fmt.Printf("terrain tiles %s -> %s\n", dir, outDir)
|
||||
|
||||
in, err := planet.Prepare(m, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := planet.CheckBake(dir, m, log); err != nil {
|
||||
return err
|
||||
}
|
||||
hpath := filepath.Join(dir, "planet_height.png")
|
||||
values, w, h, err := field.ReadHeightmap(hpath, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w (run `terrain bake` first)", hpath, err)
|
||||
}
|
||||
if w != in.P.W || h != in.P.PaintH() {
|
||||
return fmt.Errorf("%s is %dx%d but the manifest describes a %dx%d planet; the bake and the manifest "+
|
||||
"have drifted apart", hpath, w, h, in.P.W, in.P.PaintH())
|
||||
}
|
||||
height := &field.Field{W: w, H: h, CellM: in.P.CellM, Data: m.Decode(values)}
|
||||
sea := make([]bool, w*h)
|
||||
for i, v := range height.Data {
|
||||
sea[i] = float64(v) < m.SeaLevelM
|
||||
}
|
||||
log("read %s: %d x %d at %.1f m", filepath.Base(hpath), w, h, in.P.CellM)
|
||||
|
||||
// The fetch field the coastal pass measured over the whole cylinder. A tile cannot compute it - see
|
||||
// detail.CoastalParams - so a bake that did not write one leaves every shore treated as fully exposed,
|
||||
// which is said once here rather than discovered in the output.
|
||||
var exposure *field.Field
|
||||
epath := filepath.Join(dir, "coast_exposure.png")
|
||||
if ev, ew, eh, err := field.ReadHeightmap(epath, 0); err == nil {
|
||||
if ew != w || eh != h {
|
||||
return fmt.Errorf("%s is %dx%d but the heightmap beside it is %dx%d", epath, ew, eh, w, h)
|
||||
}
|
||||
exposure = &field.Field{W: ew, H: eh, CellM: in.P.CellM, Data: make([]float32, len(ev))}
|
||||
for i, v := range ev {
|
||||
exposure.Data[i] = float32(v) / 65535
|
||||
}
|
||||
log("read coast_exposure.png: the shelter the coastal pass measured, %d x %d", ew, eh)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
} else {
|
||||
log("warning %s has no coast_exposure.png, so the coastal detail pass will treat every shore as "+
|
||||
"fully exposed. Rebake to get sheltered bays their own beaches", dir)
|
||||
}
|
||||
|
||||
opt := planet.TileOptions{
|
||||
In: in, HeightM: height, Sea: sea, Exposure: exposure, Out: outDir, Prefix: *prefix, Jobs: *jobs,
|
||||
NoDetail: *noDetail, NoShore: *noShore, Log: log,
|
||||
}
|
||||
if *only != "" {
|
||||
ids, err := parseIDs(*only)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) != 4 {
|
||||
return fmt.Errorf("--only wants four numbers, x0,y0,x1,y1; got %q", *only)
|
||||
}
|
||||
opt.Only = [4]int{ids[0], ids[1], ids[2], ids[3]}
|
||||
opt.OnlySet = true
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
idx, err := planet.BakeTiles(opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total, lo, hi := 0.0, 1e30, -1e30
|
||||
worstClip := 0.0
|
||||
for _, t := range idx.Tiles {
|
||||
total += t.Seconds
|
||||
lo = math.Min(lo, t.MinM)
|
||||
hi = math.Max(hi, t.MaxM)
|
||||
worstClip = math.Max(worstClip, t.ClipFrac)
|
||||
}
|
||||
fmt.Printf("\n %d tiles of %d px at %.1f m in %s (%.0f s of work)\n",
|
||||
len(idx.Tiles), idx.TilePx, idx.CellM, time.Since(started).Round(time.Second), total)
|
||||
fmt.Printf(" %.0f..%.0f m, worst clip %.3f%%\n", lo, hi, worstClip*100)
|
||||
if s := coastalSummary(idx); s != "" {
|
||||
fmt.Print(s)
|
||||
}
|
||||
fmt.Printf(" wrote %s and tiles.json\n\n", outDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// coastalSummary pools pass 11b's accounting over the tiles that had a shore in them.
|
||||
//
|
||||
// The backshore pair is the line to read and it is deliberately not just the cliff fraction: a batch with no
|
||||
// cliffs in it is either a coast with no cliffs on it or a threshold in the wrong place, and only the height
|
||||
// of the land behind the shore tells the two apart. On the first painted template it reads 0 m median and 2 m
|
||||
// P90, which is what a coastal plain is - every land class in that legend ramps its uplift up from the
|
||||
// waterline over a kilometre or more, so its coasts are plains by construction and its beaches are beaches.
|
||||
func coastalSummary(idx *planet.TileIndex) string {
|
||||
var shore, tiles int
|
||||
var cliff, cut, scree, beach, p50, p90 float64
|
||||
for _, t := range idx.Tiles {
|
||||
c := t.Coastal
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
tiles++
|
||||
shore += c.ShoreCells
|
||||
w := float64(c.ShoreCells)
|
||||
cliff += c.CliffFrac * w
|
||||
p50 += c.BackshoreP50M * w
|
||||
p90 += c.BackshoreP90M * w
|
||||
cut += c.CutM3
|
||||
scree += c.ScreeM3
|
||||
beach += c.BeachM3
|
||||
}
|
||||
if shore == 0 {
|
||||
return ""
|
||||
}
|
||||
w := float64(shore)
|
||||
km := w * idx.CellM / 1000
|
||||
return fmt.Sprintf(
|
||||
" shore: %.0f km of waterline over %d tiles, backshore %.1f m median and %.1f m P90, so %.0f%% of it\n"+
|
||||
" is cliff; the faces lost %.0f m3 and their aprons gained %.0f m3, beaches net %+.0f m3\n",
|
||||
km, tiles, p50/w, p90/w, cliff/w*100, cut, scree, beach)
|
||||
}
|
||||
|
||||
// paletteCmd writes the built-in palette out as a file.
|
||||
//
|
||||
// It exists so the defaults are something you can read and copy rather than something you have to find in
|
||||
// the source. A palette changes no height - two bakes of the same world under two palettes are the same
|
||||
// terrain - so swapping one is cheap and reversible, which is exactly the kind of thing that should be a
|
||||
// file.
|
||||
func paletteCmd(args []string) error {
|
||||
fs := flag.NewFlagSet("palette", flag.ExitOnError)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
return fmt.Errorf("usage: terrain palette <path to write>")
|
||||
}
|
||||
path := fs.Arg(0)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("%s already exists; pick another name rather than overwrite a palette somebody "+
|
||||
"may have edited", path)
|
||||
}
|
||||
p := field.DefaultPalette()
|
||||
if err := p.Write(path); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("wrote the default palette to %s\n", path)
|
||||
fmt.Println(`point a planet manifest at it with "palette": "<path relative to the manifest>"`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bake directories are versioned, so a re-bake never destroys the one before it.
|
||||
//
|
||||
// A bake is an hour and a half and the interesting question is almost always "what did that change", which
|
||||
// needs both. `--out` still overrides, and `terrain tiles` defaults to the newest, so the common case needs
|
||||
// no flags at all.
|
||||
const bakePrefix = "Bake_"
|
||||
|
||||
// latestBakeDir is the highest version that exists, or "" when there is none.
|
||||
func latestBakeDir(base string) string {
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
best, bestN := "", -1
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !strings.HasPrefix(e.Name(), bakePrefix) {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(e.Name(), bakePrefix))
|
||||
if err == nil && n > bestN {
|
||||
best, bestN = filepath.Join(base, e.Name()), n
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// parseIDs reads a comma-separated region list.
|
||||
func parseIDs(s string) ([]int, error) {
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var out []int
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("--only %q: %w", s, err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mustWd() string {
|
||||
@@ -615,3 +1376,20 @@ func localRelief(h *field.Field, windowM float64) *field.Field {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applySeed overrides the manifest's seed when --seed was actually given.
|
||||
//
|
||||
// `fs.Visit` rather than a sentinel value, because a seed is an arbitrary int64 and every sentinel is a seed
|
||||
// somebody could legitimately want. Visit reports only the flags the command line actually set, which is
|
||||
// exactly the question being asked.
|
||||
//
|
||||
// It is on `tiles` as well as on `plan` and `bake`, and not as a convenience: the detail passes hash the seed
|
||||
// into every droplet, so a tile run has to be told the same seed the heightmap was baked under. CheckBake
|
||||
// refuses the mismatch rather than producing a tile whose gullies belong to a different world.
|
||||
func applySeed(fs *flag.FlagSet, seed *int64, m *manifest.Manifest) {
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "seed" {
|
||||
m.Source.Seed = *seed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/uplift"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// The claim the whole fault feature rests on, end to end: a difference in uplift rate across a line survives
|
||||
// the solve as an escarpment, on the side the fault raises.
|
||||
//
|
||||
// It is here rather than in internal/uplift because everything up there tests the *rate* field - that it is
|
||||
// asymmetric, that two frames agree about it, that it tapers at the tips - and none of that says the solve
|
||||
// leaves anything behind. A fault is applied as a rate precisely so that erosion cannot remove it, and
|
||||
// "erosion cannot remove it" is a statement about a thousand steps of stream power, not about a weight
|
||||
// function. Measured on the real planet it comes out at 2.7 to 50 m of scarp for throws of 139 to 399 m, all
|
||||
// five facing the right way; this is that in miniature and fast enough to run every time.
|
||||
func TestAFaultLeavesAScarpAfterTheSolve(t *testing.T) {
|
||||
const w, h = 400, 400
|
||||
const cellM = 8.0
|
||||
const steps = 400
|
||||
const dtYr = 1500.0
|
||||
const runYears = steps * dtYr
|
||||
|
||||
p := world.Planet{CellM: cellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * cellM}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := world.Whole(p)
|
||||
|
||||
// One straight east-west trace across the middle of the grid. Straight on purpose: the question is what
|
||||
// the solve does to the step, and a curve would only make the measurement harder to read.
|
||||
midM := float64(h) * cellM / 2
|
||||
pts := make([][2]float64, 17)
|
||||
for i := range pts {
|
||||
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, midM}
|
||||
}
|
||||
trace := uplift.FaultTrace{PointsM: pts, ThrowM: 300, LengthM: float64(w) * cellM}
|
||||
delta := uplift.FaultDelta(f, []uplift.FaultTrace{trace}, runYears)
|
||||
if delta == nil {
|
||||
t.Fatal("the trace reached nothing")
|
||||
}
|
||||
|
||||
// A quiet landscape to put it in: the sea along the left edge as base level, and a low uniform rate
|
||||
// everywhere else so that anything standing up is the fault's doing and not the background's.
|
||||
base := make([]bool, w*h)
|
||||
rate := make([]float32, w*h)
|
||||
height := make([]float32, w*h)
|
||||
const backgroundMYr = 4.5e-5 // 0.045 mm/yr, the shipped highland foreland
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if x < 12 {
|
||||
base[i] = true
|
||||
continue
|
||||
}
|
||||
r := backgroundMYr + float64(delta[i])
|
||||
if r < 0 {
|
||||
r = 0
|
||||
}
|
||||
rate[i] = float32(r)
|
||||
height[i] = float32(20 + 4*math.Sin(float64(x)/23)*math.Cos(float64(y)/31))
|
||||
}
|
||||
}
|
||||
|
||||
g := fluvial.NewGrid(w, h, cellM, base)
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
g.Run(height, rate, nil, fluvial.Params{
|
||||
K: 5e-5, M: 0.5, N: 1, DtYr: dtYr, Steps: steps, Diffusion: 0.02, FillEvery: 1,
|
||||
TalusSlope: math.Tan(35 * math.Pi / 180), ThermalEvery: 4, ThermalPasses: 24,
|
||||
CriticalSlope: math.Tan(35 * math.Pi / 180), SlopeCap: 0.9, MaxHillslopeSub: 24,
|
||||
}, nil)
|
||||
|
||||
// The trace runs east-west, so the two sides are north and south of it. nearestOnTrace signs a point by
|
||||
// the cross product, which for a west-to-east trace puts the *north* side at d > 0 - the steep, upthrown
|
||||
// side of a fault that is not reversed.
|
||||
const offCells = 75 // 600 m either side, the same offset the planet-scale measurement used
|
||||
midCell := h / 2
|
||||
mean := func(row int) float64 {
|
||||
sum, n := 0.0, 0
|
||||
for x := 40; x < w-40; x++ {
|
||||
sum += float64(height[row*w+x])
|
||||
n++
|
||||
}
|
||||
return sum / float64(n)
|
||||
}
|
||||
up := mean(midCell - offCells)
|
||||
down := mean(midCell + offCells)
|
||||
|
||||
if up <= down {
|
||||
t.Fatalf("no scarp: the upthrown side averages %.1f m and the downthrown side %.1f m", up, down)
|
||||
}
|
||||
// Big enough to be terrain rather than noise, and well under the throw, because erosion takes most of a
|
||||
// fault's displacement away - which is the whole reason a fault has to be applied as a rate and not as a
|
||||
// shape. The planet-scale measurement puts the survivor at a few per cent to a fifth of the throw.
|
||||
if step := up - down; step < 5 {
|
||||
t.Errorf("the scarp is only %.1f m across a 300 m throw; that is not an escarpment", step)
|
||||
} else if step > trace.ThrowM {
|
||||
t.Errorf("the scarp is %.1f m against a %.0f m throw; nothing should exceed its own displacement",
|
||||
step, trace.ThrowM)
|
||||
}
|
||||
|
||||
// And it is *at the fault*, not a general tilt of the map: the step across the trace has to be far
|
||||
// sharper than the same distance measured entirely on one side of it.
|
||||
across := up - down
|
||||
within := math.Abs(mean(midCell-offCells) - mean(midCell-2*offCells))
|
||||
if across <= within {
|
||||
t.Errorf("the step across the trace is %.1f m and a step of the same span on one side of it is "+
|
||||
"%.1f m; that is a tilted map, not a fault", across, within)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/region"
|
||||
"salty/terrain/internal/template"
|
||||
"salty/terrain/internal/thermal"
|
||||
"salty/terrain/internal/uplift"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
const planetLegend = `{"classes":[
|
||||
{"name":"sea","rgb":[0,0,255],"sea":true,"depth_m":400},
|
||||
{"name":"plain","rgb":[150,200,100],"uplift_mm_yr":0.08,"k_mult":1.0},
|
||||
{"name":"range","rgb":[60,160,100],"uplift_mm_yr":0.9,"k_mult":0.6}
|
||||
]}`
|
||||
|
||||
// syntheticPlanet paints a small world with three landmasses, one of them across the seam, and returns it
|
||||
// classified and projected. It is the smallest thing that exercises everything a real bake does: a cylinder,
|
||||
// several regions, a seam, and two uplift classes.
|
||||
func syntheticPlanet(t *testing.T, seed int64) (*manifest.Manifest, *template.Map, *region.Partition) {
|
||||
t.Helper()
|
||||
lg, err := template.Parse([]byte(planetLegend))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const w, paintH, pad = 128, 64, 6
|
||||
p := world.Planet{CellM: 40, W: w, H: paintH + 2*pad, PadY: pad, NoisePeriodM: w * 40}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sea := uint8(lg.Index("sea"))
|
||||
plain := uint8(lg.Index("plain"))
|
||||
rng := uint8(lg.Index("range"))
|
||||
|
||||
m := &template.Map{P: p, L: lg, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)}
|
||||
for i := range m.Class {
|
||||
m.Class[i], m.Sea[i] = sea, true
|
||||
}
|
||||
put := func(x0, y0, w0, h0 int, c uint8) {
|
||||
for y := y0; y < y0+h0; y++ {
|
||||
for x := x0; x < x0+w0; x++ {
|
||||
i := (y+pad)*p.W + p.WrapX(x)
|
||||
m.Class[i], m.Sea[i] = c, false
|
||||
}
|
||||
}
|
||||
}
|
||||
put(20, 10, 30, 24, plain) // a plain
|
||||
put(30, 16, 12, 10, rng) // with a range in it
|
||||
put(70, 30, 22, 20, rng) // a mountainous island
|
||||
put(-4, 44, 10, 12, plain) // and one across the seam
|
||||
|
||||
part, err := region.Build(m, 4, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) < 3 {
|
||||
t.Fatalf("got %d regions, want at least 3", len(part.Regions))
|
||||
}
|
||||
seam := false
|
||||
for _, r := range part.Regions {
|
||||
seam = seam || r.Seam
|
||||
}
|
||||
if !seam {
|
||||
t.Fatal("no region straddles the seam; the test is not testing what it claims")
|
||||
}
|
||||
|
||||
man := manifest.Defaults()
|
||||
man.Source.Seed = seed
|
||||
man.Planet = &manifest.Planet{UpliftVariation: 0.3}
|
||||
return man, m, part
|
||||
}
|
||||
|
||||
// solvePlanet runs the whole painted path: cut each region, build its painted geology, solve it, composite
|
||||
// the land back. It is deliberately the same sequence internal/planet uses.
|
||||
func solvePlanet(t *testing.T, seed int64, steps int) []float32 {
|
||||
t.Helper()
|
||||
man, m, part := syntheticPlanet(t, seed)
|
||||
rates, ks := m.L.Rates(), m.L.Erodibilities()
|
||||
out := make([]float32, m.P.W*m.P.H)
|
||||
|
||||
params := fluvial.Params{
|
||||
K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: steps, Diffusion: 0.02, FillEvery: 1,
|
||||
TalusSlope: thermal.TalusFromDegrees(35), ThermalEvery: 4, ThermalPasses: 2,
|
||||
CriticalSlope: thermal.TalusFromDegrees(35), SlopeCap: 0.9, MaxHillslopeSub: 24,
|
||||
}
|
||||
for _, rg := range part.Regions {
|
||||
class, land := part.Cut(m, rg)
|
||||
up := uplift.FromTemplate(uplift.Paint{
|
||||
Frame: rg.Frame, Class: class, Land: land,
|
||||
Rates: rates, Ks: ks, Variation: man.Planet.UpliftVariation,
|
||||
}, man)
|
||||
h := up.Height.Clone()
|
||||
g := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
|
||||
g.SetSeed(man.Source.Seed)
|
||||
g.SetFrame(rg.Frame)
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
g.Run(h.Data, up.Rate.Data, up.K.Data, params, nil)
|
||||
part.Composite(out, m, rg, h.Data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The painted path's half of cross-cutting rule 12. The square canvas already has this assertion; a planet
|
||||
// adds three ways to break it that the square canvas cannot reach - the classifier's parallel reduction, the
|
||||
// region flood, and regions solved several at a time - so it gets its own.
|
||||
func TestPaintedPlanetIsDeterministicAcrossGOMAXPROCS(t *testing.T) {
|
||||
was := runtime.GOMAXPROCS(1)
|
||||
defer runtime.GOMAXPROCS(was)
|
||||
|
||||
var want string
|
||||
for _, procs := range []int{1, 2, 4, 8, 16} {
|
||||
runtime.GOMAXPROCS(procs)
|
||||
got := hash(solvePlanet(t, 7, 60))
|
||||
if want == "" {
|
||||
want = got
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("GOMAXPROCS %d gives %s, GOMAXPROCS 1 gives %s", procs, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameSeedSamePlanet(t *testing.T) {
|
||||
a := hash(solvePlanet(t, 11, 40))
|
||||
b := hash(solvePlanet(t, 11, 40))
|
||||
if a != b {
|
||||
t.Fatalf("two runs of the same seed differ: %s and %s", a, b)
|
||||
}
|
||||
if c := hash(solvePlanet(t, 12, 40)); c == a {
|
||||
t.Fatal("two different seeds give the same planet")
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant the whole per-landmass decomposition rests on, asserted directly.
|
||||
//
|
||||
// Solving a landmass in a box of its own is only the same answer as solving the planet whole because ocean
|
||||
// cells are held fixed at sea level and nothing in the solve can move them: ComputeReceivers makes every
|
||||
// outlet its own receiver, so no flow path crosses water, and StreamPower, both diffusions, the repose clamp
|
||||
// and thermal all skip a fixed cell. If that ever stopped being true, regions would start lying to each
|
||||
// other and nothing else in the suite would say so.
|
||||
func TestOceanCellsAreUntouchedByTheSolve(t *testing.T) {
|
||||
man, m, part := syntheticPlanet(t, 7)
|
||||
rates, ks := m.L.Rates(), m.L.Erodibilities()
|
||||
params := fluvial.Params{
|
||||
K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: 80, Diffusion: 0.02, FillEvery: 1,
|
||||
TalusSlope: thermal.TalusFromDegrees(35), ThermalEvery: 4, ThermalPasses: 2,
|
||||
CriticalSlope: thermal.TalusFromDegrees(35), SlopeCap: 0.9, MaxHillslopeSub: 24,
|
||||
}
|
||||
checked := 0
|
||||
for _, rg := range part.Regions {
|
||||
class, land := part.Cut(m, rg)
|
||||
up := uplift.FromTemplate(uplift.Paint{
|
||||
Frame: rg.Frame, Class: class, Land: land,
|
||||
Rates: rates, Ks: ks, Variation: 0.3,
|
||||
}, man)
|
||||
h := up.Height.Clone()
|
||||
g := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
|
||||
g.SetSeed(man.Source.Seed)
|
||||
g.SetFrame(rg.Frame)
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
g.Run(h.Data, up.Rate.Data, up.K.Data, params, nil)
|
||||
|
||||
for i, isBase := range up.Base {
|
||||
if !isBase {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if h.Data[i] != float32(man.SeaLevelM) {
|
||||
t.Fatalf("region %d: ocean cell %d came out at %g m, not sea level. The composite writes "+
|
||||
"only land for exactly this reason, and it is now unsafe", rg.ID, i, h.Data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no ocean cells were checked")
|
||||
}
|
||||
}
|
||||
@@ -98,10 +98,57 @@ type Input struct {
|
||||
Sea []bool // the continent mask's ocean: the cells the solve held at base level
|
||||
SeaLevelM float64 // the base level the solve used, and the datum every depth here is measured from
|
||||
BreakM float64 // depth at the shelf break, positive metres
|
||||
AbyssM float64 // depth of the abyssal floor, positive metres
|
||||
Flow []float32
|
||||
Seed int64
|
||||
Cfg manifest.Coast
|
||||
|
||||
// AbyssM is how deep the open ocean is, in positive metres, and Abyss is the same thing per cell when a
|
||||
// world has one. A painted planet does: its sea classes carry their own `depth_m`, so the ocean is
|
||||
// already laid at several depths before this pass runs, and a derived shelf that bottomed out at one
|
||||
// global abyss would put a step at the shelf break wherever the two disagreed. Nil falls back to AbyssM,
|
||||
// which is what the square canvas has and what every caller had before.
|
||||
AbyssM float64
|
||||
Abyss []float32
|
||||
|
||||
// WrapX says the grid is a cylinder: column W-1 and column 0 are neighbours. A planet is measured once,
|
||||
// whole, so every march, every ray and every running sum in this pass has to cross the seam - the
|
||||
// alternative is a shelf, a fetch and a sediment budget that all stop dead at one meridian.
|
||||
WrapX bool
|
||||
|
||||
// NoisePeriodM is how far the sea-floor roughness runs before it repeats. It has to divide the
|
||||
// circumference exactly on a cylinder or the noise breaks at the seam like every other field; zero means
|
||||
// the flat-grid default, which is a multiple of the roughness wavelength and repeats wherever it likes
|
||||
// because a flat grid has no seam to break.
|
||||
NoisePeriodM float64
|
||||
|
||||
Flow []float32
|
||||
Seed int64
|
||||
Cfg manifest.Coast
|
||||
}
|
||||
|
||||
// abyssAt is how deep the open ocean is at one cell.
|
||||
func (in Input) abyssAt(i int) float64 {
|
||||
if in.Abyss != nil {
|
||||
return float64(in.Abyss[i])
|
||||
}
|
||||
return in.AbyssM
|
||||
}
|
||||
|
||||
// col brings a column index onto the grid: wrapped on a cylinder, refused past the edge of a flat one.
|
||||
func (g *Geometry) col(x int) (int, bool) {
|
||||
if g.WrapX {
|
||||
return ((x % g.W) + g.W) % g.W, true
|
||||
}
|
||||
if x < 0 || x >= g.W {
|
||||
return 0, false
|
||||
}
|
||||
return x, true
|
||||
}
|
||||
|
||||
// distAt reads the signed distance field with X wrapped on a cylinder and clamped otherwise. Y always clamps,
|
||||
// because the top and bottom of the map are the poles and not each other.
|
||||
func (g *Geometry) distAt(x, y int) float64 {
|
||||
if g.WrapX {
|
||||
x = ((x % g.W) + g.W) % g.W
|
||||
}
|
||||
return float64(g.Dist.AtClamped(x, y))
|
||||
}
|
||||
|
||||
// Result is the geometry the pass built and the accounting it kept.
|
||||
@@ -167,7 +214,7 @@ func Build(in Input) *Result {
|
||||
w, ht := h.W, h.H
|
||||
cellArea := h.CellM * h.CellM
|
||||
|
||||
g := Measure(in.Sea, w, ht, h.CellM)
|
||||
g := MeasureWrapped(in.Sea, w, ht, h.CellM, in.WrapX)
|
||||
res := &Result{Geometry: g, Exposure: field.NewLike(h), Change: field.NewLike(h)}
|
||||
|
||||
// Disabled, or a map with no coast on it: the sea floor is the flat plane at the abyssal depth, which is
|
||||
@@ -175,10 +222,11 @@ func Build(in Input) *Result {
|
||||
if !in.Cfg.Enabled || len(g.Waterline) == 0 {
|
||||
for i := range in.Sea {
|
||||
if in.Sea[i] {
|
||||
h.Data[i] = float32(in.SeaLevelM - in.AbyssM)
|
||||
h.Data[i] = float32(in.SeaLevelM - in.abyssAt(i))
|
||||
}
|
||||
}
|
||||
res.finish(h.Clone(), in)
|
||||
copy(res.Change.Data, h.Data)
|
||||
res.finish(in)
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -189,20 +237,23 @@ func Build(in Input) *Result {
|
||||
// earlier it would be a map of the sea floor: the ocean cells go from sea level to -180 m in one step, and
|
||||
// a few hundred metres of that swamps the few metres the surf and the sediment move, which is the thing
|
||||
// the map exists to show.
|
||||
before := h.Clone()
|
||||
// The "before" snapshot and the change map are the same array. Change is h minus before, so the snapshot
|
||||
// is taken *into* the field that will hold the answer and subtracted from in place at the end - one field
|
||||
// of 304 MB at planet scale rather than two, for a picture.
|
||||
copy(res.Change.Data, h.Data)
|
||||
|
||||
shoreExposure := fetch(g, in)
|
||||
res.Stats.ExposureP10, res.Stats.ExposureP50, res.Stats.ExposureP90 = shorePercentiles(shoreExposure, g)
|
||||
res.Stats.ExposureP10, res.Stats.ExposureP50, res.Stats.ExposureP90 = shorePercentiles(shoreExposure)
|
||||
carried := field.NewLike(h)
|
||||
for i, ref := range g.Ref {
|
||||
if ref >= 0 {
|
||||
carried.Data[i] = shoreExposure.Data[ref]
|
||||
carried.Data[i] = shoreExposure[ref]
|
||||
}
|
||||
}
|
||||
// Smoothed for the same reason the shelf width is: carrying a per-shore value by "the stretch nearest to
|
||||
// you" partitions the map into Voronoi wedges, and a wedge boundary inside the deposition band would put
|
||||
// a straight edge through a beach.
|
||||
res.Exposure = boxMean(carried, int(exposureSmoothM/h.CellM+0.5), 2)
|
||||
res.Exposure = boxMean(carried, int(exposureSmoothM/h.CellM+0.5), 2, g.WrapX)
|
||||
|
||||
cut := plane(h, g, res.Exposure, in)
|
||||
|
||||
@@ -210,14 +261,21 @@ func Build(in Input) *Result {
|
||||
// waterline cell, so a parallel loop would be accumulating into the same slot from several goroutines and
|
||||
// the float sum would depend on who got there first. Cross-cutting rule 12 is not negotiable here, and
|
||||
// one linear pass over the grid costs nothing next to the solve.
|
||||
supply := make([]float64, w*ht)
|
||||
// One entry per *waterline cell*, not per grid cell. There are a few hundred thousand of the first and
|
||||
// tens of millions of the second, and this used to be the second: 608 MB at planet scale for an array
|
||||
// that is only ever read at the shore. See Geometry.Ref.
|
||||
supply := make([]float64, len(g.Waterline))
|
||||
var cutM3, planedCells float64
|
||||
for i, c := range cut.Data {
|
||||
if c <= 0 {
|
||||
continue
|
||||
}
|
||||
ref := g.Ref[i]
|
||||
if ref < 0 {
|
||||
continue // no shore to credit it to; cannot happen for a cell the surf reached, but cheap to say
|
||||
}
|
||||
v := float64(c) * cellArea
|
||||
supply[g.Ref[i]] += v
|
||||
supply[ref] += v
|
||||
cutM3 += v
|
||||
planedCells++
|
||||
}
|
||||
@@ -235,7 +293,7 @@ func Build(in Input) *Result {
|
||||
res.Stats.BackshoreM = backshore
|
||||
res.Stats.BackshoreP90M = backshoreP90
|
||||
res.Stats.ShelfPctSea = shelfFraction(g, in, shelfW)
|
||||
res.finish(before, in)
|
||||
res.finish(in)
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -245,12 +303,13 @@ func Build(in Input) *Result {
|
||||
// beach the pass built out of cliff debris is land, and a low headland it planed under the waterline is not.
|
||||
// The statistics and the preview both ask what is above sea level, so they get an answer about the terrain
|
||||
// rather than about the mask that seeded it.
|
||||
func (r *Result) finish(before *field.Field, in Input) {
|
||||
func (r *Result) finish(in Input) {
|
||||
h := in.Height
|
||||
r.Sea = make([]bool, len(h.Data))
|
||||
sea, beach, drowned := 0, 0, 0
|
||||
for i := range h.Data {
|
||||
r.Change.Data[i] = h.Data[i] - before.Data[i]
|
||||
// Change came in holding the *before* heights; it leaves holding the difference.
|
||||
r.Change.Data[i] = h.Data[i] - r.Change.Data[i]
|
||||
r.Sea[i] = float64(h.Data[i]) < in.SeaLevelM
|
||||
if r.Sea[i] {
|
||||
sea++
|
||||
@@ -283,7 +342,7 @@ func (r *Result) finish(before *field.Field, in Input) {
|
||||
// hundred metres turns the wedge boundaries back into what they should have been, a shelf whose width varies
|
||||
// smoothly along the coast.
|
||||
func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
out := field.NewLike(h)
|
||||
out := make([]float32, len(g.Waterline))
|
||||
steps := int(backshoreM/h.CellM + 0.5)
|
||||
lo := in.Cfg.ShelfKm.Lo() * 1000
|
||||
hi := in.Cfg.ShelfKm.Hi() * 1000
|
||||
@@ -295,8 +354,8 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
for n := a; n < b; n++ {
|
||||
i := int(g.Waterline[n])
|
||||
x, y := i%g.W, i/g.W
|
||||
dx := float64(g.Dist.AtClamped(x+1, y) - g.Dist.AtClamped(x-1, y))
|
||||
dy := float64(g.Dist.AtClamped(x, y+1) - g.Dist.AtClamped(x, y-1))
|
||||
dx := g.distAt(x+1, y) - g.distAt(x-1, y)
|
||||
dy := g.distAt(x, y+1) - g.distAt(x, y-1)
|
||||
l := math.Hypot(dx, dy)
|
||||
if l < 1e-6 {
|
||||
dx, dy, l = 1, 0, 1
|
||||
@@ -304,9 +363,9 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
dx, dy = dx/l, dy/l
|
||||
var relief float64
|
||||
for t := 1; t <= steps; t++ {
|
||||
px := x + int(math.Round(dx*float64(t)))
|
||||
px, ok := g.col(x + int(math.Round(dx*float64(t))))
|
||||
py := y + int(math.Round(dy*float64(t)))
|
||||
if px < 0 || py < 0 || px >= g.W || py >= g.H {
|
||||
if !ok || py < 0 || py >= g.H {
|
||||
break
|
||||
}
|
||||
if e := float64(h.Data[py*g.W+px]) - in.SeaLevelM; e > relief {
|
||||
@@ -317,19 +376,19 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
out.Data[i] = float32(hi + (lo-hi)*noise.Smoothstep(t))
|
||||
out[n] = float32(hi + (lo-hi)*noise.Smoothstep(t))
|
||||
}
|
||||
})
|
||||
|
||||
carried := field.NewLike(h)
|
||||
for i, ref := range g.Ref {
|
||||
if ref >= 0 {
|
||||
carried.Data[i] = out.Data[ref]
|
||||
carried.Data[i] = out[ref]
|
||||
} else {
|
||||
carried.Data[i] = float32(hi)
|
||||
}
|
||||
}
|
||||
return boxMean(carried, int(shelfSmoothM/h.CellM+0.5), 2)
|
||||
return boxMean(carried, int(shelfSmoothM/h.CellM+0.5), 2, g.WrapX)
|
||||
}
|
||||
|
||||
// layShelf writes the sea floor: a gentle shelf out to the break, then the continental slope to the abyss.
|
||||
@@ -339,10 +398,21 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
// land in every statistic downstream.
|
||||
func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
|
||||
cfg := in.Cfg
|
||||
// The lattice has to come back to itself at the seam, so on a cylinder the period is the planet's and not
|
||||
// a multiple of the roughness wavelength. Without it the sea floor gains a metre-scale discontinuity down
|
||||
// one meridian - small, and exactly the kind of thing nobody finds by looking at the middle of the map.
|
||||
period := cfg.RoughWaveM * 256
|
||||
u, v := noise.WorldUV(g.W, g.H, h.CellM, 0, 0, period)
|
||||
rough := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcShelf),
|
||||
noise.Params{BaseCells: 256, Octaves: 3, Gain: 0.5})
|
||||
if in.NoisePeriodM > 0 {
|
||||
period = in.NoisePeriodM
|
||||
}
|
||||
cells := 256
|
||||
if in.NoisePeriodM > 0 && cfg.RoughWaveM > 0 {
|
||||
cells = int(period/cfg.RoughWaveM + 0.5)
|
||||
if cells < 1 {
|
||||
cells = 1
|
||||
}
|
||||
}
|
||||
rough := shelfRoughness(g, in, period, cells)
|
||||
|
||||
exp := cfg.ShelfExponent
|
||||
if exp <= 0 {
|
||||
@@ -364,15 +434,24 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
|
||||
if width <= 0 {
|
||||
width = cfg.ShelfKm.Hi() * 1000
|
||||
}
|
||||
// The open-ocean depth at *this* cell, so the derived slope arrives exactly where the ocean
|
||||
// already is rather than at one global number it may be hundreds of metres from. And the
|
||||
// break cannot be deeper than the water it is a break in: painted shallows - a 20 m surf
|
||||
// class against a 30 m break - are shelf all the way out, with no slope to run down.
|
||||
abyss := in.abyssAt(i)
|
||||
brk := in.BreakM
|
||||
if abyss < brk {
|
||||
brk = abyss
|
||||
}
|
||||
var depth float64
|
||||
if d < width {
|
||||
depth = in.BreakM * math.Pow(d/width, exp)
|
||||
depth = brk * math.Pow(d/width, exp)
|
||||
} else {
|
||||
t := (d - width) / slopeW
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
depth = in.BreakM + (in.AbyssM-in.BreakM)*noise.Smoothstep(t)
|
||||
depth = brk + (abyss-brk)*noise.Smoothstep(t)
|
||||
}
|
||||
taper := depth / 10
|
||||
if taper > 1 {
|
||||
@@ -385,6 +464,28 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
|
||||
})
|
||||
}
|
||||
|
||||
// shelfRoughness is the noise on the sea floor, built in row bands.
|
||||
//
|
||||
// In bands because at planet scale the two coordinate fields and the result are three arrays of 76 million
|
||||
// floats - 900 MB for a field whose amplitude is ten metres. The lattices are rebuilt from the same seeded
|
||||
// source for every band, so the bands agree exactly where they meet; that is the same trick, for the same
|
||||
// reason, as internal/planet's ocean roughness.
|
||||
func shelfRoughness(g *Geometry, in Input, period float64, cells int) *field.Field {
|
||||
out := field.New(g.W, g.H, g.CellM)
|
||||
const bandRows = 512
|
||||
params := noise.Params{BaseCells: cells, Octaves: 3, Gain: 0.5}
|
||||
for y0 := 0; y0 < g.H; y0 += bandRows {
|
||||
y1 := y0 + bandRows
|
||||
if y1 > g.H {
|
||||
y1 = g.H
|
||||
}
|
||||
u, v := noise.WorldUV(g.W, y1-y0, g.CellM, 0, float64(y0)*g.CellM, period)
|
||||
band := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcShelf), params)
|
||||
copy(out.Data[y0*g.W:y1*g.W], band.Data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetch is how open the water is in front of each waterline cell: rays cast seaward until they hit land,
|
||||
// weighted by the cosine of their angle from the shore normal, and averaged.
|
||||
//
|
||||
@@ -402,8 +503,8 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
|
||||
// sheltered lagoon. A percentile is also a global statistic, which rule 1 of the tiling plan rules out: two
|
||||
// tiles would stretch by different anchors and their shared bay would be two different colours. So the
|
||||
// anchors are fixed and physical, and the units are "fraction of the fetch range the rays got".
|
||||
func fetch(g *Geometry, in Input) *field.Field {
|
||||
out := field.New(g.W, g.H, g.CellM)
|
||||
func fetch(g *Geometry, in Input) []float32 {
|
||||
out := make([]float32, len(g.Waterline))
|
||||
dirs := in.Cfg.FetchDirections
|
||||
if dirs < 4 {
|
||||
dirs = 4
|
||||
@@ -424,8 +525,8 @@ func fetch(g *Geometry, in Input) *field.Field {
|
||||
x0, y0 := i%g.W, i/g.W
|
||||
// The seaward normal: the distance field increases inland, so its gradient points away from the
|
||||
// water and the negative of it is the direction this stretch of shore faces.
|
||||
nx := -float64(g.Dist.AtClamped(x0+1, y0) - g.Dist.AtClamped(x0-1, y0))
|
||||
ny := -float64(g.Dist.AtClamped(x0, y0+1) - g.Dist.AtClamped(x0, y0-1))
|
||||
nx := -(g.distAt(x0+1, y0) - g.distAt(x0-1, y0))
|
||||
ny := -(g.distAt(x0, y0+1) - g.distAt(x0, y0-1))
|
||||
if l := math.Hypot(nx, ny); l > 1e-6 {
|
||||
nx, ny = nx/l, ny/l
|
||||
} else {
|
||||
@@ -441,10 +542,13 @@ func fetch(g *Geometry, in Input) *field.Field {
|
||||
}
|
||||
reach := maxSteps
|
||||
for t := 1; t <= maxSteps; t++ {
|
||||
px := x0 + int(math.Round(cs[k]*float64(t)))
|
||||
px, ok := g.col(x0 + int(math.Round(cs[k]*float64(t))))
|
||||
py := y0 + int(math.Round(sn[k]*float64(t)))
|
||||
if px < 0 || py < 0 || px >= g.W || py >= g.H {
|
||||
break // off the map is open water, and the mask keeps the border at sea
|
||||
if !ok || py < 0 || py >= g.H {
|
||||
// Off the map is open water, and the mask keeps the border at sea. On a cylinder a
|
||||
// ray never runs off in X at all - it comes round - so this is the poles, where the
|
||||
// synthetic polar ocean is genuinely open.
|
||||
break
|
||||
}
|
||||
if !in.Sea[py*g.W+px] {
|
||||
reach = t
|
||||
@@ -464,20 +568,20 @@ func fetch(g *Geometry, in Input) *field.Field {
|
||||
} else if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
out.Data[i] = float32(noise.Smoothstep(t))
|
||||
out[n] = float32(noise.Smoothstep(t))
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// shorePercentiles reports the fetch distribution over the waterline itself, before it is carried anywhere.
|
||||
func shorePercentiles(shore *field.Field, g *Geometry) (p10, p50, p90 float64) {
|
||||
if len(g.Waterline) == 0 {
|
||||
func shorePercentiles(shore []float32) (p10, p50, p90 float64) {
|
||||
if len(shore) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
vals := make([]float64, 0, len(g.Waterline))
|
||||
for _, i := range g.Waterline {
|
||||
vals = append(vals, float64(shore.Data[i]))
|
||||
vals := make([]float64, 0, len(shore))
|
||||
for _, v := range shore {
|
||||
vals = append(vals, float64(v))
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
at := func(f float64) float64 {
|
||||
@@ -630,16 +734,35 @@ func deposit(h *field.Field, g *Geometry, exposure *field.Field, in Input, suppl
|
||||
continue
|
||||
}
|
||||
shallow := (cfg.DepositDepthM - depth) / cfg.DepositDepthM
|
||||
shelter := shelterFloor + (1-shelterFloor)*math.Pow(1-float64(exposure.Data[i]), cfg.ShelterBias)
|
||||
// Clamped, and not defensively. `ShelterBias` is fractional, so `math.Pow` of a negative base is NaN
|
||||
// - and one NaN here spreads through the drift kernel into every cell of the budget and comes out as
|
||||
// a laid volume of NaN with no other symptom. Exposure is a smoothed field, so it is 0..1 only to
|
||||
// within the rounding of however it was smoothed; relying on the smoother to bound it is relying on
|
||||
// an invariant a hundred lines away. Found when the coverage became separable and the divisor changed
|
||||
// from float32 to float64: the ratio went over 1 by five parts in a hundred thousand, and 1720 cells
|
||||
// of a 200x40 test came out NaN.
|
||||
e := float64(exposure.Data[i])
|
||||
if e < 0 {
|
||||
e = 0
|
||||
} else if e > 1 {
|
||||
e = 1
|
||||
}
|
||||
shelter := shelterFloor + (1-shelterFloor)*math.Pow(1-e, cfg.ShelterBias)
|
||||
want.Data[i] = float32(shelter * shallow)
|
||||
}
|
||||
norm := boxBlur(want, radius, 3)
|
||||
norm := boxBlur(want, radius, 3, g.WrapX)
|
||||
// want is still needed below; norm and share are not, past the loops that read them. Dropping the
|
||||
// references is what lets the collector reclaim 304 MB apiece at planet scale before the next one is
|
||||
// allocated, rather than after.
|
||||
|
||||
// The supply is per waterline cell and the blur works on a grid, so it is scattered back onto the cells
|
||||
// its stretches of shore sit at. Distinct slots are distinct cells, so nothing collides.
|
||||
share := field.NewLike(h)
|
||||
for i, v := range supply {
|
||||
for slot, v := range supply {
|
||||
if v <= 0 {
|
||||
continue
|
||||
}
|
||||
i := int(g.Waterline[slot])
|
||||
nb := float64(norm.Data[i])
|
||||
if nb < 1e-9 {
|
||||
unplaced += v // nowhere within a drift length will take it
|
||||
@@ -647,7 +770,8 @@ func deposit(h *field.Field, g *Geometry, exposure *field.Field, in Input, suppl
|
||||
}
|
||||
share.Data[i] = float32(v / nb)
|
||||
}
|
||||
spread := boxBlur(share, radius, 3)
|
||||
spread := boxBlur(share, radius, 3, g.WrapX)
|
||||
share, norm = nil, nil
|
||||
|
||||
// place walks the grid in index order, which keeps the running totals deterministic: the writes are to
|
||||
// distinct cells but the sums are not, so this one stays serial.
|
||||
@@ -734,25 +858,65 @@ func shelfFraction(g *Geometry, in Input, shelfW *field.Field) float64 {
|
||||
// width with the mass-preserving kernel shrank every shelf near the border to nothing and put the whole
|
||||
// margin below the break. Blurring a field of ones with the same kernel gives exactly the coverage to divide
|
||||
// by, so the two share their arithmetic and cannot drift apart.
|
||||
func boxMean(f *field.Field, radius, passes int) *field.Field {
|
||||
// The coverage is *separable*, which is what keeps this affordable at planet scale.
|
||||
//
|
||||
// Blurring a field of ones is the obvious way to get the divisor, and it was the first way: two more full
|
||||
// fields plus a second boxBlur's two temporaries, which at 76 million cells is 1.2 GB for a quantity that
|
||||
// depends on nothing but the distance to the edge. But the blur is a row pass and a column pass, and applying
|
||||
// a 1-D operation to a field that is constant along the other axis leaves it constant along that axis - so
|
||||
// the coverage factorises as cx(x)*cy(y) for every pass count, exactly. Two vectors of W and H entries say
|
||||
// everything the field said.
|
||||
func boxMean(f *field.Field, radius, passes int, wrapX bool) *field.Field {
|
||||
if radius < 1 || passes < 1 {
|
||||
return f.Clone()
|
||||
}
|
||||
ones := field.NewLike(f)
|
||||
ones.Fill(1)
|
||||
sum := boxBlur(f, radius, passes)
|
||||
cover := boxBlur(ones, radius, passes)
|
||||
out := field.NewLike(f)
|
||||
for i := range out.Data {
|
||||
if c := cover.Data[i]; c > 1e-6 {
|
||||
out.Data[i] = sum.Data[i] / c
|
||||
} else {
|
||||
out.Data[i] = f.Data[i]
|
||||
cx := boxCover(f.W, radius, passes, wrapX)
|
||||
cy := boxCover(f.H, radius, passes, false) // Y never wraps: the top and bottom of a map are the poles
|
||||
out := boxBlur(f, radius, passes, wrapX)
|
||||
for y := 0; y < f.H; y++ {
|
||||
row := y * f.W
|
||||
for x := 0; x < f.W; x++ {
|
||||
if c := cx[x] * cy[y]; c > 1e-6 {
|
||||
out.Data[row+x] /= float32(c)
|
||||
} else {
|
||||
out.Data[row+x] = f.Data[row+x]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// boxCover is what a line of ones comes back as after the same running-sum passes boxBlur applies: 1 in the
|
||||
// middle and less than 1 within a kernel of each end, or 1 everywhere when the line wraps.
|
||||
func boxCover(n, radius, passes int, wrap bool) []float64 {
|
||||
cur := make([]float64, n)
|
||||
for i := range cur {
|
||||
cur[i] = 1
|
||||
}
|
||||
if wrap {
|
||||
return cur // every cell has a full window; nothing runs off a cylinder
|
||||
}
|
||||
next := make([]float64, n)
|
||||
inv := 1 / float64(2*radius+1)
|
||||
for p := 0; p < passes; p++ {
|
||||
var sum float64
|
||||
for i := 0; i <= radius && i < n; i++ {
|
||||
sum += cur[i]
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
next[i] = sum * inv
|
||||
if hi := i + radius + 1; hi < n {
|
||||
sum += cur[hi]
|
||||
}
|
||||
if lo := i - radius; lo >= 0 {
|
||||
sum -= cur[lo]
|
||||
}
|
||||
}
|
||||
cur, next = next, cur
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// boxBlur is a separable running-sum box blur: O(n) whatever the radius, which is what makes a 300 m drift
|
||||
// kernel cost the same as a 30 m one.
|
||||
//
|
||||
@@ -762,7 +926,7 @@ func boxMean(f *field.Field, radius, passes int) *field.Field {
|
||||
// neighbour's share of it — and dividing each output by its own truncated window size breaks that symmetry at
|
||||
// the border, which cost 4 % of the sediment budget on a coast that ran off the edge of the map. Zero padding
|
||||
// keeps K(i,j) = K(j,i) everywhere, and a cell outside the map has no want, so nothing is owed to it.
|
||||
func boxBlur(f *field.Field, radius, passes int) *field.Field {
|
||||
func boxBlur(f *field.Field, radius, passes int, wrapX bool) *field.Field {
|
||||
cur := f.Clone()
|
||||
if radius < 1 || passes < 1 {
|
||||
return cur
|
||||
@@ -773,6 +937,22 @@ func boxBlur(f *field.Field, radius, passes int) *field.Field {
|
||||
field.Rows(f.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
row := y * f.W
|
||||
if wrapX {
|
||||
// On a cylinder every cell has a *full* window in X, so the running sum wraps instead of
|
||||
// being truncated. That makes the row pass lossless rather than zero-padded, which the
|
||||
// mass balance is happy with for the same reason it was happy before: the kernel stays
|
||||
// symmetric, K(i,j) = K(j,i), and now nothing runs off the side at all.
|
||||
var sum float64
|
||||
for k := -radius; k <= radius; k++ {
|
||||
sum += float64(cur.Data[row+wrapCol(k, f.W)])
|
||||
}
|
||||
for x := 0; x < f.W; x++ {
|
||||
next.Data[row+x] = float32(sum * inv)
|
||||
sum += float64(cur.Data[row+wrapCol(x+radius+1, f.W)])
|
||||
sum -= float64(cur.Data[row+wrapCol(x-radius, f.W)])
|
||||
}
|
||||
continue
|
||||
}
|
||||
var sum float64
|
||||
for x := 0; x <= radius && x < f.W; x++ {
|
||||
sum += float64(cur.Data[row+x])
|
||||
@@ -810,3 +990,7 @@ func boxBlur(f *field.Field, radius, passes int) *field.Field {
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// wrapCol brings a column index onto a cylinder of width w. A free function rather than a Geometry method
|
||||
// because boxBlur is handed a plain field and has no geometry to ask.
|
||||
func wrapCol(x, w int) int { return ((x % w) + w) % w }
|
||||
|
||||
@@ -8,52 +8,7 @@ import (
|
||||
"salty/terrain/internal/manifest"
|
||||
)
|
||||
|
||||
// TestEdtMatchesBruteForce is the one test the whole package rests on. Everything else is written in terms of
|
||||
// "how far is this cell from the waterline and which stretch does it belong to", so a distance transform that
|
||||
// is subtly wrong would not fail loudly, it would put the shelf break in slightly the wrong place everywhere.
|
||||
// Felzenszwalb's transform is exact, so the comparison is against an exhaustive search and the tolerance is
|
||||
// float32 rounding, not a percentage.
|
||||
func TestEdtMatchesBruteForce(t *testing.T) {
|
||||
const w, h = 41, 37
|
||||
seed := uint32(99)
|
||||
seeds := make([]bool, w*h)
|
||||
for i := range seeds {
|
||||
seed = seed*1664525 + 1013904223
|
||||
seeds[i] = seed>>20&7 == 0
|
||||
}
|
||||
seeds[0] = true // guarantee at least one
|
||||
|
||||
d2, near := edt(seeds, w, h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
best := math.Inf(1)
|
||||
for sy := 0; sy < h; sy++ {
|
||||
for sx := 0; sx < w; sx++ {
|
||||
if !seeds[sy*w+sx] {
|
||||
continue
|
||||
}
|
||||
dx, dy := float64(x-sx), float64(y-sy)
|
||||
if d := dx*dx + dy*dy; d < best {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
}
|
||||
i := y*w + x
|
||||
if math.Abs(float64(d2[i])-best) > 1e-3 {
|
||||
t.Fatalf("cell (%d,%d): d2 %g, brute force %g", x, y, d2[i], best)
|
||||
}
|
||||
// The feature index must be a seed, and it must be one at exactly that distance.
|
||||
n := int(near[i])
|
||||
if n < 0 || !seeds[n] {
|
||||
t.Fatalf("cell (%d,%d): nearest %d is not a seed", x, y, n)
|
||||
}
|
||||
dx, dy := float64(x-n%w), float64(y-n/w)
|
||||
if math.Abs(dx*dx+dy*dy-best) > 1e-3 {
|
||||
t.Fatalf("cell (%d,%d): nearest seed %d is at %g, not %g", x, y, n, dx*dx+dy*dy, best)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The exact distance transform this pass is built on is tested in internal/dt, where it now lives.
|
||||
|
||||
// TestSignedDistanceIsMetresEitherWay checks the sign convention and the unit on a straight coast, where the
|
||||
// answer is arithmetic. The cells asked about are named explicitly: the map's own border is forced to sea by
|
||||
@@ -312,7 +267,7 @@ func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
|
||||
before += float64(f.Data[y*64+x])
|
||||
}
|
||||
}
|
||||
out := boxBlur(f, 5, 3)
|
||||
out := boxBlur(f, 5, 3, false)
|
||||
var after float64
|
||||
for _, v := range out.Data {
|
||||
after += float64(v)
|
||||
@@ -323,7 +278,7 @@ func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
|
||||
|
||||
one := field.New(64, 64, 1)
|
||||
one.Data[32*64+32] = 1
|
||||
k := boxBlur(one, 5, 3)
|
||||
k := boxBlur(one, 5, 3, false)
|
||||
for d := 1; d <= 16; d++ {
|
||||
l, r := k.Data[32*64+32-d], k.Data[32*64+32+d]
|
||||
if math.Abs(float64(l-r)) > 1e-7 {
|
||||
@@ -349,3 +304,231 @@ func TestDisabledIsThePreCoastBehaviour(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the cylinder ------------------------------------------------------------------------------------
|
||||
//
|
||||
// A planet is measured once, whole, so every march, every ray and every running sum in this pass has to cross
|
||||
// the seam. The twins below are the flat-grid tests' questions asked again on a cylinder, and the shape of
|
||||
// each one is the same: build a world, build the *same* world rotated half a turn, and require the answer to
|
||||
// follow the ground rather than the grid. A pass that stops at column zero passes every flat test there is.
|
||||
|
||||
// rotate shifts a grid half a turn in X. On a cylinder that is not a change to the world at all, so anything
|
||||
// this pass measures has to come out rotated with it and not otherwise different.
|
||||
func rotate(f *field.Field, sea []bool, by int) (*field.Field, []bool) {
|
||||
w, h := f.W, f.H
|
||||
g := field.New(w, h, f.CellM)
|
||||
s := make([]bool, len(sea))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
src := y*w + x
|
||||
dst := y*w + (x+by)%w
|
||||
g.Data[dst] = f.Data[src]
|
||||
s[dst] = sea[src]
|
||||
}
|
||||
}
|
||||
return g, s
|
||||
}
|
||||
|
||||
// islandFixture is a round island on an otherwise open ocean, centred where the caller asks. Put the centre at
|
||||
// x=0 and it straddles the seam.
|
||||
func islandFixture(w, h, cx, cy, radius int, cellM, heightM float64) (*field.Field, []bool) {
|
||||
f := field.New(w, h, cellM)
|
||||
sea := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
dx := x - cx
|
||||
if dx > w/2 {
|
||||
dx -= w
|
||||
} else if dx < -w/2 {
|
||||
dx += w
|
||||
}
|
||||
dy := y - cy
|
||||
if dx*dx+dy*dy <= radius*radius {
|
||||
f.Data[i] = float32(heightM)
|
||||
} else {
|
||||
sea[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, sea
|
||||
}
|
||||
|
||||
// The whole pass, twice, on the same island in two places. Everything it produces has to be the same world
|
||||
// rotated - which is the one assertion that catches a march, a ray or a running sum stopping at the seam,
|
||||
// because on a flat grid the two would differ and nobody would know which was right.
|
||||
func TestTheWholePassIsRotationInvariantOnACylinder(t *testing.T) {
|
||||
const w, h, r = 256, 96, 22
|
||||
const cellM = 40.0
|
||||
cfg := testCfg()
|
||||
|
||||
// Away from the seam.
|
||||
a, aSea := islandFixture(w, h, w/2, h/2, r, cellM, 60)
|
||||
ra := Build(Input{Height: a, Sea: aSea, SeaLevelM: 0, BreakM: 30, AbyssM: 180,
|
||||
WrapX: true, Seed: 7, Cfg: cfg})
|
||||
// The same island astride it, which is the same island.
|
||||
b, bSea := islandFixture(w, h, 0, h/2, r, cellM, 60)
|
||||
rb := Build(Input{Height: b, Sea: bSea, SeaLevelM: 0, BreakM: 30, AbyssM: 180,
|
||||
WrapX: true, Seed: 7, Cfg: cfg})
|
||||
|
||||
want, _ := rotate(a, aSea, w/2) // a rotated to sit where b does
|
||||
worst, at := 0.0, -1
|
||||
for i := range want.Data {
|
||||
if d := math.Abs(float64(want.Data[i] - b.Data[i])); d > worst {
|
||||
worst, at = d, i
|
||||
}
|
||||
}
|
||||
// Exactly zero when everything wraps, measured: the same island in two places is the same arithmetic in a
|
||||
// different order, and the order happens not to matter here. The tolerance is set just under what each
|
||||
// broken piece actually costs rather than at a comfortable round number - forcing the ray march flat gives
|
||||
// 0.224 m, forcing the box blur flat gives 7.6e-5 m, and a tolerance loose enough to pass the second is a
|
||||
// test that does not cover the running sums it claims to.
|
||||
if worst > 2e-5 {
|
||||
t.Errorf("the same island at the seam and away from it differ by %g m at cell %d (%d,%d); "+
|
||||
"something in the pass stops at column zero", worst, at, at%w, at/w)
|
||||
}
|
||||
|
||||
// And the accounting follows the ground too.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
a, b float64
|
||||
tolRel float64
|
||||
}{
|
||||
{"shoreline", ra.Stats.ShorelineKm, rb.Stats.ShorelineKm, 1e-9},
|
||||
{"surf cut", ra.Stats.CutM3, rb.Stats.CutM3, 1e-3},
|
||||
{"laid", ra.Stats.LaidM3, rb.Stats.LaidM3, 1e-3},
|
||||
{"shelf share", ra.Stats.ShelfPctSea, rb.Stats.ShelfPctSea, 1e-6},
|
||||
{"exposure p50", ra.Stats.ExposureP50, rb.Stats.ExposureP50, 1e-6},
|
||||
} {
|
||||
if c.a == 0 && c.b == 0 {
|
||||
t.Errorf("%s is zero in both runs; this comparison measured nothing", c.name)
|
||||
continue
|
||||
}
|
||||
if rel := math.Abs(c.a-c.b) / math.Max(math.Abs(c.a), 1e-12); rel > c.tolRel {
|
||||
t.Errorf("%s: %.6g at the seam against %.6g away from it", c.name, c.b, c.a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The flat grid must not have changed. A cylinder is opt-in, and every template drawn before it existed was
|
||||
// drawn against the old behaviour.
|
||||
func TestAFlatGridIsUnchangedByTheCylinderOption(t *testing.T) {
|
||||
const w, h, split = 200, 40, 120
|
||||
f1, sea1 := coastFixture(w, h, split, 8, 5)
|
||||
r1 := Build(Input{Height: f1, Sea: sea1, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: testCfg()})
|
||||
|
||||
// Land at both ends and water in the middle: on a flat grid the two coasts are unrelated, on a cylinder
|
||||
// they are one landmass. The flat answer has to be the flat answer.
|
||||
if r1.Geometry.WrapX {
|
||||
t.Fatal("a caller that asked for nothing got a cylinder")
|
||||
}
|
||||
f2, sea2 := coastFixture(w, h, split, 8, 5)
|
||||
r2 := Build(Input{Height: f2, Sea: sea2, SeaLevelM: 0, BreakM: 30, AbyssM: 180, WrapX: false,
|
||||
Seed: 7, Cfg: testCfg()})
|
||||
for i := range f1.Data {
|
||||
if f1.Data[i] != f2.Data[i] {
|
||||
t.Fatalf("cell %d differs between two flat runs", i)
|
||||
}
|
||||
}
|
||||
_ = r2
|
||||
}
|
||||
|
||||
// The drift kernel on a cylinder: still mass-preserving, still symmetric, and now symmetric *across the seam*
|
||||
// as well. The deposition balance rests on K(i,j) = K(j,i), and a row pass that truncated at column zero
|
||||
// would break it exactly where a coast crosses the meridian.
|
||||
func TestBoxBlurWrapsWithoutLosingMass(t *testing.T) {
|
||||
const w, h = 64, 64
|
||||
f := field.New(w, h, 1)
|
||||
// Support astride the seam, which on a flat grid would run off both ends.
|
||||
var before float64
|
||||
for y := 20; y < 44; y++ {
|
||||
for _, x := range []int{w - 3, w - 2, w - 1, 0, 1, 2} {
|
||||
f.Data[y*w+x] = 1
|
||||
before++
|
||||
}
|
||||
}
|
||||
out := boxBlur(f, 5, 3, true)
|
||||
var after float64
|
||||
for _, v := range out.Data {
|
||||
after += float64(v)
|
||||
}
|
||||
if rel := math.Abs(after-before) / before; rel > 1e-4 {
|
||||
t.Errorf("wrapping moved the total from %.4f to %.4f (%.4f%%)", before, after, rel*100)
|
||||
}
|
||||
// And the flat kernel would have lost some of it, which is what says this test measures the wrap.
|
||||
flat := boxBlur(f, 5, 3, false)
|
||||
var flatSum float64
|
||||
for _, v := range flat.Data {
|
||||
flatSum += float64(v)
|
||||
}
|
||||
if flatSum >= before*0.999 {
|
||||
t.Error("the flat kernel kept everything too; move the support onto the seam")
|
||||
}
|
||||
|
||||
one := field.New(w, h, 1)
|
||||
one.Data[32*w+0] = 1 // a single grain exactly on the seam
|
||||
k := boxBlur(one, 5, 3, true)
|
||||
for d := 1; d <= 16; d++ {
|
||||
l, r := k.Data[32*w+wrapCol(-d, w)], k.Data[32*w+wrapCol(d, w)]
|
||||
if math.Abs(float64(l-r)) > 1e-7 {
|
||||
t.Fatalf("the wrapped kernel is not symmetric at offset %d: %g against %g", d, l, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A per-cell abyss is what lets a derived shelf meet a *painted* ocean floor. Without it the slope runs down
|
||||
// to one global depth and steps to whatever the painting said, which on a planet whose sea classes carry
|
||||
// 20, 120 and 512 m is a cliff at the shelf break in every strait.
|
||||
func TestThePerCellAbyssIsWhereTheSlopeEnds(t *testing.T) {
|
||||
// A tall coast, so the shelf comes out at its narrowest (600 m) and the 3.2 km of ocean has room for the
|
||||
// 1.6 km of continental slope behind it. On a low coast the shelf is 3 km wide and the slope never
|
||||
// finishes, which is correct behaviour and would read here as a failure.
|
||||
const w, h, split = 700, 24, 400
|
||||
const cellM = 8.0
|
||||
f, sea := coastFixture(w, h, split, cellM, 400)
|
||||
abyss := make([]float32, w*h)
|
||||
for i := range abyss {
|
||||
abyss[i] = 400 // deeper than the 180 m a global AbyssM would give
|
||||
}
|
||||
cfg := shelfOnlyCfg()
|
||||
cfg.RoughnessM = 0
|
||||
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Abyss: abyss,
|
||||
Seed: 7, Cfg: cfg})
|
||||
|
||||
// The far end of the ocean, well past shelf plus slope, has to be at the painted depth and not at AbyssM.
|
||||
deepest := 0.0
|
||||
for y := 0; y < h; y++ {
|
||||
if d := -float64(f.Data[y*w+0]); d > deepest {
|
||||
deepest = d
|
||||
}
|
||||
}
|
||||
if math.Abs(deepest-400) > 1 {
|
||||
t.Errorf("the sea floor bottoms out at %.1f m; the painted abyss is 400 m", deepest)
|
||||
}
|
||||
}
|
||||
|
||||
// The separable coverage has to be the field it replaced, exactly. It is an optimisation of a divisor, and an
|
||||
// optimisation of a divisor that is only nearly right moves every smoothed value on the map.
|
||||
func TestTheSeparableCoverageIsTheFieldItReplaced(t *testing.T) {
|
||||
for _, wrapX := range []bool{false, true} {
|
||||
for _, radius := range []int{1, 4, 11, 40, 97} { // including radii past the grid, where the coast pass really runs
|
||||
for _, passes := range []int{1, 2, 3} {
|
||||
const w, h = 37, 29
|
||||
ones := field.New(w, h, 1)
|
||||
ones.Fill(1)
|
||||
want := boxBlur(ones, radius, passes, wrapX)
|
||||
cx := boxCover(w, radius, passes, wrapX)
|
||||
cy := boxCover(h, radius, passes, false)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
got := cx[x] * cy[y]
|
||||
if d := math.Abs(got - float64(want.Data[y*w+x])); d > 1e-6 {
|
||||
t.Fatalf("wrap=%v r=%d p=%d at (%d,%d): %.8f against the blurred field's %.8f",
|
||||
wrapX, radius, passes, x, y, got, want.Data[y*w+x])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package coast
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/dt"
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
@@ -16,113 +17,35 @@ import (
|
||||
// whatever the radius, so there is nothing to buy by approximating, and a chamfer's 2 % anisotropy would show
|
||||
// up directly as a shelf that is wider along the grid axes than across them.
|
||||
|
||||
// edt returns, for every cell, the squared distance in cells to the nearest seed cell and the index of that
|
||||
// seed. A column pass finds the nearest seed in each column; a row pass takes the lower envelope of the
|
||||
// parabolas those distances define.
|
||||
//
|
||||
// Cells in a column with no seed at all are given a cost above any real distance rather than an infinity, so
|
||||
// the envelope arithmetic never sees a NaN; they are then never chosen unless the map has no seeds anywhere,
|
||||
// which the caller checks for.
|
||||
func edt(seed []bool, w, h int) (d2 []float32, near []int32) {
|
||||
d2 = make([]float32, w*h)
|
||||
near = make([]int32, w*h)
|
||||
|
||||
bigF := float64(w*w+h*h) * 4 // above any achievable dx² + dy²
|
||||
bigD := float32(math.Sqrt(bigF))
|
||||
|
||||
colD := make([]float32, w*h) // distance in cells to the nearest seed in this column
|
||||
colN := make([]int32, w*h) // that seed's row, or -1
|
||||
|
||||
field.Rows(w, func(x0, x1 int) {
|
||||
for x := x0; x < x1; x++ {
|
||||
best := -1
|
||||
for y := 0; y < h; y++ {
|
||||
i := y*w + x
|
||||
if seed[i] {
|
||||
best = y
|
||||
}
|
||||
if best < 0 {
|
||||
colD[i], colN[i] = bigD, -1
|
||||
} else {
|
||||
colD[i], colN[i] = float32(y-best), int32(best)
|
||||
}
|
||||
}
|
||||
best = -1
|
||||
for y := h - 1; y >= 0; y-- {
|
||||
i := y*w + x
|
||||
if seed[i] {
|
||||
best = y
|
||||
}
|
||||
if best >= 0 {
|
||||
if d := float32(best - y); d < colD[i] {
|
||||
colD[i], colN[i] = d, int32(best)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
field.Rows(h, func(y0, y1 int) {
|
||||
f := make([]float64, w)
|
||||
v := make([]int, w)
|
||||
z := make([]float64, w+1)
|
||||
for y := y0; y < y1; y++ {
|
||||
row := y * w
|
||||
for x := 0; x < w; x++ {
|
||||
d := float64(colD[row+x])
|
||||
f[x] = d * d
|
||||
}
|
||||
k := 0
|
||||
v[0] = 0
|
||||
z[0] = math.Inf(-1)
|
||||
z[1] = math.Inf(1)
|
||||
for q := 1; q < w; q++ {
|
||||
s := intersect(f, v[k], q)
|
||||
for s <= z[k] {
|
||||
k--
|
||||
s = intersect(f, v[k], q)
|
||||
}
|
||||
k++
|
||||
v[k] = q
|
||||
z[k] = s
|
||||
z[k+1] = math.Inf(1)
|
||||
}
|
||||
k = 0
|
||||
for q := 0; q < w; q++ {
|
||||
for z[k+1] < float64(q) {
|
||||
k++
|
||||
}
|
||||
dx := float64(q - v[k])
|
||||
d2[row+q] = float32(dx*dx + f[v[k]])
|
||||
if n := colN[row+v[k]]; n < 0 {
|
||||
near[row+q] = -1
|
||||
} else {
|
||||
near[row+q] = n*int32(w) + int32(v[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return d2, near
|
||||
}
|
||||
|
||||
// intersect is where the parabolas rooted at p and q cross.
|
||||
func intersect(f []float64, p, q int) float64 {
|
||||
return ((f[q] + float64(q*q)) - (f[p] + float64(p*p))) / float64(2*q-2*p)
|
||||
}
|
||||
// The transform itself lives in internal/dt, because three unrelated things need it: this pass, the region
|
||||
// partitioner that decides which landmasses are close enough to solve together, and the template classifier
|
||||
// that dissolves an artist's decorative stroke into the nearest class that means something. It also knows
|
||||
// how to wrap, which is what a planet needs and what wrapX below asks for.
|
||||
|
||||
// Geometry is the coastline as the rest of the pass sees it.
|
||||
type Geometry struct {
|
||||
W, H int
|
||||
CellM float64
|
||||
|
||||
// WrapX is set when the grid is a cylinder: column W-1 and column 0 are neighbours, so the shoreline,
|
||||
// the distance field and the perimeter all cross the seam.
|
||||
WrapX bool
|
||||
|
||||
// Dist is metres to the waterline: positive inland, negative offshore.
|
||||
Dist *field.Field
|
||||
|
||||
// Ref is, for every cell, the waterline cell whose stretch of shore it belongs to. A land cell takes the
|
||||
// sea cell nearest to it, which is on the waterline by construction; a sea cell takes the waterline cell
|
||||
// nearest to the land cell nearest to it, which is the stretch of shore facing it. Every per-shore
|
||||
// quantity — shelter, shelf width, the backshore relief — is computed once on the waterline and read
|
||||
// everywhere else through this.
|
||||
// Ref is, for every cell, an index into Waterline: the stretch of shore that cell belongs to, or -1. A
|
||||
// land cell takes the sea cell nearest to it, which is on the waterline by construction; a sea cell takes
|
||||
// the waterline cell nearest to the land cell nearest to it, which is the stretch of shore facing it.
|
||||
// Every per-shore quantity - shelter, shelf width, the sediment supply - is computed once per waterline
|
||||
// cell and read everywhere else through this.
|
||||
//
|
||||
// **An index into Waterline rather than a cell index**, which is worth a sentence because it decides what
|
||||
// the pass costs. There are tens of millions of cells and a few hundred thousand waterline cells, so a
|
||||
// per-shore quantity indexed by *slot* is a couple of megabytes where one indexed by cell is hundreds:
|
||||
// the sediment supply used to be a `[]float64` over the whole grid, 608 MB at planet scale for an array
|
||||
// that is only ever read at the waterline. RefCell turns one back into the other where a cell is what is
|
||||
// wanted.
|
||||
Ref []int32
|
||||
|
||||
// Waterline is the sea cells that touch land, in row-major order so anything iterating them is
|
||||
@@ -134,8 +57,17 @@ type Geometry struct {
|
||||
ShoreM float64
|
||||
}
|
||||
|
||||
// Measure builds the signed distance field and the shore reference from a land/sea mask.
|
||||
// Measure builds the signed distance field and the shore reference from a land/sea mask on a flat grid.
|
||||
func Measure(sea []bool, w, h int, cellM float64) *Geometry {
|
||||
return MeasureWrapped(sea, w, h, cellM, false)
|
||||
}
|
||||
|
||||
// MeasureWrapped is Measure with the option of a cylinder, where the left and right edges of the grid are
|
||||
// neighbours. A planet is measured once, whole, rather than a landmass at a time: the pass costs tens of
|
||||
// nanoseconds a cell, and cutting it up would truncate the fetch across every strait, split the sediment
|
||||
// budget whose conservation is the one thing here that is not derived from something already measured, and
|
||||
// leave the shoreline length and the exposure percentiles as statistics that do not pool.
|
||||
func MeasureWrapped(sea []bool, w, h int, cellM float64, wrapX bool) *Geometry {
|
||||
anySea, anyLand := false, false
|
||||
land := make([]bool, len(sea))
|
||||
for i, s := range sea {
|
||||
@@ -146,7 +78,7 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
|
||||
anyLand = true
|
||||
}
|
||||
}
|
||||
g := &Geometry{W: w, H: h, CellM: cellM, Dist: field.New(w, h, cellM), Ref: make([]int32, w*h)}
|
||||
g := &Geometry{W: w, H: h, CellM: cellM, WrapX: wrapX, Dist: field.New(w, h, cellM), Ref: make([]int32, w*h)}
|
||||
for i := range g.Ref {
|
||||
g.Ref[i] = -1
|
||||
}
|
||||
@@ -154,33 +86,50 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
|
||||
return g // an all-land or all-sea map has no coast; every pass below is a no-op on it
|
||||
}
|
||||
|
||||
d2Sea, nearSea := edt(sea, w, h) // for a land cell: how far to water, and where
|
||||
d2Land, nearLand := edt(land, w, h) // for a sea cell: how far to land, and where
|
||||
|
||||
for i := range sea {
|
||||
if sea[i] {
|
||||
g.Dist.Data[i] = float32(-math.Sqrt(float64(d2Land[i])) * cellM)
|
||||
} else {
|
||||
g.Dist.Data[i] = float32(math.Sqrt(float64(d2Sea[i])) * cellM)
|
||||
}
|
||||
}
|
||||
|
||||
// The waterline: sea cells with land in the eight-neighbourhood, which is d2Land of 1 or 2.
|
||||
for i := range sea {
|
||||
if sea[i] && d2Land[i] <= 2.001 {
|
||||
// The waterline first, and straight off the mask rather than out of a transform. It is "a sea cell with
|
||||
// land in its eight-neighbourhood", which is a local question, and asking it here rather than reading it
|
||||
// out of d2Land is what lets the two transforms below be released in turn instead of held together.
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if !sea[i] || !touchesLand(sea, w, h, x, y, wrapX) {
|
||||
continue
|
||||
}
|
||||
g.Waterline = append(g.Waterline, int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
// Land first: how far to water, and which waterline stretch that is.
|
||||
//
|
||||
// The two transforms are never both alive. At planet scale each one is a distance array and a feature
|
||||
// index over 76 million cells - 600 MB the pair - and holding all four at once was 1.2 GB on top of the
|
||||
// 600 MB this function returns. The order below is what avoids it, and it needs one observation: a sea
|
||||
// cell's stretch of shore is the stretch its *nearest land cell* already belongs to, so the second pass
|
||||
// can read the answer out of Ref rather than out of the first pass's feature index.
|
||||
d2Sea, nearSea := dt.Transform(sea, w, h, wrapX)
|
||||
for i := range sea {
|
||||
if sea[i] {
|
||||
if l := nearLand[i]; l >= 0 {
|
||||
g.Ref[i] = nearSea[l]
|
||||
}
|
||||
} else {
|
||||
g.Ref[i] = nearSea[i]
|
||||
continue
|
||||
}
|
||||
g.Dist.Data[i] = float32(math.Sqrt(float64(d2Sea[i])) * cellM)
|
||||
if n := nearSea[i]; n >= 0 {
|
||||
g.Ref[i] = slotOf(g.Waterline, n)
|
||||
}
|
||||
}
|
||||
d2Sea, nearSea = nil, nil
|
||||
|
||||
// Then sea: how far to land, and the shore that land already answered for.
|
||||
d2Land, nearLand := dt.Transform(land, w, h, wrapX)
|
||||
for i := range sea {
|
||||
if !sea[i] {
|
||||
continue
|
||||
}
|
||||
g.Dist.Data[i] = float32(-math.Sqrt(float64(d2Land[i])) * cellM)
|
||||
if l := nearLand[i]; l >= 0 {
|
||||
g.Ref[i] = g.Ref[l]
|
||||
}
|
||||
}
|
||||
d2Land, nearLand = nil, nil
|
||||
|
||||
// Perimeter by boundary edges, which is what a shoreline length means on a grid.
|
||||
edges := 0
|
||||
@@ -189,6 +138,8 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
|
||||
i := y*w + x
|
||||
if x+1 < w && sea[i] != sea[i+1] {
|
||||
edges++
|
||||
} else if x+1 == w && wrapX && sea[i] != sea[y*w] {
|
||||
edges++
|
||||
}
|
||||
if y+1 < h && sea[i] != sea[i+w] {
|
||||
edges++
|
||||
@@ -198,3 +149,59 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
|
||||
g.ShoreM = float64(edges) * cellM
|
||||
return g
|
||||
}
|
||||
|
||||
// RefCell is the cell index of the waterline stretch a cell belongs to, or -1. Ref itself is a slot; this is
|
||||
// for the few places that want the cell.
|
||||
func (g *Geometry) RefCell(i int) int32 {
|
||||
if r := g.Ref[i]; r >= 0 {
|
||||
return g.Waterline[r]
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// touchesLand reports whether a cell has land in its eight-neighbourhood: X wrapped on a cylinder, Y bounded,
|
||||
// because the top and bottom of the map are the poles and not each other.
|
||||
func touchesLand(sea []bool, w, h, x, y int, wrapX bool) bool {
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
ny := y + dy
|
||||
if ny < 0 || ny >= h {
|
||||
continue
|
||||
}
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue
|
||||
}
|
||||
nx := x + dx
|
||||
if wrapX {
|
||||
nx = ((nx % w) + w) % w
|
||||
} else if nx < 0 || nx >= w {
|
||||
continue
|
||||
}
|
||||
if !sea[ny*w+nx] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// slotOf finds a cell's index in the waterline, or -1.
|
||||
//
|
||||
// A binary search rather than a cell-indexed lookup table, which would be another four bytes a cell - 300 MB
|
||||
// at planet scale for an array read once. The waterline is built in row-major order and is therefore sorted,
|
||||
// so the search is eighteen comparisons against a few hundred thousand entries and runs only on land cells.
|
||||
func slotOf(waterline []int32, cell int32) int32 {
|
||||
lo, hi := 0, len(waterline)
|
||||
for lo < hi {
|
||||
mid := int(uint(lo+hi) >> 1)
|
||||
if waterline[mid] < cell {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
if lo < len(waterline) && waterline[lo] == cell {
|
||||
return int32(lo)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package coast
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// paintedSeaFixture is a straight coast whose whole sea is painted at one depth, the way a planet's ocean
|
||||
// class is. The sea is made wide enough to hold the derived margin and a stretch of open water past it, so
|
||||
// the test can ask the question that matters: how much of what the author painted survives.
|
||||
func paintedSeaFixture(w, h, split int, cellM, backshoreM, paintedM float64) (*field.Field, []bool, []float32) {
|
||||
f, sea := coastFixture(w, h, split, cellM, backshoreM)
|
||||
abyss := make([]float32, w*h)
|
||||
for i := range abyss {
|
||||
if sea[i] {
|
||||
abyss[i] = float32(paintedM)
|
||||
}
|
||||
}
|
||||
return f, sea, abyss
|
||||
}
|
||||
|
||||
// TestTheDerivedMarginDoesNotSwallowThePaintedOcean is the D-64 regression, stated as the property rather
|
||||
// than as the number that was wrong.
|
||||
//
|
||||
// The sea floor near a shore is derived and the sea floor away from it is the painting; the break depth is
|
||||
// what joins them. Set the break far shallower than the paint and the join stops being a join: the derived
|
||||
// profile is then a shallow bench that runs from the waterline out to the full reach of the margin, and on a
|
||||
// planet whose straits are narrower than twice that reach it *is* the ocean. That is not visible in a profile
|
||||
// test - the shape is monotone and correct at any break depth - so this measures the volume instead.
|
||||
//
|
||||
// 512 m is the first template's `ocean` class. 30 m was the inherited square-canvas break, 130 m is the
|
||||
// planet default.
|
||||
func TestTheDerivedMarginDoesNotSwallowThePaintedOcean(t *testing.T) {
|
||||
const w, h, split = 1400, 20, 1200
|
||||
const cellM, painted = 8.0, 512.0
|
||||
cfg := shelfOnlyCfg()
|
||||
|
||||
measure := func(breakM float64) (shallow float64, atBreak, far float64) {
|
||||
f, sea, abyss := paintedSeaFixture(w, h, split, cellM, 5, painted)
|
||||
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: breakM, AbyssM: painted,
|
||||
Abyss: abyss, Seed: 7, Cfg: cfg})
|
||||
y := h / 2
|
||||
n, under50 := 0, 0
|
||||
for x := 0; x < split; x++ {
|
||||
n++
|
||||
if -float64(f.Data[y*w+x]) < 50 {
|
||||
under50++
|
||||
}
|
||||
}
|
||||
// Just inside the widest shelf, and well past the shelf and the slope together.
|
||||
shelfCells := int(cfg.ShelfKm.Hi()*1000/cellM) - 2
|
||||
reachCells := int((cfg.ShelfKm.Hi() + cfg.SlopeKm) * 1000 / cellM)
|
||||
return float64(under50) / float64(n),
|
||||
-float64(f.Data[y*w+split-1-shelfCells]),
|
||||
-float64(f.Data[y*w+split-1-reachCells-20])
|
||||
}
|
||||
|
||||
oldShallow, oldBreak, oldFar := measure(30)
|
||||
newShallow, newBreak, newFar := measure(130)
|
||||
t.Logf("break 30 m: %.0f%% of this sea shallower than 50 m, %.0f m at the break, %.0f m offshore",
|
||||
oldShallow*100, oldBreak, oldFar)
|
||||
t.Logf("break 130 m: %.0f%% of this sea shallower than 50 m, %.0f m at the break, %.0f m offshore",
|
||||
newShallow*100, newBreak, newFar)
|
||||
|
||||
// Both must reach the painting in open water: the margin is a join, never a replacement.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
far float64
|
||||
}{{"30 m", oldFar}, {"130 m", newFar}} {
|
||||
if c.far < painted-2 {
|
||||
t.Errorf("break %s: open water is %.0f m, want the painted %.0f m", c.name, c.far, painted)
|
||||
}
|
||||
}
|
||||
// The break is where it was asked for, which is what makes it the knob worth having.
|
||||
if newBreak < 110 || newBreak > 140 {
|
||||
t.Errorf("the shelf break is at %.0f m, want about 130 m", newBreak)
|
||||
}
|
||||
// And the shallow bench shrinks. This is the whole defect: at a 30 m break every cell of the derived
|
||||
// margin is shallower than 50 m by construction, so the bench is as wide as the margin reaches.
|
||||
if !(newShallow < oldShallow*0.75) {
|
||||
t.Errorf("shallow water is %.0f%% of the sea at a 130 m break against %.0f%% at 30 m; deepening the "+
|
||||
"break has to shrink the bench or it is not doing anything", newShallow*100, oldShallow*100)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPaintedShallowStraitIsStillShallow is the other half, and it is what stops the fix above from being a
|
||||
// blunt instrument: the break can never be deeper than the water it is a break in. An author who paints a
|
||||
// 20 m surf class gets 20 m of water, not a 130 m trench dug through it.
|
||||
func TestAPaintedShallowStraitIsStillShallow(t *testing.T) {
|
||||
const w, h, split = 1400, 20, 1200
|
||||
const cellM, painted = 8.0, 20.0
|
||||
f, sea, abyss := paintedSeaFixture(w, h, split, cellM, 5, painted)
|
||||
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 130, AbyssM: painted,
|
||||
Abyss: abyss, Seed: 7, Cfg: shelfOnlyCfg()})
|
||||
|
||||
y := h / 2
|
||||
for x := 0; x < split; x++ {
|
||||
if d := -float64(f.Data[y*w+x]); d > painted+1 {
|
||||
t.Fatalf("%.0f m offshore: %.1f m of water over a sea painted at %.0f m",
|
||||
float64(split-x)*cellM, d, painted)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package detail
|
||||
|
||||
// Classes is what the painted class asks of the detail passes, per cell, already blended.
|
||||
//
|
||||
// It exists because two classes can have the same uplift rate and the same erodibility - which is everything
|
||||
// the geology grid knows about them - and still be completely different ground. A desert and a wet lowland
|
||||
// are both "low, slowly rising"; what separates them is at two metres, in how much running water crosses
|
||||
// them, how sharp their ledges stay and how much of them is dune.
|
||||
//
|
||||
// **Four fields rather than a class index and a lookup table**, which is what this was. The index is the
|
||||
// right thing to carry - a class is a name, and a name is never interpolated - but the *numbers* it stands
|
||||
// for are quantities, and quantities interpolate. Kept as a lookup, a desert meeting a lowland changed from
|
||||
// seven metres of dune amplitude to two in the width of one cell, along a line the painter drew with a mouse,
|
||||
// and it read exactly as what it was: a boundary in a picture rather than a change in the ground. Blended,
|
||||
// the same boundary is a few hundred metres of one becoming the other, which is what the edge of a sand sea
|
||||
// looks like from the ground.
|
||||
//
|
||||
// The blending happens where the fields are built (see planet.blendedClasses), because that is where the
|
||||
// class raster and the tile's margin both are; by the time a pass reads one it is just a number per cell.
|
||||
//
|
||||
// Nil means every cell uses the pipeline's own numbers, which is what happens on a template whose legend
|
||||
// overrides nothing.
|
||||
type Classes struct {
|
||||
Droplets []float32 // per cell: droplets a cell spawns
|
||||
AmpLo []float32 // per cell: detail noise amplitude on flat ground
|
||||
AmpHi []float32 // per cell: and on steep ground
|
||||
Contrast []float32 // per cell: strata hardness contrast
|
||||
}
|
||||
|
||||
// droplets, amp and contrast read a cell, falling back to the uniform value when there is no table.
|
||||
func (c *Classes) droplets(i int, def float64) float64 {
|
||||
if c == nil || c.Droplets == nil {
|
||||
return def
|
||||
}
|
||||
return float64(c.Droplets[i])
|
||||
}
|
||||
|
||||
func (c *Classes) amp(i int, defLo, defHi float64) (float64, float64) {
|
||||
if c == nil || c.AmpLo == nil {
|
||||
return defLo, defHi
|
||||
}
|
||||
return float64(c.AmpLo[i]), float64(c.AmpHi[i])
|
||||
}
|
||||
|
||||
func (c *Classes) contrast(i int, def float64) float64 {
|
||||
if c == nil || c.Contrast == nil {
|
||||
return def
|
||||
}
|
||||
return float64(c.Contrast[i])
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
package detail
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"salty/terrain/internal/dt"
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Pass 11b: the shore at two metres.
|
||||
//
|
||||
// The coastal pass on the geology grid (internal/coast) decides where the shore *is*: it lays the shelf,
|
||||
// planes a platform within a reach of the waterline, leaves a cliff where that reach ends, and carries the
|
||||
// sediment it cut along the shore into the bays. All of that is right and almost none of it is visible,
|
||||
// because the surf reach is 110 m and a geology cell is 8: a beach is fourteen cells wide, a berm is a
|
||||
// quarter of one cell high, and a wave-cut notch is a fifth of one.
|
||||
//
|
||||
// The surf reach is the only length in the generator set by physics rather than by the canvas - it is how far
|
||||
// a wave runs up, and a wave does not know how big the map is - so it does not shrink when the cell does. At
|
||||
// the 2 m detail cell the same 110 m is 55 cells, which is enough to hold a real profile. That is the whole
|
||||
// argument for this being a pass of its own rather than a knob on the one above.
|
||||
//
|
||||
// Everything here is measured against that reach and against the exposure the geology pass computed, so the
|
||||
// two cannot disagree about where the shore is: this pass re-evaluates the same
|
||||
// reach = SurfReachM * (0.35 + 0.65*exposure) that plane() used, and draws the profile the geology grid was
|
||||
// too coarse to hold.
|
||||
//
|
||||
// It is local, which is what lets it run per tile: nothing here reads or writes further from the waterline
|
||||
// than two surf reaches, which is 220 m against a tile margin of 244. Measured rather than reasoned - the
|
||||
// pass reaches 110 to 136 m on the fixtures in TestThePassFitsInsideTheTileMargin - but the 220 is a hard
|
||||
// limit rather than a measurement, because past it a cell has no stretch of shore to belong to at all.
|
||||
|
||||
// CoastalParams is pass 11b's input.
|
||||
type CoastalParams struct {
|
||||
Cfg manifest.CoastDetail
|
||||
Surf manifest.Coast // the geology pass's own numbers: the reach and the platform grade come from it
|
||||
Seed int64
|
||||
Frame world.Frame
|
||||
PeriodM float64 // the detail noise period, for the crenulation lattice
|
||||
|
||||
SeaLevelM float64
|
||||
|
||||
// Exposure is the geology pass's fetch field sampled onto this tile, 0 sheltered to 1 open water.
|
||||
//
|
||||
// It cannot be computed here and must not be: fetch is cast fifteen hundred metres in sixteen directions
|
||||
// and a tile is five kilometres across, so a tile has no way of knowing whether the water in front of it
|
||||
// is a bay or an ocean. It is exactly the quantity D-53's rule says has to come from the pass that ran
|
||||
// over the whole cylinder. Nil means the bake predates the field, and then every coast is treated as
|
||||
// fully exposed - which is what the geology pass's own percentiles say most coast is anyway.
|
||||
Exposure []float32
|
||||
|
||||
Hardness *Hardness
|
||||
}
|
||||
|
||||
// CoastalStats is what the pass moved, for the tile record. The cliff branch conserves: what it cuts off the
|
||||
// face it lays at the foot, per stretch of shore, and ScreeM3 is reported beside CutM3 so a run where the two
|
||||
// have drifted apart says so rather than quietly losing rock.
|
||||
type CoastalStats struct {
|
||||
ShoreCells int `json:"shore_cells"`
|
||||
CliffFrac float64 `json:"cliff_fraction"`
|
||||
CutM3 float64 `json:"cliff_cut_m3"`
|
||||
ScreeM3 float64 `json:"scree_laid_m3"`
|
||||
BeachM3 float64 `json:"beach_net_m3"`
|
||||
|
||||
// How high the land stands behind this tile's shore, over its waterline cells. It is the input the
|
||||
// beach-or-cliff decision is made from, so it is reported rather than left to be inferred from the
|
||||
// fraction: a run with no cliffs anywhere is either a coast with no cliffs on it or a threshold in the
|
||||
// wrong place, and these two numbers are the only thing that tells the two apart.
|
||||
BackshoreP50M float64 `json:"backshore_p50_m"`
|
||||
BackshoreP90M float64 `json:"backshore_p90_m"`
|
||||
}
|
||||
|
||||
// coastalTaper is how far past the surf reach the profile fades out, as a fraction of the reach. The taper
|
||||
// exists so the pass hands back to the droplets rather than ending in a line across the ground.
|
||||
const coastalTaper = 0.5
|
||||
|
||||
// beachFace is the slope of the swash face of a sand beach, which is what sets where the berm crest sits: a
|
||||
// berm bh metres high has its crest bh/beachFace metres inland. 1:10 is the ordinary figure for medium sand,
|
||||
// and it is the one number here that is a property of the sediment rather than of the wave.
|
||||
const beachFace = 0.1
|
||||
|
||||
// RunCoastal cuts the shore profile. Height is modified in place; land is the detail land mask as the passes
|
||||
// above left it and is not updated - the waterline this pass works from is the one they agreed on.
|
||||
func RunCoastal(h *field.Field, land []bool, p CoastalParams) CoastalStats {
|
||||
var st CoastalStats
|
||||
cfg := p.Cfg
|
||||
if !cfg.Enabled {
|
||||
return st
|
||||
}
|
||||
reachMax := p.Surf.SurfReachM
|
||||
if reachMax <= 0 {
|
||||
return st
|
||||
}
|
||||
w, ht := h.W, h.H
|
||||
cellM := h.CellM
|
||||
|
||||
// The shoreline, which is not the land mask's boundary.
|
||||
//
|
||||
// On a coastal plain the ground crosses sea level at a grade of about one in a hundred, so whether a cell
|
||||
// is land is decided by centimetres over a strip forty metres wide and the mask's boundary is a band of
|
||||
// speckle rather than a curve. Everything this pass does is measured from that boundary, and measuring
|
||||
// from speckle went wrong twice: it put a separate two-metre berm on every island in the band, and - less
|
||||
// visibly and worse - it wrecked the backshore, because a cell two hundred metres inland had its nearest
|
||||
// waterline cell in a puddle beside it rather than out at the coast, so the real shore was left measuring
|
||||
// the height of the land behind almost nothing.
|
||||
//
|
||||
// So the shoreline is derived: the signed distance to the raw boundary, smoothed, thresholded back. That
|
||||
// is a curve, it is within a few metres of the mask's own boundary, and everything below is measured from
|
||||
// it. Taking the waterline on the land side of it is a half-cell choice, recorded rather than hidden.
|
||||
rough := boundaryOf(land, w, ht)
|
||||
sd := signedDistance(rough, land, w, ht, cellM)
|
||||
smoothShore(sd, w, ht, int(cfg.ShoreSmoothM/cellM+0.5))
|
||||
wet := make([]bool, len(sd))
|
||||
for i, v := range sd {
|
||||
wet[i] = v > 0
|
||||
}
|
||||
line := boundaryOf(wet, w, ht)
|
||||
shore := make([]int32, 0, 4096)
|
||||
for i, on := range line {
|
||||
if on {
|
||||
shore = append(shore, int32(i))
|
||||
}
|
||||
}
|
||||
if len(shore) == 0 {
|
||||
return st
|
||||
}
|
||||
st.ShoreCells = len(shore)
|
||||
|
||||
// One transform, seeded on the waterline itself, answers both halves of every question this pass asks:
|
||||
// how far a cell is from the shore, and which stretch of shore it belongs to. The geology pass needs two
|
||||
// because it wants the sea side and the land side to answer different things; here they answer the same.
|
||||
//
|
||||
// wrapX is false and has to be: a tile is a rectangle cut out of the cylinder with a margin on it, and
|
||||
// the seam is the tiling's business rather than the pass's. A tile that wrapped its own left edge onto
|
||||
// its own right would be inventing a shore.
|
||||
d2, near := dt.Transform(line, w, ht, false)
|
||||
|
||||
// Per stretch of shore: how open it is, how far the surf reaches, how high the land behind it stands, and
|
||||
// how far the whole profile is displaced in or out. Indexed by slot rather than by cell, which is the
|
||||
// same economy the geology pass keeps - a tile has millions of cells and thousands of shore cells.
|
||||
n := len(shore)
|
||||
expo := make([]float64, n)
|
||||
reach := make([]float64, n)
|
||||
cren := make([]float64, n)
|
||||
|
||||
crenNoise := p.crenulation(h)
|
||||
for s, ci := range shore {
|
||||
e := 1.0
|
||||
if p.Exposure != nil {
|
||||
e = float64(p.Exposure[ci])
|
||||
if e < 0 {
|
||||
e = 0
|
||||
} else if e > 1 {
|
||||
e = 1
|
||||
}
|
||||
}
|
||||
expo[s] = e
|
||||
reach[s] = reachMax * (0.35 + 0.65*e)
|
||||
if crenNoise != nil {
|
||||
cren[s] = cfg.CrenulationM * (2*float64(crenNoise.Data[ci]) - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// The signed distance to that shoreline, which needs no smoothing of its own: the curve it is measured
|
||||
// from is already smooth.
|
||||
dist := make([]float32, len(d2))
|
||||
for i := range d2 {
|
||||
dm := math.Sqrt(float64(d2[i])) * cellM
|
||||
if wet[i] {
|
||||
dist[i] = float32(dm)
|
||||
} else {
|
||||
dist[i] = float32(-dm)
|
||||
}
|
||||
}
|
||||
|
||||
// Which stretch of shore each cell belongs to.
|
||||
slot := make([]int32, len(d2))
|
||||
// Two surf reaches is the outer limit of the whole pass, on both sides, and it is a limit rather than a
|
||||
// consequence: it is the window the backshore is measured in, so it is the furthest any cell has a stretch
|
||||
// of shore to belong to at all, and it is what makes the margin claim one number. 220 m at the default
|
||||
// reach, against a tile margin of 244.
|
||||
backOuter := 2 * reachMax
|
||||
for i := range d2 {
|
||||
dm := math.Sqrt(float64(d2[i])) * cellM
|
||||
slot[i] = -1
|
||||
if dm > backOuter || near[i] < 0 {
|
||||
continue
|
||||
}
|
||||
if s := slotOf(shore, near[i]); s >= 0 {
|
||||
slot[i] = int32(s)
|
||||
}
|
||||
}
|
||||
|
||||
back := marchBackshore(h, dist, wet, shore, reach, p.SeaLevelM)
|
||||
cliff := make([]float64, n)
|
||||
for s := range back {
|
||||
cliff[s] = cliffiness(back[s], cfg.CliffFromM, cfg.CliffToM)
|
||||
st.CliffFrac += cliff[s]
|
||||
}
|
||||
st.CliffFrac /= float64(n)
|
||||
st.BackshoreP50M, st.BackshoreP90M = percentiles(back)
|
||||
|
||||
// The roughness fade, before the profile is drawn on top of it.
|
||||
//
|
||||
// The profile is only a few tens of metres wide, so on its own the ground goes from a drawn beach to full
|
||||
// dune amplitude and droplet rills within the width of its taper, and the beach reads as a ribbon laid on
|
||||
// the terrain rather than as part of it. This blends the surface towards a smoothed copy of itself over a
|
||||
// wider band: the relief is untouched - the smoothing radius is metres, not tens of them - and what fades
|
||||
// is the metre-scale texture, so the backshore comes out smoother than the hillside behind it. Which is
|
||||
// what a backshore is: sand and dune over whatever the hillside is made of.
|
||||
smoothShoreRoughness(h, dist, wet, reachMax, cfg.SmoothReachM)
|
||||
|
||||
// The profile. Two targets blended by how high the land behind stands, and the result blended into the
|
||||
// surface by how far the cell is from the shore, so the pass fades out rather than ending in a line.
|
||||
cut := make([]float64, n)
|
||||
for i := range dist {
|
||||
s := slot[i]
|
||||
if s < 0 {
|
||||
continue
|
||||
}
|
||||
x := float64(dist[i]) - cren[s]
|
||||
r := reach[s]
|
||||
now := float64(h.Data[i])
|
||||
bh := p.Surf.BermM * (0.35 + 0.65*expo[s])
|
||||
|
||||
// The two branches carry their own reach as well as their own shape, which the first version of this
|
||||
// did not: a beach is over within a few tens of metres of the water, and holding its berm out to the
|
||||
// full surf reach cut a ninety-metre terrace into the land behind every beach on the map.
|
||||
crest := bh / beachFace
|
||||
face := math.Min(back[s], cfg.CliffMaxM)
|
||||
wb := branchWeight(x, crest, math.Min(crest+cfg.BermBackM, backOuter), r*0.5, math.Min(r, backOuter))
|
||||
wc := branchWeight(x, r,
|
||||
math.Min(r+face/max64(cfg.CliffGrade, 1e-3), backOuter),
|
||||
r*0.5, math.Min(r*(1+coastalTaper), backOuter))
|
||||
if wb <= 0 && wc <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// A beach is a veneer of sediment, not a landform that fills a fjord. Without the cap the equilibrium
|
||||
// profile is a *target depth*, so a shore with forty metres of water a hundred metres off it - a
|
||||
// drowned valley, which is an ordinary thing on a real coast - gets thirty-seven metres of sand
|
||||
// invented to bring the floor up to the curve. Capped, the beach is a few metres of sediment laid on
|
||||
// whatever is there, and where the water is deep it simply runs out. That is what a steep-to shore is.
|
||||
tb := beachTarget(x, bh, cfg.DeanA, p.SeaLevelM)
|
||||
if fill := now + cfg.BeachFillM; tb > fill {
|
||||
tb = fill
|
||||
}
|
||||
tc := cliffTarget(x, r, face, p.Surf.PlatformGrade, cfg.CliffGrade, p.SeaLevelM)
|
||||
|
||||
// The platform is rock, and rock does not plane flat: hard bands stand out as ledges and reefs and
|
||||
// soft ones cut down into runnels. It goes into the cliff target *before* the clamp below, which is
|
||||
// the difference between a ledge and a wall built out of the sea: a band that resisted is rock the
|
||||
// surf did not take, so it is still below where the ground started.
|
||||
if p.Hardness != nil && cfg.PlatformReliefM > 0 {
|
||||
if win := platformWindow(x, r); win > 0 {
|
||||
hard := p.Hardness.At(i, now/cellM)
|
||||
tc += cfg.PlatformReliefM * (2*hard - 1) * win
|
||||
}
|
||||
}
|
||||
|
||||
// The cliff branch never builds, on either side of the waterline. A shore platform and the face above
|
||||
// it are what is left after the sea took rock away, so a target above the ground is the pass
|
||||
// proposing to invent a headland, and the honest answer to that is to leave the ground where it is.
|
||||
// It is also what keeps the platform from being laid out across deep water: it planes what is
|
||||
// shallower than it and passes over what is not.
|
||||
if tc > now {
|
||||
tc = now
|
||||
}
|
||||
|
||||
dCliff := cliff[s] * wc * (tc - now) // never positive, by the clamp above
|
||||
dBeach := (1 - cliff[s]) * wb * (tb - now)
|
||||
h.Data[i] = float32(now + dCliff + dBeach)
|
||||
cut[s] -= dCliff
|
||||
st.BeachM3 += dBeach
|
||||
}
|
||||
area := cellM * cellM
|
||||
for _, c := range cut {
|
||||
st.CutM3 += c * area
|
||||
}
|
||||
st.BeachM3 *= area
|
||||
|
||||
st.ScreeM3 = layScree(h, dist, shore, reach, cut, cfg, area)
|
||||
return st
|
||||
}
|
||||
|
||||
// cliffiness is how much of a cliff a stretch of shore is: 0 where the land behind it is at beach height, 1
|
||||
// where it stands a cliff's worth above the water, smooth in between so the two profiles do not switch over
|
||||
// from one shore cell to the next.
|
||||
func cliffiness(backM, from, to float64) float64 {
|
||||
if to <= from {
|
||||
if backM >= to {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
t := (backM - from) / (to - from)
|
||||
if t <= 0 {
|
||||
return 0
|
||||
}
|
||||
if t >= 1 {
|
||||
return 1
|
||||
}
|
||||
return noise.Smoothstep(t)
|
||||
}
|
||||
|
||||
// beachTarget is the equilibrium beach: a swash face rising to a berm crest above water, and Dean's profile
|
||||
// below it.
|
||||
//
|
||||
// depth = A * x^(2/3) is the standard equilibrium profile, and A is a property of the sand rather than of the
|
||||
// wave - it is the shape a beach returns to whatever the last storm did to it, which is exactly the right
|
||||
// thing for a generator to draw, because what a generator has is the long-run average and never the storm.
|
||||
// The berm is the other half: its crest sits at the wave runup limit, runup scales with wave height and wave
|
||||
// height with fetch, so a berm on an exposed coast stands higher than one at the back of a bay. That is why
|
||||
// the crest height arrives already scaled by exposure.
|
||||
func beachTarget(x, bermM, deanA, seaLevelM float64) float64 {
|
||||
if x >= 0 {
|
||||
crest := bermM / beachFace
|
||||
if crest <= 0 {
|
||||
return seaLevelM
|
||||
}
|
||||
if x >= crest {
|
||||
return seaLevelM + bermM
|
||||
}
|
||||
return seaLevelM + bermM*x/crest
|
||||
}
|
||||
return seaLevelM - deanA*math.Pow(-x, 2.0/3.0)
|
||||
}
|
||||
|
||||
// cliffTarget is a shore platform out to the foot and a face above it, up to faceM high.
|
||||
//
|
||||
// faceM is capped rather than being the backshore itself, and the cap is what stops the pass carving a
|
||||
// seventy-degree wall four hundred metres up a coastal range: the only other thing that stops the face is the
|
||||
// ground rising faster than it does, and ground behind a mountain coast does. A sea cliff is what the surf
|
||||
// undercut; above that height the face is a hillslope and it belongs to the solve.
|
||||
//
|
||||
// The foot is at the surf reach, which is not a choice: it is where plane() stopped cutting on the geology
|
||||
// grid, so the cliff is already there and already in the right place. What this does is give it a *face*. At
|
||||
// 8 m the step from the platform to the backshore is one cell, and upsampled by four it is a four-cell ramp
|
||||
// at whatever angle the interpolation chose; at 2 m the same height can stand at the angle a cliff stands at.
|
||||
//
|
||||
// Seaward of the waterline the platform simply continues at its own grade, which is what a shore platform
|
||||
// does - it is cut across the intertidal and runs on a little way below low water before the sea floor takes
|
||||
// over.
|
||||
func cliffTarget(x, reachM, faceM, platformGrade, cliffGrade, seaLevelM float64) float64 {
|
||||
if x < 0 {
|
||||
return seaLevelM - platformGrade*(-x)
|
||||
}
|
||||
if x <= reachM {
|
||||
return seaLevelM + platformGrade*x
|
||||
}
|
||||
foot := seaLevelM + platformGrade*reachM
|
||||
t := foot + cliffGrade*(x-reachM)
|
||||
if top := seaLevelM + faceM; t > top {
|
||||
return top
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// branchWeight is how much of a branch's target a cell takes: all of it inside that branch's core, and
|
||||
// smoothstepping to none at its outer limit, so the pass hands back to the droplets and the noise instead of
|
||||
// ending in a line across the ground.
|
||||
func branchWeight(x, coreLand, outLand, coreSea, outSea float64) float64 {
|
||||
if x >= 0 {
|
||||
return taperTo(x, coreLand, outLand)
|
||||
}
|
||||
return taperTo(-x, coreSea, outSea)
|
||||
}
|
||||
|
||||
func taperTo(d, core, out float64) float64 {
|
||||
if d <= core {
|
||||
return 1
|
||||
}
|
||||
if d >= out || out <= core {
|
||||
return 0
|
||||
}
|
||||
return noise.Smoothstep((out - d) / (out - core))
|
||||
}
|
||||
|
||||
// platformWindow fades the strata relief in across the shore platform and out at both ends of it: nothing at
|
||||
// the foot of the cliff, where the face takes over, and nothing where the platform runs out under water.
|
||||
//
|
||||
// It reaches seaward as well as inland, because a shore platform does: it is cut across the intertidal and
|
||||
// carries on a little below low water, and that submerged half is where the ledges and the reefs are.
|
||||
func platformWindow(x, reachM float64) float64 {
|
||||
if reachM <= 0 {
|
||||
return 0
|
||||
}
|
||||
lo, hi := -reachM*0.5, reachM
|
||||
if x <= lo || x >= hi {
|
||||
return 0
|
||||
}
|
||||
t := (x - lo) / (hi - lo)
|
||||
return noise.Smoothstep(math.Min(t*4, 1)) * noise.Smoothstep(math.Min((1-t)*4, 1))
|
||||
}
|
||||
|
||||
// layScree puts back what the face lost, at the foot, at the angle of repose.
|
||||
//
|
||||
// The cliff branch only ever cuts, so it has a volume to account for, and a cliff that shed its face into
|
||||
// nothing would be the one place in this generator where rock disappears. It goes where it goes on a real
|
||||
// coast: an apron at the foot, thickest against the face and thinning seaward, at the angle blocky debris
|
||||
// stands at. The volume is matched per stretch of shore rather than per tile, so the apron under a cliff is
|
||||
// the apron that cliff produced.
|
||||
//
|
||||
// Marched along the shore normal, for the same reason marchBackshore is: a stretch of shore inside a bay owns
|
||||
// no cells at all a hundred metres out, because the nearest-shore wedges converge there, so an apron scattered
|
||||
// over those cells simply had nowhere to go. Measured on region 11 before the change, the aprons gained 2085
|
||||
// of the 3030 cubic metres the faces lost and the rest was silently dropped. A march has a line of cells to
|
||||
// put it on whatever the coast does, and the normalisation is the same one: a stretch of shore owns a strip
|
||||
// one cell wide, so a scattered wedge and a marched line cover the same area on a straight coast and agree.
|
||||
func layScree(h *field.Field, dist []float32, shore []int32, reach, cut []float64,
|
||||
cfg manifest.CoastDetail, area float64) float64 {
|
||||
|
||||
if cfg.ScreeDeg <= 0 || cfg.ScreeReachM <= 0 {
|
||||
return 0
|
||||
}
|
||||
w, ht := h.W, h.H
|
||||
cellM := h.CellM
|
||||
at := func(x, y int) float64 {
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x >= w {
|
||||
x = w - 1
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
} else if y >= ht {
|
||||
y = ht - 1
|
||||
}
|
||||
return float64(dist[y*w+x])
|
||||
}
|
||||
var laid float64
|
||||
var line [128]int32
|
||||
var wgt [128]float64
|
||||
for s, ci := range shore {
|
||||
if cut[s] <= 0 {
|
||||
continue
|
||||
}
|
||||
x, y := int(ci)%w, int(ci)/w
|
||||
dx := at(x+1, y) - at(x-1, y)
|
||||
dy := at(x, y+1) - at(x, y-1)
|
||||
l := math.Hypot(dx, dy)
|
||||
if l < 1e-9 {
|
||||
continue
|
||||
}
|
||||
dx, dy = dx/l, dy/l
|
||||
lo := int((reach[s]-cfg.ScreeReachM)/cellM + 0.5)
|
||||
hi := int(reach[s]/cellM + 0.5)
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
nsteps, total := 0, 0.0
|
||||
for t := lo; t <= hi && nsteps < len(line); t++ {
|
||||
px := x + int(math.Round(dx*float64(t)))
|
||||
py := y + int(math.Round(dy*float64(t)))
|
||||
if px < 0 || px >= w || py < 0 || py >= ht {
|
||||
break
|
||||
}
|
||||
v := screeWedge(float64(t)*cellM, reach[s], cfg.ScreeReachM)
|
||||
if v <= 0 {
|
||||
continue
|
||||
}
|
||||
line[nsteps], wgt[nsteps] = int32(py*w+px), v
|
||||
total += v
|
||||
nsteps++
|
||||
}
|
||||
if total <= 0 {
|
||||
continue
|
||||
}
|
||||
for k := 0; k < nsteps; k++ {
|
||||
add := cut[s] * wgt[k] / total
|
||||
h.Data[line[k]] += float32(add)
|
||||
laid += add
|
||||
}
|
||||
}
|
||||
return laid * area
|
||||
}
|
||||
|
||||
// crenulation is the noise that moves the whole profile in and out along the shore.
|
||||
//
|
||||
// It is applied to the *distance* rather than to the height, which is what makes it a crenulate coastline
|
||||
// rather than a rough one: the profile stays a profile and the shoreline wanders. And it is read at the
|
||||
// nearest waterline cell rather than at the cell being written, so it varies along the shore and not across
|
||||
// it - read per cell, a two-dimensional noise field would ripple the profile in the cross-shore direction
|
||||
// too, and a beach with corrugations up its face is not a beach.
|
||||
func (p CoastalParams) crenulation(h *field.Field) *field.Field {
|
||||
if p.Cfg.CrenulationM <= 0 || p.Cfg.CrenulationWaveM <= 0 || p.PeriodM <= 0 {
|
||||
return nil
|
||||
}
|
||||
f := p.Frame
|
||||
u, v := noise.WorldUV(f.W, f.H, h.CellM, f.OriginXM(), f.OriginYM(), p.PeriodM)
|
||||
base := int(p.PeriodM/p.Cfg.CrenulationWaveM + 0.5)
|
||||
if base < 2 {
|
||||
base = 2
|
||||
}
|
||||
return noise.FBMAt(u, v, noise.NewSource(p.Seed, srcCoastal),
|
||||
noise.Params{BaseCells: base, Octaves: 3, Gain: 0.5})
|
||||
}
|
||||
|
||||
// slotOf is where a waterline cell sits in the shore list, which is sorted because it was built by scanning.
|
||||
// -1 for a cell that is not on the list, which the distance transform should never hand back and which is
|
||||
// cheaper to rule out here than to debug as an index out of range at planet scale.
|
||||
func slotOf(shore []int32, cell int32) int {
|
||||
k := sort.Search(len(shore), func(k int) bool { return shore[k] >= cell })
|
||||
if k < len(shore) && shore[k] == cell {
|
||||
return k
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// smoothShore blurs a signed distance field, in place.
|
||||
//
|
||||
// Smoothing the *distance* is the point, and it is worth saying what the two obvious alternatives do instead.
|
||||
// Smoothing the mask only moves the speckle around: it is a majority vote over a band that is half land and
|
||||
// half water, so it produces different speckle. Smoothing the heightmap flattens the berm along with it. The
|
||||
// distance is the one field whose smoothing has exactly the wanted effect - the shoreline becomes a curve, a
|
||||
// few metres from where the mask put it, and nothing else about the ground changes at all.
|
||||
//
|
||||
// Two passes rather than one, because one leaves a box kernel's corners in the isolines and they show in a
|
||||
// hillshade on ground this flat.
|
||||
func smoothShore(sd []float32, w, h, radius int) {
|
||||
field.BoxSmooth(sd, w, h, radius, 2)
|
||||
}
|
||||
|
||||
// percentiles sorts a copy and reads the median and the P90 off it. A few thousand shore cells a tile, so a
|
||||
// sort is nothing; this is the one place in the detail passes where that is true, and it is why there is no
|
||||
// histogram here the way there is in internal/stats.
|
||||
func percentiles(v []float64) (p50, p90 float64) {
|
||||
if len(v) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
c := append([]float64(nil), v...)
|
||||
sort.Float64s(c)
|
||||
return c[len(c)/2], c[int(float64(len(c)-1)*0.9)]
|
||||
}
|
||||
|
||||
func max64(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// boundaryOf is the cells of a mask that are orthogonally against a cell that is not, which is to say its
|
||||
// edge on the inside.
|
||||
func boundaryOf(mask []bool, w, h int) []bool {
|
||||
out := make([]bool, len(mask))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if !mask[i] {
|
||||
continue
|
||||
}
|
||||
if (x > 0 && !mask[i-1]) || (x < w-1 && !mask[i+1]) ||
|
||||
(y > 0 && !mask[i-w]) || (y < h-1 && !mask[i+w]) {
|
||||
out[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// signedDistance is metres to the nearest boundary cell, positive inside the mask.
|
||||
//
|
||||
// Distance2 rather than Transform, because this one is thrown away after it has been smoothed and thresholded
|
||||
// back into a shoreline: nothing asks it which stretch of shore a cell belongs to, and the feature index and
|
||||
// the scratch it needs are two more arrays of four bytes a cell.
|
||||
func signedDistance(boundary, mask []bool, w, h int, cellM float64) []float32 {
|
||||
d2 := dt.Distance2(boundary, w, h, false)
|
||||
out := make([]float32, len(d2))
|
||||
for i := range d2 {
|
||||
d := float32(math.Sqrt(float64(d2[i])) * cellM)
|
||||
if mask[i] {
|
||||
out[i] = d
|
||||
} else {
|
||||
out[i] = -d
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// marchBackshore is how high the land stands behind each stretch of shore: the mean height between one and
|
||||
// two surf reaches inland, walked in along the shore normal.
|
||||
//
|
||||
// It is the window measureBackshore uses on the geology grid and for the same reason - it is clear of
|
||||
// everything the surf planed, whatever the exposure there was - and it is what decides whether a stretch of
|
||||
// shore is a beach or the foot of a cliff.
|
||||
//
|
||||
// **Walked rather than gathered**, and that is the whole of this function. The obvious implementation is to
|
||||
// scatter every cell in the band onto the stretch of shore nearest to it, which costs one pass and no marches
|
||||
// at all; it was the first one, and it is wrong in a way that only shows up on a real coastline. A cell two
|
||||
// hundred metres inland belongs to exactly one shore cell, so on a concave shore - the inside of every bay,
|
||||
// which is half of any coastline - the wedges converge and most shore cells are left owning nothing at all in
|
||||
// the band. Their backshore then reads zero, which is not "the land behind is at sea level", it is "I did not
|
||||
// look", and the two are indistinguishable afterwards. Measured on region 11: the median backshore over
|
||||
// 69 km of waterline read 0.0 m while the mean height of the land 110 to 220 m inland was 1.9 m.
|
||||
//
|
||||
// A march gives every stretch of shore its own samples, whichever way the coast bends. Where it walks off the
|
||||
// land - a spit narrower than a surf reach - the count stops rising, and a backshore of zero then means what
|
||||
// it says.
|
||||
func marchBackshore(h *field.Field, dist []float32, wet []bool, shore []int32, reach []float64,
|
||||
seaLevelM float64) []float64 {
|
||||
|
||||
w, ht := h.W, h.H
|
||||
cellM := h.CellM
|
||||
at := func(x, y int) float64 {
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x >= w {
|
||||
x = w - 1
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
} else if y >= ht {
|
||||
y = ht - 1
|
||||
}
|
||||
return float64(dist[y*w+x])
|
||||
}
|
||||
out := make([]float64, len(shore))
|
||||
for s, ci := range shore {
|
||||
x, y := int(ci)%w, int(ci)/w
|
||||
// Inland is up the gradient of the signed distance, which is smooth here because the shoreline it is
|
||||
// measured from is a curve rather than the raw mask's boundary.
|
||||
dx := at(x+1, y) - at(x-1, y)
|
||||
dy := at(x, y+1) - at(x, y-1)
|
||||
l := math.Hypot(dx, dy)
|
||||
if l < 1e-9 {
|
||||
continue
|
||||
}
|
||||
dx, dy = dx/l, dy/l
|
||||
lo := int(reach[s]/cellM + 0.5)
|
||||
hi := 2 * lo
|
||||
var sum float64
|
||||
var count int
|
||||
for t := lo; t <= hi; t++ {
|
||||
px := x + int(math.Round(dx*float64(t)))
|
||||
py := y + int(math.Round(dy*float64(t)))
|
||||
if px < 0 || px >= w || py < 0 || py >= ht {
|
||||
break
|
||||
}
|
||||
j := py*w + px
|
||||
if !wet[j] {
|
||||
break
|
||||
}
|
||||
sum += float64(h.Data[j]) - seaLevelM
|
||||
count++
|
||||
}
|
||||
if count > 0 {
|
||||
out[s] = sum / float64(count)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// screeWedge is the shape of the apron along the march: a wedge under the foot of the cliff, thickest against
|
||||
// the face and thinning to nothing a scree reach seaward of it. Zero past the foot, because an apron lying
|
||||
// *on* the cliff is not an apron.
|
||||
func screeWedge(x, reachM, screeM float64) float64 {
|
||||
if x > reachM {
|
||||
return 0
|
||||
}
|
||||
d := reachM - x
|
||||
if d >= screeM {
|
||||
return 0
|
||||
}
|
||||
return 1 - d/screeM
|
||||
}
|
||||
|
||||
// smoothShoreRoughness damps the metre-scale texture near the shore, in place.
|
||||
//
|
||||
// A blur of a few cells, mixed in by how close a cell is to the waterline. The radius is what keeps it a
|
||||
// *roughness* fade rather than a shape one: at six metres it takes the top off the detail noise and the
|
||||
// droplet rills and leaves everything the solve built, which is tens of metres across at the very least.
|
||||
//
|
||||
// Full strength within half a surf reach either side, then off over reachM more. Both sides on purpose - the
|
||||
// shallows get the same treatment as the backshore, because a shore is a *place* rather than a line and it is
|
||||
// smoother than either the land or the sea bed away from it.
|
||||
//
|
||||
// **Masked, and that is not a detail.** A plain blur across the waterline does not damp texture, it bridges
|
||||
// the shoreline: the step there is a landform and not roughness. Measured on a fixture with forty metres of
|
||||
// water against the land, an unmasked blur lifted the sea floor by twenty metres, which is a beach the size
|
||||
// of the drowned valley it was supposed to leave alone.
|
||||
func smoothShoreRoughness(h *field.Field, dist []float32, wet []bool, surfReachM, reachM float64) {
|
||||
if reachM <= 0 {
|
||||
return
|
||||
}
|
||||
radius := int(shoreRoughM/h.CellM + 0.5)
|
||||
if radius < 1 {
|
||||
return
|
||||
}
|
||||
soft := append([]float32(nil), h.Data...)
|
||||
dry := make([]bool, len(wet))
|
||||
for i, on := range wet {
|
||||
dry[i] = !on
|
||||
}
|
||||
field.BoxSmoothMasked(soft, wet, h.W, h.H, radius, 2)
|
||||
field.BoxSmoothMasked(soft, dry, h.W, h.H, radius, 2)
|
||||
|
||||
core := surfReachM * 0.5
|
||||
out := core + reachM
|
||||
for i := range h.Data {
|
||||
d := math.Abs(float64(dist[i]))
|
||||
if d >= out {
|
||||
continue
|
||||
}
|
||||
w := 1.0
|
||||
if d > core {
|
||||
w = noise.Smoothstep((out - d) / (out - core))
|
||||
}
|
||||
h.Data[i] += float32(w * (float64(soft[i]) - float64(h.Data[i])))
|
||||
}
|
||||
}
|
||||
|
||||
// shoreRoughM is the wavelength the shore fade takes off. It is deliberately short: this is meant to remove
|
||||
// the texture the detail passes added and nothing the solve built, and the solve's finest feature is a gully
|
||||
// tens of metres across.
|
||||
const shoreRoughM = 6
|
||||
@@ -0,0 +1,402 @@
|
||||
package detail
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
const testCellM = 2.0
|
||||
|
||||
// coastalCfg is the manifest's own block, so the tests fail when a default moves rather than measuring a copy
|
||||
// of it that nothing ships.
|
||||
func coastalCfg() (manifest.CoastDetail, manifest.Coast) {
|
||||
m := manifest.Defaults()
|
||||
return m.Pipeline.CoastDetail, m.Pipeline.Coast
|
||||
}
|
||||
|
||||
func coastPlanet(w, h int) world.Planet {
|
||||
return world.Planet{CellM: testCellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * testCellM}
|
||||
}
|
||||
|
||||
// straightCoast is a world cut in half: land to the left of shoreM, sea to the right. The land rises to backM
|
||||
// over one surf reach and then holds, so the backshore window the pass measures in is exactly backM and the
|
||||
// beach-or-cliff decision in a test is the number the test set.
|
||||
//
|
||||
// A straight coast rather than an island on purpose: the profile is then one dimensional, so "what did the
|
||||
// pass do" is a column that can be read off and compared against the arithmetic it is meant to be.
|
||||
func straightCoast(w, h int, shoreM, backM, reachM, seaDepthM float64) (*field.Field, []bool) {
|
||||
f := field.New(w, h, testCellM)
|
||||
land := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
inland := shoreM - float64(x)*testCellM
|
||||
if inland >= 0 {
|
||||
land[i] = true
|
||||
t := inland / reachM
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
f.Data[i] = float32(backM * t * t * (3 - 2*t))
|
||||
} else {
|
||||
f.Data[i] = float32(-seaDepthM)
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, land
|
||||
}
|
||||
|
||||
func runCoastal(t *testing.T, f *field.Field, land []bool, p world.Planet, x0, y0 int, backM float64) CoastalStats {
|
||||
t.Helper()
|
||||
cfg, surf := coastalCfg()
|
||||
return RunCoastal(f, land, CoastalParams{
|
||||
Cfg: cfg, Surf: surf, Seed: 7,
|
||||
Frame: world.Frame{P: p, X0: x0, Y0: y0, W: f.W, H: f.H},
|
||||
PeriodM: 1000, SeaLevelM: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Rule 1, for this pass: everything is keyed on absolute world position - the crenulation lattice through
|
||||
// noise.WorldUV, the distance through a transform whose seeds are the same cells - so a window cut out of a
|
||||
// bigger world and run on its own comes back bit-identical inside its margin.
|
||||
//
|
||||
// This is the test for the mistake the rule exists for: a noise field indexed by grid index instead of world
|
||||
// position looks perfect on any one tile and puts a seam down every tile boundary. Measured by breaking it -
|
||||
// passing a zero origin to WorldUV moves the interior by up to 3.6 m.
|
||||
//
|
||||
// It is *not* the test for the margin being big enough; the coast here is in the middle of the window, so the
|
||||
// answer would be the same with no margin at all. TestThePassFitsInsideTheTileMargin is that one.
|
||||
func TestATileInteriorIsWhatOneWholeRunWouldHaveGiven(t *testing.T) {
|
||||
const w, h = 512, 192
|
||||
p := coastPlanet(w, h)
|
||||
whole, land := straightCoast(w, h, 420, 40, 110, 6)
|
||||
runCoastal(t, whole, land, p, 0, 0, 40)
|
||||
|
||||
// The same world, cut out with a margin and run on its own. 130 cells is 260 m, which is past the pass's
|
||||
// own outer limit of two surf reaches.
|
||||
const margin = 130
|
||||
const cx0, cw = 160, 192
|
||||
cut := field.New(cw+2*margin, h, testCellM)
|
||||
cutLand := make([]bool, len(cut.Data))
|
||||
src, srcLand := straightCoast(w, h, 420, 40, 110, 6)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < cut.W; x++ {
|
||||
sx := cx0 - margin + x
|
||||
cut.Data[y*cut.W+x] = src.Data[y*w+sx]
|
||||
cutLand[y*cut.W+x] = srcLand[y*w+sx]
|
||||
}
|
||||
}
|
||||
runCoastal(t, cut, cutLand, p, cx0-margin, 0, 40)
|
||||
|
||||
var worst float64
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < cw; x++ {
|
||||
a := whole.Data[y*w+cx0+x]
|
||||
b := cut.Data[y*cut.W+margin+x]
|
||||
if d := math.Abs(float64(a) - float64(b)); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
}
|
||||
if worst != 0 {
|
||||
t.Fatalf("a tile's interior differs from the whole run by up to %g m; every hash and lattice in this "+
|
||||
"pass is supposed to be keyed on world position", worst)
|
||||
}
|
||||
}
|
||||
|
||||
// The cliff branch only cuts, so it owes an apron. This is the one hard conservation statement in the pass:
|
||||
// what comes off the face is what lands at its foot, per stretch of shore rather than per tile, so the debris
|
||||
// under a cliff is that cliff's debris.
|
||||
func TestTheScreeIsExactlyWhatTheCliffLost(t *testing.T) {
|
||||
const w, h = 320, 128
|
||||
p := coastPlanet(w, h)
|
||||
f, land := straightCoast(w, h, 400, 60, 110, 6)
|
||||
st := runCoastal(t, f, land, p, 0, 0, 60)
|
||||
|
||||
if st.CutM3 <= 0 {
|
||||
t.Fatalf("a 60 m backshore cut nothing off its face; cliff fraction %.2f", st.CliffFrac)
|
||||
}
|
||||
if st.CliffFrac < 0.99 {
|
||||
t.Fatalf("a 60 m backshore is %.0f%% cliff, not a cliff coast", st.CliffFrac*100)
|
||||
}
|
||||
// Float32 heights, so the tolerance is the accumulation of a few million of them rather than zero.
|
||||
if rel := math.Abs(st.ScreeM3-st.CutM3) / st.CutM3; rel > 1e-9 {
|
||||
t.Fatalf("the face lost %.3f m3 and the apron gained %.3f m3, a relative gap of %g",
|
||||
st.CutM3, st.ScreeM3, rel)
|
||||
}
|
||||
}
|
||||
|
||||
// A beach coast and a cliff coast are the same code with one number changed, and the number is the height of
|
||||
// the land behind the shore. This checks the two come out as different landforms rather than as the same one
|
||||
// scaled: a berm above the waterline on the beach, and no berm at all on the cliff.
|
||||
func TestTheBackshoreDecidesBetweenABeachAndACliff(t *testing.T) {
|
||||
const w, h = 320, 96
|
||||
p := coastPlanet(w, h)
|
||||
cfg, surf := coastalCfg()
|
||||
|
||||
// The swash zone: the strip just inland of the waterline. A berm is ground *standing* above the water
|
||||
// there, so the measurement is a height and not a change - the first version of this measured how much
|
||||
// the pass raised the ground and read 6 m on a beach, all of it the foreshore being filled up from the
|
||||
// flat sea floor the fixture starts with. What was being measured was the fixture.
|
||||
crest := func(f *field.Field) float64 {
|
||||
var top float64
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
inland := 400 - float64(x)*testCellM
|
||||
if inland < 0 || inland > float64(cfg.BermBackM) {
|
||||
continue
|
||||
}
|
||||
if v := float64(f.Data[y*w+x]); v > top {
|
||||
top = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
beach, beachLand := straightCoast(w, h, 400, 3, 110, 6)
|
||||
beachStats := runCoastal(t, beach, beachLand, p, 0, 0, 3)
|
||||
cliff, cliffLand := straightCoast(w, h, 400, 60, 110, 6)
|
||||
cliffStats := runCoastal(t, cliff, cliffLand, p, 0, 0, 60)
|
||||
|
||||
if beachStats.CliffFrac > 0.01 {
|
||||
t.Errorf("a 3 m backshore came out %.0f%% cliff", beachStats.CliffFrac*100)
|
||||
}
|
||||
if cliffStats.CliffFrac < 0.99 {
|
||||
t.Errorf("a 60 m backshore came out only %.0f%% cliff", cliffStats.CliffFrac*100)
|
||||
}
|
||||
|
||||
// With no exposure field every shore is treated as fully exposed, so the berm stands at the manifest's
|
||||
// full height.
|
||||
gotBerm := crest(beach)
|
||||
if want := surf.BermM; gotBerm < want*0.8 || gotBerm > want*1.2 {
|
||||
t.Errorf("the beach's swash zone tops out at %.2f m; a berm should stand about %.2f", gotBerm, want)
|
||||
}
|
||||
// The cliff coast has a shore platform there instead, which runs up at the platform grade and nothing
|
||||
// more: a cliff does not get a berm, it gets the rock the surf planed.
|
||||
gotPlatform := crest(cliff)
|
||||
if want := surf.PlatformGrade * cfg.BermBackM; gotPlatform > want*1.5 {
|
||||
t.Errorf("the cliff's swash zone tops out at %.2f m; the platform should reach about %.2f",
|
||||
gotPlatform, want)
|
||||
}
|
||||
if gotPlatform >= gotBerm {
|
||||
t.Errorf("the cliff coast (%.2f m) stands as high in the swash zone as the beach (%.2f m); the two "+
|
||||
"branches are not producing different landforms", gotPlatform, gotBerm)
|
||||
}
|
||||
}
|
||||
|
||||
// The claim that lets the pass run per tile at all: it never reaches further from the waterline than the tile
|
||||
// margin, so a tile's margin holds everything its interior needed.
|
||||
//
|
||||
// The margin is the droplets' - three lifetimes, 244 m at the defaults - and this pass has to fit inside a
|
||||
// number that was measured for something else. Two surf reaches is its own hard limit, and it is a limit
|
||||
// rather than a consequence: past it a cell has no stretch of shore to belong to at all.
|
||||
//
|
||||
// The test asserts both ends. Past the margin, nothing may move; and something must move a good way out, or
|
||||
// the test would pass just as well on a pass that did nothing.
|
||||
func TestThePassFitsInsideTheTileMargin(t *testing.T) {
|
||||
const w, h = 512, 96
|
||||
p := coastPlanet(w, h)
|
||||
m := manifest.Defaults()
|
||||
marginM := float64(MarginCells(m.Pipeline.Particle)) * testCellM
|
||||
|
||||
for _, backM := range []float64{3, 40, 300, 600} {
|
||||
f, land := straightCoast(w, h, 500, backM, 110, 6)
|
||||
before := f.Clone()
|
||||
runCoastal(t, f, land, p, 0, 0, backM)
|
||||
|
||||
var reachedM float64
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if f.Data[i] == before.Data[i] {
|
||||
continue
|
||||
}
|
||||
if d := math.Abs(500 - float64(x)*testCellM); d > reachedM {
|
||||
reachedM = d
|
||||
}
|
||||
}
|
||||
}
|
||||
if reachedM > marginM {
|
||||
t.Errorf("backshore %.0f m: the pass reached %.0f m from the waterline, past the %.0f m tile "+
|
||||
"margin it has to fit inside", backM, reachedM, marginM)
|
||||
}
|
||||
if reachedM < 40 {
|
||||
t.Errorf("backshore %.0f m: the pass only reached %.0f m, which is not a shore profile",
|
||||
backM, reachedM)
|
||||
}
|
||||
t.Logf("backshore %3.0f m: reached %3.0f m of the %.0f m margin", backM, reachedM, marginM)
|
||||
}
|
||||
}
|
||||
|
||||
// Dean's profile is the one piece of published geomorphology in this pass, so it is worth checking that what
|
||||
// comes out is actually it rather than something that merely slopes the right way. Away from the crenulation
|
||||
// and inside the full-weight strip, the depth under water must be A*x^(2/3).
|
||||
func TestTheForeshoreIsDeansProfile(t *testing.T) {
|
||||
const w, h = 320, 64
|
||||
p := coastPlanet(w, h)
|
||||
cfg, surf := coastalCfg()
|
||||
|
||||
// Shallow water on purpose. A beach may lay at most BeachFillM of sediment on what is already there, so a
|
||||
// fixture with a deep flat floor would measure the cap rather than the curve - which is what the first
|
||||
// version of this did, at 40 m, and it read a flat profile 3 m above the floor. At 3 m the equilibrium
|
||||
// curve sits above the floor by less than the cap everywhere it is sampled.
|
||||
f, land := straightCoast(w, h, 300, 3, 110, 3)
|
||||
runCoastal(t, f, land, p, 0, 0, 3)
|
||||
|
||||
// One row, and the crenulation read off the pass's own noise by inverting the profile at a known depth
|
||||
// would be circular - so instead the check is against the *shape*: the ratio of depths at two offsets
|
||||
// must be (x1/x2)^(2/3) whatever the crenulation shifted them by, and that is what is asserted.
|
||||
y := h / 2
|
||||
depthAt := func(offsetM float64) float64 {
|
||||
x := int((300 + offsetM) / testCellM)
|
||||
return -float64(f.Data[y*w+x])
|
||||
}
|
||||
d1, d2 := depthAt(20), depthAt(45)
|
||||
if d1 <= 0 || d2 <= d1 {
|
||||
t.Fatalf("the foreshore is not going down: %.2f m at 20 m out, %.2f m at 45 m", d1, d2)
|
||||
}
|
||||
// Solve for the shift the crenulation applied, then check A.
|
||||
// d1 = A*(20+s)^(2/3), d2 = A*(45+s)^(2/3)
|
||||
var best, bestErr = 0.0, math.Inf(1)
|
||||
for s := -cfg.CrenulationM; s <= cfg.CrenulationM; s += 0.01 {
|
||||
want := math.Pow((45+s)/(20+s), 2.0/3.0)
|
||||
if e := math.Abs(d2/d1 - want); e < bestErr {
|
||||
best, bestErr = s, e
|
||||
}
|
||||
}
|
||||
if bestErr > 0.02 {
|
||||
t.Fatalf("the two depths %.3f and %.3f are not in a 2/3-power ratio at any crenulation inside "+
|
||||
"+/-%.0f m (best miss %.3f)", d1, d2, cfg.CrenulationM, bestErr)
|
||||
}
|
||||
gotA := d1 / math.Pow(20+best, 2.0/3.0)
|
||||
if math.Abs(gotA-cfg.DeanA) > 0.01 {
|
||||
t.Fatalf("Dean's A came out %.3f against the manifest's %.3f (crenulation %.2f m)",
|
||||
gotA, cfg.DeanA, best)
|
||||
}
|
||||
_ = surf
|
||||
}
|
||||
|
||||
// speckledCoast is a coastal plain: land rising at one in a hundred, with a little roughness on it. That is
|
||||
// enough to make the land mask a forty-metre band of speckle rather than a line, which is what a real one is
|
||||
// - measured on region 11 of the first painted planet, where the shore wandered eighteen cells between rows
|
||||
// three apart and a row crossed sea level three times.
|
||||
func speckledCoast(w, h int, shoreM, grade, roughM float64) (*field.Field, []bool) {
|
||||
f := field.New(w, h, testCellM)
|
||||
land := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
inland := shoreM - float64(x)*testCellM
|
||||
// A hash of the cell, so the roughness is the same every run and has no structure in it.
|
||||
k := uint32(x*374761393+y*668265263) * 2246822519
|
||||
k ^= k >> 13
|
||||
u := float64(k%10007)/10007.0 - 0.5
|
||||
v := grade*inland + roughM*u
|
||||
f.Data[i] = float32(v)
|
||||
land[i] = v > 0
|
||||
}
|
||||
}
|
||||
return f, land
|
||||
}
|
||||
|
||||
// What a coastal plain does to a shoreline, and the reason the signed distance is smoothed before the profile
|
||||
// is measured from it.
|
||||
//
|
||||
// The pass rebuilds the surface as a monotonic function of that distance, so its output crosses sea level
|
||||
// once along any line across the shore however ragged the input was. Without the smoothing it instead builds
|
||||
// a separate berm on every island in the speckle, which is what the first run of the pass did: a string of
|
||||
// beads down the whole coast.
|
||||
func TestACoastalPlainComesOutWithOneShorelineAndNotABeadedOne(t *testing.T) {
|
||||
const w, h = 320, 128
|
||||
p := coastPlanet(w, h)
|
||||
f, land := speckledCoast(w, h, 400, 0.01, 0.30)
|
||||
|
||||
crossings := func(g *field.Field) float64 {
|
||||
total := 0
|
||||
for y := 0; y < h; y++ {
|
||||
n := 0
|
||||
for x := 1; x < w; x++ {
|
||||
a, b := g.Data[y*w+x-1], g.Data[y*w+x]
|
||||
if (a <= 0) != (b <= 0) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return float64(total) / float64(h)
|
||||
}
|
||||
|
||||
before := crossings(f)
|
||||
if before < 3 {
|
||||
t.Fatalf("the fixture is not speckled: %.1f sea-level crossings a row", before)
|
||||
}
|
||||
runCoastal(t, f, land, p, 0, 0, 4)
|
||||
after := crossings(f)
|
||||
if after > 1.05 {
|
||||
t.Errorf("the shore came out with %.2f sea-level crossings a row (%.1f before); a shoreline crosses "+
|
||||
"once, and more than that is a bead on the beach for every island in the mask", after, before)
|
||||
}
|
||||
t.Logf("sea-level crossings a row: %.1f before, %.2f after", before, after)
|
||||
}
|
||||
|
||||
// A beach is a veneer of sediment and not a landform that fills a fjord.
|
||||
//
|
||||
// The equilibrium profile is a target *depth*, so on a shore with forty metres of water a hundred metres off
|
||||
// it - a drowned valley, which is an ordinary thing on a real coast - an uncapped beach branch invents
|
||||
// thirty-seven metres of sand to bring the floor up to the curve. Capped, the beach lays a few metres on
|
||||
// whatever is there and runs out where the water gets deep, which is what a steep-to shore is.
|
||||
func TestABeachDoesNotFillADrownedValley(t *testing.T) {
|
||||
const w, h = 320, 96
|
||||
p := coastPlanet(w, h)
|
||||
cfg, _ := coastalCfg()
|
||||
|
||||
f, land := straightCoast(w, h, 400, 3, 110, 40)
|
||||
before := f.Clone()
|
||||
runCoastal(t, f, land, p, 0, 0, 3)
|
||||
|
||||
var worst float64
|
||||
for i := range f.Data {
|
||||
if d := float64(f.Data[i]) - float64(before.Data[i]); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
if worst > cfg.BeachFillM+0.01 {
|
||||
t.Fatalf("the beach laid %.2f m of sediment where the cap is %.2f; a shore with deep water close in "+
|
||||
"is a steep-to shore, not a bay to be filled", worst, cfg.BeachFillM)
|
||||
}
|
||||
if worst < cfg.BeachFillM*0.5 {
|
||||
t.Fatalf("the beach laid only %.2f m; the fixture is meant to press against the %.2f m cap",
|
||||
worst, cfg.BeachFillM)
|
||||
}
|
||||
}
|
||||
|
||||
// The pass is off when the manifest says so, and off means nothing at all rather than a cheaper version of
|
||||
// itself. Worth a test because it is the switch somebody reaches for when a coast looks wrong, and a switch
|
||||
// that half works is worse than no switch.
|
||||
func TestTheSwitchTurnsItOff(t *testing.T) {
|
||||
const w, h = 128, 64
|
||||
p := coastPlanet(w, h)
|
||||
f, land := straightCoast(w, h, 150, 40, 110, 6)
|
||||
before := f.Clone()
|
||||
|
||||
cfg, surf := coastalCfg()
|
||||
cfg.Enabled = false
|
||||
st := RunCoastal(f, land, CoastalParams{
|
||||
Cfg: cfg, Surf: surf, Seed: 7,
|
||||
Frame: world.Frame{P: p, X0: 0, Y0: 0, W: w, H: h},
|
||||
PeriodM: 1000, SeaLevelM: 0,
|
||||
})
|
||||
if st.ShoreCells != 0 {
|
||||
t.Errorf("a disabled pass reported %d shore cells", st.ShoreCells)
|
||||
}
|
||||
for i := range f.Data {
|
||||
if f.Data[i] != before.Data[i] {
|
||||
t.Fatalf("a disabled pass moved cell %d from %g to %g", i, before.Data[i], f.Data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package detail
|
||||
|
||||
import "salty/terrain/internal/manifest"
|
||||
|
||||
// MarginCells is the overlap a tile must carry for the particle pass, in detail cells.
|
||||
//
|
||||
// Rule 2 of the tiling plan says to size a margin by how far the pass can move material, and for droplets
|
||||
// that is not simply the lifetime. Within one round a droplet travels at most its lifetime, plus one cell for
|
||||
// the cut brush. Across rounds the error compounds: a droplet in round two reads heights the round-one
|
||||
// droplets moved, so the cut edge's influence walks a lifetime further in with every round.
|
||||
//
|
||||
// Taking that literally would make the margin `rounds * lifetime`, which at the defaults is 640 cells against
|
||||
// a 2500-cell tile. Measured instead, at lifetime 12 and 8 rounds (TestHowFarTheCutEdgeReachesIn), the worst
|
||||
// difference between a tile and the same ground in one whole run falls off much faster than that:
|
||||
//
|
||||
// cells in from the cut edge: 0 4 8 12 16 20 24 32 40
|
||||
// worst difference, metres: 7.97 2.53 0.72 0.49 0.44 0.18 0.03 0.00 0.00
|
||||
//
|
||||
// It is the first lifetime that carries almost all of it, and by three the error is gone - a droplet has to be
|
||||
// unlucky in the same way several rounds running for it to keep propagating, and that stops happening. Three
|
||||
// lifetimes plus the brush is the margin, which at the default lifetime of 40 is 122 detail cells, 244 m, or
|
||||
// about five per cent of a 5 km tile on each side.
|
||||
func MarginCells(cfg manifest.Particle) int {
|
||||
life := cfg.Lifetime
|
||||
if life < 1 {
|
||||
life = 1
|
||||
}
|
||||
return 3*life + 2
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package detail
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Why the detail passes need a noise period of their own, and why it is short.
|
||||
//
|
||||
// noise.Lattice allocates cells² floats an octave, and the cell count is the period divided by the
|
||||
// wavelength. Asking for an eight-metre finest octave on a hundred-kilometre period means a lattice of
|
||||
// 12500² - one and a half gigabytes for the top octave alone - so world-period noise simply cannot reach
|
||||
// detail wavelengths with this lattice.
|
||||
//
|
||||
// A short period can, and the cost is that the texture repeats. At a kilometre that is invisible: what
|
||||
// repeats is a few metres of surface roughness, not anything with a shape, and the structure it sits on comes
|
||||
// from the solve and from the paint, neither of which repeats at all. The period still has to divide the
|
||||
// circumference exactly or the pattern breaks at the seam, which the manifest checks.
|
||||
|
||||
// DetailNoiseParams is pass 9.
|
||||
type DetailNoiseParams struct {
|
||||
Cfg manifest.Detail
|
||||
Seed int64
|
||||
Frame world.Frame
|
||||
PeriodM float64 // the short period above; must divide the circumference
|
||||
|
||||
SeaLevelM float64
|
||||
|
||||
// Classes gives each cell its own amplitude. Nil means the manifest's pair everywhere.
|
||||
Classes *Classes
|
||||
}
|
||||
|
||||
// slopeFull is the slope at which detail noise reaches its full amplitude - about 27 degrees. Flat ground
|
||||
// gets the low end and steep ground the high end, which is the same instinct as the droplets' slope gate: a
|
||||
// meadow is smooth and a scree face is not, and noise applied evenly makes the meadow look like sandpaper.
|
||||
const slopeFull = 0.5
|
||||
|
||||
// shoreTaperM is how far either side of the water the amplitude is faded in. A few metres of noise at the
|
||||
// waterline turns the shallows into a scatter of one-cell islands, which is the same failure the coastal pass
|
||||
// tapers its own sea-floor roughness to avoid.
|
||||
const shoreTaperM = 12
|
||||
|
||||
// seabedAmp is how much of the flat-ground amplitude the sea bed gets. A sea bed is not a hillside: what is
|
||||
// down there is bedform and scattered rock, and it is the shape of the shelf that carries the eye rather than
|
||||
// its surface. It is a constant rather than a knob because the knob that matters is how deep the texture
|
||||
// reaches, which is Detail.SeabedM, and two dials for one effect is one too many.
|
||||
const seabedAmp = 0.45
|
||||
|
||||
// lattice builds the noise field both halves of this pass read, on world coordinates.
|
||||
//
|
||||
// BaseCells is chosen so the finest octave lands near two cells, which is as fine as a grid can carry.
|
||||
func (p DetailNoiseParams) lattice(cellM float64) *field.Field {
|
||||
oct := p.Cfg.Octaves
|
||||
f := p.Frame
|
||||
u, v := noise.WorldUV(f.W, f.H, cellM, f.OriginXM(), f.OriginYM(), p.PeriodM)
|
||||
finest := 2 * cellM
|
||||
base := int(p.PeriodM/(finest*math.Pow(2, float64(oct-1))) + 0.5)
|
||||
if base < 2 {
|
||||
base = 2
|
||||
}
|
||||
return noise.FBMAt(u, v, noise.NewSource(p.Seed, srcDetail),
|
||||
noise.Params{BaseCells: base, Octaves: oct, Gain: 0.45})
|
||||
}
|
||||
|
||||
func (p DetailNoiseParams) off() bool {
|
||||
lo, hi := p.Cfg.AmplitudeM.Lo(), p.Cfg.AmplitudeM.Hi()
|
||||
return p.Cfg.Octaves < 1 || (lo == 0 && hi == 0)
|
||||
}
|
||||
|
||||
// RunDetailNoise adds surface texture at wavelengths the geology grid cannot hold.
|
||||
//
|
||||
// It is texture and nothing more. The relief, the valleys and the divides all came from the solve; this is
|
||||
// what the ground does between them, and its amplitude is metres rather than tens of metres on purpose - the
|
||||
// lesson from the first pipeline is that noise piled on top of erosion reads as noise, not as ground.
|
||||
func RunDetailNoise(h *field.Field, land []bool, p DetailNoiseParams) {
|
||||
if p.off() {
|
||||
return
|
||||
}
|
||||
lo, hi := p.Cfg.AmplitudeM.Lo(), p.Cfg.AmplitudeM.Hi()
|
||||
n := p.lattice(h.CellM)
|
||||
slope := h.Slope()
|
||||
for i := range h.Data {
|
||||
if !land[i] {
|
||||
continue
|
||||
}
|
||||
above := float64(h.Data[i]) - p.SeaLevelM
|
||||
if above <= 0 {
|
||||
continue
|
||||
}
|
||||
t := float64(slope.Data[i]) / slopeFull
|
||||
if t > 1 {
|
||||
t = 1
|
||||
} else if t < 0 {
|
||||
t = 0
|
||||
}
|
||||
cLo, cHi := p.Classes.amp(i, lo, hi)
|
||||
amp := cLo + (cHi-cLo)*t
|
||||
if above < shoreTaperM {
|
||||
amp *= above / shoreTaperM
|
||||
}
|
||||
h.Data[i] += float32(amp * (2*float64(n.Data[i]) - 1))
|
||||
}
|
||||
}
|
||||
|
||||
// RunSeabedNoise is the same texture, under water.
|
||||
//
|
||||
// It is a second entry point rather than a branch inside the first because of *when* it can run. Passes 9 to
|
||||
// 12 work with the sea flattened to sea level, so while they are running there is no sea bed to texture: the
|
||||
// floor does not come back until the tile bake restores it, which is after pass 12 and just before the shore
|
||||
// is drawn. So this runs there, on the same lattice, keyed the same way, and a cell gets the same value it
|
||||
// would have got from one whole-world run.
|
||||
//
|
||||
// What it is for: a coast where the land is rough to the last cell and the water is glass from the first
|
||||
// reads as a cut-out rather than as a shore, and the line between the two is the land mask's own boundary -
|
||||
// the one thing in the picture that is a decision rather than a landform.
|
||||
//
|
||||
// Flat-ground amplitude only, and less of it: the slope term is what makes a scree face rough and there are
|
||||
// no scree faces down here. Faded in from nothing at the waterline, so the pass cannot turn the shallows into
|
||||
// a scatter of one-cell islands, and out to nothing at SeabedM.
|
||||
func RunSeabedNoise(h *field.Field, p DetailNoiseParams) {
|
||||
if p.off() || p.Cfg.SeabedM <= 0 {
|
||||
return
|
||||
}
|
||||
lo, hi := p.Cfg.AmplitudeM.Lo(), p.Cfg.AmplitudeM.Hi()
|
||||
n := p.lattice(h.CellM)
|
||||
for i := range h.Data {
|
||||
d := p.SeaLevelM - float64(h.Data[i])
|
||||
if d <= 0 || d >= p.Cfg.SeabedM {
|
||||
continue
|
||||
}
|
||||
cLo, _ := p.Classes.amp(i, lo, hi)
|
||||
amp := cLo * seabedAmp * math.Min(d/shoreTaperM, 1) * (1 - noise.Smoothstep(d/p.Cfg.SeabedM))
|
||||
if amp == 0 {
|
||||
continue
|
||||
}
|
||||
h.Data[i] += float32(amp * (2*float64(n.Data[i]) - 1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package detail
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// brush is the 3x3 kernel a droplet's cut goes through, weights summing to one.
|
||||
//
|
||||
// A one-cell footprint leaves every droplet path as a rill one cell wide, which reads across the lowlands as
|
||||
// brush strokes. Deposits are *not* spread through it and land on the droplet's own bilinear cell instead:
|
||||
// spread through the brush, a pit's rim rises faster than its floor, the pit never fills, and every droplet
|
||||
// that drains into it adds to the rim until there is a mound.
|
||||
var brush = [9]struct {
|
||||
dx, dy int
|
||||
w float64
|
||||
}{
|
||||
{0, 0, 0.36},
|
||||
{0, 1, 0.12}, {0, -1, 0.12}, {1, 0, 0.12}, {-1, 0, 0.12},
|
||||
{1, 1, 0.04}, {1, -1, 0.04}, {-1, 1, 0.04}, {-1, -1, 0.04},
|
||||
}
|
||||
|
||||
// Maps are the derivative fields the droplets leave behind: how much water passed, how much bedrock was
|
||||
// scraped, how much sediment was laid. The layer rules read them - scraped bedrock and convex ridges paint as
|
||||
// rock, sediment fans and basins as meadow.
|
||||
type Maps struct {
|
||||
Flow, Wear, Deposit []float32
|
||||
}
|
||||
|
||||
func newMaps(n int) *Maps {
|
||||
return &Maps{Flow: make([]float32, n), Wear: make([]float32, n), Deposit: make([]float32, n)}
|
||||
}
|
||||
|
||||
// ParticleParams is one particle pass over one tile.
|
||||
type ParticleParams struct {
|
||||
Cfg manifest.Particle
|
||||
Seed int64
|
||||
Frame world.Frame // the tile's cut, at detail resolution: what the hashes are keyed on
|
||||
|
||||
SeaLevelM float64
|
||||
Hardness *Hardness
|
||||
|
||||
// Classes gives each cell its own droplet density, which is the difference between a rain-fed landscape
|
||||
// and an arid one: drop it and the dendritic gully network thins to isolated channels.
|
||||
Classes *Classes
|
||||
}
|
||||
|
||||
// ParticleStats is what the pass moved, in metres.
|
||||
type ParticleStats struct {
|
||||
Droplets int
|
||||
Rounds int
|
||||
LargestCut, LargestFill float64
|
||||
}
|
||||
|
||||
// RunParticle erodes a tile in place with hydraulic droplets.
|
||||
//
|
||||
// h is in metres and land marks the cells droplets may spawn on. Everything inside works in *cell heights* -
|
||||
// metres over the cell size - so a slope of 1 is 45 degrees and every constant in the manifest means the same
|
||||
// thing at any resolution, which is how the numpy was tuned and why the numbers carry across.
|
||||
//
|
||||
// Determinism, which is the part that is not a port. The numpy draws spawn cells from an RNG stream; that is
|
||||
// index-dependent, so a cell would get different droplets depending on which tile it fell in and every seam
|
||||
// would show. Here a cell's droplet count and every one of their choices is a hash of (seed, world position),
|
||||
// so a droplet spawned in a tile's interior is bit-identical to the one spawned when that cell falls inside a
|
||||
// neighbour's margin.
|
||||
//
|
||||
// The pass runs in rounds, which is the numpy's batching kept deliberately rather than inherited: droplets
|
||||
// within a round read the height as it was when the round began and scatter their deltas into per-band
|
||||
// buffers summed afterwards in band order, so two droplets in one cell in one round do not see each other and
|
||||
// the result does not depend on which goroutine ran. Feedback - a channel deepening as more water follows it -
|
||||
// comes from the rounds, not from within one.
|
||||
func RunParticle(h *field.Field, land []bool, p ParticleParams) (*Maps, ParticleStats) {
|
||||
var st ParticleStats
|
||||
cellM := h.CellM
|
||||
w, ht := h.W, h.H
|
||||
maps := newMaps(w * ht)
|
||||
cfg := p.Cfg
|
||||
if cfg.Lifetime <= 0 || (cfg.DropletsPerCell <= 0 && p.Classes == nil) {
|
||||
return maps, st
|
||||
}
|
||||
|
||||
// Into cell heights, and back at the end.
|
||||
hc := make([]float64, w*ht)
|
||||
inv := 1 / cellM
|
||||
for i, v := range h.Data {
|
||||
hc[i] = float64(v) * inv
|
||||
}
|
||||
// The numpy spawns on land standing at least two metres clear of the water, which keeps droplets out of
|
||||
// the surf zone where they would only churn the beach the coastal pass laid.
|
||||
spawnAbove := (p.SeaLevelM + 2) / cellM
|
||||
|
||||
lifetime := cfg.Lifetime
|
||||
inertia := cfg.Inertia
|
||||
capacityF := cfg.Capacity
|
||||
minSlope := cfg.MinSlope
|
||||
depositRate := cfg.DepositRate
|
||||
erodeRate := cfg.ErodeRate * orOne(cfg.Scale)
|
||||
maxChange := cfg.MaxChange * orOne(cfg.Scale)
|
||||
evaporation := cfg.Evaporation
|
||||
gravity := cfg.Gravity
|
||||
maxSpeed := cfg.MaxSpeed
|
||||
maxLoad := cfg.MaxLoad
|
||||
minErode := math.Max(cfg.MinErodeSlope, 1e-6)
|
||||
limit := float64(w) - 2.001
|
||||
limitY := float64(ht) - 2.001
|
||||
|
||||
// How many droplets each cell spawns, and therefore how many rounds. Counting first costs one pass over
|
||||
// the tile and makes the round count a property of the world rather than of the loop.
|
||||
total := 0
|
||||
for y := 0; y < ht; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if !land[i] || hc[i] <= spawnAbove {
|
||||
continue
|
||||
}
|
||||
wx, wy := p.Frame.PlanetXY(x, y)
|
||||
total += int(p.Classes.droplets(i, cfg.DropletsPerCell) + hashXY(p.Seed, wx, wy, 0))
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return maps, st
|
||||
}
|
||||
// Rounds comes from the manifest and *not* from the droplet count, which is the one place this departs
|
||||
// from the numpy on purpose. Derived from the count it would depend on how big a piece of the world was
|
||||
// being worked on, so a droplet would land in a different round in a tile than in the whole map and the
|
||||
// seams would not close.
|
||||
rounds := cfg.Rounds
|
||||
if rounds < 1 {
|
||||
rounds = 1
|
||||
}
|
||||
st.Droplets, st.Rounds = total, rounds
|
||||
|
||||
reach := lifetime + 2 // a droplet steps one cell at a time; the brush adds one more
|
||||
// A fixed band size, not one per core: a cell's contributions are summed band by band and floating-point
|
||||
// addition is not associative, so a partition that moved with GOMAXPROCS would move the last bit with it.
|
||||
const bandRows = 64
|
||||
bands := field.FixedBandCount(ht, bandRows)
|
||||
type buf struct {
|
||||
y0, y1 int // the rows this band may touch
|
||||
dh []float64
|
||||
flow, wear, dep []float32
|
||||
}
|
||||
bufs := make([]buf, bands)
|
||||
|
||||
for round := 0; round < rounds; round++ {
|
||||
field.FixedBands(ht, bandRows, func(b, y0, y1 int) {
|
||||
lo := y0 - reach
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
hi := y1 + reach
|
||||
if hi > ht {
|
||||
hi = ht
|
||||
}
|
||||
n := (hi - lo) * w
|
||||
bf := &bufs[b]
|
||||
if len(bf.dh) != n {
|
||||
bf.dh = make([]float64, n)
|
||||
bf.flow = make([]float32, n)
|
||||
bf.wear = make([]float32, n)
|
||||
bf.dep = make([]float32, n)
|
||||
} else {
|
||||
clear(bf.dh)
|
||||
clear(bf.flow)
|
||||
clear(bf.wear)
|
||||
clear(bf.dep)
|
||||
}
|
||||
bf.y0, bf.y1 = lo, hi
|
||||
|
||||
add := func(x, y int, dh, flow, wear, dep float64) {
|
||||
if y < lo || y >= hi || x < 0 || x >= w {
|
||||
return
|
||||
}
|
||||
j := (y-lo)*w + x
|
||||
bf.dh[j] += dh
|
||||
bf.flow[j] += float32(flow)
|
||||
bf.wear[j] += float32(wear)
|
||||
bf.dep[j] += float32(dep)
|
||||
}
|
||||
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if !land[i] || hc[i] <= spawnAbove {
|
||||
continue
|
||||
}
|
||||
wx, wy := p.Frame.PlanetXY(x, y)
|
||||
count := int(p.Classes.droplets(i, cfg.DropletsPerCell) + hashXY(p.Seed, wx, wy, 0))
|
||||
for j := 0; j < count; j++ {
|
||||
if int(hashXY(p.Seed, wx, wy, int32(100+j))*float64(rounds)) != round {
|
||||
continue
|
||||
}
|
||||
px := clampF(float64(x)+hashXY(p.Seed, wx, wy, int32(3*j+1)), 1, limit)
|
||||
py := clampF(float64(y)+hashXY(p.Seed, wx, wy, int32(3*j+2)), 1, limitY)
|
||||
runDroplet(hc, land, w, px, py, dropletConst{
|
||||
lifetime: lifetime, inertia: inertia, capacityF: capacityF,
|
||||
minSlope: minSlope, depositRate: depositRate, erodeRate: erodeRate,
|
||||
maxChange: maxChange, evaporation: evaporation, gravity: gravity,
|
||||
maxSpeed: maxSpeed, maxLoad: maxLoad, minErode: minErode,
|
||||
limitX: limit, limitY: limitY,
|
||||
}, p.Hardness, add)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Summed in band order, never drained from a channel: the result must not depend on which goroutine
|
||||
// finished first (cross-cutting rule 12).
|
||||
for b := range bufs {
|
||||
bf := &bufs[b]
|
||||
if bf.dh == nil {
|
||||
continue
|
||||
}
|
||||
for y := bf.y0; y < bf.y1; y++ {
|
||||
src := (y - bf.y0) * w
|
||||
dst := y * w
|
||||
for x := 0; x < w; x++ {
|
||||
hc[dst+x] += bf.dh[src+x]
|
||||
maps.Flow[dst+x] += bf.flow[src+x]
|
||||
maps.Wear[dst+x] += bf.wear[src+x]
|
||||
maps.Deposit[dst+x] += bf.dep[src+x]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range h.Data {
|
||||
after := float32(hc[i] * cellM)
|
||||
if d := float64(after - h.Data[i]); d < st.LargestCut {
|
||||
st.LargestCut = d
|
||||
} else if d > st.LargestFill {
|
||||
st.LargestFill = d
|
||||
}
|
||||
h.Data[i] = after
|
||||
}
|
||||
// Wear and deposit are in cell heights; report them in metres like everything else.
|
||||
for i := range maps.Wear {
|
||||
maps.Wear[i] = float32(float64(maps.Wear[i]) * cellM)
|
||||
maps.Deposit[i] = float32(float64(maps.Deposit[i]) * cellM)
|
||||
}
|
||||
st.LargestCut = -st.LargestCut
|
||||
return maps, st
|
||||
}
|
||||
|
||||
type dropletConst struct {
|
||||
lifetime int
|
||||
inertia, capacityF, minSlope float64
|
||||
depositRate, erodeRate, maxChange float64
|
||||
evaporation, gravity, maxSpeed float64
|
||||
maxLoad, minErode float64
|
||||
limitX, limitY float64
|
||||
}
|
||||
|
||||
// runDroplet is one droplet's whole life. It reads the height as it was at the start of the round and reports
|
||||
// what it moved through add; it never writes to the shared map itself.
|
||||
func runDroplet(h []float64, land []bool, w int, px, py float64, c dropletConst, hard *Hardness,
|
||||
add func(x, y int, dh, flow, wear, dep float64)) {
|
||||
|
||||
dx, dy := 0.0, 0.0
|
||||
speed, water, sediment := 1.0, 1.0, 0.0
|
||||
|
||||
for step := 0; step < c.lifetime; step++ {
|
||||
hcv, gx, gy, x0, y0, fx, fy := sampleBilinear(h, w, px, py)
|
||||
|
||||
dx = dx*c.inertia - gx*(1-c.inertia)
|
||||
dy = dy*c.inertia - gy*(1-c.inertia)
|
||||
length := math.Hypot(dx, dy)
|
||||
if length <= 1e-9 {
|
||||
return // standing water: it cannot pick a direction, so it stops
|
||||
}
|
||||
dx /= length
|
||||
dy /= length
|
||||
|
||||
nx, ny := px+dx, py+dy
|
||||
inside := nx >= 1 && nx <= c.limitX && ny >= 1 && ny <= c.limitY
|
||||
hn, _, _, _, _, _, _ := sampleBilinear(h, w, clampF(nx, 1, c.limitX), clampF(ny, 1, c.limitY))
|
||||
dh := 0.0
|
||||
if inside {
|
||||
dh = hn - hcv
|
||||
}
|
||||
|
||||
slope := math.Max(-dh, c.minSlope)
|
||||
capacity := math.Min(slope*speed*water*c.capacityF, c.maxLoad)
|
||||
hardness := 0.0
|
||||
if hard != nil {
|
||||
hardness = hard.At(y0*w+x0, hcv)
|
||||
}
|
||||
// Flat ground resists cutting. The gate has to sit well above the median lowland slope or the
|
||||
// meadows come out brushed with rills, which is the lesson 0.25 encodes.
|
||||
holds := math.Hypot(gx, gy) / c.minErode
|
||||
if holds > 1 {
|
||||
holds = 1
|
||||
}
|
||||
holds *= holds
|
||||
|
||||
deposit, erode := 0.0, 0.0
|
||||
if dh > 0 {
|
||||
deposit = math.Min(dh, sediment) // uphill: fill the pit it is climbing out of
|
||||
} else if sediment > capacity {
|
||||
deposit = (sediment - capacity) * c.depositRate
|
||||
}
|
||||
if dh <= 0 && sediment <= capacity {
|
||||
erode = math.Min((capacity-sediment)*c.erodeRate, -dh) * (1 - hardness) * holds
|
||||
}
|
||||
|
||||
// The sea is a sink: the droplet drops its whole load at the mouth, which is what makes a fan. It is
|
||||
// the land mask that decides, not a height comparison - the sea floor is held at sea level while the
|
||||
// detail passes run (the same invariant the solve keeps), so there is no depth to compare against.
|
||||
intoSea := false
|
||||
if inside {
|
||||
nxi, nyi := int(nx+0.5), int(ny+0.5)
|
||||
if nxi >= 0 && nxi < w && nyi >= 0 && nyi*w+nxi < len(land) {
|
||||
intoSea = !land[nyi*w+nxi]
|
||||
}
|
||||
}
|
||||
if intoSea {
|
||||
deposit, erode = sediment, 0
|
||||
} else {
|
||||
deposit = math.Min(deposit, c.maxChange)
|
||||
erode = math.Min(erode, c.maxChange)
|
||||
}
|
||||
|
||||
// Neither the cut nor the deposit may touch water. Both stencils straddle the waterline whenever a
|
||||
// droplet is within a cell of it, and the sea floor is held at sea level here and put back afterwards,
|
||||
// so anything written there would be silently thrown away - sediment that should have built a beach,
|
||||
// quietly deleted. The cut is simply skipped, because cutting a sea floor that is a placeholder means
|
||||
// nothing; the deposit is given to the droplet's own cell, which is land for as long as it is alive.
|
||||
onLand := func(x, y int) bool {
|
||||
if x < 0 || x >= w || y < 0 {
|
||||
return false
|
||||
}
|
||||
i := y*w + x
|
||||
return i < len(land) && land[i]
|
||||
}
|
||||
if erode > 0 {
|
||||
for _, b := range brush {
|
||||
if onLand(x0+b.dx, y0+b.dy) {
|
||||
add(x0+b.dx, y0+b.dy, -erode*b.w, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if deposit > 0 {
|
||||
put := func(x, y int, amount float64) {
|
||||
if !onLand(x, y) {
|
||||
x, y = x0, y0
|
||||
}
|
||||
add(x, y, amount, 0, 0, 0)
|
||||
}
|
||||
put(x0, y0, deposit*(1-fx)*(1-fy))
|
||||
put(x0+1, y0, deposit*fx*(1-fy))
|
||||
put(x0, y0+1, deposit*(1-fx)*fy)
|
||||
put(x0+1, y0+1, deposit*fx*fy)
|
||||
}
|
||||
add(x0, y0, 0, water, erode, deposit)
|
||||
|
||||
sediment += erode - deposit
|
||||
speed = math.Min(math.Sqrt(math.Max(0, speed*speed-dh*c.gravity)), c.maxSpeed)
|
||||
water *= 1 - c.evaporation
|
||||
|
||||
if !inside || intoSea || water <= 0.001 {
|
||||
return
|
||||
}
|
||||
px, py = nx, ny
|
||||
}
|
||||
}
|
||||
|
||||
// sampleBilinear is the height and its gradient at a float position, with the integer cell and the
|
||||
// fractions the caller needs to scatter back. The caller keeps the position inside [1, size-2].
|
||||
func sampleBilinear(h []float64, w int, px, py float64) (hc, gx, gy float64, x0, y0 int, fx, fy float64) {
|
||||
x0 = int(px)
|
||||
y0 = int(py)
|
||||
fx = px - float64(x0)
|
||||
fy = py - float64(y0)
|
||||
i := y0*w + x0
|
||||
h00 := h[i]
|
||||
h10 := h[i+1]
|
||||
h01 := h[i+w]
|
||||
h11 := h[i+w+1]
|
||||
gx = (h10-h00)*(1-fy) + (h11-h01)*fy
|
||||
gy = (h01-h00)*(1-fx) + (h11-h10)*fx
|
||||
hc = h00*(1-fx)*(1-fy) + h10*fx*(1-fy) + h01*(1-fx)*fy + h11*fx*fy
|
||||
return hc, gx, gy, x0, y0, fx, fy
|
||||
}
|
||||
|
||||
func clampF(v, lo, hi float64) float64 {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func orOne(v float64) float64 {
|
||||
if v <= 0 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// hashXY is splitmix64's finaliser over the seed and a world position, in [0, 1). The same arithmetic as the
|
||||
// router's jitter and for the same reason: everything random has to be a hash of where a thing is, never of
|
||||
// the order it was visited in.
|
||||
func hashXY(seed int64, x, y int, k int32) float64 {
|
||||
h := uint64(seed)*0x9e3779b97f4a7c15 + 0x243f6a8885a308d3
|
||||
h ^= uint64(uint32(int32(x)))*0x9e3779b97f4a7c15 +
|
||||
uint64(uint32(int32(y)))*0xc2b2ae3d27d4eb4f +
|
||||
uint64(uint32(k))*0x165667b19e3779f9
|
||||
h ^= h >> 30
|
||||
h *= 0xbf58476d1ce4e5b9
|
||||
h ^= h >> 27
|
||||
h *= 0x94d049bb133111eb
|
||||
h ^= h >> 31
|
||||
return float64(h>>11) / float64(uint64(1)<<53)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package detail
|
||||
|
||||
import (
|
||||
"math"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
func testPlanet(t *testing.T) world.Planet {
|
||||
t.Helper()
|
||||
// 256 detail columns of 2 m is a 512 m circumference. Small, and a whole number of cells.
|
||||
p := world.Planet{CellM: 2, W: 256, H: 96, PadY: 0, NoisePeriodM: 512}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// a ridge running down the middle with some texture, so the droplets have something to cut.
|
||||
func testTerrain(f world.Frame) (*field.Field, []bool) {
|
||||
h := field.New(f.W, f.H, f.P.CellM)
|
||||
land := make([]bool, f.W*f.H)
|
||||
for y := 0; y < f.H; y++ {
|
||||
for x := 0; x < f.W; x++ {
|
||||
wx, wy := f.PlanetXY(x, y)
|
||||
fx := float64(wx)
|
||||
fy := float64(wy)
|
||||
v := 120 * math.Exp(-math.Pow((fy-48)/22, 2))
|
||||
v += 9 * math.Sin(fx*0.21) * math.Cos(fy*0.17)
|
||||
v += 4 * math.Sin(fx*0.63+fy*0.41)
|
||||
i := y*f.W + x
|
||||
h.Data[i] = float32(v)
|
||||
land[i] = v > 3
|
||||
}
|
||||
}
|
||||
return h, land
|
||||
}
|
||||
|
||||
func testCfg() manifest.Particle {
|
||||
c := manifest.Defaults().Pipeline.Particle
|
||||
c.DropletsPerCell = 1.5 // dense, so a small grid still gets a meaningful number
|
||||
c.Lifetime = 12
|
||||
c.Rounds = 1
|
||||
return c
|
||||
}
|
||||
|
||||
// The seam property the whole tiling rests on: a cell in a tile's interior must come out exactly as it would
|
||||
// have in one big run, because every droplet that can reach it spawned inside the tile's margin.
|
||||
//
|
||||
// Rounds is 1 here, which is where the margin of lifetime+2 is *exactly* sufficient: a droplet that affects an
|
||||
// interior cell passed within brush range of it, so it spawned at most lifetime cells away and every height it
|
||||
// read on the way is inside the margin. With more rounds the margin's own heights start to matter and the
|
||||
// match becomes very close rather than exact, which the test below measures instead of assuming.
|
||||
func TestATilesInteriorMatchesTheWholeMap(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
cfg := testCfg()
|
||||
const margin = 14 // lifetime 12 + 2
|
||||
|
||||
whole := world.Whole(p)
|
||||
hw, landw := testTerrain(whole)
|
||||
RunParticle(hw, landw, ParticleParams{Cfg: cfg, Seed: 7, Frame: whole})
|
||||
|
||||
// A tile covering columns 40..119, with the margin either side.
|
||||
const x0, w = 40, 80
|
||||
tf := world.Frame{P: p, X0: x0 - margin, Y0: 0, W: w + 2*margin, H: p.H}
|
||||
ht, landt := testTerrain(tf)
|
||||
RunParticle(ht, landt, ParticleParams{Cfg: cfg, Seed: 7, Frame: tf})
|
||||
|
||||
worst, at := 0.0, [2]int{}
|
||||
for y := margin; y < p.H-margin; y++ {
|
||||
for x := margin; x < margin+w; x++ {
|
||||
got := float64(ht.Data[y*tf.W+x])
|
||||
want := float64(hw.Data[y*p.W+(x0-margin+x)])
|
||||
if d := math.Abs(got - want); d > worst {
|
||||
worst, at = d, [2]int{x, y}
|
||||
}
|
||||
}
|
||||
}
|
||||
if worst > 1e-4 {
|
||||
t.Errorf("the tile's interior differs from the whole map by %.6f m at %v; the margin is not doing "+
|
||||
"its job, or something is keyed on a tile-local index", worst, at)
|
||||
}
|
||||
}
|
||||
|
||||
// With more than one round the margin's own heights feed back, so the match stops being exact and the
|
||||
// question becomes how deep into a tile the edge's influence reaches. That is a measurement, not a guess:
|
||||
// this runs a wide margin and reports the worst error at each depth, and the assertion is set at the depth
|
||||
// the bake actually uses.
|
||||
func TestHowFarTheCutEdgeReachesIn(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
cfg := testCfg()
|
||||
cfg.Rounds = 8
|
||||
const margin = 48
|
||||
|
||||
whole := world.Whole(p)
|
||||
hw, landw := testTerrain(whole)
|
||||
RunParticle(hw, landw, ParticleParams{Cfg: cfg, Seed: 7, Frame: whole})
|
||||
|
||||
const x0, w = 60, 60
|
||||
tf := world.Frame{P: p, X0: x0 - margin, Y0: 0, W: w + 2*margin, H: p.H}
|
||||
ht, landt := testTerrain(tf)
|
||||
RunParticle(ht, landt, ParticleParams{Cfg: cfg, Seed: 7, Frame: tf})
|
||||
|
||||
// worst error among cells exactly d columns in from the cut's left edge.
|
||||
at := func(d int) float64 {
|
||||
worst := 0.0
|
||||
x := d
|
||||
// The whole run and the tile run share their top and bottom edges, so those cancel; only a couple of
|
||||
// rows are dropped to keep the bilinear sampler's own clamp out of it.
|
||||
for y := 2; y < p.H-2; y++ {
|
||||
got := float64(ht.Data[y*tf.W+x])
|
||||
want := float64(hw.Data[y*p.W+p.WrapX(x0-margin+x)])
|
||||
if e := math.Abs(got - want); e > worst {
|
||||
worst = e
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
for _, d := range []int{0, 4, 8, 12, 16, 20, 24, 32, 40, 48} {
|
||||
t.Logf(" %2d cells in from the cut edge (%.0f m): worst %.4f m", d, float64(d)*p.CellM, at(d))
|
||||
}
|
||||
|
||||
// At the margin the bake uses, the edge must have stopped mattering.
|
||||
if e := at(MarginCells(cfg)); e > 0.05 {
|
||||
t.Errorf("at the bake's margin of %d cells the edge still moves the ground by %.4f m",
|
||||
MarginCells(cfg), e)
|
||||
}
|
||||
}
|
||||
|
||||
// A tile that straddles the seam must get the same answer as one that does not, which is what keying every
|
||||
// hash on the world position buys.
|
||||
func TestTheSeamIsNotSpecial(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
cfg := testCfg()
|
||||
|
||||
a := world.Frame{P: p, X0: 0, Y0: 0, W: 64, H: p.H}
|
||||
ha, landa := testTerrain(a)
|
||||
RunParticle(ha, landa, ParticleParams{Cfg: cfg, Seed: 7, Frame: a})
|
||||
|
||||
// The same physical columns, reached from a frame that starts on the far side of the seam.
|
||||
b := world.Frame{P: p, X0: p.W - 32, Y0: 0, W: 64, H: p.H}
|
||||
hb, landb := testTerrain(b)
|
||||
RunParticle(hb, landb, ParticleParams{Cfg: cfg, Seed: 7, Frame: b})
|
||||
|
||||
// Frame b's column 32+k is planet column k, which is frame a's column k. Only compare cells far enough
|
||||
// from both frames' edges that they saw the same droplets.
|
||||
const edge = 14
|
||||
checked := 0
|
||||
for y := edge; y < p.H-edge; y++ {
|
||||
for k := edge; k < 32-edge; k++ {
|
||||
got := hb.Data[y*b.W+32+k]
|
||||
want := ha.Data[y*a.W+k]
|
||||
if math.Abs(float64(got-want)) > 1e-4 {
|
||||
t.Fatalf("planet column %d row %d: %.6f across the seam, %.6f at the origin", k, y, got, want)
|
||||
}
|
||||
checked++
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("nothing was compared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticleIsDeterministicAcrossGOMAXPROCS(t *testing.T) {
|
||||
was := runtime.GOMAXPROCS(1)
|
||||
defer runtime.GOMAXPROCS(was)
|
||||
|
||||
p := testPlanet(t)
|
||||
cfg := testCfg()
|
||||
cfg.Rounds = 4
|
||||
f := world.Whole(p)
|
||||
|
||||
var want []float32
|
||||
for _, procs := range []int{1, 2, 4, 8, 16} {
|
||||
runtime.GOMAXPROCS(procs)
|
||||
h, land := testTerrain(f)
|
||||
RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
|
||||
if want == nil {
|
||||
want = append([]float32(nil), h.Data...)
|
||||
continue
|
||||
}
|
||||
for i := range want {
|
||||
if h.Data[i] != want[i] {
|
||||
t.Fatalf("GOMAXPROCS %d differs at cell %d: %v against %v", procs, i, h.Data[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The brakes are lessons, not choices, and this is the one that matters most: below the slope gate water
|
||||
// deposits but barely cuts, so lowland soil holds and meadows stay meadows instead of coming out brushed with
|
||||
// rills.
|
||||
func TestTheSlopeGateProtectsFlatGround(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
f := world.Whole(p)
|
||||
cfg := testCfg()
|
||||
cfg.DropletsPerCell = 4
|
||||
|
||||
// A gentle ramp well below min_erode_slope 0.25: 0.05 m over a 2 m cell is a slope of 0.025.
|
||||
flat := func() (*field.Field, []bool) {
|
||||
h := field.New(f.W, f.H, f.P.CellM)
|
||||
land := make([]bool, f.W*f.H)
|
||||
for y := 0; y < f.H; y++ {
|
||||
for x := 0; x < f.W; x++ {
|
||||
i := y*f.W + x
|
||||
h.Data[i] = float32(40 + 0.05*float64(y))
|
||||
land[i] = true
|
||||
}
|
||||
}
|
||||
return h, land
|
||||
}
|
||||
|
||||
h, land := flat()
|
||||
before := append([]float32(nil), h.Data...)
|
||||
_, st := RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
|
||||
if st.Droplets == 0 {
|
||||
t.Fatal("no droplets spawned")
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range h.Data {
|
||||
if d := math.Abs(float64(h.Data[i] - before[i])); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("%d droplets over flat ground moved at most %.4f m", st.Droplets, worst)
|
||||
if worst > 0.25 {
|
||||
t.Errorf("flat ground moved %.3f m; the slope gate is not holding", worst)
|
||||
}
|
||||
|
||||
// And with the gate opened right up, the same ground does get cut - so the test above is measuring the
|
||||
// gate and not simply a pass that does nothing.
|
||||
open := cfg
|
||||
open.MinErodeSlope = 0.001
|
||||
h2, land2 := flat()
|
||||
RunParticle(h2, land2, ParticleParams{Cfg: open, Seed: 7, Frame: f})
|
||||
moved := 0.0
|
||||
for i := range h2.Data {
|
||||
if d := math.Abs(float64(h2.Data[i] - before[i])); d > moved {
|
||||
moved = d
|
||||
}
|
||||
}
|
||||
if moved <= worst {
|
||||
t.Errorf("opening the gate moved %.4f m against %.4f m closed; the test is not measuring the gate",
|
||||
moved, worst)
|
||||
}
|
||||
}
|
||||
|
||||
// The sea is a sink, and it is the land mask that says so rather than a height comparison: the sea floor is
|
||||
// held at sea level while the detail passes run, exactly as the fluvial solve holds it, so there is no depth
|
||||
// to compare against. What a droplet reaching the water does is drop its whole load, which is what builds a
|
||||
// fan at a river mouth.
|
||||
func TestADropletEndsAtTheWaterAndLeavesItsLoadThere(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
f := world.Whole(p)
|
||||
cfg := testCfg()
|
||||
cfg.DropletsPerCell = 3
|
||||
|
||||
h := field.New(f.W, f.H, f.P.CellM)
|
||||
land := make([]bool, f.W*f.H)
|
||||
const shore = 60
|
||||
for y := 0; y < f.H; y++ {
|
||||
for x := 0; x < f.W; x++ {
|
||||
i := y*f.W + x
|
||||
if y >= shore {
|
||||
h.Data[i] = 0 // the sea, held at sea level
|
||||
continue
|
||||
}
|
||||
// A slope running down to the shore, steep enough to be well past the cutting gate.
|
||||
h.Data[i] = float32(2 * float64(shore-y))
|
||||
land[i] = true
|
||||
}
|
||||
}
|
||||
before := append([]float32(nil), h.Data...)
|
||||
|
||||
maps, st := RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
|
||||
if st.Droplets == 0 {
|
||||
t.Fatal("no droplets spawned")
|
||||
}
|
||||
|
||||
// Nothing in the water moved.
|
||||
for y := shore; y < f.H; y++ {
|
||||
for x := 0; x < f.W; x++ {
|
||||
i := y*f.W + x
|
||||
if h.Data[i] != before[i] {
|
||||
t.Fatalf("sea cell (%d,%d) moved from %v to %v", x, y, before[i], h.Data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the last row of land carries more deposit than the slope above it: that is the fan.
|
||||
rowDeposit := func(y int) float64 {
|
||||
s := 0.0
|
||||
for x := 0; x < f.W; x++ {
|
||||
s += float64(maps.Deposit[y*f.W+x])
|
||||
}
|
||||
return s
|
||||
}
|
||||
atShore := rowDeposit(shore - 1)
|
||||
upslope := rowDeposit(shore / 2)
|
||||
t.Logf("deposit at the shore %.2f m against %.2f m halfway up the slope", atShore, upslope)
|
||||
if atShore <= upslope {
|
||||
t.Errorf("the shore row took %.3f m of deposit and the mid-slope row %.3f m; the sea is not acting "+
|
||||
"as a sink", atShore, upslope)
|
||||
}
|
||||
}
|
||||
|
||||
// A desert and a wet lowland can have the same uplift rate and the same erodibility - which is everything the
|
||||
// geology grid knows about them - and still be completely different ground. The per-class detail tables are
|
||||
// where that difference lives, and the droplet density is the load-bearing one: drop it and the dendritic
|
||||
// gully network thins out to isolated channels.
|
||||
func TestAClassCanAskForLessRunningWater(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
f := world.Whole(p)
|
||||
cfg := testCfg()
|
||||
cfg.DropletsPerCell = 2.0
|
||||
|
||||
// Two classes over the same terrain: the left half wet, the right half arid.
|
||||
run := func(classes *Classes) (ParticleStats, float64) {
|
||||
h, land := testTerrain(f)
|
||||
before := append([]float32(nil), h.Data...)
|
||||
_, st := RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f, Classes: classes})
|
||||
moved := 0.0
|
||||
for i := range h.Data {
|
||||
moved += math.Abs(float64(h.Data[i] - before[i]))
|
||||
}
|
||||
return st, moved
|
||||
}
|
||||
|
||||
wet, wetMoved := run(nil)
|
||||
arid := uniformClasses(f.W*f.H, 0.1)
|
||||
dry, dryMoved := run(arid)
|
||||
|
||||
t.Logf("wet %d droplets moved %.0f m of material; arid %d droplets moved %.0f m",
|
||||
wet.Droplets, wetMoved, dry.Droplets, dryMoved)
|
||||
if dry.Droplets >= wet.Droplets/10 {
|
||||
t.Errorf("the arid class spawned %d droplets against %d wet; a twentieth of the density should show",
|
||||
dry.Droplets, wet.Droplets)
|
||||
}
|
||||
if dryMoved >= wetMoved/2 {
|
||||
t.Errorf("the arid class moved %.0f m against %.0f m wet; it should be far less dissected",
|
||||
dryMoved, wetMoved)
|
||||
}
|
||||
if dry.Droplets == 0 {
|
||||
t.Error("the arid class spawned nothing at all; that is not a desert, that is a table")
|
||||
}
|
||||
}
|
||||
|
||||
// And with no override, a class table changes nothing - which is what keeps every template that does not use
|
||||
// one exactly where it was.
|
||||
func TestClassTablesMatchingThePipelineChangeNothing(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
f := world.Whole(p)
|
||||
cfg := testCfg()
|
||||
|
||||
a, landA := testTerrain(f)
|
||||
RunParticle(a, landA, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
|
||||
|
||||
b, landB := testTerrain(f)
|
||||
RunParticle(b, landB, ParticleParams{Cfg: cfg, Seed: 7, Frame: f,
|
||||
Classes: uniformClasses(f.W*f.H, cfg.DropletsPerCell)})
|
||||
|
||||
for i := range a.Data {
|
||||
if a.Data[i] != b.Data[i] {
|
||||
t.Fatalf("cell %d differs: %v against %v", i, a.Data[i], b.Data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// uniformClasses is a class table that says the same thing everywhere, which is what the two tests above
|
||||
// want: one to make the whole map arid, the other to say nothing at all and prove it changes nothing.
|
||||
func uniformClasses(n int, droplets float64) *Classes {
|
||||
c := &Classes{
|
||||
Droplets: make([]float32, n),
|
||||
AmpLo: make([]float32, n),
|
||||
AmpHi: make([]float32, n),
|
||||
Contrast: make([]float32, n),
|
||||
}
|
||||
for i := range c.Droplets {
|
||||
c.Droplets[i] = float32(droplets)
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package detail is the pipeline below the geology grid: the passes that decide how the ground reads to
|
||||
// somebody standing on it.
|
||||
//
|
||||
// Every one of them is local, which is what makes the detail grid tileable at all (internal/tile): noise is
|
||||
// pointwise, thermal weathering propagates a cell at a time, and a droplet travels at most its lifetime in
|
||||
// cells. And every one of them is a port of tuned numpy from Scripts/Authoring/heightmap_erosion.py rather
|
||||
// than a reimplementation. Docs/Terrain.md is explicit about which of its constants are lessons rather than
|
||||
// choices, and they all carry across unchanged:
|
||||
//
|
||||
// - the droplet slope gate at 0.25, which must sit well above the median lowland slope or the meadows come
|
||||
// out brushed with rills;
|
||||
// - the per-step cut cap, because droplets share cells and a crowd in one runs away to infinity without it;
|
||||
// - the load cap, which bounds the mound a droplet leaves where it stops;
|
||||
// - cuts through a 3x3 brush and deposits on the droplet's own cell, because spreading the deposit makes a
|
||||
// pit's rim rise faster than its floor, so the pit never fills and every droplet feeds a mound;
|
||||
// - and thermal weathering shedding half the *largest* excess rather than half the mean.
|
||||
//
|
||||
// What does not carry across is how the randomness is drawn. The numpy picks spawn cells from an RNG stream,
|
||||
// which is index-dependent: the same cell would get different droplets depending on which tile it fell in and
|
||||
// every seam would show. Here everything is a hash of the absolute world position.
|
||||
package detail
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Hardness is rock hardness in [0, 1] as a function of position and *elevation*: horizontal bands with a slow
|
||||
// tilt, and a slow change of rock type across the map. Erosion is scaled by (1 - hardness), so a hard band
|
||||
// holds a shelf on a cut face.
|
||||
//
|
||||
// It is orthogonal to the lithology field the fluvial solve uses and both are kept, which is the point:
|
||||
// lithology varies with where you are and enters the solve at geology resolution; strata varies with how deep
|
||||
// you have cut and scales the droplets at detail resolution. One puts different rock in different valleys,
|
||||
// the other puts ledges on a cliff.
|
||||
type Hardness struct {
|
||||
W, H int
|
||||
period float64 // vertical period in cell heights
|
||||
contrast float64
|
||||
classes *Classes
|
||||
tilt []float32
|
||||
kind []float32
|
||||
}
|
||||
|
||||
// Pass indices for the detail passes' seeded sources, above everything uplift and coast use.
|
||||
const (
|
||||
srcTilt = 40
|
||||
srcKind = 41
|
||||
srcDetail = 42
|
||||
srcDroplet = 43
|
||||
srcCoastal = 44
|
||||
)
|
||||
|
||||
// NewHardness builds the two fields on world coordinates, so two tiles covering the same rock agree.
|
||||
//
|
||||
// noisePeriodM is the world period rather than the detail passes' short one: where the rock changes and how
|
||||
// the bands tilt are kilometre-scale properties, and a lattice coarse enough for them costs nothing.
|
||||
func NewHardness(f world.Frame, seed int64, noisePeriodM, strataPeriodM, contrast float64, classes *Classes) *Hardness {
|
||||
u, v := noise.WorldUV(f.W, f.H, f.P.CellM, f.OriginXM(), f.OriginYM(), noisePeriodM)
|
||||
tilt := noise.FBMAt(u, v, noise.NewSource(seed, srcTilt), noise.Params{BaseCells: 96, Octaves: 3, Gain: 0.5})
|
||||
kind := noise.FBMAt(u, v, noise.NewSource(seed, srcKind), noise.Params{BaseCells: 64, Octaves: 3, Gain: 0.5})
|
||||
period := strataPeriodM / f.P.CellM
|
||||
if period < 1e-3 {
|
||||
period = 1e-3
|
||||
}
|
||||
return &Hardness{W: f.W, H: f.H, period: period, contrast: contrast, classes: classes,
|
||||
tilt: tilt.Data, kind: kind.Data}
|
||||
}
|
||||
|
||||
// At is the hardness at cell i for material standing at heightCells, in cell heights.
|
||||
func (hd *Hardness) At(i int, heightCells float64) float64 {
|
||||
if hd == nil {
|
||||
return 0
|
||||
}
|
||||
contrast := hd.classes.contrast(i, hd.contrast)
|
||||
if contrast == 0 {
|
||||
return 0
|
||||
}
|
||||
band := 0.5 + 0.5*math.Sin(2*math.Pi*(heightCells/hd.period+float64(hd.tilt[i])*2))
|
||||
v := 0.5 + contrast*(band-0.5)*(0.4+0.8*float64(hd.kind[i]))
|
||||
if v < 0.05 {
|
||||
return 0.05
|
||||
}
|
||||
if v > 0.95 {
|
||||
return 0.95
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Package dt is the exact Euclidean distance transform, with a feature index and an optional cylinder.
|
||||
//
|
||||
// It lives on its own because three different things need it and two of them are nowhere near the coast:
|
||||
// the coastal pass writes every one of its processes as "how far is this cell from the waterline and which
|
||||
// stretch of shore does it belong to"; the region partitioner dilates the land mask to decide which
|
||||
// landmasses are close enough to be solved together; and the template classifier dissolves the decorative
|
||||
// stroke an artist drew by handing each of its pixels to the nearest pixel that means something.
|
||||
//
|
||||
// Exact, not a chamfer approximation: Felzenszwalb and Huttenlocher's transform is two 1-D passes and O(n)
|
||||
// whatever the radius, so there is nothing to buy by approximating, and a chamfer's 2 % anisotropy would
|
||||
// show up directly as a shelf wider along the grid axes than across them.
|
||||
package dt
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// Transform returns, for every cell, the squared distance in cells to the nearest seed cell and the flat
|
||||
// index of that seed. A column pass finds the nearest seed in each column; a row pass takes the lower
|
||||
// envelope of the parabolas those distances define.
|
||||
//
|
||||
// With wrapX the row pass is periodic, so the left and right edges of the grid are neighbours. That is what
|
||||
// a planet needs: a landmass straddling the seam is one landmass, and the shelf in front of it is one shelf.
|
||||
//
|
||||
// Cells in a column with no seed at all are given a cost above any real distance rather than an infinity, so
|
||||
// the envelope arithmetic never sees a NaN; they are then never chosen unless the grid has no seeds
|
||||
// anywhere, in which case every near index comes back -1.
|
||||
func Transform(seed []bool, w, h int, wrapX bool) (d2 []float32, near []int32) {
|
||||
return transform(seed, w, h, wrapX, true)
|
||||
}
|
||||
|
||||
// Distance2 is Transform without the feature index, for a caller that only wants "how far".
|
||||
//
|
||||
// It is a separate entry point rather than a nil argument because the saving is the point: at planet scale
|
||||
// the index and the column scratch it needs are two more arrays of four bytes a cell, which is most of a
|
||||
// gigabyte for an answer nobody reads. The region partitioner only asks whether a cell is within a margin
|
||||
// of land.
|
||||
func Distance2(seed []bool, w, h int, wrapX bool) []float32 {
|
||||
d2, _ := transform(seed, w, h, wrapX, false)
|
||||
return d2
|
||||
}
|
||||
|
||||
func transform(seed []bool, w, h int, wrapX, wantNear bool) (d2 []float32, near []int32) {
|
||||
d2 = make([]float32, w*h)
|
||||
if wantNear {
|
||||
near = make([]int32, w*h)
|
||||
}
|
||||
|
||||
bigF := float64(w*w+h*h) * 4 // above any achievable dx² + dy²
|
||||
bigD := float32(math.Sqrt(bigF))
|
||||
|
||||
colD := make([]float32, w*h) // distance in cells to the nearest seed in this column
|
||||
var colN []int32 // that seed's row, or -1; only needed for the feature index
|
||||
if wantNear {
|
||||
colN = make([]int32, w*h)
|
||||
}
|
||||
|
||||
field.Rows(w, func(x0, x1 int) {
|
||||
for x := x0; x < x1; x++ {
|
||||
best := -1
|
||||
for y := 0; y < h; y++ {
|
||||
i := y*w + x
|
||||
if seed[i] {
|
||||
best = y
|
||||
}
|
||||
if best < 0 {
|
||||
colD[i] = bigD
|
||||
if wantNear {
|
||||
colN[i] = -1
|
||||
}
|
||||
} else {
|
||||
colD[i] = float32(y - best)
|
||||
if wantNear {
|
||||
colN[i] = int32(best)
|
||||
}
|
||||
}
|
||||
}
|
||||
best = -1
|
||||
for y := h - 1; y >= 0; y-- {
|
||||
i := y*w + x
|
||||
if seed[i] {
|
||||
best = y
|
||||
}
|
||||
if best >= 0 {
|
||||
if d := float32(best - y); d < colD[i] {
|
||||
colD[i] = d
|
||||
if wantNear {
|
||||
colN[i] = int32(best)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The row pass. On a cylinder the row is laid out three times - one turn to the left, the row itself,
|
||||
// one turn to the right - and the answer is read out of the middle copy. From a cell in the middle copy
|
||||
// the three images of any column sit at offsets d, d-w and d+w, whose smallest absolute value is the
|
||||
// cyclic distance, so the envelope returns exactly the wrapped answer with no special cases in it.
|
||||
span := w
|
||||
off := 0
|
||||
if wrapX {
|
||||
span = 3 * w
|
||||
off = w
|
||||
}
|
||||
field.Rows(h, func(y0, y1 int) {
|
||||
f := make([]float64, span)
|
||||
v := make([]int, span)
|
||||
z := make([]float64, span+1)
|
||||
for y := y0; y < y1; y++ {
|
||||
row := y * w
|
||||
for j := 0; j < span; j++ {
|
||||
d := float64(colD[row+srcX(j, off, w)])
|
||||
f[j] = d * d
|
||||
}
|
||||
k := 0
|
||||
v[0] = 0
|
||||
z[0] = math.Inf(-1)
|
||||
z[1] = math.Inf(1)
|
||||
for q := 1; q < span; q++ {
|
||||
s := intersect(f, v[k], q)
|
||||
for s <= z[k] {
|
||||
k--
|
||||
s = intersect(f, v[k], q)
|
||||
}
|
||||
k++
|
||||
v[k] = q
|
||||
z[k] = s
|
||||
z[k+1] = math.Inf(1)
|
||||
}
|
||||
k = 0
|
||||
for q := 0; q < span; q++ {
|
||||
for z[k+1] < float64(q) {
|
||||
k++
|
||||
}
|
||||
if q < off || q >= off+w {
|
||||
continue // a replica column; only the middle copy is the answer
|
||||
}
|
||||
dx := float64(q - v[k])
|
||||
o := row + q - off
|
||||
d2[o] = float32(dx*dx + f[v[k]])
|
||||
if !wantNear {
|
||||
continue
|
||||
}
|
||||
sx := srcX(v[k], off, w)
|
||||
if n := colN[row+sx]; n < 0 {
|
||||
near[o] = -1
|
||||
} else {
|
||||
near[o] = n*int32(w) + int32(sx)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return d2, near
|
||||
}
|
||||
|
||||
// srcX maps a column of the (possibly replicated) row back to a real column.
|
||||
func srcX(j, off, w int) int {
|
||||
x := j - off
|
||||
for x < 0 {
|
||||
x += w
|
||||
}
|
||||
for x >= w {
|
||||
x -= w
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// intersect is where the parabolas rooted at p and q cross.
|
||||
func intersect(f []float64, p, q int) float64 {
|
||||
return ((f[q] + float64(q*q)) - (f[p] + float64(p*p))) / float64(2*q-2*p)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package dt
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func scatter(w, h int, seed uint32) []bool {
|
||||
seeds := make([]bool, w*h)
|
||||
for i := range seeds {
|
||||
seed = seed*1664525 + 1013904223
|
||||
seeds[i] = seed>>20&7 == 0
|
||||
}
|
||||
seeds[0] = true // guarantee at least one
|
||||
return seeds
|
||||
}
|
||||
|
||||
// brute is the definition: the smallest squared distance to any seed, with dx measured the short way round
|
||||
// when the grid is a cylinder.
|
||||
func brute(seeds []bool, w, h, x, y int, wrapX bool) float64 {
|
||||
best := math.Inf(1)
|
||||
for sy := 0; sy < h; sy++ {
|
||||
for sx := 0; sx < w; sx++ {
|
||||
if !seeds[sy*w+sx] {
|
||||
continue
|
||||
}
|
||||
dx := float64(x - sx)
|
||||
if wrapX {
|
||||
if d := math.Abs(dx); d > float64(w)/2 {
|
||||
dx = float64(w) - d
|
||||
}
|
||||
}
|
||||
dy := float64(y - sy)
|
||||
if d := dx*dx + dy*dy; d < best {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func check(t *testing.T, w, h int, wrapX bool) {
|
||||
t.Helper()
|
||||
seeds := scatter(w, h, 99)
|
||||
d2, near := Transform(seeds, w, h, wrapX)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
want := brute(seeds, w, h, x, y, wrapX)
|
||||
i := y*w + x
|
||||
if math.Abs(float64(d2[i])-want) > 1e-3 {
|
||||
t.Fatalf("wrap=%v cell (%d,%d): d2 %g, brute force %g", wrapX, x, y, d2[i], want)
|
||||
}
|
||||
// The feature index must be a seed, and it must be one at exactly that distance.
|
||||
n := int(near[i])
|
||||
if n < 0 || !seeds[n] {
|
||||
t.Fatalf("wrap=%v cell (%d,%d): nearest %d is not a seed", wrapX, x, y, n)
|
||||
}
|
||||
got := brute(onlyAt(w, h, n), w, h, x, y, wrapX)
|
||||
if math.Abs(got-want) > 1e-3 {
|
||||
t.Fatalf("wrap=%v cell (%d,%d): nearest seed %d is at %g, not %g", wrapX, x, y, n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onlyAt(w, h, i int) []bool {
|
||||
s := make([]bool, w*h)
|
||||
s[i] = true
|
||||
return s
|
||||
}
|
||||
|
||||
// The one test the coastal pass rests on. Everything there is written in terms of "how far is this cell from
|
||||
// the waterline and which stretch does it belong to", so a distance transform that is subtly wrong would not
|
||||
// fail loudly - it would put the shelf break in slightly the wrong place everywhere. The transform is exact,
|
||||
// so the comparison is against an exhaustive search and the tolerance is float32 rounding.
|
||||
func TestMatchesBruteForce(t *testing.T) { check(t, 41, 37, false) }
|
||||
|
||||
// And the same on a cylinder, which is what a planet is. The failure this catches is a shelf that stops dead
|
||||
// at the seam.
|
||||
func TestMatchesBruteForceOnACylinder(t *testing.T) { check(t, 41, 37, true) }
|
||||
|
||||
// A seed on one edge must be found from the other edge, and by the short way round.
|
||||
func TestWrapFindsTheSeedAcrossTheSeam(t *testing.T) {
|
||||
const w, h = 9, 3
|
||||
seeds := make([]bool, w*h)
|
||||
seeds[h/2*w+0] = true // one seed, at column 0 of the middle row
|
||||
|
||||
d2, near := Transform(seeds, w, h, true)
|
||||
// Column 8 is one step from column 0 the short way round, eight steps the long way.
|
||||
if got := d2[h/2*w+8]; math.Abs(float64(got)-1) > 1e-6 {
|
||||
t.Errorf("d2 at column 8 = %g, want 1", got)
|
||||
}
|
||||
if got := near[h/2*w+8]; got != int32(h/2*w) {
|
||||
t.Errorf("near at column 8 = %d, want %d", got, h/2*w)
|
||||
}
|
||||
// The far side of the cylinder is four steps away either way.
|
||||
if got := d2[h/2*w+4]; math.Abs(float64(got)-16) > 1e-6 {
|
||||
t.Errorf("d2 at column 4 = %g, want 16", got)
|
||||
}
|
||||
// Without the wrap the same grid gives eight.
|
||||
d2f, _ := Transform(seeds, w, h, false)
|
||||
if got := d2f[h/2*w+8]; math.Abs(float64(got)-64) > 1e-6 {
|
||||
t.Errorf("unwrapped d2 at column 8 = %g, want 64", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSeedsAtAll(t *testing.T) {
|
||||
const w, h = 5, 4
|
||||
seeds := make([]bool, w*h)
|
||||
_, near := Transform(seeds, w, h, true)
|
||||
for i, n := range near {
|
||||
if n != -1 {
|
||||
t.Fatalf("cell %d reports a nearest seed %d on an empty grid", i, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ import (
|
||||
type DataMapOptions struct {
|
||||
// Sea marks cells to render as flat water rather than data. Optional.
|
||||
Sea []bool
|
||||
// Size is the output side in pixels; the field is point-sampled down to it.
|
||||
// Size is the output width in pixels; the field is point-sampled down to it and the height follows the
|
||||
// field's own aspect.
|
||||
Size int
|
||||
// Log renders log10 of the value, for anything with a heavy tail — drainage area spans seven decades and
|
||||
// is unreadable linearly.
|
||||
@@ -64,9 +65,10 @@ func WriteDataMap(path string, f *Field, opt DataMapOptions) error {
|
||||
span = 1
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
for y := 0; y < size; y++ {
|
||||
sy := y * f.H / size
|
||||
sizeH := aspectH(f, size)
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
|
||||
for y := 0; y < sizeH; y++ {
|
||||
sy := y * f.H / sizeH
|
||||
for x := 0; x < size; x++ {
|
||||
sx := x * f.W / size
|
||||
i := sy*f.W + sx
|
||||
@@ -123,9 +125,13 @@ func WriteBasinMap(path string, w, h int, receiver []int32, sea []bool, size int
|
||||
}
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
for y := 0; y < size; y++ {
|
||||
sy := y * h / size
|
||||
sizeH := int(float64(size)*float64(h)/float64(w) + 0.5)
|
||||
if sizeH < 1 {
|
||||
sizeH = 1
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
|
||||
for y := 0; y < sizeH; y++ {
|
||||
sy := y * h / sizeH
|
||||
for x := 0; x < size; x++ {
|
||||
sx := x * w / size
|
||||
i := sy*w + sx
|
||||
@@ -191,6 +197,10 @@ func sampleStops(s [][3]float64, t float64) [3]float64 {
|
||||
return [3]float64{a[0] + (b[0]-a[0])*u, a[1] + (b[1]-a[1])*u, a[2] + (b[2]-a[2])*u}
|
||||
}
|
||||
|
||||
// HSV is exported because the region map colours its regions the same way the basin map colours its basins:
|
||||
// a hash of the id straight to a hue, so neighbours get unrelated colours and a boundary is a hard edge.
|
||||
func HSV(hue, sat, val float64) [3]float64 { return hsv(hue, sat, val) }
|
||||
|
||||
func hsv(hue, sat, val float64) [3]float64 {
|
||||
h6 := hue * 6
|
||||
i := int(h6)
|
||||
|
||||
@@ -176,26 +176,111 @@ func (f *Field) Blur(passes int) *Field {
|
||||
// starts and each writes only into its own, so the output is identical at any GOMAXPROCS. Every parallel
|
||||
// loop in the generator goes through here; none spawns goroutines of its own.
|
||||
func Rows(h int, fn func(y0, y1 int)) {
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > h {
|
||||
workers = h
|
||||
}
|
||||
if workers <= 1 {
|
||||
fn(0, h)
|
||||
RowsIndexed(h, func(_, y0, y1 int) { fn(y0, y1) })
|
||||
}
|
||||
|
||||
// RowsIndexed is Rows with the band number, which is what a parallel loop needs when it has to reduce
|
||||
// something rather than only write into its own rows: it gives each goroutine a pre-allocated indexed
|
||||
// slot to accumulate into, so the reduction can be replayed in band order afterwards instead of
|
||||
// depending on which goroutine finished first. Size the slots with BandCount.
|
||||
func RowsIndexed(h int, fn func(band, y0, y1 int)) {
|
||||
step := rowStep(h)
|
||||
if step >= h {
|
||||
fn(0, 0, h)
|
||||
return
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
step := (h + workers - 1) / workers
|
||||
band := 0
|
||||
for y0 := 0; y0 < h; y0 += step {
|
||||
y1 := y0 + step
|
||||
if y1 > h {
|
||||
y1 = h
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(a, b int) {
|
||||
go func(k, a, b int) {
|
||||
defer wg.Done()
|
||||
fn(a, b)
|
||||
}(y0, y1)
|
||||
fn(k, a, b)
|
||||
}(band, y0, y1)
|
||||
band++
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// FixedBands is RowsIndexed with a partition that does not depend on the core count: bands of exactly rows
|
||||
// rows, run by however many workers there are.
|
||||
//
|
||||
// It exists for one reason. A parallel loop that only writes into its own rows can be partitioned any way at
|
||||
// all, which is what Rows does. A loop that *reduces* into overlapping buffers cannot: floating-point addition
|
||||
// is not associative, so summing a cell's contributions in a different grouping gives a different last bit,
|
||||
// and the result would depend on GOMAXPROCS. The particle pass is that loop. Fix the partition and the
|
||||
// arithmetic is fixed with it.
|
||||
func FixedBands(h, rows int, fn func(band, y0, y1 int)) {
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
n := FixedBandCount(h, rows)
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > n {
|
||||
workers = n
|
||||
}
|
||||
if workers <= 1 {
|
||||
for b := 0; b < n; b++ {
|
||||
y0 := b * rows
|
||||
y1 := min(y0+rows, h)
|
||||
fn(b, y0, y1)
|
||||
}
|
||||
return
|
||||
}
|
||||
next := make(chan int)
|
||||
go func() {
|
||||
for b := 0; b < n; b++ {
|
||||
next <- b
|
||||
}
|
||||
close(next)
|
||||
}()
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for b := range next {
|
||||
y0 := b * rows
|
||||
y1 := min(y0+rows, h)
|
||||
fn(b, y0, y1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// FixedBandCount is how many bands FixedBands will make.
|
||||
func FixedBandCount(h, rows int) int {
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
if h <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (h + rows - 1) / rows
|
||||
}
|
||||
|
||||
// BandCount is how many ranges Rows and RowsIndexed split h into. It is fixed by h and GOMAXPROCS, so it
|
||||
// can be called to size a reduction before the loop starts.
|
||||
func BandCount(h int) int {
|
||||
step := rowStep(h)
|
||||
if step >= h {
|
||||
return 1
|
||||
}
|
||||
return (h + step - 1) / step
|
||||
}
|
||||
|
||||
func rowStep(h int) int {
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > h {
|
||||
workers = h
|
||||
}
|
||||
if workers <= 1 {
|
||||
return h
|
||||
}
|
||||
return (h + workers - 1) / workers
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package field
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Palette is how a preview is drawn: the hypsometric ramp, the water, the rivers, the ice and the light.
|
||||
//
|
||||
// It is a file rather than a set of constants because it is the one part of a bake that is purely a matter of
|
||||
// taste, and taste is the thing most likely to want swapping. Nothing in it changes a height; a palette is
|
||||
// read only by WritePreview, and two bakes of the same world under two palettes are the same terrain.
|
||||
//
|
||||
// The zero value is not usable - use DefaultPalette, which holds the numbers the generator shipped with.
|
||||
type Palette struct {
|
||||
// LandStops is the hypsometric ramp, from sea level at t = 0 to the top of the land at t = 1. Stops must
|
||||
// be in ascending t; the ends are clamped rather than extrapolated.
|
||||
LandStops []Stop `json:"land_stops"`
|
||||
|
||||
SeaShallow RGB `json:"sea_shallow"`
|
||||
SeaDeep RGB `json:"sea_deep"`
|
||||
River RGB `json:"river"`
|
||||
|
||||
// Ice is drawn wherever the snow mask says so, whatever height the ground is. Pure white is a poor
|
||||
// choice: it has nowhere left to go under the hillshade, so an ice sheet comes out as a flat cut-out
|
||||
// with no shape in it.
|
||||
Ice RGB `json:"ice"`
|
||||
|
||||
// LandTopPercentile is where the ramp's top is taken from, over land elevations. Not the maximum: one
|
||||
// 2800 m summit over a continent whose land is mostly under 300 m puts every other cell in the bottom
|
||||
// tenth of the ramp, and the map then says far more about one pixel than about the terrain.
|
||||
LandTopPercentile float64 `json:"land_top_percentile"`
|
||||
|
||||
// LandTopM puts the top of the ramp at a fixed height instead, in metres above sea level. Zero keeps
|
||||
// the percentile, which is what every preview did before this existed.
|
||||
//
|
||||
// It is here because a relative ramp is a picture that lies about scale, and it lies hardest exactly
|
||||
// where it matters. A lowland continent 47 m high, drawn against its own 99.5th percentile, gets the
|
||||
// whole ramp - green, tan, bare rock and snow - so its 40 m hills come out with the same white caps a
|
||||
// 2800 m range would, and a plain whose median slope is 0.6 degrees reads as an alpine massif. That was
|
||||
// measured on this planet's central landmass and it is the most misleading thing the generator draws.
|
||||
//
|
||||
// The percentile stays the default all the same, because the alternative fails the other way: an
|
||||
// absolute ramp over a world with no mountains is a flat green shape with nothing legible on it, and
|
||||
// judging "is there drainage here" needs the contrast. What is added is the *choice*, plus a line in
|
||||
// the run summary saying which ceiling a picture was drawn against - a relative picture is fine as long
|
||||
// as nobody reads it as an absolute one.
|
||||
LandTopM float64 `json:"land_top_m"`
|
||||
|
||||
// The light. Azimuth is degrees clockwise from north and altitude is degrees above the horizon; the
|
||||
// north-west at 45 degrees is the convention every DEM hillshade uses and is what these default to.
|
||||
SunAzimuthDeg float64 `json:"sun_azimuth_deg"`
|
||||
SunAltitudeDeg float64 `json:"sun_altitude_deg"`
|
||||
|
||||
// Ambient is how lit the fully shaded side is and Gain how much the lit side brightens. Ambient at zero
|
||||
// makes a shadow a hole.
|
||||
Ambient float64 `json:"ambient"`
|
||||
Gain float64 `json:"gain"`
|
||||
}
|
||||
|
||||
// Stop is one entry in the hypsometric ramp.
|
||||
type Stop struct {
|
||||
T float64 `json:"t"`
|
||||
RGB RGB `json:"rgb"`
|
||||
}
|
||||
|
||||
// RGB is a colour in 0..255, kept as float64 so the hillshade can multiply it before it is clamped.
|
||||
type RGB [3]float64
|
||||
|
||||
func (c RGB) String() string { return fmt.Sprintf("[%s, %s, %s]", num(c[0]), num(c[1]), num(c[2])) }
|
||||
|
||||
// num prints a float without trailing zeros, so a palette reads as numbers rather than as measurements.
|
||||
func num(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
|
||||
|
||||
// DefaultPalette is what the generator ships with: salt-marsh green at sea level through farmland and rock to
|
||||
// snow, with the stops chosen so the lowland does not read as one flat colour - which is where most of a map
|
||||
// is, and where a badly chosen ramp hides everything.
|
||||
func DefaultPalette() *Palette {
|
||||
return &Palette{
|
||||
LandStops: []Stop{
|
||||
{0.00, RGB{72, 106, 68}},
|
||||
{0.08, RGB{104, 132, 74}},
|
||||
{0.20, RGB{142, 152, 88}},
|
||||
{0.38, RGB{164, 148, 104}},
|
||||
{0.58, RGB{150, 128, 106}},
|
||||
{0.75, RGB{138, 130, 128}},
|
||||
{0.88, RGB{176, 174, 174}},
|
||||
{1.00, RGB{246, 246, 250}},
|
||||
},
|
||||
SeaShallow: RGB{56, 104, 136},
|
||||
SeaDeep: RGB{18, 40, 72},
|
||||
River: RGB{70, 132, 180},
|
||||
Ice: RGB{232, 238, 245},
|
||||
LandTopPercentile: 99.5,
|
||||
SunAzimuthDeg: 315, // north-west
|
||||
SunAltitudeDeg: 45,
|
||||
Ambient: 0.45,
|
||||
Gain: 0.75,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadPalette reads a palette, filling anything the file leaves out from the default. Unknown keys are an
|
||||
// error: a misspelt colour that silently keeps the default is a palette that does not do what it says.
|
||||
func LoadPalette(path string) (*Palette, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clean, err := StripJSONComments(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
p := DefaultPalette()
|
||||
dec := json.NewDecoder(strings.NewReader(string(clean)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(p); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Validate refuses a palette that would draw nonsense.
|
||||
func (p *Palette) Validate() error {
|
||||
if len(p.LandStops) < 2 {
|
||||
return fmt.Errorf("land_stops needs at least two entries, got %d", len(p.LandStops))
|
||||
}
|
||||
for i, s := range p.LandStops {
|
||||
if i > 0 && s.T <= p.LandStops[i-1].T {
|
||||
return fmt.Errorf("land_stops must ascend: stop %d is at t %.3f, after %.3f",
|
||||
i, s.T, p.LandStops[i-1].T)
|
||||
}
|
||||
for k, v := range s.RGB {
|
||||
if v < 0 || v > 255 {
|
||||
return fmt.Errorf("land_stops[%d].rgb[%d] is %v, outside 0..255", i, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.LandTopPercentile <= 0 || p.LandTopPercentile > 100 {
|
||||
return fmt.Errorf("land_top_percentile is %v, outside 0..100", p.LandTopPercentile)
|
||||
}
|
||||
if p.LandTopM < 0 {
|
||||
return fmt.Errorf("land_top_m is %v; it is metres above sea level, so positive, or zero to use "+
|
||||
"land_top_percentile instead", p.LandTopM)
|
||||
}
|
||||
if p.SunAltitudeDeg <= 0 || p.SunAltitudeDeg >= 90 {
|
||||
return fmt.Errorf("sun_altitude_deg is %v; it is degrees above the horizon", p.SunAltitudeDeg)
|
||||
}
|
||||
if p.Ambient < 0 || p.Ambient > 1 {
|
||||
return fmt.Errorf("ambient is %v, outside 0..1", p.Ambient)
|
||||
}
|
||||
if p.Gain < 0 {
|
||||
return fmt.Errorf("gain is %v", p.Gain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ramp samples the hypsometric stops, clamped at both ends.
|
||||
func (p *Palette) ramp(t float64) RGB {
|
||||
if t <= p.LandStops[0].T {
|
||||
return p.LandStops[0].RGB
|
||||
}
|
||||
for i := 1; i < len(p.LandStops); i++ {
|
||||
if t <= p.LandStops[i].T {
|
||||
a, b := p.LandStops[i-1], p.LandStops[i]
|
||||
u := (t - a.T) / (b.T - a.T)
|
||||
return RGB{
|
||||
a.RGB[0] + (b.RGB[0]-a.RGB[0])*u,
|
||||
a.RGB[1] + (b.RGB[1]-a.RGB[1])*u,
|
||||
a.RGB[2] + (b.RGB[2]-a.RGB[2])*u,
|
||||
}
|
||||
}
|
||||
}
|
||||
return p.LandStops[len(p.LandStops)-1].RGB
|
||||
}
|
||||
|
||||
// Write saves a palette as something a person can read and edit.
|
||||
//
|
||||
// Hand-formatted rather than through MarshalIndent, and that is not stubbornness: MarshalIndent re-indents
|
||||
// whatever a custom marshaler returns, so there is no way to keep a colour on one line through it, and its
|
||||
// default puts every channel of every stop on a line of its own - sixty lines for eight stops, a table whose
|
||||
// shape is invisible. The comments are the other half: this is a file somebody opens to change one number,
|
||||
// and it should say what the numbers are.
|
||||
func (p *Palette) Write(path string) error {
|
||||
var b strings.Builder
|
||||
line := func(format string, a ...any) { fmt.Fprintf(&b, format+"\n", a...) }
|
||||
|
||||
line("{")
|
||||
line(` "_comment": "How a preview is drawn. Nothing here changes a height - two bakes of the same ` +
|
||||
`world under two palettes are the same terrain. Point a planet manifest at this file with ` +
|
||||
`\"palette\": \"<path relative to the manifest>\"; leave it out and these numbers are used ` +
|
||||
`anyway. Keys beginning with an underscore are comments.",`)
|
||||
line("")
|
||||
line(` "_comment_land_stops": "The hypsometric ramp: sea level at t 0 to the top of the land at t 1. ` +
|
||||
`The top is a percentile rather than the maximum, so one high summit cannot push a whole continent ` +
|
||||
`into the bottom of the ramp.",`)
|
||||
line(` "land_stops": [`)
|
||||
for i, s := range p.LandStops {
|
||||
comma := ","
|
||||
if i == len(p.LandStops)-1 {
|
||||
comma = ""
|
||||
}
|
||||
line(` { "t": %-6s "rgb": %s }%s`, num(s.T)+",", s.RGB, comma)
|
||||
}
|
||||
line(" ],")
|
||||
line("")
|
||||
line(` "sea_shallow": %s,`, p.SeaShallow)
|
||||
line(` "sea_deep": %s,`, p.SeaDeep)
|
||||
line(` "river": %s,`, p.River)
|
||||
line(` "_comment_ice": "Drawn wherever a class is marked snow, whatever height the ground stands at. ` +
|
||||
`Not pure white: white has nowhere left to go under the hillshade, so an ice sheet comes out as a ` +
|
||||
`flat cut-out with no shape in it at all.",`)
|
||||
line(` "ice": %s,`, p.Ice)
|
||||
line("")
|
||||
line(` "_comment_top": "Where the top of the hypsometric ramp sits. The percentile is relative to the ` +
|
||||
`world being drawn, which is the only way a low continent is legible at all and is also a picture ` +
|
||||
`that lies about scale: a 47 m lowland gets the same rock and snow a 2800 m range would. Set ` +
|
||||
`land_top_m to a height in metres for an absolute ramp instead; the run summary says which ceiling ` +
|
||||
`every preview was drawn against.",`)
|
||||
line(` "land_top_percentile": %s,`, num(p.LandTopPercentile))
|
||||
line(` "land_top_m": %s,`, num(p.LandTopM))
|
||||
line("")
|
||||
line(` "_comment_light": "Azimuth is degrees clockwise from north and altitude degrees above the ` +
|
||||
`horizon; north-west at 45 is what every DEM hillshade uses. Ambient is how lit the shaded side ` +
|
||||
`is - at zero a shadow is a hole - and gain how much the lit side brightens.",`)
|
||||
line(` "sun_azimuth_deg": %s,`, num(p.SunAzimuthDeg))
|
||||
line(` "sun_altitude_deg": %s,`, num(p.SunAltitudeDeg))
|
||||
line(` "ambient": %s,`, num(p.Ambient))
|
||||
line(` "gain": %s`, num(p.Gain))
|
||||
line("}")
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
// StripJSONComments removes every object key beginning with an underscore, at any depth.
|
||||
//
|
||||
// Every manifest in this repository carries its commentary that way, and a loader that refuses unknown
|
||||
// fields - which both the legend and the palette do, because a misspelt key silently ignored is a setting
|
||||
// that does not do what it says - has to let them through.
|
||||
func StripJSONComments(data []byte) ([]byte, error) {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(stripUnderscored(v))
|
||||
}
|
||||
|
||||
func stripUnderscored(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
if strings.HasPrefix(k, "_") {
|
||||
continue
|
||||
}
|
||||
out[k] = stripUnderscored(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
for i := range t {
|
||||
t[i] = stripUnderscored(t[i])
|
||||
}
|
||||
return t
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
@@ -45,6 +46,48 @@ func WriteGray8(path string, w, h int, values []uint8, level png.CompressionLeve
|
||||
return encode(path, img, level)
|
||||
}
|
||||
|
||||
// WriteRGB writes tightly packed 8-bit RGB, three bytes a pixel. It is what a categorical map wants: a class
|
||||
// raster has no ramp to run through a palette, only a colour per class.
|
||||
// WriteRGBA writes colour with a separate alpha plane, which is what an overlay sheet is: strokes on a
|
||||
// transparent background, where blank is decided by alpha rather than by a reserved colour.
|
||||
//
|
||||
// Non-premultiplied (NRGBA), deliberately. A mark's colour has to come back out of the file exactly as it
|
||||
// went in, because the classifier reads exact colours against a tolerance; premultiplying would scale every
|
||||
// channel by the alpha and round on the way, and a mark would classify as something else or as nothing.
|
||||
func WriteRGBA(path string, w, h int, px []uint8, alpha []uint8, level png.CompressionLevel) error {
|
||||
if len(px) != w*h*3 {
|
||||
return fmt.Errorf("%s: %d bytes for a %dx%d RGB image", path, len(px), w, h)
|
||||
}
|
||||
if len(alpha) != w*h {
|
||||
return fmt.Errorf("%s: %d alpha bytes for a %dx%d image", path, len(alpha), w, h)
|
||||
}
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
row := img.Pix[y*img.Stride:]
|
||||
src := px[y*w*3:]
|
||||
a := alpha[y*w:]
|
||||
for x := 0; x < w; x++ {
|
||||
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = src[x*3], src[x*3+1], src[x*3+2], a[x]
|
||||
}
|
||||
}
|
||||
return encode(path, img, level)
|
||||
}
|
||||
|
||||
func WriteRGB(path string, w, h int, px []uint8, level png.CompressionLevel) error {
|
||||
if len(px) != w*h*3 {
|
||||
return fmt.Errorf("%s: %d bytes for a %dx%d RGB image", path, len(px), w, h)
|
||||
}
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
row := img.Pix[y*img.Stride:]
|
||||
src := px[y*w*3:]
|
||||
for x := 0; x < w; x++ {
|
||||
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = src[x*3], src[x*3+1], src[x*3+2], 255
|
||||
}
|
||||
}
|
||||
return encode(path, img, level)
|
||||
}
|
||||
|
||||
func encode(path string, img image.Image, level png.CompressionLevel) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
@@ -163,18 +206,60 @@ func isqrt(n int) int {
|
||||
return r
|
||||
}
|
||||
|
||||
// WriteHillshade writes an 8-bit relief shade of a height field, at any scale.
|
||||
//
|
||||
// It is not WriteThumbnail with a bigger number, and the difference is the whole reason it exists.
|
||||
// WriteThumbnail's shading term is a raw gradient over the cell size, which is fine on a 512-pixel picture of
|
||||
// a whole map where the gradients are small, and saturates to pure black and white the moment it is used on
|
||||
// real ground at two metres a cell. The result reads as flat-topped terraces with hard edges - a mountainside
|
||||
// rendered as a staircase - and it is convincing enough to be mistaken for a defect in the terrain. It was.
|
||||
//
|
||||
// This is the standard DEM hillshade instead: the surface normal against a light from the north-west at 45
|
||||
// degrees, which is bounded by construction and says the same thing at any cell size.
|
||||
func WriteHillshade(path string, h *Field, size int, exaggeration float64) error {
|
||||
sizeH := aspectH(h, size)
|
||||
small := h
|
||||
if size != h.W || sizeH != h.H {
|
||||
small = h.Resample(size, sizeH)
|
||||
}
|
||||
exag := exaggeration
|
||||
if exag <= 0 {
|
||||
exag = 1
|
||||
}
|
||||
px := make([]uint8, size*sizeH)
|
||||
Rows(sizeH, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
gx := float64(small.AtClamped(x+1, y)-small.AtClamped(x-1, y)) * exag
|
||||
gy := float64(small.AtClamped(x, y+1)-small.AtClamped(x, y-1)) * exag
|
||||
slope := math.Atan(math.Hypot(gx, gy) / (2 * small.CellM))
|
||||
aspect := math.Atan2(gy, -gx)
|
||||
lum := math.Cos(slope)*math.Cos(math.Pi/4) +
|
||||
math.Sin(slope)*math.Sin(math.Pi/4)*math.Cos(3*math.Pi/4-aspect)
|
||||
v := 0.25 + 0.75*math.Max(0, lum)
|
||||
if v > 1 {
|
||||
v = 1
|
||||
}
|
||||
px[y*size+x] = uint8(v * 255)
|
||||
}
|
||||
}
|
||||
})
|
||||
return WriteGray8(path, size, sizeH, px, png.BestSpeed)
|
||||
}
|
||||
|
||||
// WriteThumbnail writes a small 8-bit preview of a height field, hillshaded so the drainage is actually
|
||||
// visible: a flat grey ramp hides exactly the thing this generator exists to produce.
|
||||
func WriteThumbnail(path string, h *Field, size int) error {
|
||||
small := h.Resample(size, size)
|
||||
sizeH := aspectH(h, size)
|
||||
small := h.Resample(size, sizeH)
|
||||
lo, hi := small.MinMax()
|
||||
span := float64(hi - lo)
|
||||
if span < 1e-6 {
|
||||
span = 1
|
||||
}
|
||||
// Light from the north-west at 45 degrees, the convention every DEM hillshade uses.
|
||||
px := make([]uint8, size*size)
|
||||
for y := 0; y < size; y++ {
|
||||
px := make([]uint8, size*sizeH)
|
||||
for y := 0; y < sizeH; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
gx := float64(small.AtClamped(x+1, y) - small.AtClamped(x-1, y))
|
||||
gy := float64(small.AtClamped(x, y+1) - small.AtClamped(x, y-1))
|
||||
@@ -189,7 +274,7 @@ func WriteThumbnail(path string, h *Field, size int) error {
|
||||
px[y*size+x] = uint8(lum * 255)
|
||||
}
|
||||
}
|
||||
return WriteGray8(path, size, size, px, png.BestSpeed)
|
||||
return WriteGray8(path, size, sizeH, px, png.BestSpeed)
|
||||
}
|
||||
|
||||
var _ io.Writer = (*bufio.Writer)(nil)
|
||||
|
||||
@@ -24,65 +24,39 @@ type PreviewOptions struct {
|
||||
// Sea marks cells below sea level. Optional.
|
||||
Sea []bool
|
||||
SeaLevelM float64
|
||||
// Snow marks land that is permanently under ice. Optional, and it exists because the hypsometric ramp
|
||||
// tops out at snow by *elevation*: a polar cap fifty metres above the water therefore comes out the same
|
||||
// green as a meadow, and an ice sheet that reads as a meadow is a map lying about the one thing it is
|
||||
// for. No height is touched; only the colour.
|
||||
Snow []bool
|
||||
// RiverKm2 is the drainage area at which a channel starts being drawn.
|
||||
RiverKm2 float64
|
||||
Size int
|
||||
// Size is the output width in pixels. The height follows the field's own aspect, so a 2:1 planet comes
|
||||
// out 2:1 rather than squashed into a square; on the square canvas the two are the same number and
|
||||
// nothing changes.
|
||||
Size int
|
||||
// Crop is a sub-rectangle in map coordinates (x0, y0, x1, y1 in 0..1), rendered at full resolution.
|
||||
// A whole continent at 1500 px puts ten kilometres into a hundred pixels, which is enough to see that
|
||||
// there is drainage and not nearly enough to see whether it is the right *kind* of drainage. Judging
|
||||
// hill country against real hill country needs a crop.
|
||||
Crop [4]float64
|
||||
// Palette is how the picture is drawn: the ramp, the water, the rivers, the ice and the light. Nil is
|
||||
// the generator's own, which is what every caller wanted before this was a file.
|
||||
Palette *Palette
|
||||
// Hillshade exaggerates the vertical before shading. Lowland relief is a few tens of metres over
|
||||
// kilometres and disappears at true scale, which is the same reason every printed relief map lies.
|
||||
Exaggeration float64
|
||||
}
|
||||
|
||||
// rgb is a colour in 0..255 kept as float64 so the hillshade can multiply it before it is clamped.
|
||||
type rgb = [3]float64
|
||||
|
||||
type stop struct {
|
||||
t float64
|
||||
c rgb
|
||||
}
|
||||
|
||||
var (
|
||||
// A hypsometric ramp: salt-marsh green at sea level through farmland and rock to snow. Stops are chosen
|
||||
// so the lowland does not read as one flat colour, which is where most of the map is.
|
||||
landStops = []stop{
|
||||
{0.00, rgb{72, 106, 68}},
|
||||
{0.08, rgb{104, 132, 74}},
|
||||
{0.20, rgb{142, 152, 88}},
|
||||
{0.38, rgb{164, 148, 104}},
|
||||
{0.58, rgb{150, 128, 106}},
|
||||
{0.75, rgb{138, 130, 128}},
|
||||
{0.88, rgb{176, 174, 174}},
|
||||
{1.00, rgb{246, 246, 250}},
|
||||
}
|
||||
seaShallow = rgb{56, 104, 136}
|
||||
seaDeep = rgb{18, 40, 72}
|
||||
riverTint = rgb{70, 132, 180}
|
||||
)
|
||||
|
||||
func ramp(t float64) rgb {
|
||||
if t <= 0 {
|
||||
return landStops[0].c
|
||||
}
|
||||
for i := 1; i < len(landStops); i++ {
|
||||
if t <= landStops[i].t {
|
||||
a, b := landStops[i-1], landStops[i]
|
||||
u := (t - a.t) / (b.t - a.t)
|
||||
return rgb{
|
||||
a.c[0] + (b.c[0]-a.c[0])*u,
|
||||
a.c[1] + (b.c[1]-a.c[1])*u,
|
||||
a.c[2] + (b.c[2]-a.c[2])*u,
|
||||
}
|
||||
}
|
||||
}
|
||||
return landStops[len(landStops)-1].c
|
||||
}
|
||||
// rgb is the palette's colour type under the name the drawing code uses.
|
||||
type rgb = RGB
|
||||
|
||||
// WritePreview renders the field at opt.Size and writes an RGB PNG.
|
||||
func WritePreview(path string, h *Field, opt PreviewOptions) error {
|
||||
//
|
||||
// It returns the height the hypsometric ramp topped out at, in metres, which a caller is expected to print.
|
||||
// The ramp is relative by default and a relative picture is only honest when the reader is told so: without
|
||||
// that line, a 47 m plain drawn with snow on its hills is indistinguishable from an alpine one.
|
||||
func WritePreview(path string, h *Field, opt PreviewOptions) (topM float64, err error) {
|
||||
size := opt.Size
|
||||
if size <= 0 {
|
||||
size = 1024
|
||||
@@ -103,7 +77,8 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
|
||||
size = h.W
|
||||
}
|
||||
}
|
||||
small := h.Resample(size, size)
|
||||
sizeH := aspectH(h, size)
|
||||
small := h.Resample(size, sizeH)
|
||||
exag := opt.Exaggeration
|
||||
if exag <= 0 {
|
||||
exag = 1
|
||||
@@ -116,7 +91,12 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
|
||||
// uniform green with a white dot on it — which says far more about one pixel than about the terrain. The
|
||||
// percentile lets the tint span the distribution that is actually there; the few cells above it clamp to
|
||||
// snow, which is what they should look like anyway.
|
||||
sea := resampleMask(opt.Sea, h.W, h.H, size)
|
||||
pal := opt.Palette
|
||||
if pal == nil {
|
||||
pal = DefaultPalette()
|
||||
}
|
||||
sea := resampleMask(opt.Sea, h.W, h.H, size, sizeH)
|
||||
snow := resampleMask(opt.Snow, h.W, h.H, size, sizeH)
|
||||
landVals := make([]float64, 0, len(small.Data))
|
||||
for i, v := range small.Data {
|
||||
if sea != nil && sea[i] {
|
||||
@@ -125,9 +105,11 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
|
||||
landVals = append(landVals, float64(v))
|
||||
}
|
||||
landMax := 1.0
|
||||
if len(landVals) > 0 {
|
||||
if pal.LandTopM > 0 {
|
||||
landMax = pal.LandTopM
|
||||
} else if len(landVals) > 0 {
|
||||
sort.Float64s(landVals)
|
||||
landMax = landVals[int(0.995*float64(len(landVals)-1))]
|
||||
landMax = landVals[int(pal.LandTopPercentile/100*float64(len(landVals)-1))]
|
||||
}
|
||||
if landMax <= 0 {
|
||||
landMax = 1
|
||||
@@ -142,11 +124,11 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
|
||||
var flow *Field
|
||||
riverA := opt.RiverKm2 * 1e6
|
||||
if opt.Flow != nil && riverA > 0 {
|
||||
flow = opt.Flow.Resample(size, size)
|
||||
flow = opt.Flow.Resample(size, sizeH)
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
for y := 0; y < size; y++ {
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
|
||||
for y := 0; y < sizeH; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
i := y*size + x
|
||||
elev := float64(small.Data[i])
|
||||
@@ -158,21 +140,30 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
|
||||
d = math.Min(1, (opt.SeaLevelM-elev)/(opt.SeaLevelM-seaMin))
|
||||
}
|
||||
c = rgb{
|
||||
seaShallow[0] + (seaDeep[0]-seaShallow[0])*d,
|
||||
seaShallow[1] + (seaDeep[1]-seaShallow[1])*d,
|
||||
seaShallow[2] + (seaDeep[2]-seaShallow[2])*d,
|
||||
pal.SeaShallow[0] + (pal.SeaDeep[0]-pal.SeaShallow[0])*d,
|
||||
pal.SeaShallow[1] + (pal.SeaDeep[1]-pal.SeaShallow[1])*d,
|
||||
pal.SeaShallow[2] + (pal.SeaDeep[2]-pal.SeaShallow[2])*d,
|
||||
}
|
||||
} else {
|
||||
c = ramp(math.Min(1, math.Max(0, elev)/landMax))
|
||||
c = pal.ramp(math.Min(1, math.Max(0, elev)/landMax))
|
||||
if snow != nil && snow[i] {
|
||||
// Ice, whatever height it stands at. It still takes the hillshade below rather than
|
||||
// being stamped flat, so a dome and the valleys cut into it still read.
|
||||
c = pal.Ice
|
||||
}
|
||||
// Hillshade from the north-west at 45 degrees, the DEM convention. Applied to land only;
|
||||
// shading the sea floor would draw attention to bathymetry nobody will ever see.
|
||||
gx := float64(small.AtClamped(x+1, y)-small.AtClamped(x-1, y)) * exag
|
||||
gy := float64(small.AtClamped(x, y+1)-small.AtClamped(x, y-1)) * exag
|
||||
slope := math.Atan(math.Hypot(gx, gy) / (2 * small.CellM))
|
||||
aspect := math.Atan2(gy, -gx)
|
||||
lum := math.Cos(slope)*math.Cos(math.Pi/4) +
|
||||
math.Sin(slope)*math.Sin(math.Pi/4)*math.Cos(3*math.Pi/4-aspect)
|
||||
lum = 0.45 + 0.75*math.Max(0, lum)
|
||||
alt := pal.SunAltitudeDeg * math.Pi / 180
|
||||
// Azimuth is clockwise from north; the shading wants the direction the light comes *from*
|
||||
// measured the way Atan2 returns it, which is this quarter turn away.
|
||||
az := (90 - pal.SunAzimuthDeg) * math.Pi / 180
|
||||
lum := math.Cos(slope)*math.Sin(alt) +
|
||||
math.Sin(slope)*math.Cos(alt)*math.Cos(az-aspect)
|
||||
lum = pal.Ambient + pal.Gain*math.Max(0, lum)
|
||||
for k := range c {
|
||||
c[k] *= lum
|
||||
}
|
||||
@@ -185,7 +176,7 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
|
||||
w := math.Min(1, math.Log10(a/riverA)/2.2)
|
||||
blend := 0.45 + 0.55*w
|
||||
for k := range c {
|
||||
c[k] = c[k]*(1-blend) + riverTint[k]*blend
|
||||
c[k] = c[k]*(1-blend) + pal.River[k]*blend
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,38 +186,54 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
return landMax, err
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
return landMax, err
|
||||
}
|
||||
defer f.Close()
|
||||
bw := bufio.NewWriterSize(f, 1<<20)
|
||||
enc := png.Encoder{CompressionLevel: png.DefaultCompression}
|
||||
if err := enc.Encode(bw, img); err != nil {
|
||||
return err
|
||||
return landMax, err
|
||||
}
|
||||
return bw.Flush()
|
||||
return landMax, bw.Flush()
|
||||
}
|
||||
|
||||
// resampleMask takes a boolean mask down to the preview size by nearest neighbour; a mask has no meaningful
|
||||
// average.
|
||||
func resampleMask(mask []bool, w, h, size int) []bool {
|
||||
func resampleMask(mask []bool, w, h, sw, sh int) []bool {
|
||||
if mask == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]bool, size*size)
|
||||
for y := 0; y < size; y++ {
|
||||
sy := y * (h - 1) / (size - 1)
|
||||
for x := 0; x < size; x++ {
|
||||
sx := x * (w - 1) / (size - 1)
|
||||
out[y*size+x] = mask[sy*w+sx]
|
||||
out := make([]bool, sw*sh)
|
||||
for y := 0; y < sh; y++ {
|
||||
sy := 0
|
||||
if sh > 1 {
|
||||
sy = y * (h - 1) / (sh - 1)
|
||||
}
|
||||
for x := 0; x < sw; x++ {
|
||||
sx := 0
|
||||
if sw > 1 {
|
||||
sx = x * (w - 1) / (sw - 1)
|
||||
}
|
||||
out[y*sw+x] = mask[sy*w+sx]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// aspectH is the output height that keeps a field's shape. Every writer in this package uses it, so a
|
||||
// rectangular world is never silently squashed into a square image.
|
||||
func aspectH(f *Field, w int) int {
|
||||
h := int(float64(w)*float64(f.H)/float64(f.W) + 0.5)
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func clamp8(v float64) uint8 {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package field
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The hypsometric ramp tops out at snow by *elevation*, so an ice cap fifty metres above the water came out
|
||||
// the same green as a meadow - a map lying about the one thing it is for. The snow mask fixes the colour and
|
||||
// nothing else, and it must still take the hillshade rather than being stamped flat, or a dome and the
|
||||
// valleys cut into it read as a white cut-out.
|
||||
func TestSnowRendersAsIceAndStillTakesTheHillshade(t *testing.T) {
|
||||
// The ramp's top is the 99.5th percentile of *land* elevation, so the ice cap only reads as meadow when
|
||||
// there is real high ground on the map to set that percentile. A cap alone on an empty map is the highest
|
||||
// thing there is and the ramp would call it snow anyway - which is how the first version of this test
|
||||
// managed to pass for the wrong reason.
|
||||
const w, h = 96, 64
|
||||
f := New(w, h, 8)
|
||||
sea := make([]bool, w*h)
|
||||
snow := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
d2 := float64((x-24)*(x-24) + (y-32)*(y-32))
|
||||
v := 40 - d2/60 // a low ice dome on the left
|
||||
if x > 56 {
|
||||
// and a 700 m range on the right, which is what sets the top of the ramp
|
||||
v = 700 - float64((x-76)*(x-76)+(y-32)*(y-32))*0.7
|
||||
}
|
||||
if v < 0 {
|
||||
v = 0
|
||||
sea[i] = true
|
||||
}
|
||||
f.Data[i] = float32(v)
|
||||
snow[i] = !sea[i] && x <= 56
|
||||
}
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
plain := filepath.Join(dir, "plain.png")
|
||||
iced := filepath.Join(dir, "iced.png")
|
||||
opt := PreviewOptions{Sea: sea, SeaLevelM: 0, Size: w}
|
||||
if _, err := WritePreview(plain, f, opt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opt.Snow = snow
|
||||
if _, err := WritePreview(iced, f, opt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a, b := readRGBA(t, plain), readRGBA(t, iced)
|
||||
cx, cy := 24, 32 // the ice dome's summit
|
||||
|
||||
pr, pg, pb, _ := a.At(cx, cy).RGBA()
|
||||
sr, sg, sb, _ := b.At(cx, cy).RGBA()
|
||||
t.Logf("land at the summit: plain rgb(%d,%d,%d), iced rgb(%d,%d,%d)",
|
||||
pr>>8, pg>>8, pb>>8, sr>>8, sg>>8, sb>>8)
|
||||
|
||||
// Ice is much lighter than the ramp's low-ground green, and it is not green: blue is at least green.
|
||||
if sr <= pr || sb <= pb {
|
||||
t.Errorf("the iced summit is not lighter than the plain one")
|
||||
}
|
||||
if sb < sg {
|
||||
t.Errorf("the ice reads green (b %d < g %d); it should be neutral to slightly blue", sb>>8, sg>>8)
|
||||
}
|
||||
|
||||
// It still takes the hillshade: the lit and shaded flanks of the dome must differ.
|
||||
lr, _, _, _ := b.At(cx-12, cy-12).RGBA() // north-west flank, towards the light
|
||||
dr, _, _, _ := b.At(cx+12, cy+12).RGBA() // south-east flank, away from it
|
||||
t.Logf("ice flanks: lit %d, shaded %d", lr>>8, dr>>8)
|
||||
if lr <= dr {
|
||||
t.Errorf("the ice is flat: lit flank %d against shaded %d, so it was stamped rather than shaded",
|
||||
lr>>8, dr>>8)
|
||||
}
|
||||
|
||||
// And the water is untouched. The probe has to be a cell that really is sea - the first version used the
|
||||
// corner, which on this map is land, so it was comparing two ice pixels and calling the difference a bug.
|
||||
sx, sy := -1, -1
|
||||
for i, isSea := range sea {
|
||||
if isSea {
|
||||
sx, sy = i%w, i/w
|
||||
break
|
||||
}
|
||||
}
|
||||
if sx < 0 {
|
||||
t.Fatal("the test terrain has no sea in it")
|
||||
}
|
||||
wr, wg, wb, _ := a.At(sx, sy).RGBA()
|
||||
xr, xg, xb, _ := b.At(sx, sy).RGBA()
|
||||
if wr != xr || wg != xg || wb != xb {
|
||||
t.Errorf("the sea at %d,%d changed: rgb(%d,%d,%d) became rgb(%d,%d,%d); the mask should only touch land",
|
||||
sx, sy, wr>>8, wg>>8, wb>>8, xr>>8, xg>>8, xb>>8)
|
||||
}
|
||||
}
|
||||
|
||||
func readRGBA(t *testing.T, path string) image.Image {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
img, err := png.Decode(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// A palette is a file somebody edits, so it has to survive the trip to disk and back unchanged - and it has
|
||||
// to keep the comments the writer puts in, because a loader that refuses unknown keys would otherwise choke
|
||||
// on its own output.
|
||||
func TestPaletteRoundTripsThroughDiskWithItsComments(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "p.json")
|
||||
want := DefaultPalette()
|
||||
if err := want.Write(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "_comment") {
|
||||
t.Error("the written palette carries no commentary")
|
||||
}
|
||||
got, err := LoadPalette(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reading back what Write produced: %v", err)
|
||||
}
|
||||
if len(got.LandStops) != len(want.LandStops) {
|
||||
t.Fatalf("%d stops, want %d", len(got.LandStops), len(want.LandStops))
|
||||
}
|
||||
for i := range want.LandStops {
|
||||
if got.LandStops[i] != want.LandStops[i] {
|
||||
t.Errorf("stop %d: %v, want %v", i, got.LandStops[i], want.LandStops[i])
|
||||
}
|
||||
}
|
||||
if got.Ice != want.Ice || got.SeaDeep != want.SeaDeep || got.River != want.River {
|
||||
t.Errorf("colours differ: %v %v %v", got.Ice, got.SeaDeep, got.River)
|
||||
}
|
||||
if got.SunAzimuthDeg != want.SunAzimuthDeg || got.Ambient != want.Ambient {
|
||||
t.Errorf("light differs: %v %v", got.SunAzimuthDeg, got.Ambient)
|
||||
}
|
||||
}
|
||||
|
||||
// A palette fills what it leaves out from the default, so a two-line file is a valid one.
|
||||
func TestAPartialPaletteKeepsTheDefaults(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "p.json")
|
||||
if err := os.WriteFile(path, []byte(`{"_why": "just the sea", "sea_deep": [1, 2, 3]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadPalette(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.SeaDeep != (RGB{1, 2, 3}) {
|
||||
t.Errorf("sea_deep = %v, want the file's", got.SeaDeep)
|
||||
}
|
||||
if got.Ice != DefaultPalette().Ice {
|
||||
t.Errorf("ice = %v, want the default", got.Ice)
|
||||
}
|
||||
}
|
||||
|
||||
// And a misspelt key is an error rather than a setting that silently does nothing.
|
||||
func TestAMisspeltPaletteKeyIsRefused(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "p.json")
|
||||
if err := os.WriteFile(path, []byte(`{"sea_dep": [1,2,3]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadPalette(path); err == nil {
|
||||
t.Fatal("accepted a misspelt key")
|
||||
}
|
||||
}
|
||||
|
||||
// The palette actually reaches the picture: swapping the sea colour changes the sea.
|
||||
func TestThePaletteIsWhatGetsDrawn(t *testing.T) {
|
||||
const w, h = 32, 32
|
||||
f := New(w, h, 8)
|
||||
sea := make([]bool, w*h)
|
||||
for i := range sea {
|
||||
sea[i] = i%w < w/2
|
||||
if !sea[i] {
|
||||
f.Data[i] = 50
|
||||
}
|
||||
}
|
||||
dir := t.TempDir()
|
||||
a := filepath.Join(dir, "a.png")
|
||||
b := filepath.Join(dir, "b.png")
|
||||
if _, err := WritePreview(a, f, PreviewOptions{Sea: sea, Size: w}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pal := DefaultPalette()
|
||||
pal.SeaShallow, pal.SeaDeep = RGB{255, 0, 0}, RGB{255, 0, 0}
|
||||
if _, err := WritePreview(b, f, PreviewOptions{Sea: sea, Size: w, Palette: pal}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ar, ag, ab, _ := readRGBA(t, a).At(2, 2).RGBA()
|
||||
br, bg, bb, _ := readRGBA(t, b).At(2, 2).RGBA()
|
||||
if br>>8 != 255 || bg>>8 != 0 || bb>>8 != 0 {
|
||||
t.Errorf("the sea is rgb(%d,%d,%d), want the palette's red", br>>8, bg>>8, bb>>8)
|
||||
}
|
||||
if ar == br && ag == bg && ab == bb {
|
||||
t.Error("the palette changed nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// The ramp is relative by default and that is a picture which lies about scale: a lowland continent 47 m high
|
||||
// gets the same rock and snow a 2800 m range would, because the top of the ramp is a percentile of whatever
|
||||
// world it is drawing. land_top_m is the way out, and the point of the test is that the two differ.
|
||||
func TestAnAbsoluteRampDrawsALowContinentAsLowGround(t *testing.T) {
|
||||
const w = 96
|
||||
f := New(w, w, 10)
|
||||
sea := make([]bool, w*w)
|
||||
for y := 0; y < w; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
dx, dy := float64(x-w/2)/float64(w/2), float64(y-w/2)/float64(w/2)
|
||||
d := dx*dx + dy*dy
|
||||
if d > 0.8 {
|
||||
sea[i] = true
|
||||
f.Data[i] = -50
|
||||
continue
|
||||
}
|
||||
// A 40 m hill on a continent, which is a plain by any reading.
|
||||
f.Data[i] = float32(40 * (1 - d/0.8))
|
||||
}
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
rel := filepath.Join(dir, "relative.png")
|
||||
abs := filepath.Join(dir, "absolute.png")
|
||||
|
||||
top, err := WritePreview(rel, f, PreviewOptions{Sea: sea, Size: w})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if top > 45 {
|
||||
t.Fatalf("the relative ramp should top out near the highest land, about 40 m; got %.1f", top)
|
||||
}
|
||||
|
||||
pal := DefaultPalette()
|
||||
pal.LandTopM = 2000
|
||||
top, err = WritePreview(abs, f, PreviewOptions{Sea: sea, Size: w, Palette: pal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if top != 2000 {
|
||||
t.Fatalf("an absolute ramp tops out where it is told: got %.1f, want 2000", top)
|
||||
}
|
||||
|
||||
// And the pictures differ: the summit is high on the ramp in one and at the bottom of it in the other.
|
||||
relTop := brightestLand(t, rel, sea, w)
|
||||
absTop := brightestLand(t, abs, sea, w)
|
||||
if relTop <= absTop {
|
||||
t.Errorf("the relative picture should carry the summit far higher up the ramp: %d vs %d",
|
||||
relTop, absTop)
|
||||
}
|
||||
}
|
||||
|
||||
// brightestLand is the highest luma any land pixel reached, which is how far up the hypsometric ramp the
|
||||
// summit got: the ramp ends in near-white snow and starts in dark green.
|
||||
func brightestLand(t *testing.T, path string, sea []bool, w int) int {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
img, err := png.Decode(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
best := 0
|
||||
b := img.Bounds()
|
||||
for y := 0; y < b.Dy(); y++ {
|
||||
for x := 0; x < b.Dx(); x++ {
|
||||
if sea[y*w+x] {
|
||||
continue
|
||||
}
|
||||
r, g, bl, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
|
||||
if v := int(r+g+bl) >> 8; v > best {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package field
|
||||
|
||||
// SlidingMax is the maximum over a square window, separable and O(1) a cell whatever the radius.
|
||||
//
|
||||
// The naive form is a loop over the window, which is what internal/stats' localRelief used to do and is fine
|
||||
// on the 500 m window it uses at 8 m cells - until the map is a planet. 28 million land cells times a 63-cell
|
||||
// radius is 1.1e11 comparisons, which is not a slow diagnostic, it is one nobody will ever see the end of.
|
||||
// The monotonic deque is the standard answer: each index enters and leaves once, so the row pass is linear in
|
||||
// the row however wide the window.
|
||||
//
|
||||
// wrapX makes the row pass periodic, which is what a cylinder needs; the column pass always clamps, because
|
||||
// the top and bottom of the map are the poles and not each other.
|
||||
func SlidingMax(f *Field, radius int, wrapX bool) *Field {
|
||||
return sliding(f, radius, wrapX, func(inDeque, arriving float32) bool { return inDeque <= arriving })
|
||||
}
|
||||
|
||||
// SlidingMin is the same window, the other way up. The pair is what local relief is made of.
|
||||
func SlidingMin(f *Field, radius int, wrapX bool) *Field {
|
||||
return sliding(f, radius, wrapX, func(inDeque, arriving float32) bool { return inDeque >= arriving })
|
||||
}
|
||||
|
||||
// LocalRelief is max minus min over a square window: the standard field measure of how rugged a place is, and
|
||||
// the one thing slope cannot tell you. A 5 m hummock and a 500 m mountainside both stand at 30 degrees.
|
||||
//
|
||||
// Two sliding passes and a subtract, so it costs the same as one of them twice and nothing per radius. It
|
||||
// holds two fields at once at the peak, which at planet scale is 600 MB - worth saying, because the naive
|
||||
// version held none and could not finish.
|
||||
func LocalRelief(f *Field, radius int, wrapX bool) *Field {
|
||||
hi := SlidingMax(f, radius, wrapX)
|
||||
lo := SlidingMin(f, radius, wrapX)
|
||||
for i := range hi.Data {
|
||||
hi.Data[i] -= lo.Data[i]
|
||||
}
|
||||
return hi
|
||||
}
|
||||
|
||||
// sliding is the shared separable pass. keep reports whether the value already at the back of the deque can
|
||||
// be dropped when a new one arrives, which is the only thing that differs between the maximum and the
|
||||
// minimum: the deque holds indices whose values are monotone, so its front is always the answer for the live
|
||||
// window and anything the arriving value dominates can never be the answer again.
|
||||
func sliding(f *Field, radius int, wrapX bool, keep func(inDeque, arriving float32) bool) *Field {
|
||||
if radius < 1 {
|
||||
return f.Clone()
|
||||
}
|
||||
w, h := f.W, f.H
|
||||
row := New(w, h, f.CellM)
|
||||
buf := make([]float32, 0, w+2*radius)
|
||||
idx := make([]int, 0, w+2*radius)
|
||||
|
||||
for y := 0; y < h; y++ {
|
||||
// The row, extended by the radius at each end so the deque never has to special-case an edge.
|
||||
buf = buf[:0]
|
||||
for x := -radius; x < w+radius; x++ {
|
||||
sx := x
|
||||
if wrapX {
|
||||
sx = ((sx % w) + w) % w
|
||||
} else if sx < 0 {
|
||||
sx = 0
|
||||
} else if sx >= w {
|
||||
sx = w - 1
|
||||
}
|
||||
buf = append(buf, f.Data[y*w+sx])
|
||||
}
|
||||
slide(buf, idx[:0], 2*radius+1, keep, func(i int, v float32) {
|
||||
if i < w {
|
||||
row.Data[y*w+i] = v
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
out := New(w, h, f.CellM)
|
||||
col := make([]float32, 0, h+2*radius)
|
||||
for x := 0; x < w; x++ {
|
||||
col = col[:0]
|
||||
for y := -radius; y < h+radius; y++ {
|
||||
sy := y
|
||||
if sy < 0 {
|
||||
sy = 0
|
||||
} else if sy >= h {
|
||||
sy = h - 1
|
||||
}
|
||||
col = append(col, row.Data[sy*w+x])
|
||||
}
|
||||
slide(col, idx[:0], 2*radius+1, keep, func(i int, v float32) {
|
||||
if i < h {
|
||||
out.Data[i*w+x] = v
|
||||
}
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// slide walks a padded line with a monotonic deque and reports the window's answer ending at each output
|
||||
// position.
|
||||
func slide(line []float32, dq []int, window int, keep func(inDeque, arriving float32) bool,
|
||||
emit func(i int, v float32)) {
|
||||
|
||||
dq = dq[:0]
|
||||
for i, v := range line {
|
||||
for len(dq) > 0 && keep(line[dq[len(dq)-1]], v) {
|
||||
dq = dq[:len(dq)-1]
|
||||
}
|
||||
dq = append(dq, i)
|
||||
if dq[0] <= i-window {
|
||||
dq = dq[1:]
|
||||
}
|
||||
if out := i - window + 1; out >= 0 {
|
||||
emit(out, line[dq[0]])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BoxSmooth blurs a field in place with `passes` of a separable box blur of the given radius, clamping at the
|
||||
// edges. Two passes are near enough to a Gaussian for anything here and cost four linear sweeps.
|
||||
//
|
||||
// Deterministic by construction: fixed traversal order, running sums, no goroutines. It lives here rather than
|
||||
// in the pass that first wanted it because two now do - the coastal detail pass smooths the signed distance to
|
||||
// the shoreline, and the tile bake smooths the interpolated sea floor.
|
||||
func BoxSmooth(data []float32, w, h, radius, passes int) {
|
||||
if radius < 1 || passes < 1 || len(data) < w*h {
|
||||
return
|
||||
}
|
||||
tmp := make([]float32, len(data))
|
||||
for p := 0; p < passes; p++ {
|
||||
boxRows(data, tmp, w, h, radius)
|
||||
boxCols(tmp, data, w, h, radius)
|
||||
}
|
||||
}
|
||||
|
||||
func boxRows(src, dst []float32, w, h, radius int) {
|
||||
n := float32(2*radius + 1)
|
||||
for y := 0; y < h; y++ {
|
||||
row := y * w
|
||||
var sum float32
|
||||
for k := -radius; k <= radius; k++ {
|
||||
sum += src[row+clampIdx(k, w)]
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
dst[row+x] = sum / n
|
||||
sum += src[row+clampIdx(x+radius+1, w)] - src[row+clampIdx(x-radius, w)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func boxCols(src, dst []float32, w, h, radius int) {
|
||||
n := float32(2*radius + 1)
|
||||
for x := 0; x < w; x++ {
|
||||
var sum float32
|
||||
for k := -radius; k <= radius; k++ {
|
||||
sum += src[clampIdx(k, h)*w+x]
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
dst[y*w+x] = sum / n
|
||||
sum += src[clampIdx(y+radius+1, h)*w+x] - src[clampIdx(y-radius, h)*w+x]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clampIdx(i, n int) int {
|
||||
if i < 0 {
|
||||
return 0
|
||||
}
|
||||
if i >= n {
|
||||
return n - 1
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// BoxSmoothMasked is BoxSmooth restricted to the cells the mask selects: a cell outside it is neither read
|
||||
// nor written, so the blur never averages across the boundary.
|
||||
//
|
||||
// That distinction is the whole reason it exists. The coastal detail pass damps the metre-scale texture near
|
||||
// the shore, and an unmasked blur there does not damp texture, it bridges the waterline: measured on a
|
||||
// fixture with forty metres of water against the land, the plain blur lifted the sea floor by twenty metres.
|
||||
// The step at a shoreline is a landform, not roughness, and a filter that cannot tell them apart is the wrong
|
||||
// filter.
|
||||
//
|
||||
// Separable and weighted: the row pass carries a running sum of values and of weights, the column pass sums
|
||||
// those, and the quotient is the mean over the masked cells in the window. Deterministic, like BoxSmooth.
|
||||
func BoxSmoothMasked(data []float32, mask []bool, w, h, radius, passes int) {
|
||||
if radius < 1 || passes < 1 || len(data) < w*h || len(mask) < w*h {
|
||||
return
|
||||
}
|
||||
n := w * h
|
||||
val := make([]float32, n)
|
||||
wgt := make([]float32, n)
|
||||
tv := make([]float32, n)
|
||||
tw := make([]float32, n)
|
||||
for p := 0; p < passes; p++ {
|
||||
for i := 0; i < n; i++ {
|
||||
if mask[i] {
|
||||
val[i], wgt[i] = data[i], 1
|
||||
} else {
|
||||
val[i], wgt[i] = 0, 0
|
||||
}
|
||||
}
|
||||
boxRowsSum(val, tv, w, h, radius)
|
||||
boxRowsSum(wgt, tw, w, h, radius)
|
||||
boxColsSum(tv, val, w, h, radius)
|
||||
boxColsSum(tw, wgt, w, h, radius)
|
||||
for i := 0; i < n; i++ {
|
||||
if mask[i] && wgt[i] > 0 {
|
||||
data[i] = val[i] / wgt[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// boxRowsSum and boxColsSum are the running sums BoxSmooth uses, without the division: a masked blur needs
|
||||
// the weight sum as well as the value sum, and dividing in the middle would be dividing by the wrong thing.
|
||||
func boxRowsSum(src, dst []float32, w, h, radius int) {
|
||||
for y := 0; y < h; y++ {
|
||||
row := y * w
|
||||
var sum float32
|
||||
for k := -radius; k <= radius; k++ {
|
||||
sum += src[row+clampIdx(k, w)]
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
dst[row+x] = sum
|
||||
sum += src[row+clampIdx(x+radius+1, w)] - src[row+clampIdx(x-radius, w)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func boxColsSum(src, dst []float32, w, h, radius int) {
|
||||
for x := 0; x < w; x++ {
|
||||
var sum float32
|
||||
for k := -radius; k <= radius; k++ {
|
||||
sum += src[clampIdx(k, h)*w+x]
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
dst[y*w+x] = sum
|
||||
sum += src[clampIdx(y+radius+1, h)*w+x] - src[clampIdx(y-radius, h)*w+x]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package field
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The deque has to give the same answer as the loop it replaces, on every cell, including the edges and the
|
||||
// seam. It is O(1) a cell against O(radius squared), which is the difference between a diagnostic and a hang
|
||||
// at planet scale - and an optimisation that is only nearly right is worse than the version that was slow.
|
||||
func TestSlidingWindowsMatchTheNaiveLoop(t *testing.T) {
|
||||
r := rand.New(rand.NewPCG(7, 9))
|
||||
const w, h = 61, 37
|
||||
f := New(w, h, 8)
|
||||
for i := range f.Data {
|
||||
f.Data[i] = float32(r.NormFloat64() * 50)
|
||||
}
|
||||
|
||||
naive := func(cx, cy, radius int, wrapX, wantMax bool) float32 {
|
||||
best := float32(math.Inf(1))
|
||||
if wantMax {
|
||||
best = float32(math.Inf(-1))
|
||||
}
|
||||
for y := cy - radius; y <= cy+radius; y++ {
|
||||
sy := y
|
||||
if sy < 0 {
|
||||
sy = 0
|
||||
} else if sy >= h {
|
||||
sy = h - 1
|
||||
}
|
||||
for x := cx - radius; x <= cx+radius; x++ {
|
||||
sx := x
|
||||
if wrapX {
|
||||
sx = ((sx % w) + w) % w
|
||||
} else if sx < 0 {
|
||||
sx = 0
|
||||
} else if sx >= w {
|
||||
sx = w - 1
|
||||
}
|
||||
v := f.Data[sy*w+sx]
|
||||
if (wantMax && v > best) || (!wantMax && v < best) {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
for _, radius := range []int{1, 3, 8, 20} {
|
||||
for _, wrapX := range []bool{false, true} {
|
||||
hi := SlidingMax(f, radius, wrapX)
|
||||
lo := SlidingMin(f, radius, wrapX)
|
||||
rel := LocalRelief(f, radius, wrapX)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if got, want := hi.Data[i], naive(x, y, radius, wrapX, true); got != want {
|
||||
t.Fatalf("max r=%d wrap=%v at (%d,%d): %v want %v", radius, wrapX, x, y, got, want)
|
||||
}
|
||||
if got, want := lo.Data[i], naive(x, y, radius, wrapX, false); got != want {
|
||||
t.Fatalf("min r=%d wrap=%v at (%d,%d): %v want %v", radius, wrapX, x, y, got, want)
|
||||
}
|
||||
if got := rel.Data[i]; got != hi.Data[i]-lo.Data[i] {
|
||||
t.Fatalf("relief r=%d at (%d,%d): %v against %v", radius, x, y, got,
|
||||
hi.Data[i]-lo.Data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A radius of zero is a no-op rather than an error, which is what a caller with a window smaller than one
|
||||
// cell should get.
|
||||
func TestASlidingWindowOfNothingIsTheFieldItself(t *testing.T) {
|
||||
f := New(4, 3, 8)
|
||||
for i := range f.Data {
|
||||
f.Data[i] = float32(i)
|
||||
}
|
||||
for _, got := range []*Field{SlidingMax(f, 0, true), SlidingMin(f, 0, false)} {
|
||||
for i := range f.Data {
|
||||
if got.Data[i] != f.Data[i] {
|
||||
t.Fatalf("radius 0 changed cell %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rel := LocalRelief(f, 0, true); rel.Data[5] != 0 {
|
||||
t.Errorf("relief over a single cell is zero, got %v", rel.Data[5])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package field
|
||||
|
||||
import "math"
|
||||
|
||||
// SmoothEdgePreserving relaxes a height field towards its neighbours with a weight that falls away as the
|
||||
// step between them grows, so a channel wall or a ridge crest survives a pass that takes a grid-cut facet
|
||||
// off. It is a port of the bilateral smooth in the World Orogen browser generator, which has one for exactly
|
||||
// this reason - to blend the artefacts its own routing leaves without rounding the landforms off with them.
|
||||
//
|
||||
// It is a filter and not a process. It conserves nothing, it has no time in it, and running it inside the
|
||||
// solve loop would act as an uncontrolled extra diffusivity: that changes the steady-state slope, which is
|
||||
// U/K, which is the one knob the whole generator's relief hangs on. It runs once, after the solve, and it is
|
||||
// off by default. The point of having it is that the alternative - raising diffusion_m2_yr until the
|
||||
// artefacts go - is measured to smooth away the landforms too, at about 0.05.
|
||||
//
|
||||
// Two deviations from the reference, both about units.
|
||||
//
|
||||
// The weight is 1/(1 + |dh|/(d*slopeRef)) rather than 1/(1 + |dh|*sensitivity). A sensitivity in 1/m is a
|
||||
// height threshold, and a height threshold means one thing on a 32 m geology cell and something four times
|
||||
// as aggressive on an 8 m one, so the same painted world would come out differently at two resolutions -
|
||||
// which is the property Docs/Terrain-Next.md section 4.D says the generator lives or dies by. slopeRef is a
|
||||
// rise over run and carries across. Ground steeper than it is preserved; ground gentler is relaxed.
|
||||
//
|
||||
// And a diagonal neighbour is sqrt(2) further away, so it carries both its own distance in the slope and an
|
||||
// inverse-distance geometric weight - which is what a Gaussian would give those two offsets.
|
||||
//
|
||||
// The waterline is a wall, not a value. A neighbour that is not land is skipped entirely rather than clamped:
|
||||
// clamping to sea level would pull the shore down, and clamping the other way would drown the beach the
|
||||
// coastal pass built. Sea cells are never written.
|
||||
//
|
||||
// scratch must be at least len(h); it is used as the destination of each pass.
|
||||
func SmoothEdgePreserving(h []float32, w, hgt int, cellM float64, land []bool, passes int, slopeRef float64, scratch []float32) {
|
||||
if passes <= 0 || slopeRef <= 0 || cellM <= 0 {
|
||||
return
|
||||
}
|
||||
if passes > smoothMaxPasses {
|
||||
passes = smoothMaxPasses
|
||||
}
|
||||
tmp := scratch[:len(h)]
|
||||
|
||||
// dh/(d*slopeRef) per face, folded into one reciprocal each.
|
||||
invCard := float32(1 / (cellM * slopeRef))
|
||||
invDiag := float32(1 / (cellM * math.Sqrt2 * slopeRef))
|
||||
const geomDiag = float32(1 / math.Sqrt2)
|
||||
|
||||
for p := 0; p < passes; p++ {
|
||||
src := h
|
||||
Rows(hgt, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if !land[i] {
|
||||
tmp[i] = src[i]
|
||||
continue
|
||||
}
|
||||
c := src[i]
|
||||
var sumW, sumH float32
|
||||
face := func(nx, ny int, inv, geom float32) {
|
||||
if nx < 0 || ny < 0 || nx >= w || ny >= hgt {
|
||||
return
|
||||
}
|
||||
ni := ny*w + nx
|
||||
if !land[ni] {
|
||||
return
|
||||
}
|
||||
n := src[ni]
|
||||
d := n - c
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
wk := geom / (1 + d*inv)
|
||||
sumW += wk
|
||||
sumH += wk * n
|
||||
}
|
||||
face(x-1, y, invCard, 1)
|
||||
face(x+1, y, invCard, 1)
|
||||
face(x, y-1, invCard, 1)
|
||||
face(x, y+1, invCard, 1)
|
||||
face(x-1, y-1, invDiag, geomDiag)
|
||||
face(x+1, y-1, invDiag, geomDiag)
|
||||
face(x-1, y+1, invDiag, geomDiag)
|
||||
face(x+1, y+1, invDiag, geomDiag)
|
||||
tmp[i] = (c + sumH) / (1 + sumW)
|
||||
}
|
||||
}
|
||||
})
|
||||
copy(h, tmp)
|
||||
}
|
||||
}
|
||||
|
||||
// smoothMaxPasses is a hard ceiling, not a default. Past about three passes the edge weight has stopped
|
||||
// protecting anything - every face inside a landform is gentler than slopeRef by then - and what is left is a
|
||||
// box blur with extra steps.
|
||||
const smoothMaxPasses = 4
|
||||
@@ -0,0 +1,106 @@
|
||||
package field
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The claim the smooth has to earn: it takes the ripple off and leaves the landform. Two surfaces in one
|
||||
// grid - a plane carrying a small corrugation, and a cliff far steeper than slopeRef - and the pass has to
|
||||
// treat them differently or it is a box blur with extra arithmetic.
|
||||
func TestSmoothTakesTheRippleAndLeavesTheCliff(t *testing.T) {
|
||||
const (
|
||||
w, h = 128, 128
|
||||
cellM = 8.0
|
||||
slopeRef = 0.3
|
||||
)
|
||||
land := make([]bool, w*h)
|
||||
for i := range land {
|
||||
land[i] = true
|
||||
}
|
||||
hgt := make([]float32, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
v := 100.0
|
||||
if x >= w/2 {
|
||||
v = 400.0 // a 300 m cliff at mid-grid: 37.5 rise over run, a hundred times slopeRef
|
||||
}
|
||||
// A 4 m corrugation at a four-cell wavelength, which is 0.125 rise over run - under slopeRef.
|
||||
v += 2 * math.Sin(2*math.Pi*float64(y)/4)
|
||||
hgt[y*w+x] = float32(v)
|
||||
}
|
||||
}
|
||||
before := make([]float32, len(hgt))
|
||||
copy(before, hgt)
|
||||
|
||||
SmoothEdgePreserving(hgt, w, h, cellM, land, 2, slopeRef, make([]float32, w*h))
|
||||
|
||||
// The ripple, measured well away from the cliff.
|
||||
rip := func(f []float32, x int) float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for y := 8; y < h-8; y++ {
|
||||
v := float64(f[y*w+x])
|
||||
lo, hi = math.Min(lo, v), math.Max(hi, v)
|
||||
}
|
||||
return hi - lo
|
||||
}
|
||||
ripBefore, ripAfter := rip(before, w/4), rip(hgt, w/4)
|
||||
// The cliff, measured across the step on a row far from the edges.
|
||||
step := func(f []float32) float64 {
|
||||
y := h / 2
|
||||
return float64(f[y*w+w/2] - f[y*w+w/2-1])
|
||||
}
|
||||
stepBefore, stepAfter := step(before), step(hgt)
|
||||
|
||||
t.Logf("ripple %.2f -> %.2f m (%.0f%% removed); cliff %.1f -> %.1f m (%.0f%% kept)",
|
||||
ripBefore, ripAfter, 100*(1-ripAfter/ripBefore), stepBefore, stepAfter, 100*stepAfter/stepBefore)
|
||||
if ripAfter > 0.5*ripBefore {
|
||||
t.Errorf("the ripple is still %.0f%% of what it was; the pass is not smoothing", 100*ripAfter/ripBefore)
|
||||
}
|
||||
if stepAfter < 0.9*stepBefore {
|
||||
t.Errorf("the cliff lost %.0f%% of its height; the edge weight is not preserving", 100*(1-stepAfter/stepBefore))
|
||||
}
|
||||
}
|
||||
|
||||
// The waterline is a wall. A sea cell is never written, and a land cell beside one is never pulled towards
|
||||
// sea level - clamping either way would move the shore, and the coastal pass owns the shore.
|
||||
func TestSmoothNeverReachesAcrossTheWaterline(t *testing.T) {
|
||||
const (
|
||||
w, h = 64, 64
|
||||
cellM = 8.0
|
||||
)
|
||||
land := make([]bool, w*h)
|
||||
hgt := make([]float32, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if x < w/2 {
|
||||
hgt[i] = -40 // sea floor
|
||||
} else {
|
||||
land[i] = true
|
||||
hgt[i] = 60 // a plateau meeting it at a hundred-metre cliff
|
||||
}
|
||||
}
|
||||
}
|
||||
before := make([]float32, len(hgt))
|
||||
copy(before, hgt)
|
||||
SmoothEdgePreserving(hgt, w, h, cellM, land, 4, 0.3, make([]float32, w*h))
|
||||
|
||||
for i := range hgt {
|
||||
if !land[i] && hgt[i] != before[i] {
|
||||
t.Fatalf("a sea cell moved %.4f m", hgt[i]-before[i])
|
||||
}
|
||||
}
|
||||
// The first land column has three land neighbours and five sea ones. On a flat plateau it must not move
|
||||
// at all: the sea neighbours contribute nothing, and the land ones are all at its own height.
|
||||
worst := float32(0)
|
||||
for y := 1; y < h-1; y++ {
|
||||
if d := hgt[y*w+w/2] - before[y*w+w/2]; math.Abs(float64(d)) > float64(worst) {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("worst move on the shore column: %.6f m", worst)
|
||||
if math.Abs(float64(worst)) > 1e-3 {
|
||||
t.Errorf("the shore column moved %.4f m; the pass is reading across the waterline", worst)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ type bucketPQ struct {
|
||||
|
||||
const bucketWidthM = 0.01
|
||||
|
||||
// reposeOrderBuckets is how far ClampToRepose scatters a cell from its own bucket, in buckets, either way.
|
||||
// Sixteen buckets is sixteen centimetres: enough that a tie spreads over about thirty of them and the pop
|
||||
// order stops tracking the raster, far below the metres the clamp's ordering could ever depend on. See
|
||||
// pushJittered.
|
||||
const reposeOrderBuckets = 16
|
||||
|
||||
func newBucketPQ(loM, hiM float64) *bucketPQ {
|
||||
if hiM <= loM {
|
||||
hiM = loM + 1
|
||||
@@ -54,6 +60,34 @@ func (q *bucketPQ) push(elev float32, idx int32) {
|
||||
q.count++
|
||||
}
|
||||
|
||||
// pushJittered is push with the bucket chosen from the elevation plus j buckets, so cells at the same
|
||||
// elevation land in different buckets instead of in one and pop in hash order rather than in reverse raster
|
||||
// order.
|
||||
//
|
||||
// Only ClampToRepose wants this. The flood already scatters its own order through the epsilon it adds to the
|
||||
// height, so its cells rarely share a bucket; the clamp reads the raw surface, where flat ground puts every
|
||||
// cell in one bucket and the LIFO below then processes them bottom-right to top-left, every time, everywhere.
|
||||
//
|
||||
// Half a bucket is not enough - it splits a tie across two buckets and halves the correlation instead of
|
||||
// removing it - so the caller scatters over reposeOrderBuckets, and that is safe for a reason worth writing
|
||||
// down. The clamp's order can only matter between two cells whose heights differ by about the talus
|
||||
// allowance, which is metres: a cell popped early is marked closed and never lowered again, but every cell
|
||||
// popping after it stands within the jitter width of it, so its limit is the other cell's height plus the
|
||||
// full allowance and cannot bind. Reordering cells that are centimetres apart therefore cannot break a
|
||||
// constraint that only bites metres apart. The bound is talus*cell/2, which is 2.8 m at 35 degrees on an 8 m
|
||||
// cell; the constant below is two orders of magnitude inside it.
|
||||
func (q *bucketPQ) pushJittered(elev float32, idx int32, j float32) {
|
||||
b := int((float64(elev)-q.lo)/q.width + float64(j))
|
||||
if b < q.cur {
|
||||
b = q.cur
|
||||
}
|
||||
if b >= len(q.buckets) {
|
||||
b = len(q.buckets) - 1
|
||||
}
|
||||
q.buckets[b] = append(q.buckets[b], idx)
|
||||
q.count++
|
||||
}
|
||||
|
||||
// pop returns the lowest cell. The cursor only moves forward, so the total scan cost over a whole flood is
|
||||
// the number of buckets, not the number of pops.
|
||||
func (q *bucketPQ) pop() int32 {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package fluvial
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The diagnosis on a surface with no history.
|
||||
//
|
||||
// A planar hillslope is the one case where the right answer is known in closed form: the specific catchment
|
||||
// area - the upslope area per unit contour length - is the distance from the divide, and it is the same at
|
||||
// every point along a contour. Nothing about a plane distinguishes one flow line from its neighbour, so a
|
||||
// router that says otherwise is inventing the difference.
|
||||
//
|
||||
// D8 cannot say otherwise quietly. Every cell on the plane picks the same steepest neighbour, so the flow
|
||||
// lines run exactly parallel and never converge: a cell either sits on a line and carries the whole tube, or
|
||||
// sits off one and carries a single cell for ever. The ratio between them is the statistic below, and it is
|
||||
// why the flanks of a bake come out combed - stream power reads A^m off the lie and cuts each line in.
|
||||
//
|
||||
// There is no erosion in here. One fill, one receiver pass, one stack, one accumulate, and whatever comes
|
||||
// out belongs to the router and to nothing else.
|
||||
|
||||
// planarRamp is a plane tilted by aspectDeg from the x axis, with a whisper of noise to break exact ties.
|
||||
// The aspect matters: at 0 or 45 degrees the plane is aligned with a D8 direction and the answer is
|
||||
// degenerate in the other direction, so the test asks at 22.5, which is the worst case and the honest one.
|
||||
func planarRamp(n int, cellM, slope, aspectDeg float64) []float32 {
|
||||
t := aspectDeg * math.Pi / 180
|
||||
cs, sn := math.Cos(t), math.Sin(t)
|
||||
h := make([]float32, n*n)
|
||||
for y := 0; y < n; y++ {
|
||||
for x := 0; x < n; x++ {
|
||||
d := (float64(x)*cs + float64(y)*sn) * cellM
|
||||
// A millimetre of hash noise: enough that no two neighbours are bit-identical, far below
|
||||
// anything the router could read as structure.
|
||||
j := float64(hashXY(1, int32(x), int32(y), 99)) * 1e-3
|
||||
h[y*n+x] = float32(4000 - d*slope + j)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// concentration is max over median of the drainage area in a band of cells all the same distance from the
|
||||
// divide. On a plane the true value is 1: every cell in the band drains the same strip above it.
|
||||
func concentration(t *testing.T, area []float32, n int, cellM, aspectDeg, lo, hi float64) float64 {
|
||||
th := aspectDeg * math.Pi / 180
|
||||
cs, sn := math.Cos(th), math.Sin(th)
|
||||
dmax := (float64(n-1)*cs + float64(n-1)*sn) * cellM
|
||||
var band []float64
|
||||
for y := 2; y < n-2; y++ {
|
||||
for x := 2; x < n-2; x++ {
|
||||
d := (float64(x)*cs + float64(y)*sn) * cellM
|
||||
if d >= lo*dmax && d <= hi*dmax {
|
||||
band = append(band, float64(area[y*n+x]))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(band) < 100 {
|
||||
t.Fatalf("contour band has only %d cells", len(band))
|
||||
}
|
||||
sort.Float64s(band)
|
||||
med := band[len(band)/2]
|
||||
if med <= 0 {
|
||||
t.Fatalf("median area in the band is %g", med)
|
||||
}
|
||||
return band[len(band)-1] / med
|
||||
}
|
||||
|
||||
const (
|
||||
flowN = 256
|
||||
flowCellM = 10.0
|
||||
flowSlope = 0.1
|
||||
flowAspect = 22.5
|
||||
)
|
||||
|
||||
func TestD8ConcentratesFlowOnAPlanarSlope(t *testing.T) {
|
||||
h := planarRamp(flowN, flowCellM, flowSlope, flowAspect)
|
||||
g := NewGrid(flowN, flowN, flowCellM, nil)
|
||||
g.SetSeed(37125)
|
||||
g.FillDepressions(h, 1e-3)
|
||||
g.ComputeReceivers(h)
|
||||
g.BuildStack()
|
||||
g.Accumulate()
|
||||
|
||||
c := concentration(t, g.Area, flowN, flowCellM, flowAspect, 0.6, 0.7)
|
||||
leaves := 0
|
||||
for i := range g.Area {
|
||||
if g.Area[i] <= float32(flowCellM*flowCellM)*1.001 {
|
||||
leaves++
|
||||
}
|
||||
}
|
||||
t.Logf("D8: concentration max/median = %.1f, leaf cells = %.1f%%",
|
||||
c, 100*float64(leaves)/float64(flowN*flowN))
|
||||
if c < 5 {
|
||||
t.Errorf("D8 concentration is %.1f; this test exists because it is large, so either the router "+
|
||||
"changed or the measurement is wrong", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMFDDoesNotConcentrateFlowOnAPlanarSlope(t *testing.T) {
|
||||
h := planarRamp(flowN, flowCellM, flowSlope, flowAspect)
|
||||
g := NewGrid(flowN, flowN, flowCellM, nil)
|
||||
g.SetSeed(37125)
|
||||
g.FillDepressions(h, 1e-3)
|
||||
g.ComputeReceivers(h)
|
||||
g.AccumulateMFD(h, 1)
|
||||
|
||||
c := concentration(t, g.Area, flowN, flowCellM, flowAspect, 0.6, 0.7)
|
||||
leaves := 0
|
||||
for i := range g.Area {
|
||||
if g.Area[i] <= float32(flowCellM*flowCellM)*1.001 {
|
||||
leaves++
|
||||
}
|
||||
}
|
||||
t.Logf("MFD: concentration max/median = %.2f, leaf cells = %.1f%%",
|
||||
c, 100*float64(leaves)/float64(flowN*flowN))
|
||||
if c > 2 {
|
||||
t.Errorf("MFD concentration is %.2f on a plane, where the true answer is 1; the partition is not "+
|
||||
"spreading flow across the contour", c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFDConservesArea is the test the panic in AccumulateMFD cannot be: the walk releasing every cell says
|
||||
// nothing about how much area arrived. Over a closed basin the total that reaches the outlets has to be the
|
||||
// whole grid, because there is nowhere else for it to go.
|
||||
func TestMFDConservesArea(t *testing.T) {
|
||||
const n = 128
|
||||
const cellM = 10.0
|
||||
// A bowl, so every flow path ends at the one interior minimum rather than at the border.
|
||||
h := make([]float32, n*n)
|
||||
for y := 0; y < n; y++ {
|
||||
for x := 0; x < n; x++ {
|
||||
dx, dy := float64(x)-n/2, float64(y)-n/2
|
||||
j := float64(hashXY(7, int32(x), int32(y), 99)) * 1e-3
|
||||
h[y*n+x] = float32(100 + 0.02*(dx*dx+dy*dy) + j)
|
||||
}
|
||||
}
|
||||
g := NewGrid(n, n, cellM, nil)
|
||||
g.SetSeed(9342)
|
||||
g.ComputeReceivers(h)
|
||||
g.AccumulateMFD(h, 1)
|
||||
|
||||
// Every cell that sends nothing on is a sink: the bowl's floor and the fixed border. What rests in them
|
||||
// is the whole grid's area.
|
||||
var rest float64
|
||||
for i := 0; i < n*n; i++ {
|
||||
x, y := i%n, i/n
|
||||
lower := false
|
||||
for k := 0; k < 8; k++ {
|
||||
nx, ny := x+dx8[k], y+dy8[k]
|
||||
if nx < 0 || ny < 0 || nx >= n || ny >= n {
|
||||
continue
|
||||
}
|
||||
if h[ny*n+nx] < h[i] {
|
||||
lower = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !lower || g.fixed[i] {
|
||||
rest += float64(g.Area[i])
|
||||
}
|
||||
}
|
||||
want := float64(n*n) * cellM * cellM
|
||||
if rel := math.Abs(rest-want) / want; rel > 1e-4 {
|
||||
t.Errorf("area resting in sinks is %.0f m2, the grid is %.0f m2: %.2e relative, float32 is not enough",
|
||||
rest, want, rel)
|
||||
} else {
|
||||
t.Logf("area conserved to %.2e relative in float32", rel)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,11 @@ type Params struct {
|
||||
CriticalSlope float64
|
||||
SlopeCap float64 // where the flux stops stiffening, as a fraction of Sc
|
||||
MaxHillslopeSub int // the sub-step budget that bound buys
|
||||
|
||||
// MFDExponent selects multiple-flow-direction drainage area over D8's single receiver, and is the
|
||||
// exponent on the partition. 0 keeps the old Accumulate, which is what every bake before this ran and
|
||||
// what the A/B comparison needs. See mfd.go for why one is the right default.
|
||||
MFDExponent float64
|
||||
}
|
||||
|
||||
// Grid holds the flow topology and the scratch it is built from. Allocated once and reused across every
|
||||
@@ -85,7 +90,11 @@ type Grid struct {
|
||||
Stack []int32 // every node after its receiver
|
||||
Area []float32 // drainage area, m²
|
||||
|
||||
seed uint64 // the jitter's seed; see jitter.go and SetSeed
|
||||
cancel <-chan struct{} // closed to abandon a run mid-solve; see SetCancel
|
||||
seed uint64 // the jitter's seed; see jitter.go and SetSeed
|
||||
originX int32 // where this grid sits on the planet; see SetFrame. Zero is "this grid is the world"
|
||||
originY int32
|
||||
planetW int32 // the cylinder's width, or 0 when there is no cylinder
|
||||
donorOff []int32
|
||||
donorList []int32
|
||||
cursor []int32
|
||||
@@ -93,6 +102,13 @@ type Grid struct {
|
||||
pq *bucketPQ
|
||||
fifo []int32
|
||||
scratch []float32
|
||||
|
||||
// Multiple-flow accumulation. mfdPending is how many strictly higher neighbours a cell still owes before
|
||||
// it may be released; a byte, because a cell has eight neighbours and cannot owe more. See mfd.go.
|
||||
mfdPending []uint8
|
||||
mfdQueue []int32
|
||||
mfdMode mfdPow
|
||||
mfdExp float64
|
||||
}
|
||||
|
||||
// SetElevationRange sizes the flood's bucket queue. Called once, with the manifest's elevation range plus a
|
||||
@@ -116,6 +132,7 @@ func NewGrid(w, h int, cellM float64, base []bool) *Grid {
|
||||
Receiver: make([]int32, n), Length: make([]float32, n), Stack: make([]int32, 0, n),
|
||||
Area: make([]float32, n), donorOff: make([]int32, n+1), donorList: make([]int32, n),
|
||||
closed: make([]bool, n), fifo: make([]int32, 0, n), scratch: make([]float32, n),
|
||||
mfdPending: make([]uint8, n),
|
||||
}
|
||||
g.fixed = make([]bool, n)
|
||||
for i := range g.fixed {
|
||||
@@ -178,7 +195,7 @@ func (g *Grid) FillDepressions(h []float32, epsilon float32) {
|
||||
}
|
||||
g.closed[ni] = true
|
||||
if h[ni] <= celev {
|
||||
h[ni] = celev + epsilon*(0.5+hash01(g.seed, ni))
|
||||
h[ni] = celev + epsilon*(0.5+hashXY(g.seed, g.worldX(nx), g.worldY(ny), jitterFloodEpsilon))
|
||||
g.fifo = append(g.fifo, ni)
|
||||
} else {
|
||||
g.pq.push(h[ni], ni)
|
||||
@@ -239,7 +256,7 @@ func (g *Grid) ComputeReceivers(h []float32) {
|
||||
}
|
||||
// The tie-break, not a change of gradient: the comparison is jittered, the slope that
|
||||
// is kept is not, so Length and the stream-power update see the true geometry.
|
||||
sj := s * (1 + 1e-3*(hash01(g.seed, i*8+int32(k))-0.5))
|
||||
sj := s * (1 + 1e-3*(hashXY(g.seed, g.worldX(x), g.worldY(y), int32(k)+jitterReceiverTie)-0.5))
|
||||
if sj > bestJitter {
|
||||
bestJitter, best, bestLen = sj, ni, l
|
||||
}
|
||||
@@ -298,6 +315,11 @@ func (g *Grid) BuildStack() {
|
||||
}
|
||||
}
|
||||
|
||||
// Scratch hands out the grid's spare float32 buffer, which is the width of the grid and is dead between
|
||||
// steps. It is here so a pass that runs once after the solve - the edge-preserving smooth - does not allocate
|
||||
// a second copy of the height field at planet scale just to have somewhere to write.
|
||||
func (g *Grid) Scratch() []float32 { return g.scratch }
|
||||
|
||||
// scratchInt32 reuses the float32 scratch as int32 storage; same width, and it saves a 12 MB allocation per
|
||||
// step at the geology grid.
|
||||
func (g *Grid) scratchInt32() []int32 {
|
||||
@@ -458,6 +480,14 @@ func clampAt(a []float32, w, h, x, y int) float32 {
|
||||
return a[y*w+x]
|
||||
}
|
||||
|
||||
// SetCancel gives the solve a way to be abandoned part way through.
|
||||
//
|
||||
// It is checked once a step rather than inside one, which is the right granularity: a step is milliseconds on
|
||||
// a small region and a couple of seconds on a big one, so the longest a caller waits is one step, and nothing
|
||||
// inside a step is safe to leave half done. The height field is left wherever the solve had got to, which is
|
||||
// what a cancelled run means - it is not a checkpoint and nothing downstream should read it as one.
|
||||
func (g *Grid) SetCancel(ch <-chan struct{}) { g.cancel = ch }
|
||||
|
||||
// Run is the whole solve. Progress is reported through log, which is what a five-minute budget needs to be
|
||||
// steerable: a run that is going wrong should say so at step 500, not at the end.
|
||||
func (g *Grid) Run(h []float32, uplift, k []float32, p Params, log func(step int, total int, elapsedPct float64)) {
|
||||
@@ -466,12 +496,23 @@ func (g *Grid) Run(h []float32, uplift, k []float32, p Params, log func(step int
|
||||
fill = 1
|
||||
}
|
||||
for step := 0; step < p.Steps; step++ {
|
||||
if g.cancel != nil {
|
||||
select {
|
||||
case <-g.cancel:
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
if step%fill == 0 {
|
||||
g.FillDepressions(h, 1e-3)
|
||||
}
|
||||
g.ComputeReceivers(h)
|
||||
g.BuildStack()
|
||||
g.Accumulate()
|
||||
if p.MFDExponent > 0 {
|
||||
g.AccumulateMFD(h, p.MFDExponent)
|
||||
} else {
|
||||
g.Accumulate()
|
||||
}
|
||||
g.StreamPower(h, uplift, k, p)
|
||||
if p.CriticalSlope > 0 {
|
||||
// The clamp still runs, and it still has to: a belt rising at millimetres a year asks for slopes
|
||||
|
||||
@@ -18,7 +18,16 @@ import (
|
||||
// bucket, so what it leaves is pyramids with faces aligned to the grid — the blocky, ruler-cut facets that
|
||||
// are visible in any preview of a mountain belt here. Nothing about that is geology; it is the D8 stencil
|
||||
// printed onto the landscape. Nonlinear diffusion approaches the same limiting angle *asymptotically* and
|
||||
// through a symmetric five-point stencil, so there is no cut, no facet and no preferred direction.
|
||||
// through a symmetric stencil, so there is no cut, no facet and no preferred direction.
|
||||
//
|
||||
// The stencil is nine-point, and it has to be. Run's design is that the clamp cuts and this rounds off what
|
||||
// it cut before the next step sees it - but the clamp cuts along all eight neighbour directions and a
|
||||
// five-point stencil transports across four, so it cannot touch a diagonally-cut facet at all. That was not a
|
||||
// refinement, it was a hole in the stated design. The weights are 4/6 cardinal and 1/6 diagonal, which is the
|
||||
// isotropic nine-point Laplacian: on h = (a/2)(x^2+y^2) the cardinal faces sum to 2ad^2 and the diagonals to
|
||||
// 4ad^2, so (1/6)(8ad^2 + 4ad^2) = 2ad^2 = dx^2 * grad2(h), exactly what the five-point gave. coeff is
|
||||
// therefore unchanged. A diagonal face is sqrt(2) further away, so it carries its own critical height
|
||||
// difference; leaving that out would make every diagonal read as 1.41 times its true S/Sc.
|
||||
//
|
||||
// It is also mass-conserving, which the clamp is not: the flux out of one cell is the flux into its
|
||||
// neighbour by construction, so material shed from a divide arrives at the foot of the slope rather than
|
||||
@@ -57,6 +66,14 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
|
||||
dx := g.CellM
|
||||
dx2 := dx * dx
|
||||
|
||||
// The Courant number the sub-stepping aims for. The worst mode is the checkerboard: on the five-point
|
||||
// stencil its cardinal faces sum to -8*amp and the amplification is 1 - 8*coeff, stable to coeff 0.25;
|
||||
// on the nine-point the diagonals cancel and (4/6)*(-8*amp) leaves 1 - 5.333*coeff, stable to 0.375. Both
|
||||
// targets keep the same 1.25x margin under their own limit, and the extra room is most of what pays for
|
||||
// the four extra faces. Raising the target without the 4/6 and 1/6 weights, or adding the faces without
|
||||
// raising the target, is a scheme that checkerboards a few hundred steps in - which is the failure the
|
||||
// budget note below is about, and it does not announce itself.
|
||||
|
||||
// The steepest ground on the grid bounds D_eff for the whole call. Uplift is not applied in here and
|
||||
// diffusion only relaxes slopes, so nothing can get steeper part-way through and invalidate the bound.
|
||||
u := math.Min(g.maxSlopeRatio(h, sc), slopeCap)
|
||||
@@ -64,7 +81,7 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
|
||||
// What the sub-step budget can pay for. Lowering the cap rather than truncating the sub-step count is
|
||||
// what keeps this stable: a truncated count leaves alpha above 0.25 and the surface checkerboards a few
|
||||
// hundred steps later, which is precisely the sort of failure that does not announce itself.
|
||||
if budget := float64(maxSub) * 0.2 * dx2 / (d * dt); f > budget {
|
||||
if budget := float64(maxSub) * subTargetNine * dx2 / (d * dt); f > budget {
|
||||
f = budget
|
||||
u = invStiffness(f)
|
||||
}
|
||||
@@ -75,7 +92,7 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
|
||||
f = 1
|
||||
u = 0
|
||||
}
|
||||
sub := int(math.Ceil(d * f * dt / dx2 / 0.2))
|
||||
sub := int(math.Ceil(d * f * dt / dx2 / subTargetNine))
|
||||
if sub < 1 {
|
||||
sub = 1
|
||||
}
|
||||
@@ -87,6 +104,7 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
|
||||
// factor of dx too large, which pins every face against the cap and quietly turns the whole law into
|
||||
// linear diffusion with a constant multiplier.
|
||||
dhCrit := float32(sc * dx)
|
||||
dhCritDiag := float32(sc * dx * math.Sqrt2)
|
||||
|
||||
src := h
|
||||
tmp := g.scratch[:len(h)]
|
||||
@@ -100,13 +118,17 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
|
||||
continue
|
||||
}
|
||||
c := src[i]
|
||||
// The net inflow over the four faces. Each face is evaluated from both of its cells,
|
||||
// The net inflow over all eight faces. Each face is evaluated from both of its cells,
|
||||
// which costs twice and buys a gather: no two goroutines ever write the same cell.
|
||||
net := flux(clampAt(src, g.W, g.H, x-1, y)-c, dhCrit, uCap) +
|
||||
card := flux(clampAt(src, g.W, g.H, x-1, y)-c, dhCrit, uCap) +
|
||||
flux(clampAt(src, g.W, g.H, x+1, y)-c, dhCrit, uCap) +
|
||||
flux(clampAt(src, g.W, g.H, x, y-1)-c, dhCrit, uCap) +
|
||||
flux(clampAt(src, g.W, g.H, x, y+1)-c, dhCrit, uCap)
|
||||
tmp[i] = c + coeff*net
|
||||
diag := flux(clampAt(src, g.W, g.H, x-1, y-1)-c, dhCritDiag, uCap) +
|
||||
flux(clampAt(src, g.W, g.H, x+1, y-1)-c, dhCritDiag, uCap) +
|
||||
flux(clampAt(src, g.W, g.H, x-1, y+1)-c, dhCritDiag, uCap) +
|
||||
flux(clampAt(src, g.W, g.H, x+1, y+1)-c, dhCritDiag, uCap)
|
||||
tmp[i] = c + coeff*(nineCard*card+nineDiag*diag)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -114,6 +136,14 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
|
||||
}
|
||||
}
|
||||
|
||||
// The isotropic nine-point Laplacian's weights, and the Courant target its stability allows. See
|
||||
// DiffuseNonlinear.
|
||||
const (
|
||||
nineCard = float32(4.0 / 6.0)
|
||||
nineDiag = float32(1.0 / 6.0)
|
||||
subTargetNine = 0.3
|
||||
)
|
||||
|
||||
// flux is q/D for one face, in height differences rather than slopes: one factor of the cell spacing cancels
|
||||
// against the divergence and is carried in coeff instead. dhCrit is the height difference that corresponds to
|
||||
// Sc across one cell, so dh/dhCrit is exactly S/Sc. u is capped so the denominator cannot reach zero.
|
||||
@@ -152,9 +182,19 @@ func invStiffness(f float64) float64 {
|
||||
return lo
|
||||
}
|
||||
|
||||
// maxSlopeRatio is the steepest face on the grid as a fraction of Sc. Cardinal neighbours only, because those
|
||||
// are the faces the five-point stencil actually transports across.
|
||||
// maxSlopeRatio is the steepest face on the grid as a fraction of Sc, over every face the stencil transports
|
||||
// across - which since the stencil went to nine points means the diagonals too. A diagonal face is compared
|
||||
// against its own critical height difference, sqrt(2) larger, so what comes back is a slope ratio either way.
|
||||
//
|
||||
// What this number is for is worth being exact about, because it looks like physics and is not. It bounds the
|
||||
// stiffening for the whole call, and the flux law only caps a face when that face exceeds the bound - so on a
|
||||
// grid whose steepest face is the bound, no face is capped and the value has no effect on any cell. Its one
|
||||
// real job is to decide how many sub-steps the call pays for, which is a cost question. Where it does reach
|
||||
// the physics is when the sub-step budget cannot buy the grid's own maximum; the cap is then lowered to what
|
||||
// the budget affords, and that value is the manifest's - D, dt, the cell and MaxHillslopeSub - and not the
|
||||
// grid's, so a planet decomposed two ways still answers the same.
|
||||
func maxSlopeRatio(h []float32, w, hgt int, sc, cellM float64) float64 {
|
||||
invDiag := float32(1 / math.Sqrt2)
|
||||
var maxDiff float32
|
||||
for y := 0; y < hgt; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
@@ -164,6 +204,16 @@ func maxSlopeRatio(h []float32, w, hgt int, sc, cellM float64) float64 {
|
||||
if dv := abs32(h[i+1] - c); dv > maxDiff {
|
||||
maxDiff = dv
|
||||
}
|
||||
if y+1 < hgt {
|
||||
if dv := abs32(h[i+w+1]-c) * invDiag; dv > maxDiff {
|
||||
maxDiff = dv
|
||||
}
|
||||
}
|
||||
if y > 0 {
|
||||
if dv := abs32(h[i-w+1]-c) * invDiag; dv > maxDiff {
|
||||
maxDiff = dv
|
||||
}
|
||||
}
|
||||
}
|
||||
if y+1 < hgt {
|
||||
if dv := abs32(h[i+w] - c); dv > maxDiff {
|
||||
|
||||
@@ -152,7 +152,12 @@ func TestDiffuseNonlinearIsStable(t *testing.T) {
|
||||
field[i] = 100 // flat base level, the mean of the checkerboard
|
||||
}
|
||||
}
|
||||
for i := 0; i < 500; i++ {
|
||||
// Two thousand steps, not five hundred. The nine-point stencil damps the checkerboard more slowly per
|
||||
// step than the five-point did - the diagonal faces of a checkerboard are flat, so only the 4/6 of the
|
||||
// stencil facing the cardinals sees the mode at all - and it is run at a Courant target of 0.3 rather
|
||||
// than 0.2 because its stability limit is 0.375 rather than 0.25. Both of those are arguments on paper.
|
||||
// A slow instability takes hundreds of steps to show, and a solve runs a thousand.
|
||||
for i := 0; i < 2000; i++ {
|
||||
g.DiffuseNonlinear(field, 0.02, sc, 0.95, 1500, 24)
|
||||
}
|
||||
lo, hi := float32(math.Inf(1)), float32(math.Inf(-1))
|
||||
@@ -170,9 +175,9 @@ func TestDiffuseNonlinearIsStable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("after 500 steps the interior spans %.3f..%.3f m, from a 200 m checkerboard", lo, hi)
|
||||
t.Logf("after 2000 steps the interior spans %.3f..%.3f m, from a 200 m checkerboard", lo, hi)
|
||||
if hi-lo > 1 {
|
||||
t.Errorf("the checkerboard is still %.1f m after 500 steps: it is not being damped", hi-lo)
|
||||
t.Errorf("the checkerboard is still %.1f m after 2000 steps: it is not being damped", hi-lo)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package fluvial
|
||||
|
||||
import "salty/terrain/internal/world"
|
||||
|
||||
// Deterministic per-cell jitter, and why a router needs one.
|
||||
//
|
||||
// D8 lets a cell drain to one of eight neighbours, so every channel is a chain of 0, 45 and 90 degree
|
||||
@@ -9,28 +11,72 @@ package fluvial
|
||||
// flood's traversal geometry and draws it as rivers — ruler-straight diagonals, the polygonal network that
|
||||
// killed the first attempt at flat plains.
|
||||
//
|
||||
// The fix is to stop the epsilon being uniform. A hash of the cell index scatters it by plus or minus half,
|
||||
// which is far below anything that matters to the solve (a millimetre against metre-scale relief) and far
|
||||
// above the difference the flood's ordering would otherwise leave, so the descent direction on a flat is
|
||||
// decided by the hash rather than by scan order. The same hash breaks near-ties between two equally steep
|
||||
// neighbours, which is the other place a fixed direction order leaks a grid axis into the result.
|
||||
// The fix is to stop the epsilon being uniform. A hash scatters it by plus or minus half, which is far below
|
||||
// anything that matters to the solve (a millimetre against metre-scale relief) and far above the difference
|
||||
// the flood's ordering would otherwise leave, so the descent direction on a flat is decided by the hash
|
||||
// rather than by scan order. The same hash breaks near-ties between two equally steep neighbours, which is
|
||||
// the other place a fixed direction order leaks a grid axis into the result.
|
||||
//
|
||||
// It is a hash rather than a random source because cross-cutting rule 12 is determinism from a seed: the
|
||||
// value for a cell must not depend on how many cells were visited before it, on which goroutine ran, or on
|
||||
// how many steps the solve has taken.
|
||||
//
|
||||
// And it is a hash of a *world position* rather than of a grid index, which is rule 1 of the tiling plan in
|
||||
// Docs/Terrain-Next.md 3.3. A planet is solved one landmass at a time, so the same physical cell turns up in
|
||||
// grids of different widths at different offsets; keyed on the index it would jitter differently each time,
|
||||
// and every place two frames met would show it. Keyed on where the cell actually is, it cannot.
|
||||
|
||||
// hash01 is splitmix64 finalised to the unit interval. Cheap, no state, and well enough distributed that
|
||||
// neighbouring indices get unrelated values — which is the whole requirement here.
|
||||
func hash01(seed uint64, i int32) float32 {
|
||||
x := seed ^ (uint64(uint32(i)) * 0x9e3779b97f4a7c15)
|
||||
x ^= x >> 30
|
||||
x *= 0xbf58476d1ce4e5b9
|
||||
x ^= x >> 27
|
||||
x *= 0x94d049bb133111eb
|
||||
x ^= x >> 31
|
||||
return float32(x>>11) / float32(1<<53)
|
||||
// The k namespace. Every caller of hashXY picks a k, and two callers that share one get perfectly correlated
|
||||
// jitter - the clamp's allowance would track the router's tie-break in the same direction, which is exactly
|
||||
// the kind of hidden coupling that prints a texture nobody can attribute. They are named here so a new
|
||||
// caller has to pick a free one.
|
||||
const (
|
||||
jitterFloodEpsilon int32 = 0 // the priority-flood's per-cell fall across a flat (fluvial.go)
|
||||
jitterReceiverTie int32 = 1 // .. 8, one per D8 direction: the steepest-neighbour tie-break (fluvial.go)
|
||||
jitterReposeAllow int32 = 9 // .. 16, one per D8 direction: the repose clamp's allowance (repose.go)
|
||||
jitterReposeOrder int32 = 17 // the repose clamp's pop order (repose.go)
|
||||
)
|
||||
|
||||
// hashXY is splitmix64's finaliser over a weighted sum of the seed and the position. One finalising round,
|
||||
// because this is called eight times per cell per step - several hundred billion times over a planet bake -
|
||||
// and the requirement is only that neighbouring cells get unrelated values, not cryptographic quality. The
|
||||
// three odd constants are summed rather than exclusive-ored so that swapping x and y does not collide.
|
||||
func hashXY(seed uint64, x, y, k int32) float32 {
|
||||
h := seed ^ (uint64(uint32(x))*0x9e3779b97f4a7c15 +
|
||||
uint64(uint32(y))*0xc2b2ae3d27d4eb4f +
|
||||
uint64(uint32(k))*0x165667b19e3779f9)
|
||||
h ^= h >> 30
|
||||
h *= 0xbf58476d1ce4e5b9
|
||||
h ^= h >> 27
|
||||
h *= 0x94d049bb133111eb
|
||||
h ^= h >> 31
|
||||
return float32(h>>11) / float32(1<<53)
|
||||
}
|
||||
|
||||
// SetSeed ties the jitter to the run's seed, so two seeds do not share the same flat-routing geometry.
|
||||
// Zero is a perfectly good seed; it is the default and nothing depends on it being set.
|
||||
func (g *Grid) SetSeed(seed int64) { g.seed = uint64(seed)*0x9e3779b97f4a7c15 + 0x243f6a8885a308d3 }
|
||||
|
||||
// SetFrame says where on the planet this grid sits, which is what turns the jitter from an index hash into
|
||||
// a position hash. Without it a grid is its own world at the origin, which is what the square canvas is and
|
||||
// what every existing test expects, so it is optional and NewGrid does not require it.
|
||||
func (g *Grid) SetFrame(f world.Frame) {
|
||||
g.originX = int32(f.P.WrapX(f.X0))
|
||||
g.originY = int32(f.Y0)
|
||||
g.planetW = int32(f.P.W)
|
||||
}
|
||||
|
||||
// worldX and worldY map a grid cell to its planet cell.
|
||||
//
|
||||
// The wrap is a compare and a subtract rather than a modulo on purpose: originX is already inside the
|
||||
// planet and x is less than the planet's width, so the sum overshoots by at most one turn. A modulo here
|
||||
// would be a division in the router's innermost loop.
|
||||
func (g *Grid) worldX(x int) int32 {
|
||||
v := g.originX + int32(x)
|
||||
if g.planetW > 0 && v >= g.planetW {
|
||||
v -= g.planetW
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (g *Grid) worldY(y int) int32 { return g.originY + int32(y) }
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package fluvial
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// The whole point of the move from an index hash to a position hash: a planet is solved one landmass at a
|
||||
// time, so the same physical cell turns up in grids of different widths at different offsets. If the jitter
|
||||
// disagreed between them, every place two frames met would show a line.
|
||||
func TestJitterFollowsThePositionNotTheIndex(t *testing.T) {
|
||||
p, err := world.New(512, 8, 100, 50, 2, 512)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Two frames of different widths, both covering planet column 5, row 7.
|
||||
a := Grid{W: 16, H: 16}
|
||||
a.SetSeed(11)
|
||||
a.SetFrame(world.Frame{P: p, X0: 0, Y0: 0, W: 16, H: 16})
|
||||
|
||||
b := Grid{W: 9, H: 12}
|
||||
b.SetSeed(11)
|
||||
b.SetFrame(world.Frame{P: p, X0: 3, Y0: 4, W: 9, H: 12})
|
||||
|
||||
for k := int32(0); k < 9; k++ {
|
||||
ja := hashXY(a.seed, a.worldX(5), a.worldY(7), k)
|
||||
jb := hashXY(b.seed, b.worldX(2), b.worldY(3), k)
|
||||
if ja != jb {
|
||||
t.Fatalf("k=%d: frame a gives %v, frame b gives %v for the same planet cell", k, ja, jb)
|
||||
}
|
||||
}
|
||||
|
||||
// And it must still be a hash: the neighbouring cell gets an unrelated value.
|
||||
if hashXY(a.seed, a.worldX(5), a.worldY(7), 0) == hashXY(a.seed, a.worldX(6), a.worldY(7), 0) {
|
||||
t.Error("neighbouring cells hash the same")
|
||||
}
|
||||
}
|
||||
|
||||
// A frame that straddles the seam sees the same positions as one that does not.
|
||||
func TestJitterWrapsAtTheSeam(t *testing.T) {
|
||||
p, err := world.New(512, 8, 100, 50, 0, 512)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := Grid{W: 8, H: 8}
|
||||
at.SetSeed(3)
|
||||
at.SetFrame(world.Frame{P: p, X0: 60, Y0: 0, W: 8, H: 8})
|
||||
origin := Grid{W: 8, H: 8}
|
||||
origin.SetSeed(3)
|
||||
origin.SetFrame(world.Frame{P: p, X0: 0, Y0: 0, W: 8, H: 8})
|
||||
|
||||
// The seam frame's column 4 is planet column 0, which is the origin frame's column 0.
|
||||
if got, want := at.worldX(4), origin.worldX(0); got != want {
|
||||
t.Fatalf("world column = %d, want %d", got, want)
|
||||
}
|
||||
if hashXY(at.seed, at.worldX(4), at.worldY(2), 0) != hashXY(origin.seed, origin.worldX(0), origin.worldY(2), 0) {
|
||||
t.Error("the same planet cell jitters differently on either side of the seam")
|
||||
}
|
||||
}
|
||||
|
||||
// Without a frame a grid is its own world at the origin, which is what the square canvas is and what every
|
||||
// existing test relies on.
|
||||
func TestNoFrameMeansTheGridIsTheWorld(t *testing.T) {
|
||||
g := Grid{W: 8, H: 8}
|
||||
g.SetSeed(1)
|
||||
if got := g.worldX(7); got != 7 {
|
||||
t.Errorf("worldX(7) = %d, want 7", got)
|
||||
}
|
||||
if got := g.worldY(3); got != 3 {
|
||||
t.Errorf("worldY(3) = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package fluvial
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// Multiple-flow-direction drainage area: Freeman, Quinn and Holmgren's partition, and the answer to the one
|
||||
// thing D8 cannot do.
|
||||
//
|
||||
// A planar hillslope is where D8 fails, and it fails in closed form. The specific catchment area on a plane
|
||||
// is the distance from the divide and it is the same at every point along a contour, because nothing about a
|
||||
// plane tells one flow line from its neighbour. D8 has to disagree: every cell picks the same steepest
|
||||
// neighbour, so the flow lines run exactly parallel and never converge, and a cell either sits on a line and
|
||||
// carries the whole tube or sits off one and carries a single cell for ever. Measured on a ramp at an aspect
|
||||
// of 22.5 degrees, the most-drained cell in a contour band carries 769 times the median and 30 % of the grid
|
||||
// drains nothing at all (flow_test.go). Stream power then reads A^m off that and cuts each line in, which is
|
||||
// what a bake's mountain flanks were: a comb of ruler-straight parallel grooves, one per surviving line,
|
||||
// spaced by the mean distance between the merges the router's tie-break jitter happened to allow - a spacing
|
||||
// in cells, which is why it measured the same eighteen cells at an 8 m cell and at a 32 m one.
|
||||
//
|
||||
// The partition below splits a cell's area among every downslope neighbour by (dh_k * q_k)^p, where q_k is
|
||||
// the share of the cell's perimeter facing direction k divided by the centre-to-centre distance: 0.5 for a
|
||||
// cardinal neighbour, 0.25 for a diagonal. The cell size cancels out of the ratio, so the weights are height
|
||||
// differences times a constant - no division and no transcendental in the inner loop at p = 1.
|
||||
//
|
||||
// It replaces Accumulate and it replaces only that. Receiver, Length and Stack stay D8, because
|
||||
// Braun-Willett's implicit update walks one receiver chain and there is no multi-receiver form of it that is
|
||||
// still unconditionally stable. Stream power therefore incises along the steepest path using the area that
|
||||
// actually converges there. That pairing is deliberate and it is the standard one; it is not an oversight.
|
||||
|
||||
// mfdPow selects how the partition quantity is raised to p, once per call rather than once per cell. p is
|
||||
// almost always 1, where the whole thing is a multiply.
|
||||
type mfdPow uint8
|
||||
|
||||
const (
|
||||
mfdP1 mfdPow = iota
|
||||
mfdP2
|
||||
mfdP3
|
||||
mfdP4
|
||||
mfdGeneral
|
||||
)
|
||||
|
||||
func mfdModeFor(p float64) (mfdPow, float64) {
|
||||
switch {
|
||||
case math.Abs(p-1) < 1e-9:
|
||||
return mfdP1, 1
|
||||
case math.Abs(p-2) < 1e-9:
|
||||
return mfdP2, 2
|
||||
case math.Abs(p-3) < 1e-9:
|
||||
return mfdP3, 3
|
||||
case math.Abs(p-4) < 1e-9:
|
||||
return mfdP4, 4
|
||||
default:
|
||||
return mfdGeneral, p
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Grid) mfdRaise(v float32) float32 {
|
||||
switch g.mfdMode {
|
||||
case mfdP1:
|
||||
return v
|
||||
case mfdP2:
|
||||
return v * v
|
||||
case mfdP3:
|
||||
return v * v * v
|
||||
case mfdP4:
|
||||
v2 := v * v
|
||||
return v2 * v2
|
||||
default:
|
||||
return float32(math.Pow(float64(v), g.mfdExp))
|
||||
}
|
||||
}
|
||||
|
||||
// mfdQ is the perimeter share facing each D8 direction divided by the distance to it, in the order of dx8
|
||||
// and dy8: NW N NE W E SW S SE. Cardinal 0.5, diagonal 0.25.
|
||||
var mfdQ = [8]float32{0.25, 0.5, 0.25, 0.5, 0.5, 0.25, 0.5, 0.25}
|
||||
|
||||
// AccumulateMFD fills Area with multiple-flow drainage area, in m².
|
||||
//
|
||||
// The order is Kahn's algorithm over the flow graph rather than a sort by elevation, and neither of the two
|
||||
// obvious alternatives works. The D8 stack cannot be reused: BuildStack is a depth-first walk of the donor
|
||||
// tree, so a deep node of one subtree precedes a shallow node of the next and the order is not descending in
|
||||
// elevation - a cell would send area to a neighbour that had already been processed, and the loss would fall
|
||||
// on the flanks, which is exactly where it cannot be afforded. A bucket sort cannot either: the queue
|
||||
// quantises to a centimetre while the flood's epsilon ladder across a filled flat is a millimetre a cell, so
|
||||
// ten cells of one descending chain share a bucket and a lake bed would leak its area.
|
||||
//
|
||||
// Kahn needs no elevation comparison at all. mfdPending[i] is how many strictly higher neighbours i still
|
||||
// owes; a cell is ready when the count reaches zero. Because "strictly lower" is a strict order the graph is
|
||||
// acyclic, so every cell is released exactly once - which is asserted, because the alternative is a drainage
|
||||
// area that is quietly too small in a two-hour bake.
|
||||
//
|
||||
// The counting pass is inside this function and not folded into ComputeReceivers, which already reads all
|
||||
// eight neighbours and could have produced it for nothing. It was, and it was wrong: the walk *consumes* the
|
||||
// counts, so a second call without an intervening ComputeReceivers seeded its whole queue at once and
|
||||
// returned a drainage area that was silently wrong rather than panicking. Run happens to call the two in
|
||||
// lockstep, so nothing would have caught it there. A pass that owns its own preconditions cannot be misused
|
||||
// that way, and this one is a pure gather, so it parallelises and costs almost nothing in wall clock.
|
||||
func (g *Grid) AccumulateMFD(h []float32, p float64) {
|
||||
n := g.W * g.H
|
||||
g.mfdMode, g.mfdExp = mfdModeFor(p)
|
||||
|
||||
field.Rows(g.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < g.W; x++ {
|
||||
i := y*g.W + x
|
||||
pend := uint8(0)
|
||||
for k := 0; k < 8; k++ {
|
||||
nx, ny := x+dx8[k], y+dy8[k]
|
||||
if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H {
|
||||
continue
|
||||
}
|
||||
// How many neighbours will hand this cell a share: the ones strictly above it. The
|
||||
// weights below skip a neighbour when hn >= hc, so c sends to n exactly when
|
||||
// h[c] > h[n] - the same predicate, and it has to stay the same one or the walk ends
|
||||
// short.
|
||||
if h[ny*g.W+nx] > h[i] {
|
||||
pend++
|
||||
}
|
||||
}
|
||||
g.mfdPending[i] = pend
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cell := float32(g.CellM * g.CellM)
|
||||
for i := range g.Area {
|
||||
g.Area[i] = cell
|
||||
}
|
||||
|
||||
if cap(g.mfdQueue) < n {
|
||||
g.mfdQueue = make([]int32, 0, n)
|
||||
}
|
||||
q := g.mfdQueue[:0]
|
||||
for i := 0; i < n; i++ {
|
||||
if g.mfdPending[i] == 0 {
|
||||
q = append(q, int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
// p = 1 is the default and it is a multiply; hoisting the mode test out of the cell loop saves a call
|
||||
// and a switch on every one of the eight faces of every cell of every step.
|
||||
linear := g.mfdMode == mfdP1
|
||||
|
||||
var wgt [8]float32
|
||||
for read := 0; read < len(q); read++ {
|
||||
c := q[read]
|
||||
cx, cy := int(c)%g.W, int(c)/g.W
|
||||
hc := h[c]
|
||||
var total float32
|
||||
for k := 0; k < 8; k++ {
|
||||
nx, ny := cx+dx8[k], cy+dy8[k]
|
||||
if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H {
|
||||
wgt[k] = 0
|
||||
continue
|
||||
}
|
||||
// The same predicate the counting pass above used, written the same way round, because the
|
||||
// counts and these weights have to agree cell for cell or the walk ends short.
|
||||
hn := h[ny*g.W+nx]
|
||||
if hn >= hc {
|
||||
wgt[k] = 0
|
||||
continue
|
||||
}
|
||||
dh := hc - hn
|
||||
w := dh * mfdQ[k]
|
||||
if !linear {
|
||||
w = g.mfdRaise(w)
|
||||
}
|
||||
wgt[k] = w
|
||||
total += w
|
||||
}
|
||||
// A fixed cell is base level: it absorbs what arrives and sends nothing on. It still has to release
|
||||
// the cells below it, or their counts would never reach zero and the walk would end short - which is
|
||||
// why the release
|
||||
// loop below is not inside the `total > 0` branch.
|
||||
share := float32(0)
|
||||
if total > 0 && !g.fixed[c] {
|
||||
share = g.Area[c] / total
|
||||
}
|
||||
for k := 0; k < 8; k++ {
|
||||
if wgt[k] == 0 {
|
||||
continue
|
||||
}
|
||||
ni := int32((cy+dy8[k])*g.W + cx + dx8[k])
|
||||
if share > 0 {
|
||||
g.Area[ni] += share * wgt[k]
|
||||
}
|
||||
g.mfdPending[ni]--
|
||||
if g.mfdPending[ni] == 0 {
|
||||
q = append(q, ni)
|
||||
}
|
||||
}
|
||||
}
|
||||
g.mfdQueue = q
|
||||
|
||||
if len(q) != n {
|
||||
// Unreachable unless the pending counts and the weights disagree about which neighbours are lower,
|
||||
// which would mean area silently going missing. Loud is the only useful behaviour here.
|
||||
panic("fluvial: MFD released " + itoa(len(q)) + " of " + itoa(n) + " cells; the pending counts and " +
|
||||
"the downslope test disagree")
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for v > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package fluvial
|
||||
|
||||
import "testing"
|
||||
|
||||
// What the two accumulators cost per cell, which is the number a bake's wall clock is spent against. Both are
|
||||
// measured on the same surface with the receivers and the stack already built, because those are shared.
|
||||
func benchAccumulate(b *testing.B, mfd bool) {
|
||||
const n = 1024
|
||||
h := planarRamp(n, 8.0, 0.1, 22.5)
|
||||
g := NewGrid(n, n, 8.0, nil)
|
||||
g.SetSeed(37125)
|
||||
g.FillDepressions(h, 1e-3)
|
||||
g.ComputeReceivers(h)
|
||||
g.BuildStack()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if mfd {
|
||||
g.AccumulateMFD(h, 1)
|
||||
} else {
|
||||
g.Accumulate()
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/float64(n*n), "ns/cell")
|
||||
}
|
||||
|
||||
func BenchmarkAccumulateD8(b *testing.B) { benchAccumulate(b, false) }
|
||||
func BenchmarkAccumulateMFD(b *testing.B) { benchAccumulate(b, true) }
|
||||
@@ -30,9 +30,16 @@ func (g *Grid) ClampToRepose(h []float32, talus float64) float64 {
|
||||
for i := range g.closed {
|
||||
g.closed[i] = false
|
||||
}
|
||||
// Pushed with a jittered bucket, not a plain one. The constraint this pass imposes is isotropic; the
|
||||
// order it imposed it in was not. Every cell went in in flat-index order and the queue pops last-in
|
||||
// first-out within a bucket, so on ground flat to within a centimetre - which is most of a hillside -
|
||||
// cells popped bottom-right to top-left, and whichever popped first decided which of its neighbours got
|
||||
// cut. That is where the grid-aligned pyramid faces came from, and it is one hash away from not being
|
||||
// there. See bucketpq.go.
|
||||
g.pq.reset()
|
||||
for i := 0; i < n; i++ {
|
||||
g.pq.push(h[i], int32(i))
|
||||
x, y := i%g.W, i/g.W
|
||||
g.pq.pushJittered(h[i], int32(i), (hashXY(g.seed, g.worldX(x), g.worldY(y), jitterReposeOrder)-0.5)*2*reposeOrderBuckets)
|
||||
}
|
||||
|
||||
card := talus * g.CellM
|
||||
@@ -62,11 +69,16 @@ func (g *Grid) ClampToRepose(h []float32, talus float64) float64 {
|
||||
if dx8[k] != 0 && dy8[k] != 0 {
|
||||
allow = diag
|
||||
}
|
||||
// The same tie-break ComputeReceivers uses and for the same reason: a fixed allowance resolves
|
||||
// every near-tie the same way and prints its preferred axis. A tenth of a percent, keyed on the
|
||||
// cell being cut, so what a cell is allowed in a direction does not depend on which neighbour
|
||||
// reached it first.
|
||||
allow *= 1 + 1e-3*(float64(hashXY(g.seed, g.worldX(nx), g.worldY(ny), int32(k)+jitterReposeAllow))-0.5)
|
||||
limit := h[c] + float32(allow)
|
||||
if h[ni] > limit {
|
||||
removed += float64(h[ni] - limit)
|
||||
h[ni] = limit
|
||||
g.pq.push(limit, ni)
|
||||
g.pq.pushJittered(limit, ni, (hashXY(g.seed, g.worldX(nx), g.worldY(ny), jitterReposeOrder)-0.5)*2*reposeOrderBuckets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,3 +48,115 @@ func TestClampToReposeCutsACone(t *testing.T) {
|
||||
t.Errorf("steepest slope %.3f exceeds repose %.3f", worst, talus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampToReposeIsIsotropic asks what shape is left, which the test above cannot: a four-sided pyramid
|
||||
// satisfies "no slope exceeds repose" exactly, so the constraint check says nothing about whether the clamp
|
||||
// cut a cone or cut a pyramid.
|
||||
//
|
||||
// Clamp a cone far above repose, then for each of 360 azimuths find by bisection the radius at which the
|
||||
// surface falls through a fixed height. A cone gives a constant radius; the amplitudes of the four-fold and
|
||||
// eight-fold Fourier components of that radius, as a fraction of its mean, say how far from one it is.
|
||||
//
|
||||
// What the numbers turn out to be, and what they are not. Measured 0.97 % four-fold and 2.39 % eight-fold -
|
||||
// and *identical* with the pop-order jitter, with the allowance jitter, with both and with neither. On a cone
|
||||
// no two cells share a bucket, because the surface falls twenty metres a cell against a one-centimetre
|
||||
// bucket, so the ordering bias this file's jitter removes has nothing to bite on here. The residual is
|
||||
// geometry: a path to a point at 22.5 degrees has to be built of cardinal and diagonal steps, and the octile
|
||||
// distance it accumulates exceeds the straight line by up to 8 %, so an eight-connected clamp cuts an
|
||||
// octagon out of a cone whatever order it works in. That is irreducible without a wider neighbourhood, and
|
||||
// the thresholds below sit above it: this test guards against a regression to something far worse, and
|
||||
// TestBucketPQDoesNotPreferRasterOrder is what actually holds the ordering honest.
|
||||
func TestClampToReposeIsIsotropic(t *testing.T) {
|
||||
const (
|
||||
w, h = 201, 201
|
||||
cellM = 10.0
|
||||
talus = 0.4
|
||||
level = 300.0
|
||||
)
|
||||
field := make([]float32, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
d := math.Hypot(float64(x-w/2), float64(y-h/2)) * cellM
|
||||
field[y*w+x] = float32(math.Max(0, 2000-2.0*d))
|
||||
}
|
||||
}
|
||||
g := NewGrid(w, h, cellM, make([]bool, w*h))
|
||||
g.SetSeed(37125)
|
||||
g.SetElevationRange(-100, 4000)
|
||||
g.ClampToRepose(field, talus)
|
||||
|
||||
at := func(fx, fy float64) float64 { // bilinear, in cells
|
||||
x0, y0 := int(fx), int(fy)
|
||||
if x0 < 0 || y0 < 0 || x0 >= w-1 || y0 >= h-1 {
|
||||
return 0
|
||||
}
|
||||
tx, ty := fx-float64(x0), fy-float64(y0)
|
||||
return (1-ty)*((1-tx)*float64(field[y0*w+x0])+tx*float64(field[y0*w+x0+1])) +
|
||||
ty*((1-tx)*float64(field[(y0+1)*w+x0])+tx*float64(field[(y0+1)*w+x0+1]))
|
||||
}
|
||||
|
||||
const rays = 360
|
||||
var sum, c4r, c4i, c8r, c8i float64
|
||||
for i := 0; i < rays; i++ {
|
||||
th := 2 * math.Pi * float64(i) / rays
|
||||
cs, sn := math.Cos(th), math.Sin(th)
|
||||
lo, hi := 0.0, float64(w/2-2)
|
||||
for n := 0; n < 40; n++ { // bisect on the radius where the surface crosses `level`
|
||||
mid := (lo + hi) / 2
|
||||
if at(float64(w/2)+mid*cs, float64(h/2)+mid*sn) > level {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
r := (lo + hi) / 2
|
||||
sum += r
|
||||
c4r += r * math.Cos(4*th)
|
||||
c4i += r * math.Sin(4*th)
|
||||
c8r += r * math.Cos(8*th)
|
||||
c8i += r * math.Sin(8*th)
|
||||
}
|
||||
a4 := 2 * math.Hypot(c4r, c4i) / sum
|
||||
a8 := 2 * math.Hypot(c8r, c8i) / sum
|
||||
t.Logf("clamped cone: mean radius %.2f cells, four-fold %.2f%%, eight-fold %.2f%%",
|
||||
sum/rays, a4*100, a8*100)
|
||||
if a4 > 0.02 || a8 > 0.04 {
|
||||
t.Errorf("the clamped cone is %.2f%% four-fold and %.2f%% eight-fold against 0.97 and 2.39 measured: "+
|
||||
"it is a pyramid, not an octagon", a4*100, a8*100)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBucketPQDoesNotPreferRasterOrder is the unit underneath it. Pushed plain, cells at one elevation come
|
||||
// back in exactly reverse insertion order, which is a Spearman correlation of -1.
|
||||
func TestBucketPQDoesNotPreferRasterOrder(t *testing.T) {
|
||||
const n = 4096
|
||||
order := func(jitter bool) float64 {
|
||||
q := newBucketPQ(0, 100)
|
||||
for i := 0; i < n; i++ {
|
||||
if jitter {
|
||||
q.pushJittered(50, int32(i), (hashXY(1, int32(i%64), int32(i/64), jitterReposeOrder)-0.5)*2*reposeOrderBuckets)
|
||||
} else {
|
||||
q.push(50, int32(i))
|
||||
}
|
||||
}
|
||||
var sum float64
|
||||
for pos := 0; pos < n; pos++ {
|
||||
idx := float64(q.pop())
|
||||
sum += (float64(pos) - float64(n-1)/2) * (idx - float64(n-1)/2)
|
||||
}
|
||||
var varr float64
|
||||
for i := 0; i < n; i++ {
|
||||
d := float64(i) - float64(n-1)/2
|
||||
varr += d * d
|
||||
}
|
||||
return sum / varr
|
||||
}
|
||||
plain, jittered := order(false), order(true)
|
||||
t.Logf("pop order against flat index: plain %.3f, jittered %.3f", plain, jittered)
|
||||
if plain > -0.99 {
|
||||
t.Errorf("plain push no longer pops in reverse insertion order (%.3f); this test's premise is gone", plain)
|
||||
}
|
||||
if math.Abs(jittered) > 0.05 {
|
||||
t.Errorf("jittered push still correlates with flat index at %.3f", jittered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package manifest
|
||||
|
||||
import "testing"
|
||||
|
||||
// The shelf break is the one number that decides how deep the near-shore sea is, and for as long as it was
|
||||
// not a key it was read off `continent.sea_floor_m.hi()` — a square-canvas number, -30 m, because a real
|
||||
// margin does not fit on a 14.28 km canvas. A painted planet inherited it in silence.
|
||||
//
|
||||
// What that cost is worth writing down, because no test and no printed statistic could see it: the derived
|
||||
// margin reaches `shelf_km.hi() + slope_km` = 4.6 km from every shore, and the first template has 1069 km of
|
||||
// shoreline against a 3100 km² sea, so the margin covers the whole ocean. The painting said 512 m; 40 % of
|
||||
// the planet came out between 0 and 30 m and the shelf halo around every landmass encoded to the same grey
|
||||
// as the land, which is what "it is just a landmass and no oceans really" looks like from the outside.
|
||||
//
|
||||
// So these tests are about provenance rather than about arithmetic: a planet must name its own break depth,
|
||||
// and must not be able to acquire the square canvas's by default.
|
||||
|
||||
func TestPlanetDoesNotInheritTheSquareCanvasShelfBreak(t *testing.T) {
|
||||
m := Defaults()
|
||||
m.Planet = &Planet{}
|
||||
m.fillPlanetDefaults()
|
||||
|
||||
canvas := -m.Pipeline.Continent.SeaFloorM.Hi()
|
||||
if got := m.ShelfBreakM(); got == canvas {
|
||||
t.Fatalf("a planet's shelf break is %.0f m, the square canvas's own number; it must not be "+
|
||||
"inherited from continent.sea_floor_m", got)
|
||||
}
|
||||
// And it is a shelf break rather than a puddle: deeper than the shallowest thing an author paints.
|
||||
if got := m.ShelfBreakM(); got < 100 {
|
||||
t.Errorf("a planet's default shelf break is %.0f m; a continental shelf breaks at a hundred "+
|
||||
"metres and more, and anything shallower makes the painted ocean unreachable", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The square canvas keeps what it had. Its sea floor is a range and the break is the shallow end of it, so
|
||||
// the fallback reads that rather than inventing a key for a manifest written without one.
|
||||
func TestSquareCanvasShelfBreakIsUnchanged(t *testing.T) {
|
||||
m := Defaults()
|
||||
if m.IsPlanet() {
|
||||
t.Fatal("Defaults() should not be a planet")
|
||||
}
|
||||
if got, want := m.ShelfBreakM(), -m.Pipeline.Continent.SeaFloorM.Hi(); got != want {
|
||||
t.Errorf("square canvas shelf break %.0f m, want %.0f m", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit key wins everywhere, which is what makes the flag override and a hand-written manifest work.
|
||||
func TestExplicitShelfBreakWins(t *testing.T) {
|
||||
for _, planet := range []bool{false, true} {
|
||||
m := Defaults()
|
||||
if planet {
|
||||
m.Planet = &Planet{}
|
||||
}
|
||||
m.Pipeline.Coast.BreakM = 275
|
||||
if planet {
|
||||
m.fillPlanetDefaults()
|
||||
}
|
||||
if got := m.ShelfBreakM(); got != 275 {
|
||||
t.Errorf("planet=%v: shelf break %.0f m, want the 275 m asked for", planet, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The margin's reach is what makes the break depth matter: everything nearer than this to a shore is the
|
||||
// derived profile and the painting is not consulted, so a sea narrower than twice it never reaches the depth
|
||||
// it was painted. Pinned so that widening the shelf is a deliberate act with this arithmetic in view.
|
||||
func TestDerivedMarginReachIsBoundedAndKnown(t *testing.T) {
|
||||
m := Defaults()
|
||||
m.Planet = &Planet{}
|
||||
m.fillPlanetDefaults()
|
||||
|
||||
c := m.Pipeline.Coast
|
||||
reach := c.ShelfKm.Hi() + c.SlopeKm
|
||||
if reach > 5 {
|
||||
t.Errorf("the derived margin reaches %.1f km from every shore; past about 5 km it swallows the "+
|
||||
"straits of a 100 km planet and the painted depths stop meaning anything", reach)
|
||||
}
|
||||
if c.ShelfKm.Lo() <= 0 || c.ShelfKm.Lo() >= c.ShelfKm.Hi() {
|
||||
t.Errorf("shelf width range %.1f..%.1f km is not an increasing positive range",
|
||||
c.ShelfKm.Lo(), c.ShelfKm.Hi())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"salty/terrain/internal/plates"
|
||||
)
|
||||
|
||||
// The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m.
|
||||
@@ -110,6 +112,18 @@ type Fluvial struct {
|
||||
CriticalSlopeDeg float64 `json:"critical_slope_deg"`
|
||||
SlopeCap float64 `json:"slope_cap"`
|
||||
MaxHillslopeSub int `json:"max_hillslope_substeps"`
|
||||
|
||||
// MFDExponent is the exponent on the multiple-flow-direction partition of drainage area. 0 goes back to
|
||||
// D8's single receiver, which is what every bake before this one ran. See internal/fluvial/mfd.go.
|
||||
MFDExponent float64 `json:"mfd_exponent"`
|
||||
}
|
||||
|
||||
// Smooth is the edge-preserving pass that runs once after the solve. It is a filter, not a process, and it
|
||||
// is off by default: 0 passes. See internal/field/smooth.go for why it cannot go inside the step loop, and
|
||||
// Docs/Terrain-Next.md for the statistics a run with it on has to match a run with it off.
|
||||
type Smooth struct {
|
||||
Passes int `json:"passes"`
|
||||
SlopeRef float64 `json:"slope_ref"` // rise over run; ground steeper than this is preserved
|
||||
}
|
||||
|
||||
type Thermal struct {
|
||||
@@ -127,12 +141,43 @@ type Strata struct {
|
||||
type Detail struct {
|
||||
Octaves int `json:"octaves"`
|
||||
AmplitudeM Range `json:"amplitude_m"`
|
||||
|
||||
// ClassBlendM is how far a painted class's detail numbers fade into its neighbour's.
|
||||
//
|
||||
// The class *index* is never interpolated - a class is a name - but the numbers it stands for are
|
||||
// quantities, and a boundary somebody drew with a mouse should not be a step in the ground. At 0 it is a
|
||||
// step, which is what it was before: seven metres of dune amplitude to two in the width of one cell.
|
||||
ClassBlendM float64 `json:"class_blend_m"`
|
||||
|
||||
// SeabedM is how deep the detail texture reaches below the waterline, and the reason it is not zero is
|
||||
// that a coast where the land is rough and the water is glass reads as a cut-out rather than as a shore.
|
||||
// The amplitude fades in from nothing at the waterline - a few metres of noise there turns the shallows
|
||||
// into a scatter of one-cell islands - and back out to nothing at this depth.
|
||||
SeabedM float64 `json:"seabed_m"`
|
||||
|
||||
// TilePx is the interior side of a detail tile, in detail cells. It must divide the planet's width in
|
||||
// geology cells once divided by geology_factor, because X wraps and a tile grid that did not come out
|
||||
// whole would leave the last tile overlapping the first by an arbitrary amount.
|
||||
TilePx int `json:"tile_px"`
|
||||
}
|
||||
|
||||
// Particle is the droplet block, demoted by D-47 from "carves the valleys" to detail only. Every brake in it
|
||||
// was learned the hard way; see Docs/Terrain.md.
|
||||
type Particle struct {
|
||||
Droplets int `json:"droplets"`
|
||||
Droplets int `json:"droplets"`
|
||||
|
||||
// DropletsPerCell is what the tiled detail pass uses instead of Droplets, because a tile does not know
|
||||
// how big the world is and must not: a cell has to spawn the same droplets whichever tile it falls in.
|
||||
// 0.18 is the density Droplets 9e6 at 7141 squared comes to, which is the density the numpy was tuned at.
|
||||
DropletsPerCell float64 `json:"droplets_per_cell"`
|
||||
|
||||
// Rounds is how many passes the droplets are split into. Within a round they read the height as it was
|
||||
// when it began, so this is what lets a channel deepen as more water follows it; the numpy got the same
|
||||
// effect from its batch size, and this is that number expressed so it does not depend on how big a piece
|
||||
// of the world is being worked on. A tile and the whole map must agree about which round a droplet is in
|
||||
// or the seams would not close.
|
||||
Rounds int `json:"rounds"`
|
||||
|
||||
Lifetime int `json:"lifetime"`
|
||||
Scale float64 `json:"scale"`
|
||||
MinErodeSlope float64 `json:"min_erode_slope"`
|
||||
@@ -190,7 +235,18 @@ type Coast struct {
|
||||
// range that this replaces was neither. ShelfKm is a range because the shelf width is not a constant:
|
||||
// it is wide off a low coastal plain and narrow off a mountain range that comes down to the water, so it
|
||||
// is interpolated per stretch of shore by the relief standing behind that stretch.
|
||||
ShelfKm Range `json:"shelf_km"`
|
||||
ShelfKm Range `json:"shelf_km"`
|
||||
// BreakM is the depth at the shelf break, in positive metres: how deep the water is where the gentle
|
||||
// shelf ends and the continental slope begins. It is the one number that decides how deep the near-shore
|
||||
// sea *is*, and until D-64 it was not a key at all - it was read off `continent.sea_floor_m.hi()`, whose
|
||||
// default is -30 because the square canvas is 14.28 km a side and a real margin does not fit on it.
|
||||
// Applied unchanged to a 100 km painted planet that reads as no ocean at all: the derived margin is up to
|
||||
// 3 km of shelf and 1.6 km of slope, 1069 km of shoreline carries 4900 km2 of it against a 3100 km2 sea,
|
||||
// so the margin covers the whole ocean and pins it between 0 and 30 m whatever the author painted. Zero
|
||||
// keeps the old behaviour, which is what the square canvas wants; a planet gets 130 m from
|
||||
// fillPlanetDefaults, and the first template's own `shelf` class is 120 m, which is the same number by
|
||||
// the other route. The break can never be deeper than the water it is a break in - see layShelf.
|
||||
BreakM float64 `json:"break_m"`
|
||||
SteepCoastM float64 `json:"steep_coast_m"`
|
||||
SlopeKm float64 `json:"slope_km"`
|
||||
ShelfExponent float64 `json:"shelf_exponent"`
|
||||
@@ -224,19 +280,219 @@ type Coast struct {
|
||||
RiverChannelKm2 float64 `json:"river_channel_km2"`
|
||||
}
|
||||
|
||||
// CoastDetail is the shore at the detail cell: pass 11b, and the one landform the geology grid cannot hold.
|
||||
//
|
||||
// Every length in it is in metres and none of them scales with the canvas, which is the argument for it being
|
||||
// a block of its own rather than more knobs on Coast. The geology pass decides where the shore is, how far the
|
||||
// surf reaches and how sheltered each stretch is, and those are *its* numbers, read from Coast; this decides
|
||||
// what the shore looks like once there are cells small enough to draw it.
|
||||
type CoastDetail struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Crenulation moves the whole profile in and out along the shore, which is what a crenulate coastline is.
|
||||
// It is added to the distance rather than to the height and it is drawn at the nearest waterline cell, so
|
||||
// it varies along the shore and not across it. This is the fine end of the same idea as the template's
|
||||
// coast_jitter_px, three orders of magnitude down: that one decides which pixels are land, this one wiggles
|
||||
// a waterline that is already decided.
|
||||
CrenulationM float64 `json:"crenulation_m"`
|
||||
CrenulationWaveM float64 `json:"crenulation_wavelength_m"`
|
||||
|
||||
// ShoreSmoothM is how far the signed distance to the waterline is smoothed before the profile is measured
|
||||
// from it, and it is not cosmetic. On a coastal plain the ground crosses sea level at a grade of about
|
||||
// one in a hundred, so the land mask there is not a line but a forty-metre band of speckle, and a profile
|
||||
// measured from it builds a separate two-metre berm on every isolated cell in it. Measured on the first
|
||||
// run of the pass: a string of beads down the whole coast at a spacing of twenty to thirty metres.
|
||||
// Smoothing the distance rather than the mask is what keeps the profile a profile - the shoreline moves,
|
||||
// the shape crossing it does not.
|
||||
ShoreSmoothM float64 `json:"shore_smooth_m"`
|
||||
|
||||
// The beach. DeanA is the A of the equilibrium profile depth = A*x^(2/3), in metres to the one third: 0.1
|
||||
// is fine sand and 0.2 is coarse. BermBackM is how far inland the berm crest is held before the profile
|
||||
// hands back to whatever the droplets left.
|
||||
DeanA float64 `json:"dean_a"`
|
||||
BermBackM float64 `json:"berm_back_m"`
|
||||
|
||||
// BeachFillM is the most sediment a beach may lay on what is already there. The equilibrium profile is a
|
||||
// target *depth*, so without a cap a shore with deep water close in - a drowned valley, which is an
|
||||
// ordinary thing - gets tens of metres of sand invented to bring the floor up to the curve.
|
||||
BeachFillM float64 `json:"beach_fill_m"`
|
||||
|
||||
// The cliff. A stretch of shore is a beach below CliffFromM of backshore and a cliff above CliffToM, and
|
||||
// blended between. CliffGrade is the tangent of the angle the face stands at - 2.75 is 70 degrees, which
|
||||
// is a sea cliff rather than a hillside. ScreeDeg is the angle its debris comes to rest at and ScreeReachM
|
||||
// is how far out from the foot the apron reaches.
|
||||
// CliffMaxM is how tall a face the surf is allowed to have cut. Past it the ground is a mountain coming
|
||||
// down to the water rather than a wave-cut cliff, and its face is a hillslope that belongs to the solve.
|
||||
// Without it a coastal range gets a seventy-degree wall carved four hundred metres inland, because the
|
||||
// only thing stopping the face is the ground rising faster than it does.
|
||||
CliffFromM float64 `json:"cliff_from_m"`
|
||||
CliffToM float64 `json:"cliff_to_m"`
|
||||
CliffMaxM float64 `json:"cliff_max_m"`
|
||||
CliffGrade float64 `json:"cliff_grade"`
|
||||
ScreeDeg float64 `json:"scree_repose_deg"`
|
||||
ScreeReachM float64 `json:"scree_reach_m"`
|
||||
|
||||
// PlatformReliefM is how far the strata field is allowed to move the shore platform, which is how a
|
||||
// platform gets its ledges and runnels instead of being planed flat.
|
||||
PlatformReliefM float64 `json:"platform_relief_m"`
|
||||
|
||||
// SmoothReachM is how far past the profile the shore damps the ground's *roughness* - not its shape.
|
||||
//
|
||||
// The profile itself is only a few tens of metres wide, so without this the ground goes from a drawn
|
||||
// beach to full dune amplitude and droplet rills in the width of the taper, and the beach reads as a
|
||||
// ribbon laid on top of the terrain rather than as part of it. What this does is blend the surface
|
||||
// towards a smoothed copy of itself over a wider band: the relief is untouched, the metre-scale texture
|
||||
// fades, and the backshore of a beach comes out smoother than the hillside behind it - which is what a
|
||||
// backshore is. 0 turns it off.
|
||||
SmoothReachM float64 `json:"smooth_reach_m"`
|
||||
}
|
||||
|
||||
type Pipeline struct {
|
||||
GeologyFactor int `json:"geology_factor"`
|
||||
Continent Continent `json:"continent"`
|
||||
Coast Coast `json:"coast"`
|
||||
Plates Plates `json:"plates"`
|
||||
Faults Faults `json:"faults"`
|
||||
Lithology Lithology `json:"lithology"`
|
||||
Relief Relief `json:"relief"`
|
||||
Fluvial Fluvial `json:"fluvial"`
|
||||
Thermal Thermal `json:"thermal"`
|
||||
Strata Strata `json:"strata"`
|
||||
Detail Detail `json:"detail"`
|
||||
Particle Particle `json:"particle"`
|
||||
GeologyFactor int `json:"geology_factor"`
|
||||
Continent Continent `json:"continent"`
|
||||
Coast Coast `json:"coast"`
|
||||
CoastDetail CoastDetail `json:"coast_detail"`
|
||||
Plates Plates `json:"plates"`
|
||||
Faults Faults `json:"faults"`
|
||||
Lithology Lithology `json:"lithology"`
|
||||
Relief Relief `json:"relief"`
|
||||
Fluvial Fluvial `json:"fluvial"`
|
||||
Thermal Thermal `json:"thermal"`
|
||||
Smooth Smooth `json:"smooth"`
|
||||
Strata Strata `json:"strata"`
|
||||
Detail Detail `json:"detail"`
|
||||
Particle Particle `json:"particle"`
|
||||
}
|
||||
|
||||
// Planet turns a manifest into a planet-scale bake driven by a painted template instead of a seed.
|
||||
//
|
||||
// Its presence is what switches the generator from the square canvas to the cylinder; a manifest without it
|
||||
// is the world the `generate` command has always built, unchanged. Paths are relative to the manifest file.
|
||||
type Planet struct {
|
||||
Template string `json:"template"` // the painted map
|
||||
Legend string `json:"legend"` // what its colours mean
|
||||
|
||||
// Palette is how the preview is *drawn* - the hypsometric ramp, the water, the rivers, the ice and the
|
||||
// light. Optional, and deliberately a file of its own rather than part of the legend: the legend says
|
||||
// what the colours in the input mean and is about the world, while this is purely a matter of taste
|
||||
// about the picture, and taste is the thing most likely to want swapping. Empty means the generator's
|
||||
// own, which internal/field.DefaultPalette holds.
|
||||
Palette string `json:"palette"`
|
||||
|
||||
// CircumferenceKm is how far it is all the way round. With the geology cell fixed at 8 m by D-48 this
|
||||
// is the one number that sets how big the world is, and it must be a whole number of cells or the seam
|
||||
// would fall between two columns.
|
||||
CircumferenceKm float64 `json:"circumference_km"`
|
||||
|
||||
// OceanMarginKm is how much water each region carries around its landmass.
|
||||
//
|
||||
// The solve needs only one cell of it - a grid edge is an outlet, and the edge has to be water - because
|
||||
// the coastal pass runs once on the whole cylinder rather than per region. What the margin actually
|
||||
// decides is clustering: two landmasses within twice this distance are solved in one box, which is the
|
||||
// right call when they are close enough to be one drainage problem and a waste of memory when they are
|
||||
// not.
|
||||
OceanMarginKm float64 `json:"ocean_margin_km"`
|
||||
|
||||
// MinLandCells drops specks. A stray paint pixel classified as land would otherwise cost a whole region
|
||||
// for a rock; below this many cells a landmass goes back to the sea and the run says how many did.
|
||||
MinLandCells int `json:"min_land_cells"`
|
||||
|
||||
// PadClass is the sea class filling the synthetic rows above and below the painted map, which exist so
|
||||
// that a polar cap has a shore to drain to. Empty means the legend's first sea class.
|
||||
PadClass string `json:"pad_class"`
|
||||
|
||||
// NoisePeriodKm is how far a world-coordinate noise lattice runs before repeating. It must divide the
|
||||
// circumference exactly or every noise field breaks at the seam. Zero means one turn.
|
||||
NoisePeriodKm float64 `json:"noise_period_km"`
|
||||
|
||||
// DetailNoisePeriodKm is the same thing for the detail passes, and it is short because it has to be: a
|
||||
// noise lattice holds (period/wavelength)^2 floats, so an eight-metre octave on a hundred-kilometre
|
||||
// period is a gigabyte and a half. What repeats at a kilometre is a few metres of surface roughness with
|
||||
// no shape to it; everything with a shape comes from the solve and the paint, which do not repeat.
|
||||
// It must divide the circumference too.
|
||||
DetailNoisePeriodKm float64 `json:"detail_noise_period_km"`
|
||||
|
||||
// UpliftVariation is how much the painted uplift rate is modulated by sub-pixel noise, as a fraction.
|
||||
//
|
||||
// It is not decoration. D-49: uniform uplift over a wide area produces no divides, and with no divides
|
||||
// the router falls back on the priority-flood's epsilon and draws its traversal order as rivers. A
|
||||
// painted lowland holds one rate over tens of kilometres, so without this it would come out table-flat
|
||||
// with the flood's geometry scratched across it.
|
||||
UpliftVariation float64 `json:"uplift_variation"`
|
||||
|
||||
// MassifWavelengthKm is how big the planet's upland fabric is: the size of the blocks a class with a
|
||||
// massif breaks into. One fabric for the whole world rather than one per class, deliberately, so that a
|
||||
// highland belt and the hills in the lowland beside it are high and low parts of a single structure - a
|
||||
// foreland and its outliers - instead of two unrelated noises that happen to meet at a painted edge.
|
||||
//
|
||||
// It is rounded to a whole number of lattice cells in the noise period, because noise.Lattice.Sample
|
||||
// wraps modulo its cell count and anything else breaks at the seam. `terrain plan` prints what it was
|
||||
// rounded to.
|
||||
MassifWavelengthKm float64 `json:"massif_wavelength_km"`
|
||||
|
||||
// LithologyWavelengthKm is how big the planet's rock provinces are. Zero means no lithology at all, which
|
||||
// is what every painted planet had before D-58: one flat erodibility inside each painted class, so
|
||||
// map_erodibility.png was a recolour of map_class.png and there was nothing to make one flank of a range
|
||||
// read differently from the next.
|
||||
//
|
||||
// The types and their multipliers are `pipeline.lithology`, shared with the procedural path. What is new
|
||||
// here is the wavelength, because a province on a 100 km planet is a different size from one on a 14 km
|
||||
// canvas, and the cut is a quantile of the planet rather than a percentile of whatever grid is in front
|
||||
// of it - see internal/uplift's painted_rock.go for why that distinction is not optional.
|
||||
LithologyWavelengthKm float64 `json:"lithology_wavelength_km"`
|
||||
|
||||
// FaultGrainKm is the wavelength of the fault set's orientation field: faults within one of its cells
|
||||
// come out sub-parallel, and the strike swings gradually across the world.
|
||||
//
|
||||
// It is a field rather than one global angle because a single strike is what the procedural path has and
|
||||
// it reads as corduroy across a whole map. Which classes are faulted at all, and how hard, is the
|
||||
// legend's `faults` block; this is only how they are aimed.
|
||||
FaultGrainKm float64 `json:"fault_grain_km"`
|
||||
|
||||
// CoastJitterPx perturbs the painted waterline by this many template pixels of world-coordinate noise.
|
||||
//
|
||||
// An upsampled painted outline is a smooth polygon, and a coastline is fractal - which is the whole
|
||||
// content of the Richardson paradox and, measured, the difference between a shore with bays the shelter
|
||||
// model can work with and one the fetch reports as fully open everywhere.
|
||||
CoastJitterPx float64 `json:"coast_jitter_px"`
|
||||
|
||||
// CoastJitterWavelengthPx is the coarsest octave: the size of the biggest bay it can cut, in template
|
||||
// pixels. Octaves halve from there, so the finest detail is this over 2^(octaves-1).
|
||||
CoastJitterWavelengthPx float64 `json:"coast_jitter_wavelength_px"`
|
||||
|
||||
// CoastJitterOctaves and CoastJitterGain are the fractal structure. A gain near 0.5 makes each scale as
|
||||
// prominent as the last, which is the property a real coastline has and a single wobble does not - it is
|
||||
// the whole content of the Richardson paradox, and it is why one octave reads as a wobbly line rather
|
||||
// than as a coast.
|
||||
CoastJitterOctaves int `json:"coast_jitter_octaves"`
|
||||
CoastJitterGain float64 `json:"coast_jitter_gain"`
|
||||
|
||||
// Overlay and OverlayLegend are the annotation layer: a second painting registered to the first, and a
|
||||
// legend of marks saying what its colours stand for. Both empty means there is no overlay, which is what
|
||||
// every planet had before D-57 and what one still has until an author paints one.
|
||||
//
|
||||
// It is a second *image* rather than more colours on the first because the two answer different
|
||||
// questions. A class is geology - every colour on the template changes an uplift rate or an erodibility,
|
||||
// and the solve answers for it - while a mark is a thing placed on the finished world: a forest, a
|
||||
// village, a road, or a stretch of coast the author drew deliberately and does not want roughened. There
|
||||
// is no uplift rate for a town, and a mark has to be able to sit on top of any class without changing it.
|
||||
//
|
||||
// See internal/overlay. Only one mark property is read by the generator at all (coast_jitter); the rest
|
||||
// travel through to the engine as per-tile masks and as features in world metres in overlay.json.
|
||||
Overlay string `json:"overlay"`
|
||||
OverlayLegend string `json:"overlay_legend"`
|
||||
|
||||
// Plates is the tectonic model: how many rigid pieces the lithosphere is in and how fast they move.
|
||||
//
|
||||
// It is `planet.plates` rather than `pipeline.plates` deliberately. The two are different models of the
|
||||
// same word: the procedural block below is a percentile range band over whatever grid it is handed, which
|
||||
// D-53 forbids on a decomposed planet, while this one is drawn once for the whole cylinder in world
|
||||
// metres and produces boundary *geometry* - the lines pass 3 was always specified to read.
|
||||
//
|
||||
// A count of zero switches it off, which is what every template painted before it had. It is off by
|
||||
// default because nothing in the solve reads it yet: what it produces today is a diagnostic map and a
|
||||
// set of lines in meta.json.
|
||||
Plates plates.Config `json:"plates"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
@@ -253,6 +509,9 @@ type Manifest struct {
|
||||
Layers Layers `json:"layers"`
|
||||
Pipeline Pipeline `json:"pipeline"`
|
||||
|
||||
// Planet is present only on a planet manifest. Its absence is what keeps `generate` exactly as it was.
|
||||
Planet *Planet `json:"planet"`
|
||||
|
||||
// Erosion is the pre-D-47 block. Kept only so a manifest that still carries it can be reported rather
|
||||
// than silently ignored.
|
||||
Erosion map[string]any `json:"erosion"`
|
||||
@@ -311,6 +570,25 @@ func Defaults() *Manifest {
|
||||
// has a number to work from instead of an impression of a picture.
|
||||
RiverM3PerKm2: 1.2e5, RiverExponent: 0.6, RiverChannelKm2: 0.5,
|
||||
},
|
||||
CoastDetail: CoastDetail{
|
||||
Enabled: true,
|
||||
// A bay a hundred and twenty metres across with six metres of wander in it. That is the
|
||||
// scale the template's own coast_jitter cannot reach: its wavelength is 384 template px,
|
||||
// which is five kilometres here, and its finest octave is still 150 m of paint.
|
||||
CrenulationM: 6, CrenulationWaveM: 120, ShoreSmoothM: 12,
|
||||
// Dean's A for medium sand. 0.12 puts the 2 m contour 65 m offshore and the 5 m contour
|
||||
// 260 m, which is a beach you can wade out on and not a shelf.
|
||||
DeanA: 0.12, BermBackM: 25, BeachFillM: 3,
|
||||
// A coast with eight metres of land behind it is a beach; one with thirty is a cliff. Both
|
||||
// are the backshore *mean* between one and two surf reaches inland, so a low headland in a
|
||||
// bay does not turn the bay into a cliff coast.
|
||||
CliffFromM: 8, CliffToM: 30, CliffMaxM: 60,
|
||||
// tan 70 degrees. A heightfield cannot hold an overhang, so a wave-cut notch is the one
|
||||
// piece of a cliff this pass cannot draw; what it can do is stop a thirty-metre cliff
|
||||
// arriving as a four-cell ramp, which is what the upsample makes of it.
|
||||
CliffGrade: 2.75, ScreeDeg: 34, ScreeReachM: 30,
|
||||
PlatformReliefM: 0.6, SmoothReachM: 90,
|
||||
},
|
||||
Plates: Plates{
|
||||
Count: 6, VelocityCmYr: Range{1, 5}, BandKm: Range{2, 4},
|
||||
DivergentMmYr: Range{-2, -1}, RiftKm: Range{3, 6},
|
||||
@@ -378,12 +656,31 @@ func Defaults() *Manifest {
|
||||
CriticalSlopeDeg: 35,
|
||||
SlopeCap: 0.9,
|
||||
MaxHillslopeSub: 24,
|
||||
// One, and the choice is not a tuning decision. On a planar hillslope the correct specific
|
||||
// catchment area is the same at every point along a contour, and D8 cannot say so: it gives
|
||||
// one cell the whole flow tube and its neighbour a single cell for ever. Measured on a ramp
|
||||
// at an aspect of 22.5 degrees, the most-drained cell in a contour band carried 769 times
|
||||
// the median and 30 % of the grid drained nothing; at an exponent of one it is 1.34 and
|
||||
// 0.4 %. Raising it past one narrows the spread again, so it is the knob to reach for if
|
||||
// map_flow reads as broad smears rather than rivers - but a real valley has its cross-valley
|
||||
// neighbours *above* it, which get zero weight whatever the exponent, so MFD is already D8
|
||||
// wherever convergence is real.
|
||||
MFDExponent: 1,
|
||||
},
|
||||
Thermal: Thermal{CoarsePasses: 2, Every: 4, FinePasses: 24, TalusDeg: 35},
|
||||
Strata: Strata{PeriodM: 160, Contrast: 0.6},
|
||||
Detail: Detail{Octaves: 4, AmplitudeM: Range{2, 8}},
|
||||
// Off. Turning it on is a decision to hide something rather than to fix it, so it is a decision
|
||||
// somebody makes in a file. 0.3 is about seventeen degrees: steeper than that is a landform and
|
||||
// is left alone.
|
||||
Smooth: Smooth{Passes: 0, SlopeRef: 0.3},
|
||||
Strata: Strata{PeriodM: 160, Contrast: 0.6},
|
||||
// ClassBlendM 120 is fifteen geology cells, which is exactly half the tile margin and therefore the
|
||||
// most a tile can blend without reading past its own cut: two passes of a box blur reach twice the
|
||||
// radius. SeabedM 24 is twice the shore taper, so the texture is fully in by the time the water is
|
||||
// deep enough to hold it and gone again before the shelf.
|
||||
Detail: Detail{Octaves: 4, AmplitudeM: Range{2, 8}, TilePx: 2500, ClassBlendM: 120, SeabedM: 24},
|
||||
Particle: Particle{
|
||||
Droplets: 9000000, Lifetime: 40, Scale: 0.5, MinErodeSlope: 0.25, MaxChange: 0.2,
|
||||
Droplets: 9000000, DropletsPerCell: 0.18, Rounds: 16,
|
||||
Lifetime: 40, Scale: 0.5, MinErodeSlope: 0.25, MaxChange: 0.2,
|
||||
MaxSpeed: 5, MaxLoad: 2, Inertia: 0.1, Capacity: 2, MinSlope: 0.01,
|
||||
ErodeRate: 0.2, DepositRate: 0.2, Evaporation: 0.02, Gravity: 4, Batch: 200000,
|
||||
},
|
||||
@@ -404,9 +701,254 @@ func Load(path string) (*Manifest, error) {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
m.Path = path
|
||||
m.fillPlanetDefaults()
|
||||
return m, m.Validate()
|
||||
}
|
||||
|
||||
// fillPlanetDefaults runs after the merge rather than in Defaults(), because the block is a pointer: a
|
||||
// manifest without one is not a planet at all, and json.Unmarshal would allocate a zero struct over
|
||||
// anything Defaults had put there.
|
||||
func (m *Manifest) fillPlanetDefaults() {
|
||||
p := m.Planet
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p.CircumferenceKm == 0 {
|
||||
p.CircumferenceKm = 100
|
||||
}
|
||||
if p.OceanMarginKm == 0 {
|
||||
p.OceanMarginKm = 0.5
|
||||
}
|
||||
if p.MinLandCells == 0 {
|
||||
p.MinLandCells = 16
|
||||
}
|
||||
if p.UpliftVariation == 0 {
|
||||
p.UpliftVariation = 0.30
|
||||
}
|
||||
// 12 px is about 155 m on a 100 km planet drawn at 7738 px, and the octaves run from 2.5 km down to
|
||||
// 155 m. The old default was 1.5 px, which is one template pixel of wobble and would have been invisible
|
||||
// - it was never read by anything, so it was never a measured number.
|
||||
if p.CoastJitterPx == 0 {
|
||||
p.CoastJitterPx = 12
|
||||
}
|
||||
if p.CoastJitterWavelengthPx == 0 {
|
||||
p.CoastJitterWavelengthPx = 192
|
||||
}
|
||||
if p.CoastJitterOctaves == 0 {
|
||||
p.CoastJitterOctaves = 5
|
||||
}
|
||||
if p.CoastJitterGain == 0 {
|
||||
p.CoastJitterGain = 0.55
|
||||
}
|
||||
if p.MassifWavelengthKm == 0 {
|
||||
p.MassifWavelengthKm = 12
|
||||
}
|
||||
if p.NoisePeriodKm == 0 {
|
||||
p.NoisePeriodKm = p.CircumferenceKm
|
||||
}
|
||||
if p.DetailNoisePeriodKm == 0 {
|
||||
p.DetailNoisePeriodKm = 1
|
||||
}
|
||||
// A real shelf break, because on a planet a real margin fits. The square canvas's 30 m is not a shelf
|
||||
// break at all, it is the shallow end of a 14 km canvas's sea floor range, and inheriting it here is
|
||||
// what made a painted 512 m ocean come out as a 20 m pond (D-64).
|
||||
if m.Pipeline.Coast.BreakM == 0 {
|
||||
m.Pipeline.Coast.BreakM = 130
|
||||
}
|
||||
}
|
||||
|
||||
// ShelfBreakM is how deep the water is at the shelf break, in positive metres.
|
||||
//
|
||||
// The planet names it outright. The square canvas never did: its sea floor is a range and the shelf break is
|
||||
// the shallow end of it, so the fallback keeps that reading rather than inventing a key for a manifest that
|
||||
// was written without one.
|
||||
func (m *Manifest) ShelfBreakM() float64 {
|
||||
if m.Pipeline.Coast.BreakM > 0 {
|
||||
return m.Pipeline.Coast.BreakM
|
||||
}
|
||||
return -m.Pipeline.Continent.SeaFloorM.Hi()
|
||||
}
|
||||
|
||||
// IsPlanet reports whether this manifest describes a painted planet rather than the square canvas.
|
||||
func (m *Manifest) IsPlanet() bool { return m.Planet != nil }
|
||||
|
||||
// TemplatePath and LegendPath resolve the planet's two inputs against the manifest's own directory.
|
||||
func (m *Manifest) TemplatePath() string { return m.relative(m.Planet.Template) }
|
||||
func (m *Manifest) LegendPath() string { return m.relative(m.Planet.Legend) }
|
||||
|
||||
// OverlayPath and OverlayLegendPath resolve the annotation layer, or "" when there is none. The image may
|
||||
// be named by the manifest or, failing that, by the overlay legend itself; the manifest wins, which is what
|
||||
// lets the studio's versioned saves repoint without rewriting a file the author wrote.
|
||||
func (m *Manifest) OverlayLegendPath() string {
|
||||
if m.Planet == nil || m.Planet.OverlayLegend == "" {
|
||||
return ""
|
||||
}
|
||||
return m.relative(m.Planet.OverlayLegend)
|
||||
}
|
||||
|
||||
// OverlayPath is the painted overlay named by the manifest, or "" when it names none.
|
||||
func (m *Manifest) OverlayPath() string {
|
||||
if m.Planet == nil || m.Planet.Overlay == "" {
|
||||
return ""
|
||||
}
|
||||
return m.relative(m.Planet.Overlay)
|
||||
}
|
||||
|
||||
// HasOverlay reports whether an annotation layer is configured at all.
|
||||
func (m *Manifest) HasOverlay() bool { return m.OverlayLegendPath() != "" }
|
||||
|
||||
// PlatesLayerPath and PlatesLegendPath resolve the painted tectonic layer, or "" when there is none. Same
|
||||
// shape as the overlay's pair above, and for the same reason: the manifest names the image so that a
|
||||
// versioned save can be repointed without rewriting the legend an author wrote.
|
||||
func (m *Manifest) PlatesLayerPath() string {
|
||||
if m.Planet == nil || m.Planet.Plates.Layer == "" {
|
||||
return ""
|
||||
}
|
||||
return m.relative(m.Planet.Plates.Layer)
|
||||
}
|
||||
|
||||
func (m *Manifest) PlatesLegendPath() string {
|
||||
if m.Planet == nil || m.Planet.Plates.Legend == "" {
|
||||
return ""
|
||||
}
|
||||
return m.relative(m.Planet.Plates.Legend)
|
||||
}
|
||||
|
||||
// HasPaintedPlates reports whether the tectonics are drawn rather than generated.
|
||||
func (m *Manifest) HasPaintedPlates() bool {
|
||||
return m.PlatesLayerPath() != "" && m.PlatesLegendPath() != ""
|
||||
}
|
||||
|
||||
// PalettePath is the preview palette, or "" when the manifest names none.
|
||||
func (m *Manifest) PalettePath() string {
|
||||
if m.Planet == nil || m.Planet.Palette == "" {
|
||||
return ""
|
||||
}
|
||||
return m.relative(m.Planet.Palette)
|
||||
}
|
||||
|
||||
func (m *Manifest) relative(p string) string {
|
||||
if p == "" || filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(filepath.Dir(m.Path), p)
|
||||
}
|
||||
|
||||
// validatePlanet checks the numbers that would otherwise fail deep inside a bake, or - worse - not fail.
|
||||
func (m *Manifest) validatePlanet() error {
|
||||
p := m.Planet
|
||||
if p.Template == "" {
|
||||
return fmt.Errorf("%s: planet.template is empty", m.Path)
|
||||
}
|
||||
if p.Legend == "" {
|
||||
return fmt.Errorf("%s: planet.legend is empty", m.Path)
|
||||
}
|
||||
if p.CircumferenceKm <= 0 {
|
||||
return fmt.Errorf("%s: planet.circumference_km is %v", m.Path, p.CircumferenceKm)
|
||||
}
|
||||
cell := m.GeologyCellM()
|
||||
cols := p.CircumferenceKm * 1000 / cell
|
||||
if d := cols - math.Round(cols); d > 1e-9 || d < -1e-9 {
|
||||
return fmt.Errorf("%s: a %.3f km circumference is %.4f cells of %.1f m. It must be a whole number, "+
|
||||
"or the seam falls between two columns; the nearest that works is %.3f km",
|
||||
m.Path, p.CircumferenceKm, cols, cell, math.Round(cols)*cell/1000)
|
||||
}
|
||||
if p.OceanMarginKm <= 0 {
|
||||
return fmt.Errorf("%s: planet.ocean_margin_km is %v; a region needs a ring of water", m.Path, p.OceanMarginKm)
|
||||
}
|
||||
if p.CoastJitterPx < 0 {
|
||||
return fmt.Errorf("%s: planet.coast_jitter_px is %v", m.Path, p.CoastJitterPx)
|
||||
}
|
||||
if p.CoastJitterPx > 0 {
|
||||
if p.CoastJitterWavelengthPx <= 0 {
|
||||
return fmt.Errorf("%s: planet.coast_jitter_wavelength_px is %v", m.Path, p.CoastJitterWavelengthPx)
|
||||
}
|
||||
if p.CoastJitterOctaves < 1 || p.CoastJitterOctaves > 12 {
|
||||
return fmt.Errorf("%s: planet.coast_jitter_octaves is %d, outside 1..12",
|
||||
m.Path, p.CoastJitterOctaves)
|
||||
}
|
||||
if p.CoastJitterGain <= 0 || p.CoastJitterGain >= 1 {
|
||||
return fmt.Errorf("%s: planet.coast_jitter_gain is %v, outside 0..1 exclusive",
|
||||
m.Path, p.CoastJitterGain)
|
||||
}
|
||||
}
|
||||
if p.MassifWavelengthKm <= 0 {
|
||||
return fmt.Errorf("%s: planet.massif_wavelength_km is %v", m.Path, p.MassifWavelengthKm)
|
||||
}
|
||||
for _, w := range []struct {
|
||||
key string
|
||||
km float64
|
||||
}{{"lithology_wavelength_km", p.LithologyWavelengthKm}, {"fault_grain_km", p.FaultGrainKm}} {
|
||||
if w.km < 0 {
|
||||
return fmt.Errorf("%s: planet.%s is %v; it is a wavelength in kilometres", m.Path, w.key, w.km)
|
||||
}
|
||||
if w.km > p.NoisePeriodKm {
|
||||
return fmt.Errorf("%s: planet.%s is %v km, longer than the noise period of %v km, so the field "+
|
||||
"would be one lattice cell and flat over the whole world",
|
||||
m.Path, w.key, w.km, p.NoisePeriodKm)
|
||||
}
|
||||
}
|
||||
if p.MassifWavelengthKm > p.NoisePeriodKm {
|
||||
return fmt.Errorf("%s: planet.massif_wavelength_km is %v against a noise period of %v. The fabric "+
|
||||
"would be a single lattice cell, so every massif on the planet would be the same one",
|
||||
m.Path, p.MassifWavelengthKm, p.NoisePeriodKm)
|
||||
}
|
||||
for _, np := range []struct {
|
||||
key string
|
||||
period float64
|
||||
}{{"noise_period_km", p.NoisePeriodKm}, {"detail_noise_period_km", p.DetailNoisePeriodKm}} {
|
||||
if np.period <= 0 {
|
||||
return fmt.Errorf("%s: planet.%s is %v", m.Path, np.key, np.period)
|
||||
}
|
||||
if k := p.CircumferenceKm / np.period; math.Abs(k-math.Round(k)) > 1e-9 || k < 1 {
|
||||
return fmt.Errorf("%s: planet.%s %v does not divide the circumference %v (%.4f times); every "+
|
||||
"noise field built on it would break at the seam", m.Path, np.key, np.period,
|
||||
p.CircumferenceKm, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LithologyCells is the rock field's wavelength in lattice cells of the noise period, or 0 when the planet
|
||||
// asks for no lithology. Rounded the same way MassifCells is and for the same reason: noise.Lattice.Sample
|
||||
// wraps modulo its cell count, so anything else breaks at the seam.
|
||||
func (p *Planet) LithologyCells() int {
|
||||
if p.LithologyWavelengthKm <= 0 {
|
||||
return 0
|
||||
}
|
||||
n := int(p.NoisePeriodKm/p.LithologyWavelengthKm + 0.5)
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// MassifCells is the upland fabric's wavelength counted in lattice cells of the noise period, which is what
|
||||
// noise.Params.BaseCells takes. It has to be a whole number: noise.Lattice.Sample wraps modulo its cell count,
|
||||
// so a fraction of a cell at the seam is a discontinuity down one meridian.
|
||||
func (p *Planet) MassifCells() int {
|
||||
n := int(p.NoisePeriodKm/p.MassifWavelengthKm + 0.5)
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// MassifWavelengthRoundedKm is the wavelength MassifCells actually delivers, which is what a run should
|
||||
// report rather than what was asked for.
|
||||
func (p *Planet) MassifWavelengthRoundedKm() float64 {
|
||||
return p.NoisePeriodKm / float64(p.MassifCells())
|
||||
}
|
||||
|
||||
// MarginCells is the ocean margin in geology cells, at least one.
|
||||
func (p *Planet) MarginCells(cellM float64) int {
|
||||
n := int(p.OceanMarginKm*1000/cellM + 0.5)
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (m *Manifest) Validate() error {
|
||||
if m.VerticesPerSide < 2 {
|
||||
return fmt.Errorf("%s: vertices_per_side must be at least 2", m.Path)
|
||||
@@ -439,6 +981,9 @@ func (m *Manifest) Validate() error {
|
||||
if f := m.Pipeline.GeologyFactor; f < 1 || q%f != 0 {
|
||||
return fmt.Errorf("%s: geology_factor %d must divide the quad count %d exactly", m.Path, f, q)
|
||||
}
|
||||
if m.IsPlanet() {
|
||||
return m.validatePlanet()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -521,16 +1066,36 @@ func (m *Manifest) ClipFraction(metres []float32) float64 {
|
||||
if len(metres) == 0 {
|
||||
return 0
|
||||
}
|
||||
var n int
|
||||
return float64(m.ClipCells(metres)) / float64(len(metres))
|
||||
}
|
||||
|
||||
// ClipCells is the same count before it is turned into a fraction.
|
||||
//
|
||||
// A fraction of one region is not a fraction of a planet and cannot be made into one without carrying the
|
||||
// region's size beside it, so anything that pools across regions counts cells and divides at the end. See
|
||||
// internal/stats.
|
||||
func (m *Manifest) ClipCells(metres []float32) int64 {
|
||||
var n int64
|
||||
for _, v := range metres {
|
||||
if float64(v) < m.ElevationM.Min || float64(v) > m.ElevationM.Max {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return float64(n) / float64(len(metres))
|
||||
return n
|
||||
}
|
||||
|
||||
// Encode turns metres into the 16-bit values the PNG carries, clamping to the range.
|
||||
// Decode is Encode's inverse: 16-bit samples back to metres. It is what lets the detail bake read a geology
|
||||
// bake's heightmap off disk instead of holding it, which is what makes the two commands separable.
|
||||
func (m *Manifest) Decode(values []uint16) []float32 {
|
||||
span := m.ElevationM.Max - m.ElevationM.Min
|
||||
out := make([]float32, len(values))
|
||||
for i, v := range values {
|
||||
out[i] = float32(m.ElevationM.Min + float64(v)/65535*span)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manifest) Encode(metres []float32) []uint16 {
|
||||
out := make([]uint16, len(metres))
|
||||
for i, v := range metres {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// What the overlay hands to whatever builds the level.
|
||||
//
|
||||
// Two shapes, because two different things want it. A *raster* answers "what is under this square metre" and
|
||||
// is what a per-tile importer wants: one 8-bit image beside each height tile, an index per detail cell, zero
|
||||
// for nothing. A *feature list* answers "where do I put the village" and "what curve does the road follow",
|
||||
// and lives once at the planet root in world metres because a point is not a pixel and a spline crossing a
|
||||
// tile boundary is still one spline.
|
||||
//
|
||||
// Nothing in the generator reads either of them back. That is the point of the layer.
|
||||
|
||||
// CoastScale is the per-pixel multiplier on the waterline roughening, or nil when no mark asks for one.
|
||||
//
|
||||
// A pixel with no instruction comes back negative rather than 1, which is the contract template.Coast.Scale
|
||||
// documents: an unmarked cell takes its instruction from the far side of the waterline instead of overriding
|
||||
// what the marked side said.
|
||||
func (l *Legend) CoastScale(r *Raster) []float32 {
|
||||
if !l.TouchesCoast() {
|
||||
return nil
|
||||
}
|
||||
per := make([]float32, len(l.Marks)+1)
|
||||
per[Blank] = -1
|
||||
for i := range l.Marks {
|
||||
if j, set := l.Marks[i].Jitter(); set {
|
||||
per[i+1] = float32(j)
|
||||
} else {
|
||||
per[i+1] = -1
|
||||
}
|
||||
}
|
||||
out := make([]float32, len(r.Mark))
|
||||
field.Rows(r.H, func(y0, y1 int) {
|
||||
for i := y0 * r.W; i < y1*r.W; i++ {
|
||||
out[i] = per[r.Mark[i]]
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Document is overlay.json: everything an importer needs to place what the author painted.
|
||||
type Document struct {
|
||||
Image string `json:"image"`
|
||||
Legend string `json:"legend"`
|
||||
|
||||
// The frame the coordinates are in: metres east from the seam and metres south from the top painted row,
|
||||
// which is the same frame world.Planet uses for a painted cell once the polar pad is taken off.
|
||||
CircumferenceM float64 `json:"circumference_m"`
|
||||
HeightM float64 `json:"height_m"`
|
||||
PaintW int `json:"paint_w"`
|
||||
PaintH int `json:"paint_h"`
|
||||
MetresPerPxX float64 `json:"metres_per_px_x"`
|
||||
MetresPerPxY float64 `json:"metres_per_px_y"`
|
||||
|
||||
Marks []MarkShare `json:"marks"`
|
||||
Features []Feature `json:"features"`
|
||||
}
|
||||
|
||||
// MarkShare is one mark and how much of the world carries it.
|
||||
type MarkShare struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
RGB [3]int `json:"rgb"`
|
||||
Cells int `json:"cells_px"`
|
||||
AreaKm2 float64 `json:"area_km2"`
|
||||
Pieces int `json:"pieces"`
|
||||
WidthM float64 `json:"width_m,omitempty"`
|
||||
|
||||
// Jitter and HasJitter are a pair and neither is omitempty, because the interesting value of the first
|
||||
// is **zero** - a pinned coastline - and omitting it would leave every consumer of this file unable to
|
||||
// tell "pin it" from "said nothing", which is the one distinction the key exists to make.
|
||||
Jitter float64 `json:"coast_jitter"`
|
||||
HasJitter bool `json:"has_coast_jitter"`
|
||||
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Describe builds the document from a classified overlay.
|
||||
func (l *Legend) Describe(r *Raster, m Match, s Scale, image, legend string) *Document {
|
||||
feats := l.Features(r, s)
|
||||
pieces := make([]int, len(l.Marks)+1)
|
||||
for _, f := range feats {
|
||||
pieces[f.Index]++
|
||||
}
|
||||
doc := &Document{
|
||||
Image: image, Legend: legend,
|
||||
CircumferenceM: s.CircumferenceM,
|
||||
HeightM: float64(r.H) * s.MetresPerPxY,
|
||||
PaintW: r.W, PaintH: r.H,
|
||||
MetresPerPxX: s.MetresPerPxX, MetresPerPxY: s.MetresPerPxY,
|
||||
Features: feats,
|
||||
}
|
||||
for i := range l.Marks {
|
||||
mk := &l.Marks[i]
|
||||
cells := 0
|
||||
if i+1 < len(m.Counts) {
|
||||
cells = m.Counts[i+1]
|
||||
}
|
||||
share := MarkShare{
|
||||
Index: i + 1, Name: mk.Name, Kind: mk.Kind, RGB: mk.RGB,
|
||||
Cells: cells, AreaKm2: float64(cells) * s.MetresPerPxX * s.MetresPerPxY / 1e6,
|
||||
Pieces: pieces[i+1], WidthM: mk.WidthM, Note: mk.Note,
|
||||
}
|
||||
if j, set := mk.Jitter(); set {
|
||||
share.Jitter, share.HasJitter = j, true
|
||||
}
|
||||
doc.Marks = append(doc.Marks, share)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// WriteJSON writes overlay.json.
|
||||
func (d *Document) WriteJSON(dir string) error {
|
||||
data, err := json.MarshalIndent(d, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "overlay.json"), append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// WriteMask writes an 8-bit indexed PNG: one mark index a pixel, zero for nothing. It is the per-tile
|
||||
// output, and it is indexed rather than one image a mark because marks cannot overlap - the overlay is one
|
||||
// painting and a pixel is one colour - so 254 masks fit in the file one would have taken.
|
||||
func WriteMask(path string, w, h int, marks []uint8) error {
|
||||
return field.WriteGray8(path, w, h, marks, png.BestCompression)
|
||||
}
|
||||
|
||||
// SampleWorld reads the overlay over a rectangle of some other grid, described in world metres.
|
||||
//
|
||||
// World metres rather than cell indices, because the caller is a *detail* tile: it is 2 m where the overlay
|
||||
// is 12.9 and the geology is 8, and it sits at an origin that is only expressible in metres. Going through
|
||||
// the common frame is the only way the three agree, and it is rule 1 of the tiling plan applied to a raster
|
||||
// instead of to a noise - a cell gets the same mark whichever tile reaches it.
|
||||
//
|
||||
// Nearest neighbour, for the same reason the class raster is: an index is a name, and the average of
|
||||
// "forest" and "road" is neither.
|
||||
func (r *Raster) SampleWorld(originXM, originYM, cellM float64, w, h int, s Scale) []uint8 {
|
||||
out := make([]uint8, w*h)
|
||||
field.Rows(h, func(b0, b1 int) {
|
||||
for y := b0; y < b1; y++ {
|
||||
py := int((originYM + (float64(y)+0.5)*cellM) / s.MetresPerPxY)
|
||||
if py < 0 {
|
||||
py = 0
|
||||
} else if py >= r.H {
|
||||
py = r.H - 1
|
||||
}
|
||||
row := py * r.W
|
||||
for x := 0; x < w; x++ {
|
||||
px := int((originXM + (float64(x)+0.5)*cellM) / s.MetresPerPxX)
|
||||
px = ((px % r.W) + r.W) % r.W
|
||||
out[y*w+x] = r.Mark[row+px]
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Turning painted strokes into things an engine can place.
|
||||
//
|
||||
// A raster is enough for anything that is a mask - where the forest is, where the ground is a town - and the
|
||||
// per-tile output is exactly that. It is not enough for anything that is a *position* or a *line*: "put a
|
||||
// village here" wants a point and a radius, and "run a road along this" wants an ordered polyline, because
|
||||
// the thing being built on the other side is a spline. So the marks are also reduced to features in world
|
||||
// metres, once, over the whole cylinder.
|
||||
//
|
||||
// Both reductions work on connected components with X wrapped, because the world does. A component that
|
||||
// straddles the seam is one thing, and reporting it as two would put half a forest at each end of the map.
|
||||
|
||||
// Feature is one connected piece of one mark, reduced to something placeable.
|
||||
type Feature struct {
|
||||
Mark string `json:"mark"`
|
||||
Index int `json:"index"`
|
||||
Kind string `json:"kind"`
|
||||
ID int `json:"id"`
|
||||
|
||||
// CentreM is the centroid in world metres. X is a circular mean, so a component across the seam reports
|
||||
// a centre on the component rather than on the far side of the world.
|
||||
CentreM [2]float64 `json:"centre_m"`
|
||||
|
||||
// AreaM2 is the painted area, and RadiusM the radius of the disc with that area - the number to hand a
|
||||
// placement rule that wants "how big is this village".
|
||||
AreaM2 float64 `json:"area_m2"`
|
||||
RadiusM float64 `json:"radius_m"`
|
||||
|
||||
// ExtentM is the bounding box, as width and height in metres. For a component across the seam the width
|
||||
// is measured the short way round, which is the way it was painted.
|
||||
ExtentM [2]float64 `json:"extent_m"`
|
||||
|
||||
Cells int `json:"cells_px"`
|
||||
|
||||
// PointsM is the centreline, in world metres, for a path. Empty for an area.
|
||||
PointsM [][2]float64 `json:"points_m,omitempty"`
|
||||
LengthM float64 `json:"length_m,omitempty"`
|
||||
WidthM float64 `json:"width_m,omitempty"`
|
||||
}
|
||||
|
||||
// Scale converts overlay pixels to world metres. The overlay is painted at the template's resolution, which
|
||||
// is not the geology grid's, so nothing here may assume a pixel is a cell.
|
||||
type Scale struct {
|
||||
MetresPerPxX float64
|
||||
MetresPerPxY float64
|
||||
// CircumferenceM is how far X runs before it comes back to itself, for the circular mean.
|
||||
CircumferenceM float64
|
||||
}
|
||||
|
||||
// Features reduces every mark on the raster to placeable pieces, in mark order and then in a stable order
|
||||
// within a mark.
|
||||
//
|
||||
// Stable means "does not depend on which goroutine ran", which is cross-cutting rule 12 and is why this is
|
||||
// serial: it is one pass over a raster of a few tens of millions of pixels and it runs once per plan.
|
||||
func (l *Legend) Features(r *Raster, s Scale) []Feature {
|
||||
var out []Feature
|
||||
// A visited flag and nothing more. It is a bool rather than a component id because nothing downstream
|
||||
// asks which component a pixel belonged to, and at planet scale that is 29 MB against 116.
|
||||
seen := make([]bool, len(r.Mark))
|
||||
var stack []int32
|
||||
|
||||
for mi := range l.Marks {
|
||||
m := &l.Marks[mi]
|
||||
idx := uint8(mi + 1)
|
||||
minArea := l.MinArea(m)
|
||||
var found []Feature
|
||||
for start := 0; start < len(r.Mark); start++ {
|
||||
if r.Mark[start] != idx || seen[start] {
|
||||
continue
|
||||
}
|
||||
cells := flood(r, idx, int32(start), seen, &stack)
|
||||
if len(cells) < minArea {
|
||||
continue
|
||||
}
|
||||
f := describe(r, m, mi+1, len(found), cells, s)
|
||||
if !m.Area() {
|
||||
pts := trace(r, cells)
|
||||
f.PointsM, f.LengthM = project(pts, r, s)
|
||||
f.WidthM = m.WidthM
|
||||
}
|
||||
found = append(found, f)
|
||||
}
|
||||
// Biggest first: a placement rule that takes the first few wants the ones that matter.
|
||||
sort.SliceStable(found, func(a, b int) bool { return found[a].Cells > found[b].Cells })
|
||||
for i := range found {
|
||||
found[i].ID = i
|
||||
}
|
||||
out = append(out, found...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// flood collects one 8-connected component with X wrapped. The scratch stack is reused across components so
|
||||
// a map with thousands of specks does not allocate thousands of slices.
|
||||
func flood(r *Raster, idx uint8, start int32, seen []bool, stack *[]int32) []int32 {
|
||||
cells := []int32{start}
|
||||
seen[start] = true
|
||||
*stack = (*stack)[:0]
|
||||
*stack = append(*stack, start)
|
||||
for len(*stack) > 0 {
|
||||
i := (*stack)[len(*stack)-1]
|
||||
*stack = (*stack)[:len(*stack)-1]
|
||||
x, y := int(i)%r.W, int(i)/r.W
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
ny := y + dy
|
||||
if ny < 0 || ny >= r.H {
|
||||
continue
|
||||
}
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue
|
||||
}
|
||||
nx := x + dx
|
||||
if nx < 0 {
|
||||
nx += r.W
|
||||
} else if nx >= r.W {
|
||||
nx -= r.W
|
||||
}
|
||||
n := int32(ny*r.W + nx)
|
||||
if seen[n] || r.Mark[n] != idx {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
cells = append(cells, n)
|
||||
*stack = append(*stack, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
// describe measures a component: centroid, area, extent.
|
||||
//
|
||||
// X is a circular mean - the average of the unit vectors at each cell's longitude, turned back into an angle.
|
||||
// A plain mean would put the centre of a component straddling the seam on the opposite side of the planet,
|
||||
// which is the one failure mode a cylindrical map has and the one nobody notices until a village appears in
|
||||
// the ocean.
|
||||
func describe(r *Raster, m *Mark, idx, id int, cells []int32, s Scale) Feature {
|
||||
var sx, sy, cx float64
|
||||
for _, i := range cells {
|
||||
x, y := float64(int(i)%r.W), float64(int(i)/r.W)
|
||||
th := 2 * math.Pi * x / float64(r.W)
|
||||
sx += math.Sin(th)
|
||||
cx += math.Cos(th)
|
||||
sy += y
|
||||
}
|
||||
n := float64(len(cells))
|
||||
th := math.Atan2(sx/n, cx/n)
|
||||
if th < 0 {
|
||||
th += 2 * math.Pi
|
||||
}
|
||||
meanX := th / (2 * math.Pi) * float64(r.W)
|
||||
meanY := sy / n
|
||||
|
||||
// The extent, measured relative to the circular centre so the seam is not a boundary.
|
||||
var lo, hi, y0, y1 float64
|
||||
lo, hi = math.Inf(1), math.Inf(-1)
|
||||
y0, y1 = math.Inf(1), math.Inf(-1)
|
||||
for _, i := range cells {
|
||||
x, y := float64(int(i)%r.W), float64(int(i)/r.W)
|
||||
d := x - meanX
|
||||
if d > float64(r.W)/2 {
|
||||
d -= float64(r.W)
|
||||
} else if d < -float64(r.W)/2 {
|
||||
d += float64(r.W)
|
||||
}
|
||||
lo = math.Min(lo, d)
|
||||
hi = math.Max(hi, d)
|
||||
y0 = math.Min(y0, y)
|
||||
y1 = math.Max(y1, y)
|
||||
}
|
||||
|
||||
areaM2 := n * s.MetresPerPxX * s.MetresPerPxY
|
||||
return Feature{
|
||||
Mark: m.Name, Index: idx, Kind: m.Kind, ID: id,
|
||||
CentreM: [2]float64{meanX * s.MetresPerPxX, meanY * s.MetresPerPxY},
|
||||
AreaM2: areaM2,
|
||||
RadiusM: math.Sqrt(areaM2 / math.Pi),
|
||||
ExtentM: [2]float64{(hi - lo + 1) * s.MetresPerPxX, (y1 - y0 + 1) * s.MetresPerPxY},
|
||||
Cells: len(cells),
|
||||
}
|
||||
}
|
||||
|
||||
// trace reduces a painted stroke to its centreline, as an ordered run of pixel indices.
|
||||
//
|
||||
// The stroke's width is not the road; a brush eight pixels wide standing for a cart track is an author saying
|
||||
// "along here", not "this is eighty metres of carriageway". What comes out is the longest line through the
|
||||
// component, which for a stroke is the stroke.
|
||||
//
|
||||
// It is the geodesic diameter, found by two breadth-first searches: from any cell to the furthest cell A,
|
||||
// then from A to the furthest cell B, keeping parents. The walk from B back to A is the path. That is the
|
||||
// standard trick and it is exact on a tree; on a stroke with a loop in it, it takes the long way round, which
|
||||
// is the right answer for a road that loops and the wrong one for a road that forks - a fork reports its two
|
||||
// longest arms as one path and drops the third. The remedy is an author's, not the tool's: paint each run as
|
||||
// its own stroke. `terrain plan` says how many components each path mark has, which is where that shows.
|
||||
//
|
||||
// The walk is then smoothed once and simplified, because a breadth-first search leaves a D8 staircase and a
|
||||
// spline built straight from it would wobble at the pixel scale.
|
||||
func trace(r *Raster, cells []int32) []int32 {
|
||||
if len(cells) < 2 {
|
||||
return cells
|
||||
}
|
||||
// A local index for the component, so the searches do not allocate over the whole map.
|
||||
local := make(map[int32]int32, len(cells)*2)
|
||||
for i, c := range cells {
|
||||
local[c] = int32(i)
|
||||
}
|
||||
|
||||
far := func(from int32) (int32, []int32) {
|
||||
dist := make([]int32, len(cells))
|
||||
parent := make([]int32, len(cells))
|
||||
for i := range dist {
|
||||
dist[i] = -1
|
||||
parent[i] = -1
|
||||
}
|
||||
start := local[from]
|
||||
dist[start] = 0
|
||||
queue := []int32{start}
|
||||
best, bestD := start, int32(0)
|
||||
for head := 0; head < len(queue); head++ {
|
||||
cur := queue[head]
|
||||
ci := cells[cur]
|
||||
x, y := int(ci)%r.W, int(ci)/r.W
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
ny := y + dy
|
||||
if ny < 0 || ny >= r.H {
|
||||
continue
|
||||
}
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue
|
||||
}
|
||||
nx := x + dx
|
||||
if nx < 0 {
|
||||
nx += r.W
|
||||
} else if nx >= r.W {
|
||||
nx -= r.W
|
||||
}
|
||||
n, ok := local[int32(ny*r.W+nx)]
|
||||
if !ok || dist[n] >= 0 {
|
||||
continue
|
||||
}
|
||||
dist[n] = dist[cur] + 1
|
||||
parent[n] = cur
|
||||
if dist[n] > bestD {
|
||||
bestD, best = dist[n], n
|
||||
}
|
||||
queue = append(queue, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, parent
|
||||
}
|
||||
|
||||
a, _ := far(cells[0])
|
||||
b, parent := far(cells[a])
|
||||
|
||||
var path []int32
|
||||
for n := b; n >= 0; n = parent[n] {
|
||||
path = append(path, cells[n])
|
||||
if parent[n] < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Reversed so the line runs from A to B, which is the order the search found them in and therefore the
|
||||
// same order on every run.
|
||||
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
|
||||
path[i], path[j] = path[j], path[i]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// project turns a run of pixels into a simplified polyline in world metres, and measures its length.
|
||||
//
|
||||
// Simplification is Douglas-Peucker at half a pixel of the overlay, which is well below anything an author
|
||||
// drew and well above the single-pixel staircase the walk leaves behind. The seam is handled by unrolling X:
|
||||
// each point is taken to the branch nearest the last, so a road crossing the meridian comes out as one
|
||||
// continuous run of coordinates rather than jumping the width of the world. A consumer that wraps it back
|
||||
// does so knowing the circumference; a consumer that does not gets a spline that still looks right.
|
||||
func project(path []int32, r *Raster, s Scale) ([][2]float64, float64) {
|
||||
if len(path) == 0 {
|
||||
return nil, 0
|
||||
}
|
||||
pts := make([][2]float64, len(path))
|
||||
prevX := float64(int(path[0]) % r.W)
|
||||
for i, p := range path {
|
||||
x, y := float64(int(p)%r.W), float64(int(p)/r.W)
|
||||
for x-prevX > float64(r.W)/2 {
|
||||
x -= float64(r.W)
|
||||
}
|
||||
for prevX-x > float64(r.W)/2 {
|
||||
x += float64(r.W)
|
||||
}
|
||||
prevX = x
|
||||
pts[i] = [2]float64{x, y}
|
||||
}
|
||||
pts = smooth(pts)
|
||||
pts = simplify(pts, 0.5)
|
||||
|
||||
out := make([][2]float64, len(pts))
|
||||
length := 0.0
|
||||
for i, p := range pts {
|
||||
out[i] = [2]float64{p[0] * s.MetresPerPxX, p[1] * s.MetresPerPxY}
|
||||
if i > 0 {
|
||||
length += math.Hypot(out[i][0]-out[i-1][0], out[i][1]-out[i-1][1])
|
||||
}
|
||||
}
|
||||
return out, length
|
||||
}
|
||||
|
||||
// smooth is a three-point moving average with the ends pinned. One pass: enough to take the staircase off a
|
||||
// D8 walk, not enough to pull a real corner off the line it was drawn on.
|
||||
func smooth(p [][2]float64) [][2]float64 {
|
||||
if len(p) < 3 {
|
||||
return p
|
||||
}
|
||||
out := make([][2]float64, len(p))
|
||||
out[0], out[len(p)-1] = p[0], p[len(p)-1]
|
||||
for i := 1; i < len(p)-1; i++ {
|
||||
out[i] = [2]float64{
|
||||
(p[i-1][0] + p[i][0] + p[i+1][0]) / 3,
|
||||
(p[i-1][1] + p[i][1] + p[i+1][1]) / 3,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// simplify is Douglas-Peucker, iterative so a ten-thousand-point stroke cannot blow the stack.
|
||||
func simplify(p [][2]float64, tol float64) [][2]float64 {
|
||||
if len(p) < 3 {
|
||||
return p
|
||||
}
|
||||
keep := make([]bool, len(p))
|
||||
keep[0], keep[len(p)-1] = true, true
|
||||
type span struct{ a, b int }
|
||||
stack := []span{{0, len(p) - 1}}
|
||||
for len(stack) > 0 {
|
||||
sp := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
if sp.b <= sp.a+1 {
|
||||
continue
|
||||
}
|
||||
worst, worstD := -1, tol
|
||||
for i := sp.a + 1; i < sp.b; i++ {
|
||||
if d := perpendicular(p[i], p[sp.a], p[sp.b]); d > worstD {
|
||||
worstD, worst = d, i
|
||||
}
|
||||
}
|
||||
if worst < 0 {
|
||||
continue
|
||||
}
|
||||
keep[worst] = true
|
||||
stack = append(stack, span{sp.a, worst}, span{worst, sp.b})
|
||||
}
|
||||
out := make([][2]float64, 0, len(p))
|
||||
for i, k := range keep {
|
||||
if k {
|
||||
out = append(out, p[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func perpendicular(p, a, b [2]float64) float64 {
|
||||
dx, dy := b[0]-a[0], b[1]-a[1]
|
||||
l := math.Hypot(dx, dy)
|
||||
if l == 0 {
|
||||
return math.Hypot(p[0]-a[0], p[1]-a[1])
|
||||
}
|
||||
return math.Abs(dy*(p[0]-a[0])-dx*(p[1]-a[1])) / l
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/noise"
|
||||
)
|
||||
|
||||
// Filling an overlay in from a baked world, so an author starts from something rather than from nothing.
|
||||
//
|
||||
// The annotation layer is hand-painted and it starts blank, which is the right default and a bad starting
|
||||
// point. Where a forest can grow, where a town would actually stand and what a road between two towns would
|
||||
// follow are all *consequences of the terrain* - of slope, of where the rivers run, of how far the sea is -
|
||||
// and the terrain is the one thing an author cannot see while painting, because the solve has not happened
|
||||
// yet when they are painting classes and the heightmap is 29 million pixels when it has. So the generator
|
||||
// reads a finished bake and proposes the marks the terrain implies. The author then moves them.
|
||||
//
|
||||
// Four rules, and they are the whole design:
|
||||
//
|
||||
// - **A painted pixel is never touched.** Generation fills blank pixels only. An author who has drawn the
|
||||
// capital exactly where they want it can regenerate everything else around it as often as they like, and
|
||||
// the two halves compose rather than competing. This is what makes the feature safe to re-run.
|
||||
// - **It is opt-in per mark.** A mark generates only if it carries a `generate` block. A legend written
|
||||
// before this existed produces exactly the blank sheet it always did, and a mark the author wants to own
|
||||
// completely simply says nothing.
|
||||
// - **It runs at the template's resolution**, which is the overlay's own. Generating on the 8 m geology
|
||||
// grid and downsampling would smear a road across two colours, and the classifier reads exact colours -
|
||||
// a blended pixel is dropped or becomes a different mark. Nothing here antialiases anything, for the
|
||||
// same reason the studio's brush does not.
|
||||
// - **It proposes, it does not decide.** These are starting points. The numbers below are chosen to put
|
||||
// something plausible on the sheet, not to be a settlement model.
|
||||
//
|
||||
// What it is emphatically not: a simulation. There is no economy, no history and no climate here - the Go
|
||||
// generator has no climate model at all - so "where would a city be" is answered with drainage, slope and
|
||||
// distance to the sea, which is the part of the question the terrain can actually answer.
|
||||
|
||||
// Generate kinds. A mark's `generate.kind` picks one.
|
||||
const (
|
||||
// GenForest fills ground that could carry trees: shallow enough, below the treeline, and broken up by a
|
||||
// noise field so it reads as woodland rather than as a contour band.
|
||||
GenForest = "forest"
|
||||
|
||||
// GenSettlement places discs at scored sites - rivers, flat ground, the coast - with a minimum spacing,
|
||||
// largest tier first. Several marks may use it; they share one spacing rule, so a village never lands
|
||||
// inside a city.
|
||||
GenSettlement = "settlement"
|
||||
|
||||
// GenRoad joins the settlements that were placed, along least-cost paths over the terrain. Water is
|
||||
// impassable, so roads never swim: an island group comes out as one road network per island.
|
||||
GenRoad = "road"
|
||||
|
||||
// GenCoast bands the waterline. It is the one kind whose mark usually carries `coast_jitter`, which is
|
||||
// the only overlay property any pass reads.
|
||||
GenCoast = "coast"
|
||||
)
|
||||
|
||||
// GenSpec is a mark's `generate` block: what to put where, and the few numbers worth varying. Every zero
|
||||
// field takes a default that is derived from the world being generated rather than from a constant, because
|
||||
// a treeline in metres means nothing until you know how high the land got.
|
||||
type GenSpec struct {
|
||||
Kind string `json:"kind"`
|
||||
|
||||
// MaxSlopeDeg is the steepest ground this mark will be put on. Forests stop at cliffs, towns stand on
|
||||
// flat ground, and roads climb but grudgingly.
|
||||
MaxSlopeDeg float64 `json:"max_slope_deg"`
|
||||
|
||||
// MinHeightM and MaxHeightM bound the elevation band. MaxHeightM zero means "derive a treeline from the
|
||||
// land's own height distribution", which is the only honest default on a world whose relief is unknown
|
||||
// until it is baked.
|
||||
MinHeightM float64 `json:"min_height_m"`
|
||||
MaxHeightM float64 `json:"max_height_m"`
|
||||
|
||||
// Cover is roughly the fraction of the eligible ground this mark should take, for area kinds. It is a
|
||||
// quantile of the noise field rather than a count, so it means the same thing on any size of world.
|
||||
Cover float64 `json:"cover"`
|
||||
|
||||
// WavelengthKm is how big the patches are, for area kinds.
|
||||
WavelengthKm float64 `json:"wavelength_km"`
|
||||
|
||||
// Count is how many of this mark to place, for settlements.
|
||||
Count int `json:"count"`
|
||||
|
||||
// MinSpacingKm is how far apart settlements must stand. Shared across every settlement mark, taken from
|
||||
// the largest that sets one.
|
||||
MinSpacingKm float64 `json:"min_spacing_km"`
|
||||
|
||||
// RadiusM is how big the painted blob is. Zero derives one from the mark's own min_area_px, so the blob
|
||||
// this writes is never one the feature reducer would then discard as a speck.
|
||||
RadiusM float64 `json:"radius_m"`
|
||||
|
||||
// WidthM is how wide a band or a road is painted. For a road the legend's own width_m is used when this
|
||||
// is zero, because that is the same number said once.
|
||||
WidthM float64 `json:"width_m"`
|
||||
|
||||
// CoastKm is how far inland a coast band reaches, and how close to the sea a settlement wants to be for
|
||||
// its coastal bonus.
|
||||
CoastKm float64 `json:"coast_km"`
|
||||
|
||||
// OnlyClasses and NotClasses restrict a mark to, or bar it from, ground painted with named classes from
|
||||
// the *class* legend.
|
||||
//
|
||||
// They exist because height and slope cannot tell an ice cap from a meadow. The first run of this
|
||||
// generator grew woodland across both polar caps: the caps are flat, they are below the treeline, and
|
||||
// nothing the terrain knows says otherwise - the only thing that does is the colour the author painted
|
||||
// there. A class name that is not in the legend is an error rather than an empty filter, because a
|
||||
// misspelt exclusion is a forest on an ice cap that nobody notices.
|
||||
OnlyClasses []string `json:"only_classes"`
|
||||
NotClasses []string `json:"not_classes"`
|
||||
|
||||
// Resolved forms of the two lists above, as class indices. Filled in by Generate.
|
||||
onlyIdx map[int]bool
|
||||
notIdx map[int]bool
|
||||
}
|
||||
|
||||
// resolveClasses turns the class names into indices against the class legend that was actually loaded.
|
||||
func (g *GenSpec) resolveClasses(markName string, names []string) error {
|
||||
find := func(list []string) (map[int]bool, error) {
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("mark %q names classes, but no class legend was handed to the generator",
|
||||
markName)
|
||||
}
|
||||
out := map[int]bool{}
|
||||
for _, want := range list {
|
||||
found := -1
|
||||
for i, n := range names {
|
||||
if n == want {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found < 0 {
|
||||
return nil, fmt.Errorf("mark %q names the class %q, which is not in the class legend",
|
||||
markName, want)
|
||||
}
|
||||
out[found] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var err error
|
||||
if g.onlyIdx, err = find(g.OnlyClasses); err != nil {
|
||||
return err
|
||||
}
|
||||
g.notIdx, err = find(g.NotClasses)
|
||||
return err
|
||||
}
|
||||
|
||||
func (g *GenSpec) validate(markName string) error {
|
||||
switch g.Kind {
|
||||
case GenForest, GenSettlement, GenRoad, GenCoast:
|
||||
default:
|
||||
return fmt.Errorf("mark %q: generate.kind %q is not one of %q, %q, %q, %q",
|
||||
markName, g.Kind, GenForest, GenSettlement, GenRoad, GenCoast)
|
||||
}
|
||||
if g.Cover < 0 || g.Cover > 1 {
|
||||
return fmt.Errorf("mark %q: generate.cover is %v, outside 0..1", markName, g.Cover)
|
||||
}
|
||||
if g.Count < 0 {
|
||||
return fmt.Errorf("mark %q: generate.count is %d", markName, g.Count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenInputs is the baked world the marks are read off, at the overlay's own resolution.
|
||||
type GenInputs struct {
|
||||
W, H int
|
||||
CellM float64 // metres per overlay pixel
|
||||
|
||||
// HeightM is the surface in metres and Sea is which cells are under water, both over the painted rows
|
||||
// only - the polar pad is scaffolding and has no marks on it.
|
||||
HeightM []float32
|
||||
Sea []bool
|
||||
|
||||
// FlowM2 is drainage area in square metres. Nil is allowed: rivers then contribute nothing to a
|
||||
// settlement's score, which is worth saying out loud rather than silently scoring zero everywhere.
|
||||
FlowM2 []float32
|
||||
|
||||
// ClassAt is the class legend's index per cell, and ClassNames the names those indices mean. Both are
|
||||
// optional together: without them only_classes and not_classes cannot be honoured, and asking for one is
|
||||
// then an error rather than a filter that quietly does nothing.
|
||||
ClassAt []uint8
|
||||
ClassNames []string
|
||||
|
||||
Seed int64
|
||||
|
||||
// Existing is the overlay as it stands. Its painted pixels are preserved exactly and generation fills
|
||||
// around them. Nil is a blank sheet.
|
||||
Existing *Raster
|
||||
}
|
||||
|
||||
// GenReport is what was placed, for the run summary.
|
||||
type GenReport struct {
|
||||
Marks []GenMarkReport
|
||||
Kept int // pixels that were already painted and were left alone
|
||||
Painted int // pixels this generation filled
|
||||
TreelineM float64
|
||||
Settlement []Placed
|
||||
}
|
||||
|
||||
// GenMarkReport is one mark's share of a generation.
|
||||
type GenMarkReport struct {
|
||||
Name string
|
||||
Kind string
|
||||
Cells int
|
||||
Pieces int // settlements placed, or roads traced
|
||||
|
||||
// Wanted is how many were asked for, when that is a number the legend gave. Reported separately from
|
||||
// Pieces so a run that could not fit them all says so: the spacing and the amount of flat ground are
|
||||
// what ration settlements, and an author who asked for forty and got eighteen needs to be told, not left
|
||||
// to count the dots.
|
||||
Wanted int
|
||||
}
|
||||
|
||||
// Placed is one settlement, kept so the roads can be run between them and so the summary can say where they
|
||||
// went.
|
||||
type Placed struct {
|
||||
Mark int // raster index
|
||||
X, Y int
|
||||
Score float64
|
||||
RadPx int
|
||||
Region int // which connected landmass, so roads never try to cross open water
|
||||
}
|
||||
|
||||
// wantedFor is how many of a mark the legend asked for, or zero when it is not a counted kind.
|
||||
func wantedFor(m *Mark) int {
|
||||
if m.Generate == nil {
|
||||
return 0
|
||||
}
|
||||
return m.Generate.Count
|
||||
}
|
||||
|
||||
// Generate fills the blank parts of an overlay from a baked world.
|
||||
func (l *Legend) Generate(in GenInputs) (*Raster, GenReport, error) {
|
||||
var rep GenReport
|
||||
if in.W <= 0 || in.H <= 0 {
|
||||
return nil, rep, fmt.Errorf("overlay generation needs a size, got %dx%d", in.W, in.H)
|
||||
}
|
||||
if len(in.HeightM) != in.W*in.H || len(in.Sea) != in.W*in.H {
|
||||
return nil, rep, fmt.Errorf("overlay generation: height and sea must be %d cells", in.W*in.H)
|
||||
}
|
||||
for i := range l.Marks {
|
||||
if g := l.Marks[i].Generate; g != nil {
|
||||
if err := g.validate(l.Marks[i].Name); err != nil {
|
||||
return nil, rep, err
|
||||
}
|
||||
if err := g.resolveClasses(l.Marks[i].Name, in.ClassNames); err != nil {
|
||||
return nil, rep, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if in.ClassAt != nil && len(in.ClassAt) != in.W*in.H {
|
||||
return nil, rep, fmt.Errorf("overlay generation: the class raster is %d cells and the grid is %d",
|
||||
len(in.ClassAt), in.W*in.H)
|
||||
}
|
||||
|
||||
out := &Raster{W: in.W, H: in.H, Mark: make([]uint8, in.W*in.H)}
|
||||
// What was on the sheet before this run, kept separately from what is on it now. The distinction is the
|
||||
// whole layering rule: a hand-painted pixel is never touched, while a mark this run has just put down
|
||||
// may be built over by a later one - a road through generated woodland is a road, and a town on it is a
|
||||
// town. Without the two being different, whichever kind painted first would block every kind after it,
|
||||
// which is exactly what happened on the first run: a coastal band claimed a fifth of the world and the
|
||||
// settlements and roads placed inside it painted nothing at all.
|
||||
protectedPx := make([]bool, in.W*in.H)
|
||||
if in.Existing != nil {
|
||||
if in.Existing.W != in.W || in.Existing.H != in.H {
|
||||
return nil, rep, fmt.Errorf("the overlay on disk is %dx%d and the generator is working at %dx%d",
|
||||
in.Existing.W, in.Existing.H, in.W, in.H)
|
||||
}
|
||||
copy(out.Mark, in.Existing.Mark)
|
||||
for i, m := range out.Mark {
|
||||
if m != Blank {
|
||||
protectedPx[i] = true
|
||||
rep.Kept++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d := newGenData(in)
|
||||
d.protected = protectedPx
|
||||
rep.TreelineM = d.treelineM
|
||||
|
||||
// Painting order is coarse to fine: the coastal band, then woodland, then the roads across it, then the
|
||||
// settlements the roads run between.
|
||||
//
|
||||
// Two area marks never overwrite each other - the first in legend order claims the overlap, because
|
||||
// deciding that a forest beats a coastline or the reverse is an authoring judgement and not one a
|
||||
// generator should make silently. Roads and settlements do overwrite generated areas, because they are
|
||||
// the thing being placed and the area is the ground it stands on.
|
||||
order := []string{GenCoast, GenForest, GenRoad, GenSettlement}
|
||||
byKind := map[string][]int{}
|
||||
for i := range l.Marks {
|
||||
if g := l.Marks[i].Generate; g != nil {
|
||||
byKind[g.Kind] = append(byKind[g.Kind], i)
|
||||
}
|
||||
}
|
||||
|
||||
// Settlements are placed before the roads are drawn even though they are painted after, because the
|
||||
// roads are the paths between them and cannot be traced until they exist.
|
||||
if len(byKind[GenSettlement]) > 0 {
|
||||
rep.Settlement = l.placeSettlements(byKind[GenSettlement], d)
|
||||
}
|
||||
|
||||
for _, kind := range order {
|
||||
for _, mi := range byKind[kind] {
|
||||
m := &l.Marks[mi]
|
||||
idx := uint8(mi + 1)
|
||||
var cells, pieces int
|
||||
switch kind {
|
||||
case GenCoast:
|
||||
cells = l.paintCoastBand(m, d, out, idx)
|
||||
case GenForest:
|
||||
cells = l.paintForest(m, d, out, idx)
|
||||
case GenRoad:
|
||||
cells, pieces = l.paintRoads(m, d, out, idx, rep.Settlement)
|
||||
case GenSettlement:
|
||||
cells, pieces = paintSettlements(d, out, idx, rep.Settlement, l.MinArea(m))
|
||||
}
|
||||
rep.Marks = append(rep.Marks, GenMarkReport{
|
||||
Name: m.Name, Kind: kind, Cells: cells, Pieces: pieces, Wanted: wantedFor(m),
|
||||
})
|
||||
rep.Painted += cells
|
||||
}
|
||||
}
|
||||
return out, rep, nil
|
||||
}
|
||||
|
||||
// genData is everything derived once and shared by the kinds: slope, distance to the sea, the treeline and
|
||||
// the landmass labels.
|
||||
type genData struct {
|
||||
in GenInputs
|
||||
|
||||
// protected marks the pixels that were already painted when this run started. Nothing here may write to
|
||||
// one, whatever kind it is.
|
||||
protected []bool
|
||||
|
||||
slopeDeg []float32
|
||||
coastKm []float32 // distance to the nearest sea cell, kilometres; land only
|
||||
landID []int32 // connected landmass, -1 at sea
|
||||
treelineM float64
|
||||
landMaxM float64
|
||||
flowLog []float32 // log10 of drainage area, normalised 0..1 over the land
|
||||
}
|
||||
|
||||
func newGenData(in GenInputs) *genData {
|
||||
d := &genData{in: in}
|
||||
d.slopeDeg = slopeField(in.HeightM, in.W, in.H, in.CellM)
|
||||
d.coastKm = coastDistanceKm(in.Sea, in.W, in.H, in.CellM)
|
||||
d.landID = labelLandmasses(in.Sea, in.W, in.H)
|
||||
|
||||
// The treeline is a quantile of the land's own heights rather than a number in metres, because a metre
|
||||
// means nothing until the world is baked: the same legend over a 47 m plain and a 2800 m range has to
|
||||
// put trees on both. Two thirds of the way up leaves the summits bare on a world that has summits and
|
||||
// takes almost nothing off a world that does not - which is correct, a lowland has no treeline.
|
||||
var hs []float32
|
||||
for i, s := range in.Sea {
|
||||
if !s {
|
||||
hs = append(hs, in.HeightM[i])
|
||||
}
|
||||
}
|
||||
if len(hs) > 0 {
|
||||
sort.Slice(hs, func(a, b int) bool { return hs[a] < hs[b] })
|
||||
d.landMaxM = float64(hs[len(hs)-1])
|
||||
d.treelineM = float64(hs[int(float64(len(hs)-1)*0.94)])
|
||||
}
|
||||
|
||||
if in.FlowM2 != nil && len(in.FlowM2) == in.W*in.H {
|
||||
d.flowLog = make([]float32, in.W*in.H)
|
||||
cell := in.CellM * in.CellM
|
||||
// Normalised against a trunk river's catchment rather than the map's largest, so one enormous basin
|
||||
// cannot flatten every other river to nothing.
|
||||
hi := math.Log10(math.Max(cell*4, 5e7))
|
||||
lo := math.Log10(math.Max(cell, 1))
|
||||
for i, f := range in.FlowM2 {
|
||||
if in.Sea[i] || f <= 0 {
|
||||
continue
|
||||
}
|
||||
t := (math.Log10(float64(f)) - lo) / (hi - lo)
|
||||
d.flowLog[i] = float32(math.Max(0, math.Min(1, t)))
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// slopeField is the surface gradient in degrees, central differences, X wrapped because the world is a
|
||||
// cylinder and Y clamped because it is not a sphere.
|
||||
func slopeField(h []float32, w, hgt int, cellM float64) []float32 {
|
||||
out := make([]float32, w*hgt)
|
||||
field.Rows(hgt, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
ym := y - 1
|
||||
if ym < 0 {
|
||||
ym = 0
|
||||
}
|
||||
yp := y + 1
|
||||
if yp >= hgt {
|
||||
yp = hgt - 1
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
xm := (x - 1 + w) % w
|
||||
xp := (x + 1) % w
|
||||
dzdx := float64(h[y*w+xp]-h[y*w+xm]) / (2 * cellM)
|
||||
dzdy := float64(h[yp*w+x]-h[ym*w+x]) / (2 * cellM)
|
||||
out[y*w+x] = float32(math.Atan(math.Hypot(dzdx, dzdy)) * 180 / math.Pi)
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// coastDistanceKm is how far each land cell is from the sea, by a multi-source breadth-first walk over the
|
||||
// eight neighbours with X wrapped. Hop distance rather than Euclidean: it is a score input, and a BFS over
|
||||
// 29 million cells costs one pass where a distance transform costs several.
|
||||
func coastDistanceKm(sea []bool, w, h int, cellM float64) []float32 {
|
||||
out := make([]float32, w*h)
|
||||
for i := range out {
|
||||
out[i] = -1
|
||||
}
|
||||
queue := make([]int32, 0, w*8)
|
||||
for i, s := range sea {
|
||||
if s {
|
||||
continue
|
||||
}
|
||||
x, y := i%w, i/w
|
||||
if touchesSea(sea, w, h, x, y) {
|
||||
out[i] = 0
|
||||
queue = append(queue, int32(i))
|
||||
}
|
||||
}
|
||||
hop := float32(cellM / 1000)
|
||||
for head := 0; head < len(queue); head++ {
|
||||
c := int(queue[head])
|
||||
cx, cy := c%w, c/w
|
||||
d := out[c] + hop
|
||||
for _, o := range neighbours8 {
|
||||
nx := (cx + o[0] + w) % w
|
||||
ny := cy + o[1]
|
||||
if ny < 0 || ny >= h {
|
||||
continue
|
||||
}
|
||||
n := ny*w + nx
|
||||
if sea[n] || out[n] >= 0 {
|
||||
continue
|
||||
}
|
||||
out[n] = d
|
||||
queue = append(queue, int32(n))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var neighbours8 = [8][2]int{{-1, -1}, {0, -1}, {1, -1}, {-1, 0}, {1, 0}, {-1, 1}, {0, 1}, {1, 1}}
|
||||
|
||||
func touchesSea(sea []bool, w, h, x, y int) bool {
|
||||
for _, o := range neighbours8 {
|
||||
nx := (x + o[0] + w) % w
|
||||
ny := y + o[1]
|
||||
if ny < 0 || ny >= h {
|
||||
continue
|
||||
}
|
||||
if sea[ny*w+nx] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// labelLandmasses numbers the connected land components, X wrapped, so a landmass across the seam is one
|
||||
// landmass. Roads are built per component, which is what stops them crossing open water.
|
||||
func labelLandmasses(sea []bool, w, h int) []int32 {
|
||||
out := make([]int32, w*h)
|
||||
for i := range out {
|
||||
out[i] = -1
|
||||
}
|
||||
var stack []int32
|
||||
next := int32(0)
|
||||
for start := range sea {
|
||||
if sea[start] || out[start] >= 0 {
|
||||
continue
|
||||
}
|
||||
id := next
|
||||
next++
|
||||
out[start] = id
|
||||
stack = append(stack[:0], int32(start))
|
||||
for len(stack) > 0 {
|
||||
c := int(stack[len(stack)-1])
|
||||
stack = stack[:len(stack)-1]
|
||||
cx, cy := c%w, c/w
|
||||
for _, o := range neighbours8 {
|
||||
nx := (cx + o[0] + w) % w
|
||||
ny := cy + o[1]
|
||||
if ny < 0 || ny >= h {
|
||||
continue
|
||||
}
|
||||
n := ny*w + nx
|
||||
if sea[n] || out[n] >= 0 {
|
||||
continue
|
||||
}
|
||||
out[n] = id
|
||||
stack = append(stack, int32(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// eligible is the shared test every kind starts from: on land, not too steep, inside the height band.
|
||||
func (d *genData) eligible(i int, g *GenSpec, maxDefault float64) bool {
|
||||
if d.in.Sea[i] {
|
||||
return false
|
||||
}
|
||||
maxSlope := g.MaxSlopeDeg
|
||||
if maxSlope <= 0 {
|
||||
maxSlope = maxDefault
|
||||
}
|
||||
if float64(d.slopeDeg[i]) > maxSlope {
|
||||
return false
|
||||
}
|
||||
if !d.classAllows(i, g) {
|
||||
return false
|
||||
}
|
||||
hm := float64(d.in.HeightM[i])
|
||||
if hm < g.MinHeightM {
|
||||
return false
|
||||
}
|
||||
top := g.MaxHeightM
|
||||
if top <= 0 {
|
||||
top = d.treelineM
|
||||
}
|
||||
return top <= 0 || hm <= top
|
||||
}
|
||||
|
||||
// classAllows applies a mark's only_classes and not_classes to one cell.
|
||||
func (d *genData) classAllows(i int, g *GenSpec) bool {
|
||||
if d.in.ClassAt == nil || (g.onlyIdx == nil && g.notIdx == nil) {
|
||||
return true
|
||||
}
|
||||
c := int(d.in.ClassAt[i])
|
||||
if g.notIdx != nil && g.notIdx[c] {
|
||||
return false
|
||||
}
|
||||
if g.onlyIdx != nil && !g.onlyIdx[c] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// paintForest fills eligible ground where a noise field stands above a quantile, so woodland has an outline
|
||||
// rather than a contour edge. The field is indexed by world position (cross-cutting rule 1), so the same
|
||||
// ground gets the same trees whatever else changes.
|
||||
func (l *Legend) paintForest(m *Mark, d *genData, out *Raster, idx uint8) int {
|
||||
g := m.Generate
|
||||
cover := g.Cover
|
||||
if cover <= 0 {
|
||||
cover = 0.45
|
||||
}
|
||||
wavelengthKm := g.WavelengthKm
|
||||
if wavelengthKm <= 0 {
|
||||
wavelengthKm = 6
|
||||
}
|
||||
in := d.in
|
||||
circM := float64(in.W) * in.CellM
|
||||
u, v := noise.WorldUV(in.W, in.H, in.CellM, 0, 0, math.Max(circM, 1))
|
||||
cells := math.Max(1, math.Round(circM/(wavelengthKm*1000)))
|
||||
f := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcOverlayForest),
|
||||
noise.Params{BaseCells: int(cells), Octaves: 4, Gain: 0.5})
|
||||
|
||||
// The threshold is a quantile of the noise *over the eligible ground*, so `cover` means what it says on a
|
||||
// world whose eligible ground is a thin strip as much as on one where it is everything.
|
||||
// The quantile is taken over the ground this mark can actually take - eligible and not already claimed -
|
||||
// so `cover` means the same fraction whether or not another area mark got there first.
|
||||
var vals []float32
|
||||
for i := range out.Mark {
|
||||
if out.Mark[i] == Blank && d.eligible(i, g, 25) {
|
||||
vals = append(vals, f.Data[i])
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return 0
|
||||
}
|
||||
sort.Slice(vals, func(a, b int) bool { return vals[a] < vals[b] })
|
||||
cut := vals[int(float64(len(vals)-1)*(1-cover))]
|
||||
|
||||
n := 0
|
||||
for i := range out.Mark {
|
||||
if out.Mark[i] != Blank || f.Data[i] < cut || !d.eligible(i, g, 25) {
|
||||
continue
|
||||
}
|
||||
out.Mark[i] = idx
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// paintCoastBand marks a strip inland of the waterline. Its usual purpose is to carry `coast_jitter`, so it
|
||||
// deliberately follows the shore rather than any other feature.
|
||||
func (l *Legend) paintCoastBand(m *Mark, d *genData, out *Raster, idx uint8) int {
|
||||
g := m.Generate
|
||||
reachKm := g.CoastKm
|
||||
if reachKm <= 0 {
|
||||
if g.WidthM > 0 {
|
||||
reachKm = g.WidthM / 1000
|
||||
} else {
|
||||
reachKm = 1.5
|
||||
}
|
||||
}
|
||||
n := 0
|
||||
for i := range out.Mark {
|
||||
if out.Mark[i] != Blank || d.in.Sea[i] {
|
||||
continue
|
||||
}
|
||||
if c := d.coastKm[i]; c >= 0 && float64(c) <= reachKm {
|
||||
out.Mark[i] = idx
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// placeSettlements scores the land and takes the best sites, largest tier first, with one spacing rule
|
||||
// shared by every settlement mark so a village never lands inside a city.
|
||||
//
|
||||
// The score is the part of "where would a town be" that terrain can answer: fresh water, flat ground, and
|
||||
// the sea. Everything else about a settlement - trade, history, who won a war - is the author's, which is
|
||||
// why these are proposals in an editable sheet rather than a placement the bake bakes in.
|
||||
func (l *Legend) placeSettlements(marks []int, d *genData) []Placed {
|
||||
in := d.in
|
||||
spacingKm := 0.0
|
||||
for _, mi := range marks {
|
||||
if s := l.Marks[mi].Generate.MinSpacingKm; s > spacingKm {
|
||||
spacingKm = s
|
||||
}
|
||||
}
|
||||
if spacingKm <= 0 {
|
||||
spacingKm = 4
|
||||
}
|
||||
spacingPx := math.Max(2, spacingKm*1000/in.CellM)
|
||||
|
||||
// Tiers in the order the legend lists them, which is how an author already writes them: city, town,
|
||||
// village. The first listed takes the best sites.
|
||||
type tier struct {
|
||||
mi int
|
||||
g *GenSpec
|
||||
radPx int
|
||||
}
|
||||
var tiers []tier
|
||||
for _, mi := range marks {
|
||||
g := l.Marks[mi].Generate
|
||||
radM := g.RadiusM
|
||||
if radM <= 0 {
|
||||
// Big enough that the feature reducer will not drop it as a speck. The margin is generous on
|
||||
// purpose: a disc loses area wherever it meets ground that is already painted, and a settlement
|
||||
// that came out just under its own min_area_px would be placed, reported, and then silently
|
||||
// dropped by the feature pass - which is what happened to a city on the first real run. 1.6
|
||||
// linear is 2.6x the area, so it survives losing more than half of itself.
|
||||
minArea := float64(l.MinArea(&l.Marks[mi]))
|
||||
radM = math.Sqrt(minArea/math.Pi) * in.CellM * 1.6
|
||||
}
|
||||
radPx := int(math.Max(1, math.Round(radM/in.CellM)))
|
||||
tiers = append(tiers, tier{mi: mi, g: g, radPx: radPx})
|
||||
}
|
||||
|
||||
// Candidates are taken on a stride rather than from every cell: two sites a quarter of the spacing apart
|
||||
// are the same site, and sorting 29 million scores to throw away all but fifty is work for nothing.
|
||||
stride := int(math.Max(1, math.Floor(spacingPx/4)))
|
||||
type cand struct {
|
||||
i int
|
||||
score float64
|
||||
}
|
||||
var cands []cand
|
||||
for y := 0; y < in.H; y += stride {
|
||||
for x := 0; x < in.W; x += stride {
|
||||
i := y*in.W + x
|
||||
s := d.settlementScore(i)
|
||||
if s > 0 {
|
||||
// The seed picks among the plausible sites; the terrain decides which sites are plausible at
|
||||
// all. Without this the score is a pure function of the ground, so every press of the
|
||||
// studio's generate button proposes exactly the same towns and a re-roll re-rolls nothing.
|
||||
// A third either way reshuffles the ranking among comparable ground while still leaving a
|
||||
// river mouth on a plain beating a hillside.
|
||||
s *= 1 + settlementJitter*(hash01(uint64(i), uint64(in.Seed))-0.5)
|
||||
cands = append(cands, cand{i: i, score: s})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sorted by score, ties broken by index so the result does not depend on the sort's stability.
|
||||
sort.Slice(cands, func(a, b int) bool {
|
||||
if cands[a].score != cands[b].score {
|
||||
return cands[a].score > cands[b].score
|
||||
}
|
||||
return cands[a].i < cands[b].i
|
||||
})
|
||||
|
||||
var placed []Placed
|
||||
taken := make([][2]int, 0, 64)
|
||||
sp2 := spacingPx * spacingPx
|
||||
farEnough := func(x, y int) bool {
|
||||
for _, t := range taken {
|
||||
dx := float64(wrapDelta(x-t[0], in.W))
|
||||
dy := float64(y - t[1])
|
||||
if dx*dx+dy*dy < sp2 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, t := range tiers {
|
||||
want := t.g.Count
|
||||
if want <= 0 {
|
||||
continue
|
||||
}
|
||||
got := 0
|
||||
maxSlope := t.g.MaxSlopeDeg
|
||||
if maxSlope <= 0 {
|
||||
maxSlope = 8
|
||||
}
|
||||
for _, c := range cands {
|
||||
if got >= want {
|
||||
break
|
||||
}
|
||||
if float64(d.slopeDeg[c.i]) > maxSlope {
|
||||
continue
|
||||
}
|
||||
x, y := c.i%in.W, c.i/in.W
|
||||
if !farEnough(x, y) {
|
||||
continue
|
||||
}
|
||||
taken = append(taken, [2]int{x, y})
|
||||
placed = append(placed, Placed{
|
||||
Mark: t.mi + 1, X: x, Y: y, Score: c.score, RadPx: t.radPx,
|
||||
Region: int(d.landID[c.i]),
|
||||
})
|
||||
got++
|
||||
}
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
// settlementJitter is how far the seed may move a site's score, as a fraction. Large enough that the
|
||||
// ranking among comparable ground genuinely reshuffles between presses, small enough that a site three times
|
||||
// better than its neighbour still wins every time.
|
||||
const settlementJitter = 0.65
|
||||
|
||||
// hash01 is a deterministic value in [0,1) from two integers: splitmix64 finalised. Not a stream, so it does
|
||||
// not matter which order the cells are visited in, which is cross-cutting rule 12.
|
||||
func hash01(a, b uint64) float64 {
|
||||
x := a*0x9e3779b97f4a7c15 + b*0xbf58476d1ce4e5b9
|
||||
x ^= x >> 30
|
||||
x *= 0xbf58476d1ce4e5b9
|
||||
x ^= x >> 27
|
||||
x *= 0x94d049bb133111eb
|
||||
x ^= x >> 31
|
||||
return float64(x>>11) / float64(1<<53)
|
||||
}
|
||||
|
||||
// settlementScore is 0 where nobody would build and rises with the three things the terrain knows.
|
||||
//
|
||||
// Ground that is already painted scores zero, which is not a judgement about the ground: a site there cannot
|
||||
// be stamped, because nothing may overwrite a hand-painted pixel. Scoring it anyway is how a settlement gets
|
||||
// placed, counted and reported and then paints nothing at all - measured on the shipped template, one city of
|
||||
// three and six villages of eighteen came out as empty blobs that the feature pass then dropped, so the run
|
||||
// summary and `terrain plan` disagreed with each other and neither was wrong.
|
||||
func (d *genData) settlementScore(i int) float64 {
|
||||
if d.in.Sea[i] || d.protected[i] {
|
||||
return 0
|
||||
}
|
||||
slope := float64(d.slopeDeg[i])
|
||||
if slope > 12 {
|
||||
return 0
|
||||
}
|
||||
flat := 1 - slope/12
|
||||
|
||||
river := 0.0
|
||||
if d.flowLog != nil {
|
||||
river = float64(d.flowLog[i])
|
||||
}
|
||||
|
||||
// A coast bonus that falls off over a few kilometres: a harbour is worth a great deal, being forty
|
||||
// kilometres inland is worth nothing either way.
|
||||
coast := 0.0
|
||||
if c := d.coastKm[i]; c >= 0 {
|
||||
coast = math.Max(0, 1-float64(c)/5)
|
||||
}
|
||||
|
||||
// Flat ground is a precondition rather than an attraction, so it multiplies; water and the sea are the
|
||||
// reasons to be here, so they add.
|
||||
return flat * (0.15 + 1.5*river + 1.0*coast)
|
||||
}
|
||||
|
||||
// paintSettlements stamps each placed site, growing the disc until the blob is big enough to survive the
|
||||
// feature pass.
|
||||
//
|
||||
// The growth loop is not a flourish. A disc loses whatever part of itself falls on a coastline somebody has
|
||||
// already painted, or on the sea, and settlements are scored *towards* the coast, so the loss is routine
|
||||
// rather than rare. Without it the generator places a town, reports it, writes it, and the feature reducer
|
||||
// then drops it as a speck - so `terrain plan` lists fewer settlements than the run said it made, with
|
||||
// nothing anywhere to explain the difference. Measured on the shipped template: three cities placed and two
|
||||
// reported, eighteen villages placed and twelve reported.
|
||||
//
|
||||
// It gives up after a few tries rather than growing without limit: a site hemmed in on every side is telling
|
||||
// you it is a bad site, and a village the size of a county is worse than a missing one.
|
||||
func paintSettlements(d *genData, out *Raster, idx uint8, placed []Placed, minArea int) (int, int) {
|
||||
n, pieces := 0, 0
|
||||
for _, p := range placed {
|
||||
if uint8(p.Mark) != idx {
|
||||
continue
|
||||
}
|
||||
pieces++
|
||||
got, r := 0, p.RadPx
|
||||
for try := 0; try < 4; try++ {
|
||||
// Re-stamping a larger disc only adds the new ring, because the cells already taken carry this
|
||||
// mark, so the area accumulates rather than being recounted.
|
||||
got += stampDisc(out, d, p.X, p.Y, r, idx, true)
|
||||
if got >= minArea {
|
||||
break
|
||||
}
|
||||
r = int(math.Ceil(float64(r) * 1.5))
|
||||
}
|
||||
n += got
|
||||
}
|
||||
return n, pieces
|
||||
}
|
||||
|
||||
// stampDisc paints a filled circle, wrapping in X.
|
||||
//
|
||||
// overArea says whether this mark may cover ground another generated mark has already taken. A hand-painted
|
||||
// pixel is never covered either way, which is what keeps a drawn stroke intact underneath a generated town.
|
||||
func stampDisc(out *Raster, d *genData, cx, cy, r int, idx uint8, overArea bool) int {
|
||||
n := 0
|
||||
r2 := r * r
|
||||
for dy := -r; dy <= r; dy++ {
|
||||
y := cy + dy
|
||||
if y < 0 || y >= out.H {
|
||||
continue
|
||||
}
|
||||
for dx := -r; dx <= r; dx++ {
|
||||
if dx*dx+dy*dy > r2 {
|
||||
continue
|
||||
}
|
||||
x := ((cx+dx)%out.W + out.W) % out.W
|
||||
i := y*out.W + x
|
||||
if d.in.Sea[i] || d.protected[i] || (!overArea && out.Mark[i] != Blank) {
|
||||
continue
|
||||
}
|
||||
out.Mark[i] = idx
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func wrapDelta(d, w int) int {
|
||||
if d > w/2 {
|
||||
d -= w
|
||||
} else if d < -w/2 {
|
||||
d += w
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// srcOverlayForest is this pass's seeded noise stream. It sits above the detail passes' 40s and the
|
||||
// tectonic 50s so that adding one here cannot reshuffle any existing field.
|
||||
const srcOverlayForest = 60
|
||||
@@ -0,0 +1,335 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Roads: the least-cost paths between the settlements that were just placed.
|
||||
//
|
||||
// A road is the one mark whose shape is not a judgement at all. Given where two towns are, the line between
|
||||
// them is whatever the ground allows - up the valley, round the spur, across the saddle - and that is a
|
||||
// shortest-path problem with a cost function, not a drawing. It is also the single most tedious thing to
|
||||
// paint by hand, because getting it right means reading a heightmap pixel by pixel.
|
||||
//
|
||||
// Three decisions worth stating:
|
||||
//
|
||||
// - **Water is impassable, so roads never swim.** Each landmass gets its own network. A bridge or a ferry
|
||||
// is a deliberate act and belongs to the author, and a generator that guessed at them would put a
|
||||
// motorway across a strait it has no idea is thirty kilometres wide.
|
||||
// - **A minimum spanning tree, not every pair.** Joining all pairs gives a cobweb; the tree gives exactly
|
||||
// enough road to reach everywhere, which is both what a road network minimally is and the thing an
|
||||
// author can most easily add to. Edges are weighted by path *cost*, not by straight-line distance, so
|
||||
// two towns either side of a range are correctly further apart than the map says.
|
||||
// - **It runs on a coarsened grid.** A road at the overlay's full resolution would be a Dijkstra over
|
||||
// twenty-nine million cells per settlement. The cost surface is smooth at the scale a road cares about,
|
||||
// so it is pooled to a few hundred cells across, solved there, and the resulting polyline is stamped
|
||||
// back at full resolution with the mark's real width.
|
||||
|
||||
// roadGrid is the coarsened cost surface the paths are solved on.
|
||||
type roadGrid struct {
|
||||
w, h int
|
||||
step int // overlay pixels per coarse cell
|
||||
cost []float32 // per coarse cell, +Inf where impassable
|
||||
scale float64 // overlay pixels per coarse cell, as a float
|
||||
}
|
||||
|
||||
func buildRoadGrid(d *genData, maxSlopeDeg float64) *roadGrid {
|
||||
in := d.in
|
||||
// About six hundred cells around the world: fine enough that a coarse cell is well under a kilometre on
|
||||
// any world this tool makes, coarse enough that fifty Dijkstras are a second's work.
|
||||
step := int(math.Max(1, math.Round(float64(in.W)/600)))
|
||||
gw := (in.W + step - 1) / step
|
||||
gh := (in.H + step - 1) / step
|
||||
g := &roadGrid{w: gw, h: gh, step: step, scale: float64(step), cost: make([]float32, gw*gh)}
|
||||
|
||||
inf := float32(math.Inf(1))
|
||||
for gy := 0; gy < gh; gy++ {
|
||||
for gx := 0; gx < gw; gx++ {
|
||||
// Pool the block: any sea in it makes the cell water, because a road that clips a bay is a road
|
||||
// in the sea. The slope taken is the worst in the block, for the same reason.
|
||||
var worst float64
|
||||
wet := false
|
||||
for y := gy * step; y < (gy+1)*step && y < in.H; y++ {
|
||||
for x := gx * step; x < (gx+1)*step && x < in.W; x++ {
|
||||
i := y*in.W + x
|
||||
if in.Sea[i] {
|
||||
wet = true
|
||||
break
|
||||
}
|
||||
if s := float64(d.slopeDeg[i]); s > worst {
|
||||
worst = s
|
||||
}
|
||||
}
|
||||
if wet {
|
||||
break
|
||||
}
|
||||
}
|
||||
gi := gy*gw + gx
|
||||
switch {
|
||||
case wet:
|
||||
g.cost[gi] = inf
|
||||
case worst > maxSlopeDeg:
|
||||
g.cost[gi] = inf
|
||||
default:
|
||||
// Slope is what a road pays for. Quadratic rather than linear so that a route prefers a long
|
||||
// gentle way round to a short steep one, which is what a real road does.
|
||||
t := worst / math.Max(maxSlopeDeg, 1e-6)
|
||||
g.cost[gi] = float32(1 + 12*t*t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *roadGrid) idx(x, y int) int { return y*g.w + x }
|
||||
|
||||
// dijkstra returns the cost to every reachable coarse cell from a source, and the predecessor chain to walk
|
||||
// a path back. A binary heap over a few hundred thousand cells; the graph is eight-connected and X wraps.
|
||||
func (g *roadGrid) dijkstra(src int) (cost []float32, pred []int32) {
|
||||
n := g.w * g.h
|
||||
cost = make([]float32, n)
|
||||
pred = make([]int32, n)
|
||||
inf := float32(math.Inf(1))
|
||||
for i := range cost {
|
||||
cost[i] = inf
|
||||
pred[i] = -1
|
||||
}
|
||||
if math.IsInf(float64(g.cost[src]), 1) {
|
||||
return cost, pred
|
||||
}
|
||||
cost[src] = 0
|
||||
h := &costHeap{keys: []float32{0}, items: []int32{int32(src)}}
|
||||
for h.Len() > 0 {
|
||||
c := int(h.pop())
|
||||
cx, cy := c%g.w, c/g.w
|
||||
base := cost[c]
|
||||
for _, o := range neighbours8 {
|
||||
nx := (cx + o[0] + g.w) % g.w
|
||||
ny := cy + o[1]
|
||||
if ny < 0 || ny >= g.h {
|
||||
continue
|
||||
}
|
||||
n := g.idx(nx, ny)
|
||||
cc := g.cost[n]
|
||||
if math.IsInf(float64(cc), 1) {
|
||||
continue
|
||||
}
|
||||
// Diagonal steps cost their real length, or the network shows a bias along the axes.
|
||||
step := float32(1.0)
|
||||
if o[0] != 0 && o[1] != 0 {
|
||||
step = float32(math.Sqrt2)
|
||||
}
|
||||
next := base + cc*step
|
||||
if next < cost[n] {
|
||||
cost[n] = next
|
||||
pred[n] = int32(c)
|
||||
h.push(int32(n), next)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cost, pred
|
||||
}
|
||||
|
||||
// costHeap is a binary min-heap of coarse cells. Lazy deletion is not needed because a cell is only pushed
|
||||
// when its cost strictly improves, and a stale entry pops with a cost no better than the settled one.
|
||||
type costHeap struct {
|
||||
keys []float32
|
||||
items []int32
|
||||
}
|
||||
|
||||
func (h *costHeap) Len() int { return len(h.items) }
|
||||
|
||||
func (h *costHeap) push(item int32, key float32) {
|
||||
h.keys = append(h.keys, key)
|
||||
h.items = append(h.items, item)
|
||||
i := len(h.items) - 1
|
||||
for i > 0 {
|
||||
p := (i - 1) / 2
|
||||
if h.keys[p] <= h.keys[i] {
|
||||
break
|
||||
}
|
||||
h.keys[p], h.keys[i] = h.keys[i], h.keys[p]
|
||||
h.items[p], h.items[i] = h.items[i], h.items[p]
|
||||
i = p
|
||||
}
|
||||
}
|
||||
|
||||
func (h *costHeap) pop() int32 {
|
||||
top := h.items[0]
|
||||
last := len(h.items) - 1
|
||||
h.keys[0], h.items[0] = h.keys[last], h.items[last]
|
||||
h.keys = h.keys[:last]
|
||||
h.items = h.items[:last]
|
||||
i := 0
|
||||
for {
|
||||
l := 2*i + 1
|
||||
if l >= last {
|
||||
break
|
||||
}
|
||||
if r := l + 1; r < last && h.keys[r] < h.keys[l] {
|
||||
l = r
|
||||
}
|
||||
if h.keys[l] >= h.keys[i] {
|
||||
break
|
||||
}
|
||||
h.keys[l], h.keys[i] = h.keys[i], h.keys[l]
|
||||
h.items[l], h.items[i] = h.items[i], h.items[l]
|
||||
i = l
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
// paintRoads traces a spanning tree over the settlements of each landmass and stamps it.
|
||||
func (l *Legend) paintRoads(m *Mark, d *genData, out *Raster, idx uint8, placed []Placed) (int, int) {
|
||||
if len(placed) < 2 {
|
||||
return 0, 0
|
||||
}
|
||||
g := m.Generate
|
||||
maxSlope := g.MaxSlopeDeg
|
||||
if maxSlope <= 0 {
|
||||
maxSlope = 22
|
||||
}
|
||||
widthM := g.WidthM
|
||||
if widthM <= 0 {
|
||||
widthM = m.WidthM
|
||||
}
|
||||
if widthM <= 0 {
|
||||
widthM = 8
|
||||
}
|
||||
// A road eight metres wide is less than one overlay pixel at 12.9 m, and a mark thinner than a pixel is
|
||||
// not a mark. It is painted at least one pixel wide and the true width travels in the legend, which is
|
||||
// exactly how `width_m` is meant to be read.
|
||||
halfPx := int(math.Max(0, math.Round(widthM/d.in.CellM/2)))
|
||||
|
||||
rg := buildRoadGrid(d, maxSlope)
|
||||
|
||||
// Settlements grouped by landmass: a spanning tree per island, never between islands.
|
||||
byRegion := map[int][]int{}
|
||||
for i, p := range placed {
|
||||
if p.Region < 0 {
|
||||
continue
|
||||
}
|
||||
byRegion[p.Region] = append(byRegion[p.Region], i)
|
||||
}
|
||||
regions := make([]int, 0, len(byRegion))
|
||||
for r := range byRegion {
|
||||
regions = append(regions, r)
|
||||
}
|
||||
sort.Ints(regions) // deterministic order, cross-cutting rule 12
|
||||
|
||||
total, pieces := 0, 0
|
||||
for _, r := range regions {
|
||||
members := byRegion[r]
|
||||
if len(members) < 2 {
|
||||
continue
|
||||
}
|
||||
total += l.connectRegion(rg, d, out, idx, placed, members, halfPx, &pieces)
|
||||
}
|
||||
return total, pieces
|
||||
}
|
||||
|
||||
// connectRegion solves the paths among one landmass's settlements and stamps its spanning tree.
|
||||
func (l *Legend) connectRegion(rg *roadGrid, d *genData, out *Raster, idx uint8,
|
||||
placed []Placed, members []int, halfPx int, pieces *int) int {
|
||||
|
||||
n := len(members)
|
||||
src := make([]int, n)
|
||||
for k, pi := range members {
|
||||
p := placed[pi]
|
||||
gx := (p.X / rg.step) % rg.w
|
||||
gy := p.Y / rg.step
|
||||
if gy >= rg.h {
|
||||
gy = rg.h - 1
|
||||
}
|
||||
src[k] = rg.idx(gx, gy)
|
||||
}
|
||||
|
||||
// One Dijkstra per settlement, kept: the coarse grid is a few hundred thousand cells and a landmass has
|
||||
// a handful of towns, so holding the predecessor chains costs a few megabytes and saves solving twice.
|
||||
costs := make([][]float32, n)
|
||||
preds := make([][]int32, n)
|
||||
for k := range members {
|
||||
costs[k], preds[k] = rg.dijkstra(src[k])
|
||||
}
|
||||
|
||||
// Prim's, on path cost. Unreachable pairs are skipped, so a landmass whose towns are separated by ground
|
||||
// too steep for a road comes out as two networks rather than one impossible line.
|
||||
inTree := make([]bool, n)
|
||||
inTree[0] = true
|
||||
painted := 0
|
||||
for added := 1; added < n; added++ {
|
||||
bestA, bestB := -1, -1
|
||||
best := float32(math.Inf(1))
|
||||
for a := 0; a < n; a++ {
|
||||
if !inTree[a] {
|
||||
continue
|
||||
}
|
||||
for b := 0; b < n; b++ {
|
||||
if inTree[b] {
|
||||
continue
|
||||
}
|
||||
if c := costs[a][src[b]]; c < best {
|
||||
best, bestA, bestB = c, a, b
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestA < 0 || math.IsInf(float64(best), 1) {
|
||||
break // nothing else on this landmass is reachable by road
|
||||
}
|
||||
inTree[bestB] = true
|
||||
painted += stampPath(rg, preds[bestA], src[bestA], src[bestB], d, out, idx, halfPx)
|
||||
*pieces++
|
||||
}
|
||||
return painted
|
||||
}
|
||||
|
||||
// stampPath walks the predecessor chain back from dst to src and paints it at full resolution.
|
||||
func stampPath(rg *roadGrid, pred []int32, src, dst int, d *genData, out *Raster, idx uint8, halfPx int) int {
|
||||
var chain []int
|
||||
for c := dst; c >= 0; {
|
||||
chain = append(chain, c)
|
||||
if c == src {
|
||||
break
|
||||
}
|
||||
p := pred[c]
|
||||
if p < 0 {
|
||||
return 0 // no route; leave the ground unpainted rather than drawing a guess
|
||||
}
|
||||
c = int(p)
|
||||
}
|
||||
painted := 0
|
||||
for k := 0; k+1 < len(chain); k++ {
|
||||
ax, ay := coarseCentre(rg, chain[k])
|
||||
bx, by := coarseCentre(rg, chain[k+1])
|
||||
painted += stampSegment(out, d, ax, ay, bx, by, halfPx, idx)
|
||||
}
|
||||
return painted
|
||||
}
|
||||
|
||||
func coarseCentre(rg *roadGrid, c int) (int, int) {
|
||||
gx, gy := c%rg.w, c/rg.w
|
||||
return gx*rg.step + rg.step/2, gy*rg.step + rg.step/2
|
||||
}
|
||||
|
||||
// stampSegment draws one straight run between two coarse-cell centres, wrapping in X the short way so a road
|
||||
// crossing the seam is one road rather than a line back across the whole map.
|
||||
func stampSegment(out *Raster, d *genData, ax, ay, bx, by, halfPx int, idx uint8) int {
|
||||
dx := wrapDelta(bx-ax, out.W)
|
||||
dy := by - ay
|
||||
steps := int(math.Max(math.Abs(float64(dx)), math.Abs(float64(dy))))
|
||||
if steps == 0 {
|
||||
return stampDisc(out, d, ax, ay, halfPx, idx, true)
|
||||
}
|
||||
painted := 0
|
||||
for s := 0; s <= steps; s++ {
|
||||
t := float64(s) / float64(steps)
|
||||
x := ax + int(math.Round(float64(dx)*t))
|
||||
y := ay + int(math.Round(float64(dy)*t))
|
||||
if y < 0 || y >= out.H {
|
||||
continue
|
||||
}
|
||||
painted += stampDisc(out, d, x, y, halfPx, idx, true)
|
||||
}
|
||||
return painted
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A small world with a sea on the left, a flat coastal plain, and a steep ridge on the right, so every
|
||||
// generated kind has somewhere it should go and somewhere it should not.
|
||||
func testWorld(w, h int) GenInputs {
|
||||
height := make([]float32, w*h)
|
||||
sea := make([]bool, w*h)
|
||||
flow := make([]float32, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
switch {
|
||||
case x < w/5:
|
||||
sea[i] = true
|
||||
height[i] = -50
|
||||
case x < 3*w/5:
|
||||
height[i] = float32(x-w/5) * 0.2 // a gentle plain
|
||||
default:
|
||||
height[i] = float32(w/5)*0.2 + float32(x-3*w/5)*12 // a wall
|
||||
}
|
||||
// One river down the middle row of the plain.
|
||||
if y == h/2 && !sea[i] {
|
||||
flow[i] = 5e7
|
||||
}
|
||||
}
|
||||
}
|
||||
return GenInputs{W: w, H: h, CellM: 100, HeightM: height, Sea: sea, FlowM2: flow, Seed: 11}
|
||||
}
|
||||
|
||||
func genLegend(specs map[string]*GenSpec) *Legend {
|
||||
l := &Legend{
|
||||
MatchDistance: DefaultMatchDistance,
|
||||
MinAreaPx: DefaultMinAreaPx,
|
||||
Marks: []Mark{
|
||||
{Name: "forest", RGB: [3]int{0, 128, 0}},
|
||||
{Name: "town", RGB: [3]int{220, 30, 30}},
|
||||
{Name: "road", RGB: [3]int{90, 60, 30}, Kind: KindPath, WidthM: 8},
|
||||
{Name: "wild_coast", RGB: [3]int{255, 128, 0}},
|
||||
{Name: "hand", RGB: [3]int{10, 10, 200}},
|
||||
},
|
||||
}
|
||||
for i := range l.Marks {
|
||||
if g, ok := specs[l.Marks[i].Name]; ok {
|
||||
l.Marks[i].Generate = g
|
||||
}
|
||||
}
|
||||
if err := l.resolve(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// The property the whole feature rests on: generation never touches a pixel somebody painted. Without it,
|
||||
// re-running the generator would quietly destroy an author's work, and the round trip would be unusable.
|
||||
func TestGenerationNeverOverwritesPaintedPixels(t *testing.T) {
|
||||
in := testWorld(200, 120)
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 0.9},
|
||||
"town": {Kind: GenSettlement, Count: 6, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 3},
|
||||
})
|
||||
|
||||
// A hand-painted stripe right across the plain, where the generator badly wants to put things.
|
||||
handIdx := uint8(l.Index("hand"))
|
||||
existing := &Raster{W: in.W, H: in.H, Mark: make([]uint8, in.W*in.H)}
|
||||
handAt := map[int]bool{}
|
||||
for y := 0; y < in.H; y++ {
|
||||
for x := in.W / 5; x < in.W/2; x += 3 {
|
||||
i := y*in.W + x
|
||||
existing.Mark[i] = handIdx
|
||||
handAt[i] = true
|
||||
}
|
||||
}
|
||||
in.Existing = existing
|
||||
|
||||
out, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range handAt {
|
||||
if out.Mark[i] != handIdx {
|
||||
t.Fatalf("cell %d was painted %d by hand and the generator changed it to %d", i, handIdx, out.Mark[i])
|
||||
}
|
||||
}
|
||||
if rep.Kept != len(handAt) {
|
||||
t.Errorf("kept %d painted pixels, want %d", rep.Kept, len(handAt))
|
||||
}
|
||||
if rep.Painted == 0 {
|
||||
t.Error("the generator filled nothing at all; the test world should have room for every kind")
|
||||
}
|
||||
}
|
||||
|
||||
// A mark with no generate block is only ever painted by hand. This is what makes the feature opt-in and what
|
||||
// keeps every legend written before it producing exactly the blank sheet it always did.
|
||||
func TestMarksWithoutAGenerateBlockAreNeverGenerated(t *testing.T) {
|
||||
in := testWorld(160, 100)
|
||||
l := genLegend(map[string]*GenSpec{"forest": {Kind: GenForest, Cover: 0.8}})
|
||||
|
||||
out, _, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forest := uint8(l.Index("forest"))
|
||||
for i, m := range out.Mark {
|
||||
if m != Blank && m != forest {
|
||||
t.Fatalf("cell %d got mark %d, but only %q asked to be generated", i, m, "forest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is ever put in the sea. A forest, a town or a road on open water is the one output that is simply
|
||||
// wrong rather than merely a matter of taste.
|
||||
func TestNothingIsGeneratedAtSea(t *testing.T) {
|
||||
in := testWorld(200, 120)
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 1},
|
||||
"town": {Kind: GenSettlement, Count: 8, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 4},
|
||||
})
|
||||
out, _, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, m := range out.Mark {
|
||||
if m != Blank && in.Sea[i] {
|
||||
t.Fatalf("cell %d is sea and was marked %d", i, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settlements keep their spacing, across tiers as well as within one. A village inside a city is two marks
|
||||
// for one place.
|
||||
func TestSettlementsKeepTheirSpacing(t *testing.T) {
|
||||
in := testWorld(300, 160)
|
||||
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
|
||||
{Name: "city", RGB: [3]int{220, 30, 30}, Generate: &GenSpec{Kind: GenSettlement, Count: 3, MinSpacingKm: 5}},
|
||||
{Name: "village", RGB: [3]int{150, 90, 200}, Generate: &GenSpec{Kind: GenSettlement, Count: 12}},
|
||||
}}
|
||||
if err := l.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rep.Settlement) < 2 {
|
||||
t.Fatalf("only %d settlements placed; the test world should hold more", len(rep.Settlement))
|
||||
}
|
||||
minPx := 5 * 1000 / in.CellM
|
||||
for a := range rep.Settlement {
|
||||
for b := a + 1; b < len(rep.Settlement); b++ {
|
||||
p, q := rep.Settlement[a], rep.Settlement[b]
|
||||
dx := float64(wrapDelta(p.X-q.X, in.W))
|
||||
dy := float64(p.Y - q.Y)
|
||||
if d := math.Hypot(dx, dy); d < minPx-1e-9 {
|
||||
t.Fatalf("settlements %d and %d are %.1f px apart, closer than the %.1f px spacing", a, b, d, minPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Roads connect only what is on the same landmass. Water is impassable, so a two-island world gets no road
|
||||
// between the islands however close they are.
|
||||
func TestRoadsNeverCrossWater(t *testing.T) {
|
||||
w, h := 240, 120
|
||||
height := make([]float32, w*h)
|
||||
sea := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
// Two flat islands with a channel between them.
|
||||
island := (x > 20 && x < 100) || (x > 140 && x < 220)
|
||||
if !island {
|
||||
sea[i] = true
|
||||
height[i] = -30
|
||||
} else {
|
||||
height[i] = 10
|
||||
}
|
||||
}
|
||||
}
|
||||
in := GenInputs{W: w, H: h, CellM: 100, HeightM: height, Sea: sea, Seed: 3}
|
||||
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
|
||||
{Name: "town", RGB: [3]int{220, 30, 30}, Generate: &GenSpec{Kind: GenSettlement, Count: 8, MinSpacingKm: 3}},
|
||||
{Name: "road", RGB: [3]int{90, 60, 30}, Kind: KindPath, WidthM: 8, Generate: &GenSpec{Kind: GenRoad}},
|
||||
}}
|
||||
if err := l.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Towns landed on both islands, so a network that ignored water would have had a reason to cross.
|
||||
regions := map[int]bool{}
|
||||
for _, p := range rep.Settlement {
|
||||
regions[p.Region] = true
|
||||
}
|
||||
if len(regions) < 2 {
|
||||
t.Fatalf("settlements only landed on %d landmass(es); the test cannot show anything", len(regions))
|
||||
}
|
||||
road := uint8(l.Index("road"))
|
||||
for i, m := range out.Mark {
|
||||
if m == road && sea[i] {
|
||||
t.Fatalf("a road was painted at sea, cell %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same world and seed generate the same sheet. Determinism is cross-cutting rule 12 and it is what makes
|
||||
// a regenerated overlay reviewable in a diff.
|
||||
func TestGenerationIsDeterministic(t *testing.T) {
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 0.5},
|
||||
"town": {Kind: GenSettlement, Count: 5, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 2},
|
||||
})
|
||||
a, repA, err := l.Generate(testWorld(200, 120))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, repB, err := l.Generate(testWorld(200, 120))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range a.Mark {
|
||||
if a.Mark[i] != b.Mark[i] {
|
||||
t.Fatalf("two runs disagree at cell %d: %d against %d", i, a.Mark[i], b.Mark[i])
|
||||
}
|
||||
}
|
||||
if repA.Painted != repB.Painted || len(repA.Settlement) != len(repB.Settlement) {
|
||||
t.Errorf("reports differ: %d/%d painted, %d/%d settlements",
|
||||
repA.Painted, repB.Painted, len(repA.Settlement), len(repB.Settlement))
|
||||
}
|
||||
}
|
||||
|
||||
// A generated sheet has to survive the round trip: encoded to RGBA and classified back, it must be the same
|
||||
// raster. If it did not, what the studio opened would not be what the generator wrote.
|
||||
func TestGeneratedSheetSurvivesClassifyingItBack(t *testing.T) {
|
||||
in := testWorld(200, 120)
|
||||
l := genLegend(map[string]*GenSpec{
|
||||
"forest": {Kind: GenForest, Cover: 0.5},
|
||||
"town": {Kind: GenSettlement, Count: 5, MinSpacingKm: 2},
|
||||
"road": {Kind: GenRoad},
|
||||
"wild_coast": {Kind: GenCoast, CoastKm: 2},
|
||||
})
|
||||
out, _, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
px, alpha := l.Encode(out)
|
||||
back, match := l.Classify(px, alpha, out.W, out.H)
|
||||
if match.Far != 0 {
|
||||
t.Errorf("%d pixels of a sheet this legend wrote matched no mark", match.Far)
|
||||
}
|
||||
for i := range out.Mark {
|
||||
if out.Mark[i] != back.Mark[i] {
|
||||
t.Fatalf("round trip changed cell %d from %d to %d", i, out.Mark[i], back.Mark[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsAnUnknownKind(t *testing.T) {
|
||||
l := genLegend(map[string]*GenSpec{"forest": {Kind: "woods"}})
|
||||
if _, _, err := l.Generate(testWorld(80, 40)); err == nil {
|
||||
t.Fatal("a kind the generator does not know should be an error, not a silent no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsAMismatchedExistingSheet(t *testing.T) {
|
||||
in := testWorld(80, 40)
|
||||
in.Existing = &Raster{W: 40, H: 20, Mark: make([]uint8, 800)}
|
||||
l := genLegend(map[string]*GenSpec{"forest": {Kind: GenForest}})
|
||||
if _, _, err := l.Generate(in); err == nil {
|
||||
t.Fatal("an existing sheet of the wrong size should be an error; it is registered to the template")
|
||||
}
|
||||
}
|
||||
|
||||
// A re-roll must actually re-roll. The studio's Generate button hands a fresh seed every press, and if the
|
||||
// placement does not move, the button does nothing an author can see: the forest count is a quantile and so
|
||||
// is invariant by construction, which makes the settlements the only visible difference between two drafts.
|
||||
func TestASecondSeedMovesTheSettlements(t *testing.T) {
|
||||
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
|
||||
{Name: "town", RGB: [3]int{220, 30, 30},
|
||||
Generate: &GenSpec{Kind: GenSettlement, Count: 8, MinSpacingKm: 3}},
|
||||
}}
|
||||
if err := l.resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
place := func(seed int64) []Placed {
|
||||
in := testWorld(300, 160)
|
||||
in.Seed = seed
|
||||
_, rep, err := l.Generate(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rep.Settlement
|
||||
}
|
||||
a, b := place(11), place(20260920)
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
t.Fatalf("no settlements placed (%d, %d); the test world should hold some", len(a), len(b))
|
||||
}
|
||||
same := 0
|
||||
for i := range a {
|
||||
if i < len(b) && a[i].X == b[i].X && a[i].Y == b[i].Y {
|
||||
same++
|
||||
}
|
||||
}
|
||||
if same == len(a) && len(a) == len(b) {
|
||||
t.Fatalf("both seeds placed the same %d settlements in the same places; the seed is not reaching "+
|
||||
"the placement", len(a))
|
||||
}
|
||||
|
||||
// And the same seed twice is still the same world, or nothing is reproducible.
|
||||
c := place(11)
|
||||
for i := range a {
|
||||
if a[i].X != c[i].X || a[i].Y != c[i].Y {
|
||||
t.Fatalf("the same seed placed settlement %d differently on two runs", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Package overlay is the second painting: a layer over the same cylinder whose colours name things the
|
||||
// geology does not simulate.
|
||||
//
|
||||
// The class legend answers "what is the rock doing here" and every colour on it changes the terrain. That is
|
||||
// the wrong place to say "a forest grows here", "this is the village", "a road runs along this valley" or
|
||||
// "leave this stretch of coast exactly as I drew it": three of those four are not geology at all, and the
|
||||
// fourth is a constraint on a pass rather than a rate. Painting them as classes would mean inventing an
|
||||
// uplift rate for a town.
|
||||
//
|
||||
// So there is a second image, registered to the first, painted in the same studio, with a legend of its own.
|
||||
// Its marks are sparse - most of the sheet is nothing - and unlike a class a mark is allowed to mean nothing
|
||||
// to the generator at all. Two rules follow from that and they are the whole design:
|
||||
//
|
||||
// - **A mark that no pass reads still travels.** Every mark comes out as an index in a per-tile raster and,
|
||||
// where it has a shape worth naming, as a feature in world metres in overlay.json. The engine reads those;
|
||||
// the generator never does. That is what makes the layer useful for content an author places by hand and
|
||||
// the simulation has no opinion about.
|
||||
// - **A mark that a pass does read changes one number and never the terrain's shape directly.** The one
|
||||
// built is `coast_jitter`, which scales how far the waterline roughening may move the shore inside the
|
||||
// mark - zero pins a hand-drawn coastline exactly as painted. The list is meant to stay short: anything
|
||||
// that wants to *make* terrain belongs in the class legend, where it is an uplift rate and the solve
|
||||
// answers for it.
|
||||
//
|
||||
// Blank is decided by alpha, not by a colour. An overlay is a transparent sheet with strokes on it, which is
|
||||
// what every image editor gives you and what the studio paints; reserving a background colour instead would
|
||||
// spend one of the author's colours on nothing and would break the moment they exported with a white matte.
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// KindArea and KindPath are what a mark's shape is taken to mean. An area keeps its outline - a forest, a
|
||||
// district, a stretch of coast to leave alone - and comes out as a region with a centre and an extent. A path
|
||||
// is a stroke whose *width is not the point*: it is thinned to a centreline and comes out as an ordered
|
||||
// polyline, because a road drawn eight pixels wide is a spline with a width, not a ribbon-shaped polygon.
|
||||
const (
|
||||
KindArea = "area"
|
||||
KindPath = "path"
|
||||
)
|
||||
|
||||
// Mark is one painted colour on the overlay and everything it means.
|
||||
type Mark struct {
|
||||
Name string `json:"name"`
|
||||
RGB [3]int `json:"rgb"`
|
||||
|
||||
// Kind is "area" or "path"; empty is "area".
|
||||
Kind string `json:"kind"`
|
||||
|
||||
// CoastJitter scales the waterline roughening inside this mark. 1 is the planet's own amplitude, 0 pins
|
||||
// the shore exactly where it was painted, and above 1 chews it harder than the rest of the world.
|
||||
//
|
||||
// It is a pointer so that "not set" and "set to zero" are different things: zero is the whole reason the
|
||||
// key exists. A mark that says nothing about the coast leaves the amplitude alone.
|
||||
//
|
||||
// Painting either side of the waterline is enough. The roughening already knows, for every cell it might
|
||||
// move, which cell on the other side it would take its class from, so a stroke that covers only the water
|
||||
// or only the land still protects the shore between them - see template.Coast.
|
||||
CoastJitter *float64 `json:"coast_jitter"`
|
||||
|
||||
// WidthM is how wide the thing this stroke stands for really is, in metres. Paths only, and it is
|
||||
// carried rather than used: the generator has no opinion about how wide a road is, the engine that builds
|
||||
// the spline does. Zero means unstated.
|
||||
WidthM float64 `json:"width_m"`
|
||||
|
||||
// MinAreaPx drops components smaller than this many painted pixels. A brush leaves specks, a save through
|
||||
// a lossy codec leaves more, and a speck in overlay.json is a village the author never placed.
|
||||
// Zero takes the legend's own default.
|
||||
MinAreaPx int `json:"min_area_px"`
|
||||
|
||||
// Note is for the author and for whatever reads overlay.json. Nothing here parses it.
|
||||
Note string `json:"note"`
|
||||
|
||||
// Generate, when set, lets `terrain overlay` propose this mark from a baked world - woodland where trees
|
||||
// would grow, towns where somebody would build, the roads between them. It is a starting point an author
|
||||
// then edits, and it is opt-in per mark: without this block the mark is only ever painted by hand, which
|
||||
// is what every mark was before it existed. Generation never touches a pixel that is already painted.
|
||||
// See generate.go.
|
||||
Generate *GenSpec `json:"generate,omitempty"`
|
||||
}
|
||||
|
||||
// Area reports whether this mark keeps its outline rather than being thinned to a line.
|
||||
func (m Mark) Area() bool { return m.Kind != KindPath }
|
||||
|
||||
// Jitter is the coast jitter multiplier this mark asks for, and whether it asks for one at all.
|
||||
func (m Mark) Jitter() (float64, bool) {
|
||||
if m.CoastJitter == nil {
|
||||
return 1, false
|
||||
}
|
||||
return *m.CoastJitter, true
|
||||
}
|
||||
|
||||
// Legend is the overlay image and what its colours mean. It sits beside the class legend and has the same
|
||||
// shape, deliberately: an author who has edited one can edit the other without learning a second file format.
|
||||
type Legend struct {
|
||||
// Image is the painted overlay, relative to this file unless it is absolute. The manifest's
|
||||
// planet.overlay overrides it, which is how the studio's versioned saves repoint without rewriting this.
|
||||
Image string `json:"image"`
|
||||
|
||||
// MatchDistance is how far, in RGB, an opaque pixel may sit from the nearest mark before it is treated as
|
||||
// blank rather than as that mark. It is a *tolerance* and not the class legend's warn distance: there,
|
||||
// every pixel must become something, so the nearest class always wins and the distance only warns. Here
|
||||
// most of the sheet is nothing, so a pixel that matches nothing has an obvious right answer.
|
||||
MatchDistance float64 `json:"match_distance"`
|
||||
|
||||
// MinAreaPx is the default for every mark that does not set its own.
|
||||
MinAreaPx int `json:"min_area_px"`
|
||||
|
||||
Marks []Mark `json:"marks"`
|
||||
}
|
||||
|
||||
// DefaultMatchDistance is tight compared with the class legend's 60, because an overlay painted in the studio
|
||||
// is exact to the byte and one brought in from elsewhere is a flat stroke rather than a scanned wash. Wide
|
||||
// tolerances here would swallow an unrelated colour into whichever mark it happened to be nearest.
|
||||
const DefaultMatchDistance = 40
|
||||
|
||||
// DefaultMinAreaPx is about a brush tip. Below it a component is a speck.
|
||||
const DefaultMinAreaPx = 24
|
||||
|
||||
// Blank is the raster index for a pixel with no mark on it. Marks are numbered from 1 so that the raster can
|
||||
// be written straight out as an 8-bit image whose zero means "nothing here".
|
||||
const Blank = 0
|
||||
|
||||
// Load reads an overlay legend from JSON.
|
||||
func Load(path string) (*Legend, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l, err := Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// Parse reads an overlay legend already in memory. Unknown fields are refused for the same reason the class
|
||||
// legend refuses them: a misspelt key is a mark quietly running on the default rather than on what was
|
||||
// written. Keys beginning with an underscore carry the commentary and are allowed.
|
||||
func Parse(data []byte) (*Legend, error) {
|
||||
clean, err := field.StripJSONComments(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var l Legend
|
||||
dec := json.NewDecoder(bytes.NewReader(clean))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := l.resolve(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (l *Legend) resolve() error {
|
||||
if l.MatchDistance <= 0 {
|
||||
l.MatchDistance = DefaultMatchDistance
|
||||
}
|
||||
if l.MinAreaPx <= 0 {
|
||||
l.MinAreaPx = DefaultMinAreaPx
|
||||
}
|
||||
if len(l.Marks) > 254 {
|
||||
return fmt.Errorf("overlay has %d marks; the raster holds 254 plus blank", len(l.Marks))
|
||||
}
|
||||
seen := make(map[string]int, len(l.Marks))
|
||||
byRGB := make(map[[3]int]string, len(l.Marks))
|
||||
for i := range l.Marks {
|
||||
m := &l.Marks[i]
|
||||
if m.Name == "" {
|
||||
return fmt.Errorf("mark %d has no name", i)
|
||||
}
|
||||
if j, dup := seen[m.Name]; dup {
|
||||
return fmt.Errorf("marks %d and %d are both named %q", j, i, m.Name)
|
||||
}
|
||||
seen[m.Name] = i
|
||||
for k, v := range m.RGB {
|
||||
if v < 0 || v > 255 {
|
||||
return fmt.Errorf("mark %q: rgb[%d] is %d, outside 0..255", m.Name, k, v)
|
||||
}
|
||||
}
|
||||
if other, dup := byRGB[m.RGB]; dup {
|
||||
return fmt.Errorf("marks %q and %q share the colour %v; nothing could tell them apart",
|
||||
other, m.Name, m.RGB)
|
||||
}
|
||||
byRGB[m.RGB] = m.Name
|
||||
switch m.Kind {
|
||||
case "", KindArea:
|
||||
m.Kind = KindArea
|
||||
case KindPath:
|
||||
default:
|
||||
return fmt.Errorf("mark %q: kind %q is neither %q nor %q", m.Name, m.Kind, KindArea, KindPath)
|
||||
}
|
||||
if m.CoastJitter != nil && *m.CoastJitter < 0 {
|
||||
return fmt.Errorf("mark %q: coast_jitter is %v; it is a multiplier on how far the waterline "+
|
||||
"may move, so it is never negative", m.Name, *m.CoastJitter)
|
||||
}
|
||||
if m.WidthM < 0 {
|
||||
return fmt.Errorf("mark %q: width_m is %v", m.Name, m.WidthM)
|
||||
}
|
||||
if m.WidthM > 0 && m.Area() {
|
||||
return fmt.Errorf("mark %q: width_m is for a path's spline, and this mark is an area; give it "+
|
||||
"kind %q or drop the width", m.Name, KindPath)
|
||||
}
|
||||
if m.MinAreaPx < 0 {
|
||||
return fmt.Errorf("mark %q: min_area_px is %d", m.Name, m.MinAreaPx)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Index is the raster index of the mark with this name, or Blank when there is none. Marks are numbered
|
||||
// from 1 in legend order.
|
||||
func (l *Legend) Index(name string) int {
|
||||
for i := range l.Marks {
|
||||
if l.Marks[i].Name == name {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return Blank
|
||||
}
|
||||
|
||||
// MinArea is how many painted pixels a component of this mark must have to be reported.
|
||||
func (l *Legend) MinArea(m *Mark) int {
|
||||
if m != nil && m.MinAreaPx > 0 {
|
||||
return m.MinAreaPx
|
||||
}
|
||||
return l.MinAreaPx
|
||||
}
|
||||
|
||||
// TouchesCoast reports whether any mark changes the waterline roughening, so a caller can skip building the
|
||||
// scale field when nothing would read it.
|
||||
func (l *Legend) TouchesCoast() bool {
|
||||
for i := range l.Marks {
|
||||
if _, set := l.Marks[i].Jitter(); set {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Raster is one mark index per overlay pixel, row-major, at the overlay image's own resolution. X wraps;
|
||||
// Y does not, the same convention as every other cylindrical raster here.
|
||||
type Raster struct {
|
||||
W, H int
|
||||
Mark []uint8
|
||||
}
|
||||
|
||||
// At reads a pixel, wrapping X and clamping Y.
|
||||
func (r *Raster) At(x, y int) uint8 {
|
||||
x = ((x % r.W) + r.W) % r.W
|
||||
if y < 0 {
|
||||
y = 0
|
||||
} else if y >= r.H {
|
||||
y = r.H - 1
|
||||
}
|
||||
return r.Mark[y*r.W+x]
|
||||
}
|
||||
|
||||
// Match is what the overlay classifier saw.
|
||||
type Match struct {
|
||||
Total int
|
||||
Blank int
|
||||
Counts []int // per mark index, so Counts[0] is blank
|
||||
// Far is opaque pixels that matched no mark inside the tolerance and were therefore treated as blank.
|
||||
// It is the one number that catches a colour the legend forgot, and unlike the class legend's Far it is
|
||||
// not merely advisory: those pixels are painted and are being thrown away.
|
||||
Far int
|
||||
MaxDist float64
|
||||
MaxAt [2]int
|
||||
}
|
||||
|
||||
func (m Match) String() string {
|
||||
if m.Total == 0 {
|
||||
return "no overlay"
|
||||
}
|
||||
painted := m.Total - m.Blank
|
||||
s := fmt.Sprintf("%d px painted of %d (%.1f%%)", painted, m.Total,
|
||||
100*float64(painted)/float64(m.Total))
|
||||
if m.Far > 0 {
|
||||
s += fmt.Sprintf("; %d px match no mark and were dropped (worst %.0f at %d,%d)",
|
||||
m.Far, m.MaxDist, m.MaxAt[0], m.MaxAt[1])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Classify assigns every pixel to a mark, or to Blank.
|
||||
//
|
||||
// Two ways to be blank, and both are needed. A pixel whose alpha is below half is unpainted, which is what a
|
||||
// transparent sheet gives and what the studio writes. A pixel that is opaque but sits further than the
|
||||
// legend's tolerance from every mark is a colour the legend has never heard of - a flattened matte, an
|
||||
// anti-aliased edge between two strokes, a JPEG artefact - and taking the nearest mark there is how a halo
|
||||
// round a road becomes a road.
|
||||
func (l *Legend) Classify(px []uint8, alpha []uint8, w, h int) (*Raster, Match) {
|
||||
r := &Raster{W: w, H: h, Mark: make([]uint8, w*h)}
|
||||
partial := make([]Match, field.BandCount(h))
|
||||
for i := range partial {
|
||||
partial[i].Counts = make([]int, len(l.Marks)+1)
|
||||
}
|
||||
tol2 := l.MatchDistance * l.MatchDistance
|
||||
|
||||
field.RowsIndexed(h, func(band, y0, y1 int) {
|
||||
p := &partial[band]
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
p.Total++
|
||||
if alpha != nil && alpha[i] < 128 {
|
||||
p.Blank++
|
||||
p.Counts[Blank]++
|
||||
continue
|
||||
}
|
||||
o := i * 3
|
||||
cr, cg, cb := int(px[o]), int(px[o+1]), int(px[o+2])
|
||||
best, bestD := -1, 1<<30
|
||||
for mi := range l.Marks {
|
||||
m := &l.Marks[mi]
|
||||
dr, dg, db := cr-m.RGB[0], cg-m.RGB[1], cb-m.RGB[2]
|
||||
if d := dr*dr + dg*dg + db*db; d < bestD {
|
||||
bestD, best = d, mi
|
||||
}
|
||||
}
|
||||
if best < 0 || float64(bestD) > tol2 {
|
||||
p.Blank++
|
||||
p.Counts[Blank]++
|
||||
if alpha != nil || best >= 0 {
|
||||
p.Far++
|
||||
if float64(bestD) > p.MaxDist {
|
||||
p.MaxDist = float64(bestD)
|
||||
p.MaxAt = [2]int{x, y}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.Mark[i] = uint8(best + 1)
|
||||
p.Counts[best+1]++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
out := Match{Counts: make([]int, len(l.Marks)+1)}
|
||||
out.MaxAt = [2]int{-1, -1}
|
||||
for i := range partial {
|
||||
p := &partial[i]
|
||||
out.Total += p.Total
|
||||
out.Blank += p.Blank
|
||||
out.Far += p.Far
|
||||
for c, n := range p.Counts {
|
||||
out.Counts[c] += n
|
||||
}
|
||||
// Tie-broken by position so the report does not depend on GOMAXPROCS (cross-cutting rule 12).
|
||||
if p.MaxDist > out.MaxDist || (p.MaxDist == out.MaxDist && earlier(p.MaxAt, out.MaxAt)) {
|
||||
out.MaxDist = p.MaxDist
|
||||
out.MaxAt = p.MaxAt
|
||||
}
|
||||
}
|
||||
out.MaxDist = math.Sqrt(out.MaxDist)
|
||||
return r, out
|
||||
}
|
||||
|
||||
func earlier(a, b [2]int) bool {
|
||||
if b[1] < 0 {
|
||||
return true
|
||||
}
|
||||
if a[1] != b[1] {
|
||||
return a[1] < b[1]
|
||||
}
|
||||
return a[0] < b[0]
|
||||
}
|
||||
|
||||
// Encode turns a raster back into the RGBA sheet an author opens: each mark in its own legend colour, fully
|
||||
// opaque, and blank left transparent.
|
||||
//
|
||||
// It is the exact inverse of Classify for anything this package wrote, and that has to stay true: a sheet
|
||||
// written here is read back by Classify on the next plan, so a colour that did not survive the round trip
|
||||
// would be a mark that vanished between writing the file and reading it. Nothing is blended or antialiased,
|
||||
// for the reason the studio's brush is hard-edged - a pixel between two mark colours is not a blend of two
|
||||
// marks, it is a pixel that classifies as whichever one it happens to sit nearer, or as nothing at all.
|
||||
func (l *Legend) Encode(r *Raster) (px []uint8, alpha []uint8) {
|
||||
n := r.W * r.H
|
||||
px = make([]uint8, n*3)
|
||||
alpha = make([]uint8, n)
|
||||
for i, m := range r.Mark {
|
||||
if m == Blank || int(m) > len(l.Marks) {
|
||||
continue
|
||||
}
|
||||
rgb := l.Marks[m-1].RGB
|
||||
px[i*3] = uint8(rgb[0])
|
||||
px[i*3+1] = uint8(rgb[1])
|
||||
px[i*3+2] = uint8(rgb[2])
|
||||
alpha[i] = 255
|
||||
}
|
||||
return px, alpha
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A legend with one of each kind of mark, written the way an author would.
|
||||
const legendJSON = `{
|
||||
"_comment": "commentary survives a parse",
|
||||
"image": "sheet.png",
|
||||
"marks": [
|
||||
{ "name": "drawn_coast", "rgb": [255, 0, 255], "coast_jitter": 0 },
|
||||
{ "name": "wild_coast", "rgb": [255, 128, 0], "coast_jitter": 2.5 },
|
||||
{ "name": "forest", "rgb": [0, 128, 0] },
|
||||
{ "name": "road", "rgb": [90, 60, 30], "kind": "path", "width_m": 8 }
|
||||
]
|
||||
}`
|
||||
|
||||
func mustLegend(t *testing.T) *Legend {
|
||||
t.Helper()
|
||||
l, err := Parse([]byte(legendJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestParseFillsDefaultsAndRefusesNonsense(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
if l.MatchDistance != DefaultMatchDistance || l.MinAreaPx != DefaultMinAreaPx {
|
||||
t.Fatalf("defaults not filled: %v %v", l.MatchDistance, l.MinAreaPx)
|
||||
}
|
||||
if l.Index("forest") != 3 || l.Index("nope") != Blank {
|
||||
t.Fatalf("marks are numbered from 1 in legend order, got %d", l.Index("forest"))
|
||||
}
|
||||
if !l.TouchesCoast() {
|
||||
t.Fatal("this legend has a coast mark, so the roughening has a scale field to build")
|
||||
}
|
||||
if j, set := l.Marks[0].Jitter(); !set || j != 0 {
|
||||
t.Fatalf("a zero coast_jitter is the whole reason the key is a pointer; got %v set=%v", j, set)
|
||||
}
|
||||
if j, set := l.Marks[2].Jitter(); set || j != 1 {
|
||||
t.Fatalf("a mark that says nothing about the coast leaves the amplitude alone; got %v set=%v", j, set)
|
||||
}
|
||||
|
||||
for _, bad := range []struct{ what, src string }{
|
||||
{"two marks one colour", `{"marks":[{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[1,2,3]}]}`},
|
||||
{"two marks one name", `{"marks":[{"name":"a","rgb":[1,2,3]},{"name":"a","rgb":[4,5,6]}]}`},
|
||||
{"a width on an area", `{"marks":[{"name":"a","rgb":[1,2,3],"width_m":4}]}`},
|
||||
{"a negative jitter", `{"marks":[{"name":"a","rgb":[1,2,3],"coast_jitter":-1}]}`},
|
||||
{"an unknown kind", `{"marks":[{"name":"a","rgb":[1,2,3],"kind":"blob"}]}`},
|
||||
{"a misspelt key", `{"marks":[{"name":"a","rgb":[1,2,3],"coastjitter":0}]}`},
|
||||
} {
|
||||
if _, err := Parse([]byte(bad.src)); err == nil {
|
||||
t.Errorf("%s should not parse", bad.what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paint builds an RGBA sheet the size asked for, all transparent, and returns setters.
|
||||
func paint(w, h int) (px, alpha []uint8, set func(x, y int, rgb [3]int)) {
|
||||
px = make([]uint8, w*h*3)
|
||||
alpha = make([]uint8, w*h)
|
||||
return px, alpha, func(x, y int, rgb [3]int) {
|
||||
i := y*w + x
|
||||
px[i*3], px[i*3+1], px[i*3+2] = uint8(rgb[0]), uint8(rgb[1]), uint8(rgb[2])
|
||||
alpha[i] = 255
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlankIsAlphaAndTolerance is the rule the whole layer rests on: most of the sheet is nothing, and there
|
||||
// are two ways to be nothing. An opaque pixel near no mark is dropped rather than snapped to the nearest,
|
||||
// which is the opposite of what the class legend does and is why they are different code.
|
||||
func TestBlankIsAlphaAndTolerance(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 8, 4
|
||||
px, alpha, set := paint(w, h)
|
||||
set(1, 1, [3]int{0, 128, 0}) // forest, exactly
|
||||
set(2, 1, [3]int{6, 132, 4}) // forest, near enough
|
||||
set(3, 1, [3]int{0, 0, 255}) // a colour the legend has never heard of
|
||||
// A transparent pixel that happens to carry a mark's colour: alpha wins.
|
||||
i := 1*w + 4
|
||||
px[i*3], px[i*3+1], px[i*3+2] = 0, 128, 0
|
||||
|
||||
r, m := l.Classify(px, alpha, w, h)
|
||||
if got := r.At(1, 1); got != 3 {
|
||||
t.Fatalf("an exact colour is its mark; got %d", got)
|
||||
}
|
||||
if got := r.At(2, 1); got != 3 {
|
||||
t.Fatalf("within the tolerance is its mark; got %d", got)
|
||||
}
|
||||
if got := r.At(3, 1); got != Blank {
|
||||
t.Fatalf("a colour no mark is near is blank, not the nearest mark; got %d", got)
|
||||
}
|
||||
if got := r.At(4, 1); got != Blank {
|
||||
t.Fatalf("transparent is blank whatever colour is under it; got %d", got)
|
||||
}
|
||||
if m.Far != 1 {
|
||||
t.Fatalf("the one unmatched opaque pixel should be reported; Far=%d", m.Far)
|
||||
}
|
||||
if m.Total != w*h || m.Blank != w*h-2 {
|
||||
t.Fatalf("counts: total %d blank %d", m.Total, m.Blank)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoastScaleLeavesUnmarkedPixelsUninstructed is the contract template.Coast.Scale depends on. An
|
||||
// unmarked cell must come back negative rather than 1, or a stroke painted on the land would be overruled by
|
||||
// the water beside it and the coastline would move anyway.
|
||||
func TestCoastScaleLeavesUnmarkedPixelsUninstructed(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 6, 2
|
||||
px, alpha, set := paint(w, h)
|
||||
set(0, 0, [3]int{255, 0, 255}) // drawn_coast: pinned
|
||||
set(1, 0, [3]int{255, 128, 0}) // wild_coast: chewed harder
|
||||
set(2, 0, [3]int{0, 128, 0}) // forest: says nothing about the coast
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
|
||||
sc := l.CoastScale(r)
|
||||
if sc == nil {
|
||||
t.Fatal("this legend has coast marks, so there is a scale")
|
||||
}
|
||||
if sc[0] != 0 {
|
||||
t.Errorf("a pinned coast is exactly zero, got %v", sc[0])
|
||||
}
|
||||
if sc[1] != 2.5 {
|
||||
t.Errorf("wild_coast is 2.5, got %v", sc[1])
|
||||
}
|
||||
if sc[2] >= 0 {
|
||||
t.Errorf("a mark that says nothing about the coast is uninstructed, got %v", sc[2])
|
||||
}
|
||||
if sc[3] >= 0 {
|
||||
t.Errorf("blank is uninstructed, got %v", sc[3])
|
||||
}
|
||||
|
||||
// And a legend with no coast marks builds nothing at all, so the roughening pays nothing.
|
||||
plain, err := Parse([]byte(`{"marks":[{"name":"forest","rgb":[0,128,0]}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pr, _ := plain.Classify(px, alpha, w, h)
|
||||
if plain.CoastScale(pr) != nil {
|
||||
t.Error("no mark asks about the coast, so there should be no scale field")
|
||||
}
|
||||
}
|
||||
|
||||
func testScale(w, h int) Scale {
|
||||
return Scale{MetresPerPxX: 10, MetresPerPxY: 10, CircumferenceM: float64(w) * 10}
|
||||
}
|
||||
|
||||
// TestFeaturesMeasureAreasInWorldMetres covers the ordinary case and the speck filter.
|
||||
func TestFeaturesMeasureAreasInWorldMetres(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
// A 6x6 block of forest, and a single speck of it far away.
|
||||
for y := 4; y < 10; y++ {
|
||||
for x := 10; x < 16; x++ {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
set(30, 15, [3]int{0, 128, 0})
|
||||
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
feats := l.Features(r, testScale(w, h))
|
||||
if len(feats) != 1 {
|
||||
t.Fatalf("the 36 px block is a feature and the 1 px speck is below min_area_px; got %d", len(feats))
|
||||
}
|
||||
f := feats[0]
|
||||
if f.Mark != "forest" || f.Kind != KindArea {
|
||||
t.Fatalf("wrong mark: %+v", f)
|
||||
}
|
||||
if f.Cells != 36 || math.Abs(f.AreaM2-3600) > 1 {
|
||||
t.Fatalf("36 px at 10x10 m is 3600 m2; got %d px %v m2", f.Cells, f.AreaM2)
|
||||
}
|
||||
if math.Abs(f.CentreM[0]-125) > 1 || math.Abs(f.CentreM[1]-65) > 1 {
|
||||
t.Fatalf("centre should be the middle of the block in metres; got %v", f.CentreM)
|
||||
}
|
||||
if math.Abs(f.ExtentM[0]-60) > 1 || math.Abs(f.ExtentM[1]-60) > 1 {
|
||||
t.Fatalf("a 6x6 block is 60x60 m; got %v", f.ExtentM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASeamCrossingFeatureIsOneThing is the failure a cylindrical map has and nobody notices: a plain mean of
|
||||
// the longitudes puts the centre of a blob straddling the seam on the opposite side of the world.
|
||||
func TestASeamCrossingFeatureIsOneThing(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
for y := 6; y < 14; y++ {
|
||||
for _, x := range []int{38, 39, 0, 1} {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
feats := l.Features(r, testScale(w, h))
|
||||
if len(feats) != 1 {
|
||||
t.Fatalf("the blob crosses the seam and is one thing; got %d features", len(feats))
|
||||
}
|
||||
f := feats[0]
|
||||
if f.Cells != 32 {
|
||||
t.Fatalf("all 32 px belong to it; got %d", f.Cells)
|
||||
}
|
||||
// Columns 38, 39, 0, 1 have their circular centre at 39.5, which is 395 m.
|
||||
if d := math.Abs(f.CentreM[0] - 395); d > 6 && math.Abs(f.CentreM[0]-395+400) > 6 {
|
||||
t.Fatalf("the centre should sit on the blob, near 395 m; got %v", f.CentreM[0])
|
||||
}
|
||||
if math.Abs(f.ExtentM[0]-40) > 1 {
|
||||
t.Fatalf("the extent is measured the short way round: 4 px is 40 m; got %v", f.ExtentM[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPathBecomesACentrelineNotAnOutline is the difference between a road and a ribbon-shaped polygon.
|
||||
func TestAPathBecomesACentrelineNotAnOutline(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 60, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
// A horizontal stroke three pixels thick from x=5 to x=50.
|
||||
for x := 5; x <= 50; x++ {
|
||||
for y := 9; y <= 11; y++ {
|
||||
set(x, y, [3]int{90, 60, 30})
|
||||
}
|
||||
}
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
feats := l.Features(r, testScale(w, h))
|
||||
if len(feats) != 1 {
|
||||
t.Fatalf("one stroke is one path; got %d", len(feats))
|
||||
}
|
||||
f := feats[0]
|
||||
if f.Kind != KindPath || f.WidthM != 8 {
|
||||
t.Fatalf("the path's width travels with it: %+v", f)
|
||||
}
|
||||
if len(f.PointsM) < 2 {
|
||||
t.Fatalf("a path needs at least two points; got %d", len(f.PointsM))
|
||||
}
|
||||
// Simplified, so a straight stroke is a handful of points and not one per pixel.
|
||||
if len(f.PointsM) > 8 {
|
||||
t.Errorf("a straight stroke should simplify to a few points; got %d", len(f.PointsM))
|
||||
}
|
||||
// It runs the length of the stroke, not round its outline: 45 px is 450 m, an outline would be ~960.
|
||||
if f.LengthM < 400 || f.LengthM > 500 {
|
||||
t.Errorf("a 45 px stroke at 10 m a pixel is about 450 m of centreline; got %v", f.LengthM)
|
||||
}
|
||||
for _, p := range f.PointsM {
|
||||
if p[1] < 85 || p[1] > 115 {
|
||||
t.Errorf("every point should sit on the stroke, y near 100 m; got %v", p)
|
||||
}
|
||||
}
|
||||
// An area mark never gets points, whatever shape it is drawn in.
|
||||
for _, g := range feats {
|
||||
if g.Kind == KindArea && len(g.PointsM) > 0 {
|
||||
t.Error("an area keeps its outline and is not thinned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSampleWorldIsIndependentOfTheWindow is rule 1 for a raster: a cell gets the same mark whichever tile
|
||||
// reaches it, because the lookup goes through world metres rather than through a tile-local index.
|
||||
func TestSampleWorldIsIndependentOfTheWindow(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
for y := 4; y < 10; y++ {
|
||||
for x := 10; x < 16; x++ {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
r, _ := l.Classify(px, alpha, w, h)
|
||||
s := testScale(w, h)
|
||||
|
||||
// Two windows of a 2 m grid overlapping the same ground: one starting at 100 m, one at 60 m.
|
||||
a := r.SampleWorld(100, 40, 2, 40, 40, s)
|
||||
b := r.SampleWorld(60, 40, 2, 60, 40, s)
|
||||
for y := 0; y < 40; y++ {
|
||||
for x := 0; x < 40; x++ {
|
||||
if a[y*40+x] != b[y*60+x+20] {
|
||||
t.Fatalf("the same ground read two marks at (%d,%d): %d vs %d",
|
||||
x, y, a[y*40+x], b[y*60+x+20])
|
||||
}
|
||||
}
|
||||
}
|
||||
// And it wraps, rather than clamping, past the seam.
|
||||
past := r.SampleWorld(s.CircumferenceM+100, 40, 2, 40, 40, s)
|
||||
for i := range a {
|
||||
if a[i] != past[i] {
|
||||
t.Fatalf("a window a whole world to the east must read the same ground; differ at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentReportsEveryMarkPaintedOrNot(t *testing.T) {
|
||||
l := mustLegend(t)
|
||||
const w, h = 40, 20
|
||||
px, alpha, set := paint(w, h)
|
||||
for y := 4; y < 10; y++ {
|
||||
for x := 10; x < 16; x++ {
|
||||
set(x, y, [3]int{0, 128, 0})
|
||||
}
|
||||
}
|
||||
r, m := l.Classify(px, alpha, w, h)
|
||||
doc := l.Describe(r, m, testScale(w, h), "sheet.png", "sheet.json")
|
||||
if len(doc.Marks) != 4 {
|
||||
t.Fatalf("every mark is reported, painted or not; got %d", len(doc.Marks))
|
||||
}
|
||||
byName := map[string]MarkShare{}
|
||||
for _, mk := range doc.Marks {
|
||||
byName[mk.Name] = mk
|
||||
}
|
||||
if f := byName["forest"]; f.Cells != 36 || f.Pieces != 1 || math.Abs(f.AreaKm2-0.0036) > 1e-6 {
|
||||
t.Errorf("forest: %+v", f)
|
||||
}
|
||||
if c := byName["drawn_coast"]; !c.HasJitter || c.Jitter != 0 || c.Pieces != 0 {
|
||||
t.Errorf("an unpainted coast mark still reports what it would ask for: %+v", c)
|
||||
}
|
||||
if rd := byName["road"]; rd.Kind != KindPath || rd.WidthM != 8 {
|
||||
t.Errorf("road: %+v", rd)
|
||||
}
|
||||
if doc.CircumferenceM != 400 || doc.PaintW != w {
|
||||
t.Errorf("the frame is the overlay's own: %v x %d", doc.CircumferenceM, doc.PaintW)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"salty/terrain/internal/coast"
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/region"
|
||||
"salty/terrain/internal/stats"
|
||||
"salty/terrain/internal/template"
|
||||
"salty/terrain/internal/thermal"
|
||||
"salty/terrain/internal/uplift"
|
||||
)
|
||||
|
||||
// Bake solves the geology of every region and composites the result into one planet.
|
||||
//
|
||||
// The order is the whole design: each landmass is solved in a box of its own with water all round it, which
|
||||
// is exactly the same answer as solving the planet whole because no flow path crosses open water; then the
|
||||
// land is written back; then the sea floor is laid once, over the finished cylinder.
|
||||
|
||||
// Result is a baked planet at geology resolution.
|
||||
type Result struct {
|
||||
In *Inputs
|
||||
Height *field.Field // metres, over the whole planet including the polar pad
|
||||
Flow []float32 // drainage area in m2, for the rivers on the preview
|
||||
Sea []bool // after the bake: below sea level
|
||||
|
||||
Regions []RegionResult
|
||||
Craters []CraterStats
|
||||
Elapsed time.Duration
|
||||
|
||||
// Coast is the shelf, the surf and the sediment budget, run once over the whole cylinder after every
|
||||
// region is composited. Nil only when there is no planet to run it on.
|
||||
Coast *coast.Result
|
||||
|
||||
// Stats is the whole planet's, pooled from the regions. Nil until Write computes it.
|
||||
//
|
||||
// Pooled rather than averaged, which is the one thing that makes it a planet statistic at all: a region
|
||||
// holds a histogram and histograms add, so merging them and taking a quantile of the sum gives exactly
|
||||
// what one pass over the whole world would have. See internal/stats.
|
||||
Stats *stats.Report
|
||||
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
// RegionResult is what one region's solve cost and produced.
|
||||
type RegionResult struct {
|
||||
ID int
|
||||
Cells int
|
||||
LandCells int
|
||||
Seconds float64
|
||||
MinM float64
|
||||
MaxM float64
|
||||
ClipFrac float64
|
||||
|
||||
// FaultClamped is how many cells the fault pass pushed past the angle of repose and had to bound. Not an
|
||||
// error - it is the set saying the throws are large for the class they sit in - but worth seeing in the
|
||||
// summary rather than discovering later in a hillshade full of polygonal facets.
|
||||
FaultClamped int
|
||||
|
||||
// stats is this region's land statistics, unexported because it is scaffolding: it exists to be merged
|
||||
// into the planet's and is not part of what a region result means.
|
||||
stats *stats.Accumulator
|
||||
}
|
||||
|
||||
// statsOptions is what a world is judged against, in one place so a region and the planet it belongs to
|
||||
// cannot disagree about it - two accumulators built on different bounds do not merge.
|
||||
func statsOptions(m *manifest.Manifest) stats.Options {
|
||||
return stats.Options{
|
||||
ElevMin: m.ElevationM.Min, ElevMax: m.ElevationM.Max,
|
||||
TalusDeg: m.Pipeline.Thermal.TalusDeg, ReliefWindowM: reliefWindowM,
|
||||
ChannelM2: channelKm2 * 1e6,
|
||||
K: m.Pipeline.Fluvial.K, M: m.Pipeline.Fluvial.M, N: m.Pipeline.Fluvial.N,
|
||||
}
|
||||
}
|
||||
|
||||
// The two constants the statistics are taken at. They are flags on `generate` and fixed here, because a bake
|
||||
// is compared against other bakes and a window that moved between them would make the relief column
|
||||
// meaningless: 500 m is the usual choice, and 1 km2 is the incoming spec's channel definition.
|
||||
const (
|
||||
reliefWindowM = 500.0
|
||||
channelKm2 = 1.0
|
||||
)
|
||||
|
||||
// BakeOptions steer a run without editing the manifest.
|
||||
type BakeOptions struct {
|
||||
Only []int // region ids; empty means all
|
||||
Steps int // override the fluvial step count
|
||||
Jobs int // regions solved at once; 0 is the default
|
||||
Log func(string, ...any)
|
||||
|
||||
// OnRegion is called each time a region's land has been written back, with the planet as it stands.
|
||||
//
|
||||
// It runs holding the composite lock, so Height and Flow can be read without racing the workers still
|
||||
// running - and every one of them is stopped while it does, so it has to be quick and it must not keep a
|
||||
// reference to either past the call. It exists so a caller can *watch* a bake: two hours is a long time
|
||||
// to find out at the end that the numbers were wrong, and the world filling in one landmass at a time
|
||||
// answers that at the first one.
|
||||
OnRegion func(*Result, RegionResult)
|
||||
|
||||
// Cancel abandons the run when it is closed. Regions in flight stop at the end of their current step and
|
||||
// their land is composited in whatever state the solve had reached, so a cancelled Result is for looking
|
||||
// at and never for writing out as a bake.
|
||||
Cancel <-chan struct{}
|
||||
}
|
||||
|
||||
// Cancelled reports whether a run was abandoned before it finished.
|
||||
func (r *Result) Cancelled() bool { return r.cancelled }
|
||||
|
||||
// Bake runs the geology solve over a prepared planet.
|
||||
func Bake(in *Inputs, opt BakeOptions) (*Result, error) {
|
||||
log := opt.Log
|
||||
if log == nil {
|
||||
log = func(string, ...any) {}
|
||||
}
|
||||
m := in.M
|
||||
p := in.P
|
||||
n := p.W * p.H
|
||||
started := time.Now()
|
||||
|
||||
params := solveParams(m)
|
||||
if opt.Steps > 0 {
|
||||
params.Steps = opt.Steps
|
||||
}
|
||||
log("fluvial %d steps of %.0f yr (%.2f Myr), K %.1e, m %.2f, n %.2f, fill every %d",
|
||||
params.Steps, params.DtYr, float64(params.Steps)*params.DtYr/1e6,
|
||||
params.K, params.M, params.N, params.FillEvery)
|
||||
|
||||
res := &Result{In: in, Height: field.New(p.W, p.H, p.CellM), Flow: make([]float32, n)}
|
||||
for i := range res.Height.Data {
|
||||
res.Height.Data[i] = float32(m.SeaLevelM)
|
||||
}
|
||||
|
||||
rates := in.Legend.Rates()
|
||||
ks := in.Legend.Erodibilities()
|
||||
plainM, plainFloor := in.Legend.CoastalPlains()
|
||||
massifFloor, massifFraction := in.Legend.Massifs()
|
||||
wanted := wantedRegions(in.Part.Regions, opt.Only)
|
||||
|
||||
// Regions are solved a few at a time. They are independent - each writes only the land it owns, and no
|
||||
// flow path crosses the water between them - so this changes nothing about the result; what it buys is
|
||||
// the parts of the solve that are sequential *within* a region. Terrain.md's profile says most of the
|
||||
// runtime is the stack walk and the flood's cursor, neither of which parallelises inside one grid, so
|
||||
// overlapping regions is where the cores actually go.
|
||||
//
|
||||
// Results land in indexed slots and are read back in region order afterwards, never drained from a
|
||||
// channel: cross-cutting rule 12 means the output must not depend on which goroutine finished first.
|
||||
tables := paint{rates: rates, ks: ks, plainM: plainM, plainFloor: plainFloor,
|
||||
massifFloor: massifFloor, massifFraction: massifFraction, massifCells: m.Planet.MassifCells(),
|
||||
rockCells: m.Planet.LithologyCells(), rockMult: m.Pipeline.Lithology.KMultipliers,
|
||||
lithMix: in.Legend.LithologyMixes(), faults: in.Faults,
|
||||
// The *manifest's* step count, not the overridden one. `throw_m` is a total displacement over the
|
||||
// run and it becomes a rate by dividing by the run's length, so taking the override would make a
|
||||
// short run raise the *rate* to build the same scarp in less time - which at `--steps 200` is five
|
||||
// times the uplift and past the repose ceiling, so a tuning run would show every fault pinned
|
||||
// against the clamp and tell an author nothing about the world they are tuning. `--steps` means
|
||||
// "run less time"; everything else in the solve is under-done by it and faults should be too.
|
||||
runYears: float64(m.Pipeline.Fluvial.Steps) * params.DtYr,
|
||||
clampCeilM: faultCeilingMYr(m),
|
||||
}
|
||||
|
||||
jobs := opt.Jobs
|
||||
if jobs <= 0 {
|
||||
jobs = 3
|
||||
}
|
||||
if jobs > len(wanted) {
|
||||
jobs = len(wanted)
|
||||
}
|
||||
// Biggest first, so the long poles start early and the short ones fill the tail.
|
||||
order := append([]region.Region(nil), wanted...)
|
||||
sort.Slice(order, func(a, b int) bool { return order[a].Cells() > order[b].Cells() })
|
||||
|
||||
out := make([]RegionResult, len(order))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
// Guards the composite - the one place workers touch shared state - so that OnRegion can read the whole
|
||||
// planet without racing them.
|
||||
var composite sync.Mutex
|
||||
safeLog := func(format string, a ...any) {
|
||||
mu.Lock()
|
||||
log(format, a...)
|
||||
mu.Unlock()
|
||||
}
|
||||
next := make(chan int)
|
||||
go func() {
|
||||
for i := range order {
|
||||
next <- i
|
||||
}
|
||||
close(next)
|
||||
}()
|
||||
for w := 0; w < jobs; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range next {
|
||||
if cancelled(opt.Cancel) {
|
||||
continue // drain the queue; the workers already running stop at their next step
|
||||
}
|
||||
out[i] = solveRegion(in, order[i], params, tables, res, safeLog, opt, &composite)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
res.Regions = out
|
||||
sort.Slice(res.Regions, func(a, b int) bool { return res.Regions[a].ID < res.Regions[b].ID })
|
||||
|
||||
if cancelled(opt.Cancel) {
|
||||
res.cancelled = true
|
||||
res.Elapsed = time.Since(started)
|
||||
log("cancelled after %s", res.Elapsed.Round(time.Second))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
res.Craters = stampCraters(in, res, log)
|
||||
runCoast(in, res, log)
|
||||
res.Elapsed = time.Since(started)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func wantedRegions(all []region.Region, only []int) []region.Region {
|
||||
if len(only) == 0 {
|
||||
return all
|
||||
}
|
||||
keep := make(map[int]bool, len(only))
|
||||
for _, id := range only {
|
||||
keep[id] = true
|
||||
}
|
||||
var out []region.Region
|
||||
for _, r := range all {
|
||||
if keep[r.ID] {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// solveRegion is one landmass: cut it out, build its painted geology, run the solve, write the land back.
|
||||
// progressCells is where a region becomes big enough to be worth reporting on: below a couple of million it
|
||||
// is over in a minute or two and the lines are noise.
|
||||
const progressCells = 2_000_000
|
||||
|
||||
// paint is the per-class tables a region's geology is built from, bundled so the worker signature does not
|
||||
// grow a column every time the legend learns a new word.
|
||||
type paint struct {
|
||||
rates, ks []float32
|
||||
plainM []float64
|
||||
plainFloor []float32
|
||||
massifFloor []float32
|
||||
// massifFraction is per class, zero for a class that is one rate all over. massifCells is the planet's
|
||||
// upland fabric wavelength in lattice cells, which is a property of the planet rather than of a class.
|
||||
massifFraction []float64
|
||||
massifCells int
|
||||
|
||||
// The rock field and the fault set: both are properties of the planet that every region reads the same
|
||||
// way, which is the whole point of computing them once above rather than per region.
|
||||
rockCells int
|
||||
rockMult []float64
|
||||
lithMix []float64
|
||||
faults []uplift.FaultTrace
|
||||
runYears float64
|
||||
clampCeilM float64
|
||||
}
|
||||
|
||||
func cancelled(ch <-chan struct{}) bool {
|
||||
if ch == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-ch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func solveRegion(in *Inputs, rg region.Region, params fluvial.Params, t paint,
|
||||
res *Result, log func(string, ...any), opt BakeOptions, composite *sync.Mutex) RegionResult {
|
||||
|
||||
m := in.M
|
||||
start := time.Now()
|
||||
class, land := in.Part.Cut(in.Map, rg)
|
||||
|
||||
up := uplift.FromTemplate(uplift.Paint{
|
||||
Frame: rg.Frame, Class: class, Land: land,
|
||||
Rates: t.rates, Ks: t.ks, PlainM: t.plainM, PlainFloor: t.plainFloor,
|
||||
MassifFloor: t.massifFloor, MassifFraction: t.massifFraction, MassifCells: t.massifCells,
|
||||
RockCells: t.rockCells, RockMult: t.rockMult, LithMix: t.lithMix,
|
||||
Faults: t.faults, RunYears: t.runYears, ClampCeilM: t.clampCeilM,
|
||||
Variation: m.Planet.UpliftVariation,
|
||||
}, m)
|
||||
|
||||
h := up.Height.Clone()
|
||||
grid := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
|
||||
grid.SetSeed(m.Source.Seed)
|
||||
grid.SetCancel(opt.Cancel)
|
||||
grid.SetFrame(rg.Frame) // the jitter is a hash of world position, not of grid index
|
||||
grid.SetElevationRange(m.ElevationM.Min-200, m.ElevationM.Max+500)
|
||||
|
||||
log("region %2d start %.1f x %.1f km, %d cells (%d land)%s",
|
||||
rg.ID, float64(rg.Frame.W)*rg.Frame.P.CellM/1000, float64(rg.Frame.H)*rg.Frame.P.CellM/1000,
|
||||
rg.Cells(), rg.LandCells, seamNote(rg.Seam))
|
||||
|
||||
// Progress, because a big region is an hour on its own and silence for an hour is indistinguishable from
|
||||
// a hang. Every tenth, and only for regions big enough to be worth waiting on: with several in flight the
|
||||
// lines interleave, so each one carries its region id and none of them is per-step.
|
||||
var progress func(step, total int, pct float64)
|
||||
if rg.Cells() > progressCells {
|
||||
next := 10.0
|
||||
progress = func(step, total int, pct float64) {
|
||||
if pct < next || step == 0 {
|
||||
return
|
||||
}
|
||||
next = pct + 10
|
||||
lo, hi := landExtent(h.Data, land)
|
||||
elapsed := time.Since(start)
|
||||
eta := time.Duration(float64(elapsed) / (pct / 100) * (1 - pct/100))
|
||||
log("region %2d %3.0f%% step %d/%d, land %.0f..%.0f m, eta %s",
|
||||
rg.ID, pct, step, total, lo, hi, eta.Round(time.Second))
|
||||
}
|
||||
}
|
||||
grid.Run(h.Data, up.Rate.Data, up.K.Data, params, progress)
|
||||
|
||||
// The edge-preserving pass, if the manifest asked for one. Here and not after the write: the region's
|
||||
// statistics are gathered a few lines below while the grid is alive, and a smooth applied after them
|
||||
// would put a surface on disk that meta.json does not describe - which is the bake-against-tiles drift
|
||||
// trap in a new costume.
|
||||
//
|
||||
// It reads a cell's eight neighbours, and that is safe against the decomposition for a reason particular
|
||||
// to this pass: a region's frame edges are open ocean by construction and every landmass in it sits at
|
||||
// least margin_cells from them, so a land cell's neighbours are always inside its own region. The rule
|
||||
// this would otherwise break - no neighbourhood operation near a region edge - still stands for anything
|
||||
// that reaches across the waterline.
|
||||
if sm := m.Pipeline.Smooth; sm.Passes > 0 {
|
||||
field.SmoothEdgePreserving(h.Data, h.W, h.H, h.CellM, land, sm.Passes, sm.SlopeRef, grid.Scratch())
|
||||
}
|
||||
|
||||
lo, hi := landExtent(h.Data, land)
|
||||
peakRate := 0.0
|
||||
for i, isLand := range land {
|
||||
if isLand && float64(up.Rate.Data[i]) > peakRate {
|
||||
peakRate = float64(up.Rate.Data[i])
|
||||
}
|
||||
}
|
||||
clip := clipFraction(h.Data, land, m.ElevationM.Min, m.ElevationM.Max)
|
||||
secs := time.Since(start).Seconds()
|
||||
clampNote := ""
|
||||
if up.FaultClamped > 0 {
|
||||
clampNote = fmt.Sprintf(", %.2f%% of it bounded at repose by faults",
|
||||
100*float64(up.FaultClamped)/float64(rg.LandCells))
|
||||
}
|
||||
log("region %2d done %.0f..%.0f m, %.3f%% clipped, %.2f mm/yr peak%s [%.0f s]",
|
||||
rg.ID, lo, hi, clip*100, peakRate*1000, clampNote, secs)
|
||||
|
||||
// The region's own land statistics, gathered here while its grid is still alive - it is thrown away a
|
||||
// few lines below, and the composited planet has no uplift field or flow topology to recover them from.
|
||||
// Only the land this region *owns*: `Cut` marks nothing else, so the pieces are disjoint and the sum is
|
||||
// the planet.
|
||||
acc := stats.New(statsOptions(m))
|
||||
acc.Add(stats.Input{
|
||||
H: h, Land: land, UpliftMYr: up.Rate.Data, KLocal: up.K.Data,
|
||||
Area: grid.Area, Receiver: grid.Receiver, Length: grid.Length,
|
||||
})
|
||||
|
||||
rr := RegionResult{ID: rg.ID, Cells: rg.Cells(), LandCells: rg.LandCells,
|
||||
Seconds: secs, MinM: lo, MaxM: hi, ClipFrac: clip, FaultClamped: up.FaultClamped, stats: acc}
|
||||
|
||||
// The write-back and the hook under one lock. Composite is the only place a worker touches shared state,
|
||||
// so holding it here is what lets OnRegion read the whole planet without racing the others.
|
||||
composite.Lock()
|
||||
in.Part.Composite(res.Height.Data, in.Map, rg, h.Data)
|
||||
compositeFlow(in.Part, in.Map, rg, res.Flow, grid.Area)
|
||||
if opt.OnRegion != nil {
|
||||
opt.OnRegion(res, rr)
|
||||
}
|
||||
composite.Unlock()
|
||||
|
||||
return rr
|
||||
}
|
||||
|
||||
func seamNote(seam bool) string {
|
||||
if seam {
|
||||
return ", across the seam"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// compositeFlow writes a region's drainage area back for the preview's rivers. It follows Composite's rule:
|
||||
// only the land this region owns.
|
||||
func compositeFlow(part *region.Partition, mp *template.Map, rg region.Region, dst, area []float32) {
|
||||
for y := 0; y < rg.Frame.H; y++ {
|
||||
for x := 0; x < rg.Frame.W; x++ {
|
||||
pi := rg.Frame.PlanetIdx(x, y)
|
||||
if part.Owner[pi] != int32(rg.ID) || mp.Sea[pi] {
|
||||
continue
|
||||
}
|
||||
dst[pi] = area[y*rg.Frame.W+x]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func solveParams(m *manifest.Manifest) fluvial.Params {
|
||||
f := m.Pipeline.Fluvial
|
||||
return fluvial.Params{
|
||||
K: f.K, M: f.M, N: f.N, DtYr: f.DtYr, Steps: f.Steps,
|
||||
Diffusion: f.DiffusionM2Yr, FillEvery: f.FillEvery,
|
||||
TalusSlope: thermal.TalusFromDegrees(m.Pipeline.Thermal.TalusDeg),
|
||||
ThermalEvery: m.Pipeline.Thermal.Every,
|
||||
ThermalPasses: m.Pipeline.Thermal.CoarsePasses,
|
||||
CriticalAreaM2: f.CriticalAreaM2,
|
||||
ChannelTaper: f.ChannelTaper,
|
||||
CriticalSlope: thermal.TalusFromDegrees(f.CriticalSlopeDeg),
|
||||
SlopeCap: f.SlopeCap,
|
||||
MaxHillslopeSub: f.MaxHillslopeSub,
|
||||
MFDExponent: f.MFDExponent,
|
||||
}
|
||||
}
|
||||
|
||||
// runCoast lays the sea floor and works the shoreline, once, over the whole cylinder.
|
||||
//
|
||||
// Once and whole rather than per region, which is D-53's rule and is not a convenience: the pass costs tens
|
||||
// of nanoseconds a cell against tens of nanoseconds a cell *per step* for the solve, and cutting it up would
|
||||
// truncate the fetch across every strait, split the sediment budget whose conservation is the one thing in it
|
||||
// not derived from something already measured, and leave the shoreline length and the exposure percentiles as
|
||||
// statistics that do not pool. It runs after every region is composited, because two of its three processes
|
||||
// read the finished land: the shelf width comes off the relief standing behind each shore, and the surf cuts
|
||||
// into whatever the solve built.
|
||||
//
|
||||
// The painted depths go in as the *abyss*, one value per cell. That is what makes the derived margin meet the
|
||||
// painted ocean instead of stepping to it: an author who painted `shelf` at 120 m, `deep` at 512 and `surf`
|
||||
// at 20 gets a continental slope that runs down to each of those where each of them is, and a strait painted
|
||||
// shallower than the shelf break comes out as shelf all the way across rather than as a trench.
|
||||
func runCoast(in *Inputs, res *Result, log func(string, ...any)) {
|
||||
m := in.M
|
||||
depths := in.Legend.Depths()
|
||||
abyss := make([]float32, len(res.Height.Data))
|
||||
for i := range abyss {
|
||||
if in.Map.Sea[i] {
|
||||
abyss[i] = depths[in.Map.Class[i]]
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
cs := coast.Build(coast.Input{
|
||||
Height: res.Height, Sea: in.Map.Sea, SeaLevelM: m.SeaLevelM,
|
||||
BreakM: m.ShelfBreakM(),
|
||||
// AbyssM is the square canvas's key and is never read on this path: `Abyss` below is per-cell and
|
||||
// always allocated, so `abyssAt` takes the painting every time. Left as the fallback it is rather
|
||||
// than removed, and named here because it is the same shape as the read D-64 had to fix one line up.
|
||||
AbyssM: -m.Pipeline.Continent.SeaFloorM.Lo(),
|
||||
Abyss: abyss,
|
||||
// The cylinder, which is the whole of what D-59's successor had to add: every march, every ray and
|
||||
// every running sum in the pass crosses the seam now, and a planet measured on a flat grid would have
|
||||
// had its shelf, its fetch and its sediment budget all stop dead at one meridian.
|
||||
WrapX: true,
|
||||
NoisePeriodM: in.P.NoisePeriodM,
|
||||
Flow: res.Flow,
|
||||
Seed: m.Source.Seed,
|
||||
Cfg: m.Pipeline.Coast,
|
||||
})
|
||||
res.Coast = cs
|
||||
res.Sea = cs.Sea
|
||||
|
||||
if !m.Pipeline.Coast.Enabled {
|
||||
log("ocean the painted depths, flat: the coastal pass is switched off in the manifest")
|
||||
return
|
||||
}
|
||||
// The margin's own numbers, said out loud, because they decide how much of the painted ocean survives
|
||||
// and they were a square-canvas inheritance nobody could see until D-64. A shelf and a slope together
|
||||
// reach `ShelfKm.Hi() + SlopeKm` from every shore; where the sea is narrower than twice that, the
|
||||
// painting's depth is never reached anywhere in it and the author's ocean is whatever `break_m` says.
|
||||
cfg := m.Pipeline.Coast
|
||||
reach := cfg.ShelfKm.Hi() + cfg.SlopeKm
|
||||
log(" sea floor: shelf %.1f..%.1f km to a break at %.0f m, then %.1f km of slope to the painted "+
|
||||
"depth, so the painting owns the water past %.1f km offshore and nothing nearer",
|
||||
cfg.ShelfKm.Lo(), cfg.ShelfKm.Hi(), m.ShelfBreakM(), cfg.SlopeKm, reach)
|
||||
log("coast %s", cs.Stats.Summary())
|
||||
log(" over the whole cylinder in %s, once: the pass is tens of nanoseconds a cell and cutting it",
|
||||
time.Since(start).Round(time.Millisecond))
|
||||
log(" up would truncate the fetch across every strait and split the sediment budget")
|
||||
}
|
||||
|
||||
func landExtent(h []float32, land []bool) (lo, hi float64) {
|
||||
lo, hi = 1e30, -1e30
|
||||
any := false
|
||||
for i, v := range h {
|
||||
if !land[i] {
|
||||
continue
|
||||
}
|
||||
any = true
|
||||
if float64(v) < lo {
|
||||
lo = float64(v)
|
||||
}
|
||||
if float64(v) > hi {
|
||||
hi = float64(v)
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return 0, 0
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
// clipFraction is how much of the land the 16-bit encoding would cut off. Above a fraction of a per cent it
|
||||
// is a failed run rather than a rounded one, and painted uplift makes it easier to hit: an author can ask
|
||||
// for more relief than the elevation range holds.
|
||||
func clipFraction(h []float32, land []bool, minM, maxM float64) float64 {
|
||||
n, clipped := 0, 0
|
||||
for i, v := range h {
|
||||
if !land[i] {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
if float64(v) < minM || float64(v) > maxM {
|
||||
clipped++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(clipped) / float64(n)
|
||||
}
|
||||
|
||||
// faultCeilingMYr is the uplift rate at which a divide stands at the angle of repose, in metres a year at
|
||||
// K x1. It is the number `terrain plan` already prints as the clamp ceiling, in the unit the solve works in;
|
||||
// here it bounds what a *fault* may add on top of what an author painted, and nothing else. Zero when the
|
||||
// repose clamp is switched off, which switches the bound off with it.
|
||||
func faultCeilingMYr(m *manifest.Manifest) float64 {
|
||||
if m.Pipeline.Thermal.TalusDeg <= 0 || m.Pipeline.Thermal.TalusDeg >= 90 {
|
||||
return 0
|
||||
}
|
||||
return clampCeiling(m.Pipeline.Fluvial.K, m.GeologyCellM(), m.Pipeline.Fluvial.M,
|
||||
m.Pipeline.Thermal.TalusDeg) / 1000
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/dt"
|
||||
"salty/terrain/internal/template"
|
||||
)
|
||||
|
||||
// Impacts, stamped onto the finished terrain.
|
||||
//
|
||||
// A crater is not an uplift rate and it cannot be one, which is worth writing down because it is the obvious
|
||||
// thing to try. A closed basin does not survive the fluvial solve: the priority-flood runs every step and
|
||||
// *raises* every depression to its spill level, so a crater built out of negative uplift would be filled in
|
||||
// before the run was a hundred steps old. It is also the wrong model. A crater is an event, not a rate - it
|
||||
// postdates the landscape it sits in - and a pass running after the solve is what that means.
|
||||
//
|
||||
// The shape is derived from the painted blob rather than drawn. Distance inward from the blob's own boundary,
|
||||
// normalised by its widest point, is a coordinate that runs 0 at the shore to 1 at the centre whatever size
|
||||
// and shape the author painted, so one set of numbers describes every crater on the map.
|
||||
|
||||
// craterProfile is the height at a normalised distance t in from the shore.
|
||||
//
|
||||
// t = 0 the waterline: sea level, so the island keeps the outline that was painted
|
||||
// t = RimAt the crest
|
||||
// t = WallAt the foot of the inner wall
|
||||
// t > WallAt floor
|
||||
//
|
||||
// Both segments are smoothstepped, so the crest is a ridge rather than a corner and the floor meets the wall
|
||||
// without a crease. A corner at either would be ground the detail passes then spend their time sanding off.
|
||||
func craterProfile(c template.Crater, seaLevelM, t float64) float64 {
|
||||
switch {
|
||||
case t <= 0:
|
||||
return seaLevelM
|
||||
case t < c.RimAt:
|
||||
return seaLevelM + (c.RimM-seaLevelM)*smoothstep(t/c.RimAt)
|
||||
case t < c.WallAt:
|
||||
return c.RimM + (c.FloorM-c.RimM)*smoothstep((t-c.RimAt)/(c.WallAt-c.RimAt))
|
||||
default:
|
||||
return c.FloorM
|
||||
}
|
||||
}
|
||||
|
||||
func smoothstep(t float64) float64 {
|
||||
if t <= 0 {
|
||||
return 0
|
||||
}
|
||||
if t >= 1 {
|
||||
return 1
|
||||
}
|
||||
return t * t * (3 - 2*t)
|
||||
}
|
||||
|
||||
// CraterStats is what the pass stamped.
|
||||
//
|
||||
// The floor is measured over the cells that actually reached it rather than over the whole blob, and that is
|
||||
// not fussiness: the profile starts at sea level on the shoreline, so the minimum over a blob is always zero
|
||||
// and reporting it as the floor says nothing at all. What is worth knowing is whether the blob was wide
|
||||
// enough for the profile to get there - a crater painted smaller than its own rim is a hill.
|
||||
type CraterStats struct {
|
||||
Class string
|
||||
Blobs int
|
||||
Cells int
|
||||
FloorCells int // cells past wall_at, which are the ones at the floor
|
||||
RadiusM float64 // the widest blob's inradius: what the profile is normalised by
|
||||
RimM float64 // the highest point actually stamped
|
||||
FloorM float64 // the lowest point among the floor cells
|
||||
}
|
||||
|
||||
// stampCraters reshapes every blob of every crater class in the planet raster.
|
||||
//
|
||||
// It runs after the regions are composited and before the ocean is laid, so it sees finished land and writes
|
||||
// only onto land the paint marked as crater.
|
||||
func stampCraters(in *Inputs, res *Result, log func(string, ...any)) []CraterStats {
|
||||
if !in.Legend.HasCraters() {
|
||||
return nil
|
||||
}
|
||||
p := in.P
|
||||
n := p.W * p.H
|
||||
var out []CraterStats
|
||||
|
||||
for ci := range in.Legend.Classes {
|
||||
c := in.Legend.Classes[ci]
|
||||
if c.Crater == nil {
|
||||
continue
|
||||
}
|
||||
mask := make([]bool, n)
|
||||
outside := make([]bool, n)
|
||||
count := 0
|
||||
for i := 0; i < n; i++ {
|
||||
if in.Map.Class[i] == uint8(ci) && !in.Map.Sea[i] {
|
||||
mask[i] = true
|
||||
count++
|
||||
} else {
|
||||
outside[i] = true
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Distance inward from the blob's boundary: seed the transform with everything that is *not* this
|
||||
// class, and every cell of it then knows how far it is from the nearest edge. Wrapped, because a
|
||||
// crater on the seam is one crater.
|
||||
d2 := dt.Distance2(outside, p.W, p.H, true)
|
||||
dist := make([]float64, n)
|
||||
for i := range d2 {
|
||||
if mask[i] {
|
||||
dist[i] = math.Sqrt(float64(d2[i]))
|
||||
}
|
||||
}
|
||||
|
||||
// Each blob is normalised by its own widest point, so a big crater and a small one get the same
|
||||
// shape rather than the same depth. Components are found with the same wrap-aware flood the region
|
||||
// partitioner uses; there are a handful of them and they are tiny.
|
||||
comp, maxDist, blobs := craterComponents(mask, dist, p.W, p.H, p.WrapX)
|
||||
|
||||
st := CraterStats{Class: c.Name, Blobs: blobs, Cells: count, FloorM: math.Inf(1),
|
||||
RimM: math.Inf(-1)}
|
||||
for _, d := range maxDist {
|
||||
if d*p.CellM > st.RadiusM {
|
||||
st.RadiusM = d * p.CellM
|
||||
}
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if !mask[i] || comp[i] < 0 {
|
||||
continue
|
||||
}
|
||||
d := maxDist[comp[i]]
|
||||
if d <= 0 {
|
||||
continue
|
||||
}
|
||||
t := dist[i] / d
|
||||
h := craterProfile(*c.Crater, in.M.SeaLevelM, t)
|
||||
res.Height.Data[i] = float32(h)
|
||||
if h > st.RimM {
|
||||
st.RimM = h
|
||||
}
|
||||
if t >= c.Crater.WallAt {
|
||||
st.FloorCells++
|
||||
if h < st.FloorM {
|
||||
st.FloorM = h
|
||||
}
|
||||
}
|
||||
}
|
||||
if st.FloorCells == 0 {
|
||||
st.FloorM = 0
|
||||
}
|
||||
out = append(out, st)
|
||||
log("crater %s: %d blob(s), %d cells, widest %.0f m across; rim reached %.0f m, "+
|
||||
"%d cells at the floor (%.0f m)",
|
||||
st.Class, st.Blobs, st.Cells, 2*st.RadiusM, st.RimM, st.FloorCells, st.FloorM)
|
||||
if st.FloorCells == 0 {
|
||||
log(" WARNING no cell reached the floor: every blob is narrower than wall_at asks for, " +
|
||||
"so this is a hill rather than a crater. Paint it wider or lower wall_at.")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// craterComponents labels each blob and records its widest point, which is the radius the profile is
|
||||
// normalised by.
|
||||
func craterComponents(mask []bool, dist []float64, w, h int, wrap func(int) int) (comp []int32, maxDist []float64, n int) {
|
||||
comp = make([]int32, len(mask))
|
||||
for i := range comp {
|
||||
comp[i] = -1
|
||||
}
|
||||
var stack []int32
|
||||
for start := 0; start < len(mask); start++ {
|
||||
if !mask[start] || comp[start] >= 0 {
|
||||
continue
|
||||
}
|
||||
id := int32(len(maxDist))
|
||||
maxDist = append(maxDist, 0)
|
||||
comp[start] = id
|
||||
stack = append(stack[:0], int32(start))
|
||||
for len(stack) > 0 {
|
||||
c := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
if dist[c] > maxDist[id] {
|
||||
maxDist[id] = dist[c]
|
||||
}
|
||||
cx, cy := int(c)%w, int(c)/w
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
ny := cy + dy
|
||||
if ny < 0 || ny >= h {
|
||||
continue
|
||||
}
|
||||
base := ny * w
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue
|
||||
}
|
||||
ni := int32(base + wrap(cx+dx))
|
||||
if mask[ni] && comp[ni] < 0 {
|
||||
comp[ni] = id
|
||||
stack = append(stack, ni)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return comp, maxDist, len(maxDist)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/template"
|
||||
)
|
||||
|
||||
// The profile is the whole of what a crater is, so it is worth pinning down: shore at sea level, a crest, an
|
||||
// inner wall, a floor, and nothing anywhere that is not monotonic on its own segment.
|
||||
func TestCraterProfileIsRimThenFloor(t *testing.T) {
|
||||
c := template.Crater{RimM: 340, FloorM: 60, RimAt: 0.30, WallAt: 0.62}
|
||||
|
||||
if got := craterProfile(c, 0, 0); got != 0 {
|
||||
t.Errorf("at the shore = %.1f m, want sea level: the island keeps the outline that was painted", got)
|
||||
}
|
||||
if got := craterProfile(c, 0, c.RimAt); math.Abs(got-c.RimM) > 1e-9 {
|
||||
t.Errorf("at rim_at = %.1f m, want the rim %.1f", got, c.RimM)
|
||||
}
|
||||
if got := craterProfile(c, 0, c.WallAt); math.Abs(got-c.FloorM) > 1e-9 {
|
||||
t.Errorf("at wall_at = %.1f m, want the floor %.1f", got, c.FloorM)
|
||||
}
|
||||
if got := craterProfile(c, 0, 1); got != c.FloorM {
|
||||
t.Errorf("at the centre = %.1f m, want the floor %.1f", got, c.FloorM)
|
||||
}
|
||||
|
||||
// Rising to the crest, falling to the floor, and the floor is dry.
|
||||
prev := craterProfile(c, 0, 0)
|
||||
for i := 1; i <= 30; i++ {
|
||||
v := craterProfile(c, 0, c.RimAt*float64(i)/30)
|
||||
if v < prev-1e-9 {
|
||||
t.Fatalf("the outer flank dips at t = %.3f", c.RimAt*float64(i)/30)
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
prev = craterProfile(c, 0, c.RimAt)
|
||||
for i := 1; i <= 30; i++ {
|
||||
v := craterProfile(c, 0, c.RimAt+(c.WallAt-c.RimAt)*float64(i)/30)
|
||||
if v > prev+1e-9 {
|
||||
t.Fatalf("the inner wall rises at t = %.3f", c.RimAt+(c.WallAt-c.RimAt)*float64(i)/30)
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
if c.FloorM <= 0 {
|
||||
t.Error("this crater's floor is not above sea level, which is what was asked for")
|
||||
}
|
||||
}
|
||||
|
||||
// A blob is normalised by its own widest point, so the same numbers describe a small crater and a large one.
|
||||
func TestCraterComponentsMeasureEachBlobsOwnRadius(t *testing.T) {
|
||||
const w, h = 40, 12
|
||||
mask := make([]bool, w*h)
|
||||
dist := make([]float64, w*h)
|
||||
set := func(x0, y0, wid, hei int, d float64) {
|
||||
for y := y0; y < y0+hei; y++ {
|
||||
for x := x0; x < x0+wid; x++ {
|
||||
mask[y*w+x] = true
|
||||
dist[y*w+x] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
set(2, 2, 6, 6, 3) // a blob whose widest point is 3
|
||||
set(20, 4, 4, 4, 1.5) // and a smaller one at 1.5
|
||||
wrap := func(x int) int { return ((x % w) + w) % w }
|
||||
|
||||
comp, maxDist, n := craterComponents(mask, dist, w, h, wrap)
|
||||
if n != 2 {
|
||||
t.Fatalf("found %d blobs, want 2", n)
|
||||
}
|
||||
a := maxDist[comp[3*w+3]]
|
||||
b := maxDist[comp[5*w+21]]
|
||||
if a != 3 || b != 1.5 {
|
||||
t.Errorf("radii %.1f and %.1f, want 3 and 1.5", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// A crater on the seam is one crater, not two half craters with two different radii.
|
||||
func TestACraterOnTheSeamIsOneBlob(t *testing.T) {
|
||||
const w, h = 40, 12
|
||||
mask := make([]bool, w*h)
|
||||
dist := make([]float64, w*h)
|
||||
for y := 4; y < 8; y++ {
|
||||
for _, x := range []int{38, 39, 0, 1} {
|
||||
mask[y*w+x] = true
|
||||
dist[y*w+x] = 2
|
||||
}
|
||||
}
|
||||
wrap := func(x int) int { return ((x % w) + w) % w }
|
||||
_, _, n := craterComponents(mask, dist, w, h, wrap)
|
||||
if n != 1 {
|
||||
t.Fatalf("found %d blobs across the seam, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"image/png"
|
||||
"math"
|
||||
"path/filepath"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/uplift"
|
||||
)
|
||||
|
||||
// The maps the plan command writes, and why each one earns its place.
|
||||
//
|
||||
// They are rendered by point-sampling the planet's own arrays straight into an image rather than by building
|
||||
// a full-resolution Field and handing it to field.WriteDataMap. At 78 million cells a Field is 312 MB, and
|
||||
// the whole point of the plan command is that it costs a minute and nothing else.
|
||||
|
||||
// water is the flat blue every map uses for cells that are not land, so the land reads against it.
|
||||
var water = [3]uint8{24, 44, 74}
|
||||
|
||||
// renderRGB point-samples the painted rows of the planet down to width pixels, keeping the aspect, and asks
|
||||
// at() for a colour per sampled cell. The polar pad is not drawn: it is scaffolding, not world.
|
||||
// The callback is given the image pixel as well as the planet cell, because a map may have a field of its
|
||||
// own built at the image's resolution rather than the planet's - the uplift map does, since the massif fabric
|
||||
// is a field and not a per-class constant.
|
||||
func renderRGB(in *Inputs, width int, at func(planetIdx, imgIdx int) [3]uint8) (px []uint8, w, h int) {
|
||||
p := in.P
|
||||
if width <= 0 || width > p.W {
|
||||
width = p.W
|
||||
}
|
||||
paintH := p.PaintH()
|
||||
height := int(float64(width)*float64(paintH)/float64(p.W) + 0.5)
|
||||
if height < 1 {
|
||||
height = 1
|
||||
}
|
||||
px = make([]uint8, width*height*3)
|
||||
field.Rows(height, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
sy := p.PadY + y*paintH/height
|
||||
for x := 0; x < width; x++ {
|
||||
sx := x * p.W / width
|
||||
c := at(sy*p.W+sx, y*width+x)
|
||||
o := (y*width + x) * 3
|
||||
px[o], px[o+1], px[o+2] = c[0], c[1], c[2]
|
||||
}
|
||||
}
|
||||
})
|
||||
return px, width, height
|
||||
}
|
||||
|
||||
func write(path string, px []uint8, w, h int) error {
|
||||
return field.WriteRGB(path, w, h, px, png.DefaultCompression)
|
||||
}
|
||||
|
||||
// WriteClassMap is the first thing to look at when a template comes out wrong: the legend's own colours,
|
||||
// after the strokes have been dissolved and the poles rescued. If this is not the painting, nothing
|
||||
// downstream can be.
|
||||
func WriteClassMap(dir string, in *Inputs, width int) error {
|
||||
cols := make([][3]uint8, len(in.Legend.Classes))
|
||||
for i, c := range in.Legend.Classes {
|
||||
cols[i] = [3]uint8{uint8(c.RGB[0]), uint8(c.RGB[1]), uint8(c.RGB[2])}
|
||||
}
|
||||
px, w, h := renderRGB(in, width, func(i, _ int) [3]uint8 { return cols[in.Map.Class[i]] })
|
||||
return write(filepath.Join(dir, "map_class.png"), px, w, h)
|
||||
}
|
||||
|
||||
// WriteRegionMap shows how the planet was cut up. Each region gets its own hue: its land saturated, the
|
||||
// water it carries as a margin the same hue dimmed. Water owned by nobody is flat blue.
|
||||
//
|
||||
// What to read from it: that the seam-straddling landmass is one colour and not two, that no two landmasses
|
||||
// which should be independent have been merged into one enormous box, and that the margins are not so wide
|
||||
// that the regions have swallowed the ocean.
|
||||
func WriteRegionMap(dir string, in *Inputs, width int) error {
|
||||
hues := in.RegionHues()
|
||||
px, w, h := renderRGB(in, width, func(i, _ int) [3]uint8 {
|
||||
r := in.Part.Owner[i]
|
||||
if r < 0 {
|
||||
return water
|
||||
}
|
||||
c := hues[r]
|
||||
if in.Map.Sea[i] {
|
||||
// The margin: the same region, dimmed, so the box it will be solved in is visible.
|
||||
return [3]uint8{c[0]/3 + water[0]/2, c[1]/3 + water[1]/2, c[2]/3 + water[2]/2}
|
||||
}
|
||||
return c
|
||||
})
|
||||
return write(filepath.Join(dir, "map_regions.png"), px, w, h)
|
||||
}
|
||||
|
||||
// goldenAngle is the fraction of a turn between consecutive region hues: 1/phi, or 137.507 degrees.
|
||||
const goldenAngle = 0.6180339887498949
|
||||
|
||||
// RegionHues is the colour each region is drawn in, indexed the same way in.Part.Regions is.
|
||||
//
|
||||
// Exported so that a caller drawing a key beside the map gets the colours from here rather than
|
||||
// reimplementing it. A legend that is a second copy of the thing it describes is a legend that will
|
||||
// eventually be wrong about it.
|
||||
//
|
||||
// The hue walks by the golden angle rather than coming out of a hash of the index, which is what it used to
|
||||
// do. A hash gives *independent* hues, and independent hues collide: the closest pair of the hash's first
|
||||
// twenty was 8.5 apart in RGB, which is two colours nobody can tell apart, on a map whose entire job is
|
||||
// answering "is that one landmass or two". Stepping 137.5 degrees is the arrangement that keeps every prefix
|
||||
// of the sequence as far apart as a sequence can be, and saturation and value then cycle on 3 and 2 so that
|
||||
// two regions coming round to the same hue still differ in something else. Measured over the same walk: the
|
||||
// closest pair is 44.0 at twenty regions, 41.9 at twenty-six and 37.7 at forty.
|
||||
//
|
||||
// Neither cycle is pushed far. The ocean margin is drawn as this colour thirded and mixed with water, so a
|
||||
// region that starts dim dims to the same grey-blue as every other dim one.
|
||||
func (in *Inputs) RegionHues() [][3]uint8 {
|
||||
hues := make([][3]uint8, len(in.Part.Regions))
|
||||
for i := range hues {
|
||||
c := field.HSV(math.Mod(float64(i)*goldenAngle, 1), 0.48+0.17*float64(i%3), 0.96-0.16*float64(i%2))
|
||||
hues[i] = [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
|
||||
}
|
||||
return hues
|
||||
}
|
||||
|
||||
// RegionLabels is where to write each region's id over the region map: the centroid of its land, as fractions
|
||||
// of the drawn map - 0..1 across, 0..1 down the painted rows, the same frame renderRGB draws into.
|
||||
//
|
||||
// Exported for the reason RegionHues is. The alternative is a caller reproducing the polar row offset and the
|
||||
// seam wrap in a second language, and a label half a region away from the region it names is worse than no
|
||||
// label at all. Colour alone cannot carry this: even at 137.5 degrees a step, forty regions are forty hues and
|
||||
// a person matching a hue to a swatch is doing work a two-digit number does for them.
|
||||
//
|
||||
// Two details it would be wrong to leave out. The mean across is *circular*, because a landmass over the seam
|
||||
// has land at x=0 and at x=W-1 and a plain average puts its number on the opposite side of the planet. And it
|
||||
// is sampled on a stride rather than walked whole: this is a place to put a number, the planet is seventy-six
|
||||
// million cells, and a quarter of a cell of accuracy is not worth a sixteenth of a plan. A region too small to
|
||||
// catch a sample falls back to the middle of its frame, which is the only thing left to say about it.
|
||||
func (in *Inputs) RegionLabels() [][2]float64 {
|
||||
p := in.P
|
||||
out := make([][2]float64, len(in.Part.Regions))
|
||||
paintH := p.PaintH()
|
||||
if len(out) == 0 || paintH <= 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
const stride = 4
|
||||
cosX := make([]float64, p.W)
|
||||
sinX := make([]float64, p.W)
|
||||
for x := 0; x < p.W; x++ {
|
||||
a := 2 * math.Pi * float64(x) / float64(p.W)
|
||||
cosX[x], sinX[x] = math.Cos(a), math.Sin(a)
|
||||
}
|
||||
|
||||
sumC := make([]float64, len(out))
|
||||
sumS := make([]float64, len(out))
|
||||
sumY := make([]float64, len(out))
|
||||
n := make([]float64, len(out))
|
||||
for y := p.PadY; y < p.PadY+paintH; y += stride {
|
||||
row := y * p.W
|
||||
for x := 0; x < p.W; x += stride {
|
||||
i := row + x
|
||||
r := in.Part.Owner[i]
|
||||
if r < 0 || in.Map.Sea[i] {
|
||||
continue
|
||||
}
|
||||
sumC[r] += cosX[x]
|
||||
sumS[r] += sinX[x]
|
||||
sumY[r] += float64(y - p.PadY)
|
||||
n[r]++
|
||||
}
|
||||
}
|
||||
|
||||
for r := range out {
|
||||
if n[r] == 0 {
|
||||
f := in.Part.Regions[r].Frame
|
||||
// X0 can run past W on a seam region and Y0 can reach into the polar pad, so both are brought
|
||||
// back into the drawn frame rather than trusted.
|
||||
u := math.Mod(float64(f.X0)+float64(f.W)/2, float64(p.W)) / float64(p.W)
|
||||
v := (float64(f.Y0-p.PadY) + float64(f.H)/2) / float64(paintH)
|
||||
out[r] = [2]float64{clamp01(u), clamp01(v)}
|
||||
continue
|
||||
}
|
||||
a := math.Atan2(sumS[r]/n[r], sumC[r]/n[r])
|
||||
if a < 0 {
|
||||
a += 2 * math.Pi
|
||||
}
|
||||
out[r] = [2]float64{a / (2 * math.Pi), clamp01(sumY[r] / n[r] / float64(paintH))}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if v >= 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// UpliftScale is the top of the uplift map's ramp, in mm/yr, and the colours along it. Same argument as
|
||||
// RegionHues: the key comes from the code that drew the picture.
|
||||
func (in *Inputs) UpliftScale(stops int) (hi float64, ramp [][3]uint8) {
|
||||
for _, r := range in.Legend.Rates() {
|
||||
if v := float64(r) * 1000; v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
if hi <= 0 {
|
||||
hi = 1
|
||||
}
|
||||
return hi, sampleRamp(stops, field.Inferno)
|
||||
}
|
||||
|
||||
// ErodibilityScale is the low and high end of the erodibility map's ramp and the colours along it.
|
||||
func (in *Inputs) ErodibilityScale(stops int) (lo, hi float64, ramp [][3]uint8) {
|
||||
lo, hi = in.erodibilityRange()
|
||||
return lo, hi, sampleRamp(stops, field.Viridis)
|
||||
}
|
||||
|
||||
// erodibilityRange is what the erodibility map spans: every land class's own multiplier, widened by the rock
|
||||
// field's extremes wherever a class lets them through. It has to account for the lithology or the ramp would
|
||||
// clip exactly the variation the field was added to show.
|
||||
func (in *Inputs) erodibilityRange() (lo, hi float64) {
|
||||
lo, hi = 1, 1
|
||||
mult := in.M.Pipeline.Lithology.KMultipliers
|
||||
rockLo, rockHi := 1.0, 1.0
|
||||
if in.M.Planet.LithologyCells() > 0 && len(mult) > 1 {
|
||||
rockLo, rockHi = mult[0], mult[0]
|
||||
for _, v := range mult {
|
||||
rockLo = math.Min(rockLo, v)
|
||||
rockHi = math.Max(rockHi, v)
|
||||
}
|
||||
}
|
||||
for i := range in.Legend.Classes {
|
||||
c := in.Legend.Classes[i]
|
||||
if !c.Land() {
|
||||
continue
|
||||
}
|
||||
k, mix := c.K(), c.LithMix()
|
||||
lo = math.Min(lo, k*(1+mix*(rockLo-1)))
|
||||
hi = math.Max(hi, k*(1+mix*(rockHi-1)))
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
func sampleRamp(stops int, f func(float64) [3]float64) [][3]uint8 {
|
||||
if stops < 2 {
|
||||
stops = 2
|
||||
}
|
||||
out := make([][3]uint8, stops)
|
||||
for i := range out {
|
||||
c := f(float64(i) / float64(stops-1))
|
||||
out[i] = [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WriteUpliftMap is the field everything else is a consequence of, in mm/yr. On the square canvas this map
|
||||
// would have shown, at a glance and with no arithmetic, that the plains were being raised at mountain rates.
|
||||
// On a painted planet it is the direct check that the legend's numbers landed where the author painted them.
|
||||
func WriteUpliftMap(dir string, in *Inputs, width int) error {
|
||||
rates := in.Legend.Rates()
|
||||
massifFloor, massifFraction := in.Legend.Massifs()
|
||||
hi := 0.0
|
||||
for _, r := range rates {
|
||||
if v := float64(r) * 1000; v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
if hi <= 0 {
|
||||
hi = 1
|
||||
}
|
||||
|
||||
// The fabric, at the image's resolution rather than the planet's. It has to be drawn, not left out: with
|
||||
// massifs the rate is a field and not a per-class constant, and a map that showed the class rate flat
|
||||
// across a landmass would be showing the one thing that is no longer true about it. Building it here
|
||||
// costs a couple of million noise samples rather than the planet's seventy-eight.
|
||||
var rank *field.Field
|
||||
if in.Legend.HasMassifs() {
|
||||
u, v := in.renderUV(width)
|
||||
rank = uplift.MassifRank(in.P, in.M.Source.Seed, in.M.Planet.MassifCells(), u, v)
|
||||
}
|
||||
|
||||
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
|
||||
if in.Map.Sea[i] {
|
||||
return water
|
||||
}
|
||||
cl := in.Map.Class[i]
|
||||
r := float64(rates[cl])
|
||||
if rank != nil && massifFraction[cl] > 0 {
|
||||
r = uplift.MassifRate(float64(massifFloor[cl]), r, float64(rank.Data[img]), massifFraction[cl])
|
||||
}
|
||||
c := field.Inferno(r * 1000 / hi)
|
||||
return [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
|
||||
})
|
||||
drawFaults(in, px, w, h)
|
||||
return write(filepath.Join(dir, "map_uplift.png"), px, w, h)
|
||||
}
|
||||
|
||||
// faultInk is the colour traces are drawn in: cyan, which appears nowhere in the Inferno ramp underneath, so
|
||||
// a trace cannot be mistaken for a value.
|
||||
var faultInk = [3]uint8{80, 240, 255}
|
||||
|
||||
// drawFaults strokes every fault trace over a map, as a line.
|
||||
//
|
||||
// The *line* rather than the rate it contributes, deliberately. A fault's escarpment is a couple of hundred
|
||||
// metres wide and this image is a hundred kilometres across, so the thing it changes is a twentieth of a
|
||||
// pixel and rendering the field would show nothing at all. What an author wants from this map is where the
|
||||
// faults are and which way they run - the same question `map_regions` answers about the region cuts - and a
|
||||
// stroked polyline answers it exactly.
|
||||
func drawFaults(in *Inputs, px []uint8, w, h int) {
|
||||
if len(in.Faults) == 0 {
|
||||
return
|
||||
}
|
||||
p := in.P
|
||||
sx := float64(w) / p.CircumferenceM()
|
||||
sy := float64(h) / p.HeightM()
|
||||
set := func(x, y int) {
|
||||
if y < 0 || y >= h {
|
||||
return
|
||||
}
|
||||
x = ((x % w) + w) % w // X wraps, because the traces do
|
||||
o := (y*w + x) * 3
|
||||
px[o], px[o+1], px[o+2] = faultInk[0], faultInk[1], faultInk[2]
|
||||
}
|
||||
for _, f := range in.Faults {
|
||||
for j := 0; j+1 < len(f.PointsM); j++ {
|
||||
ax, ay := f.PointsM[j][0]*sx, f.PointsM[j][1]*sy
|
||||
bx, by := f.PointsM[j+1][0]*sx, f.PointsM[j+1][1]*sy
|
||||
steps := int(math.Hypot(bx-ax, by-ay)) + 1
|
||||
for k := 0; k <= steps; k++ {
|
||||
t := float64(k) / float64(steps)
|
||||
set(int(ax+(bx-ax)*t), int(ay+(by-ay)*t))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderUV is the world coordinates of the cells renderRGB will point-sample, in the same order it writes
|
||||
// them. Exactly the sampled cells rather than an even walk across the map: a diagnostic that showed the
|
||||
// fabric half a pixel from where the class was read would be a diagnostic nobody could trust to the pixel.
|
||||
func (in *Inputs) renderUV(width int) (u, v *field.Field) {
|
||||
p := in.P
|
||||
if width <= 0 || width > p.W {
|
||||
width = p.W
|
||||
}
|
||||
paintH := p.PaintH()
|
||||
height := int(float64(width)*float64(paintH)/float64(p.W) + 0.5)
|
||||
if height < 1 {
|
||||
height = 1
|
||||
}
|
||||
cellM := p.CircumferenceM() / float64(width)
|
||||
u = field.New(width, height, cellM)
|
||||
v = field.New(width, height, cellM)
|
||||
for y := 0; y < height; y++ {
|
||||
sy := p.PadY + y*paintH/height
|
||||
vy := float32(p.YM(sy) / p.NoisePeriodM)
|
||||
for x := 0; x < width; x++ {
|
||||
i := y*width + x
|
||||
u.Data[i] = float32(p.XM(x*p.W/width) / p.NoisePeriodM)
|
||||
v.Data[i] = vy
|
||||
}
|
||||
}
|
||||
return u, v
|
||||
}
|
||||
|
||||
// WriteErodibilityMap is where texture inside a range comes from: the multiplier on stream-power K.
|
||||
func WriteErodibilityMap(dir string, in *Inputs, width int) error {
|
||||
ks := in.Legend.Erodibilities()
|
||||
mix := in.Legend.LithologyMixes()
|
||||
lo, hi := in.erodibilityRange()
|
||||
span := hi - lo
|
||||
if span < 1e-9 {
|
||||
span = 1
|
||||
}
|
||||
|
||||
// The rock field at the image's resolution rather than the planet's, the same way and for the same reason
|
||||
// the uplift map builds the massif fabric: with lithology the erodibility is a *field*, and a map drawing
|
||||
// the class multiplier flat across a landmass would be showing the one thing that is no longer true of it.
|
||||
var rock *field.Field
|
||||
if cells := in.M.Planet.LithologyCells(); cells > 0 && in.Legend.HasLithology() {
|
||||
u, v := in.renderUV(width)
|
||||
rock = uplift.RockK(in.P, in.M.Source.Seed, cells, in.M.Pipeline.Lithology.KMultipliers, u, v)
|
||||
}
|
||||
|
||||
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
|
||||
if in.Map.Sea[i] {
|
||||
return water
|
||||
}
|
||||
cl := in.Map.Class[i]
|
||||
k := float64(ks[cl])
|
||||
if rock != nil && mix[cl] > 0 {
|
||||
k *= 1 + mix[cl]*(float64(rock.Data[img])-1)
|
||||
}
|
||||
c := field.Viridis((k - lo) / span)
|
||||
return [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
|
||||
})
|
||||
return write(filepath.Join(dir, "map_erodibility.png"), px, w, h)
|
||||
}
|
||||
|
||||
func clamp8(v float64) uint8 {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if v >= 255 {
|
||||
return 255
|
||||
}
|
||||
return uint8(v + 0.5)
|
||||
}
|
||||
|
||||
// WriteOverlayMap draws the annotation layer over a dimmed class map, which is the only way to judge it: a
|
||||
// mark means nothing on its own and everything relative to the coastline or the range it was drawn against.
|
||||
//
|
||||
// It samples the overlay at its own resolution rather than the planet's. Everything else here reads a planet
|
||||
// array; the overlay is registered to the *template*, so going through the planet grid would resample it
|
||||
// twice and lose thin strokes on the way.
|
||||
func WriteOverlayMap(dir string, in *Inputs, width int) error {
|
||||
if in.OverlayRaster == nil {
|
||||
return nil
|
||||
}
|
||||
cols := make([][3]uint8, len(in.Overlay.Marks)+1)
|
||||
for i, m := range in.Overlay.Marks {
|
||||
cols[i+1] = [3]uint8{uint8(m.RGB[0]), uint8(m.RGB[1]), uint8(m.RGB[2])}
|
||||
}
|
||||
class := make([][3]uint8, len(in.Legend.Classes))
|
||||
for i, c := range in.Legend.Classes {
|
||||
// Halved towards black, so a full-strength mark on top of it cannot be mistaken for the ground.
|
||||
class[i] = [3]uint8{uint8(c.RGB[0] / 2), uint8(c.RGB[1] / 2), uint8(c.RGB[2] / 2)}
|
||||
}
|
||||
|
||||
ov := in.OverlayRaster
|
||||
p := in.P
|
||||
paintH := p.PaintH()
|
||||
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
|
||||
// The planet cell this pixel came from, turned back into an overlay pixel. Both rasters cover the
|
||||
// same painted rows, so the conversion is two ratios and no interpolation.
|
||||
x := i % p.W
|
||||
y := i/p.W - p.PadY
|
||||
ox := x * ov.W / p.W
|
||||
oy := y * ov.H / paintH
|
||||
if m := ov.At(ox, oy); m != 0 && int(m) < len(cols) {
|
||||
return cols[m]
|
||||
}
|
||||
return class[in.Map.Class[i]]
|
||||
})
|
||||
return write(filepath.Join(dir, "map_overlay.png"), px, w, h)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/plates"
|
||||
)
|
||||
|
||||
// The tectonic map: which plate every place belongs to, and what is happening where two of them meet.
|
||||
//
|
||||
// It earns its place the same way map_uplift does. Every belt, every fault and every basin this model
|
||||
// produces is a consequence of one line and what is happening across it, so when a range comes out in the
|
||||
// wrong place the question is always "what did the boundary there do", and this is the only picture that
|
||||
// answers it. The lines are drawn by *kind* rather than by rate, for the reason drawFaults gives about
|
||||
// stroking traces: a collision belt is tens of kilometres wide, a map of a whole planet is a few thousand
|
||||
// pixels, and the thing an author needs from it is where the margins are and which ones are closing.
|
||||
|
||||
// plateInk is the colour of each kind of margin. They are picked to be distinguishable from each other and
|
||||
// from the plate fills underneath, which are pastel by construction so that these read on top of them.
|
||||
var plateInk = map[plates.Kind][3]uint8{
|
||||
plates.Collision: {255, 64, 48}, // red: two continents, the thing that makes mountains
|
||||
plates.Subduction: {255, 156, 32}, // orange: an ocean going under
|
||||
plates.Rift: {96, 240, 120}, // green: a continent pulling apart
|
||||
plates.Ridge: {72, 196, 255}, // blue: new ocean floor
|
||||
plates.Transform: {236, 232, 128}, // yellow: sliding, neither up nor down
|
||||
}
|
||||
|
||||
// WritePlateMap draws the tectonic model over the painted land.
|
||||
func WritePlateMap(dir string, in *Inputs, width int) error {
|
||||
m := in.Plates
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A hue per plate, walked round the wheel by a step coprime-ish with the count so that neighbouring ids
|
||||
// are not neighbouring hues - plates 3 and 4 are usually adjacent on the map, and two greens meeting
|
||||
// would hide the very boundary this map exists to show.
|
||||
n := len(m.Plates)
|
||||
hue := make([]float64, n)
|
||||
for i := range hue {
|
||||
hue[i] = math.Mod(float64(i)*0.61803398875, 1)
|
||||
}
|
||||
|
||||
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
|
||||
id := m.PlateAt(in.P.XM(i%in.P.W), in.P.YM(i/in.P.W))
|
||||
if id < 0 || id >= n {
|
||||
return water
|
||||
}
|
||||
// Land is the plate's hue at full strength and sea is the same hue dimmed, so the painting stays
|
||||
// legible underneath: a margin is only interesting relative to where the coasts are.
|
||||
sat, val := 0.55, 0.78
|
||||
if in.Map.Sea[i] {
|
||||
sat, val = 0.38, 0.34
|
||||
}
|
||||
// A continental plate is warmer than an oceanic one at the same hue, because which of the two a
|
||||
// plate is decides what every convergent margin around it does.
|
||||
if !m.Plates[id].Continental {
|
||||
sat *= 0.5
|
||||
}
|
||||
c := field.HSV(hue[id]*360, sat, val)
|
||||
return [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
|
||||
})
|
||||
|
||||
drawBoundaries(m, in, px, w, h)
|
||||
return write(filepath.Join(dir, "map_plates.png"), px, w, h)
|
||||
}
|
||||
|
||||
// WritePlateProposal writes a tectonic layer and its legend for an author to open and edit.
|
||||
//
|
||||
// A blank canvas is the wrong place to start this. Seven plates with plausible motions is a second's work for
|
||||
// the generator and an afternoon's by hand, and what an author actually wants to do is move two of them and
|
||||
// change a heading - which is editing. The pair it writes is exactly what `planet.plates.layer` and
|
||||
// `planet.plates.legend` take, so adopting it is two lines in the manifest.
|
||||
func WritePlateProposal(dir string, in *Inputs, width int) error {
|
||||
if in.Plates == nil {
|
||||
return nil
|
||||
}
|
||||
px, w, h, lg := in.Plates.Propose(width)
|
||||
const name = "plates_proposal"
|
||||
if err := write(filepath.Join(dir, name+".png"), px, w, h); err != nil {
|
||||
return err
|
||||
}
|
||||
lg.Image = name + ".png"
|
||||
data, err := plates.MarshalLegend(lg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, name+".json"), data, 0o644)
|
||||
}
|
||||
|
||||
// boundaryWidthMYr is the closing rate, in metres a year, at which a margin is drawn at its full width. A
|
||||
// margin at a tenth of it is still one pixel, so a slow boundary is visible without a fast one being a blot.
|
||||
const boundaryWidthMYr = 0.08
|
||||
|
||||
// drawBoundaries strokes every margin, coloured by what it is doing and thickened by how fast.
|
||||
func drawBoundaries(m *plates.Model, in *Inputs, px []uint8, w, h int) {
|
||||
p := in.P
|
||||
sx := float64(w) / p.CircumferenceM()
|
||||
sy := float64(h) / p.HeightM()
|
||||
|
||||
set := func(x, y int, c [3]uint8) {
|
||||
if y < 0 || y >= h {
|
||||
return
|
||||
}
|
||||
x = ((x % w) + w) % w // X wraps, because the boundaries do
|
||||
o := (y*w + x) * 3
|
||||
px[o], px[o+1], px[o+2] = c[0], c[1], c[2]
|
||||
}
|
||||
disc := func(x, y, r int, c [3]uint8) {
|
||||
for dy := -r; dy <= r; dy++ {
|
||||
for dx := -r; dx <= r; dx++ {
|
||||
if dx*dx+dy*dy <= r*r {
|
||||
set(x+dx, y+dy, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, b := range m.Boundaries {
|
||||
for j := 0; j+1 < len(b.V); j++ {
|
||||
v := b.V[j]
|
||||
ink := plateInk[v.Kind]
|
||||
r := int(math.Abs(v.ClosingMYr)/boundaryWidthMYr*2 + 0.5)
|
||||
if r > 2 {
|
||||
r = 2
|
||||
}
|
||||
ax, ay := v.XM*sx, v.YM*sy
|
||||
bx, by := b.V[j+1].XM*sx, b.V[j+1].YM*sy
|
||||
steps := int(math.Hypot(bx-ax, by-ay)) + 1
|
||||
for k := 0; k <= steps; k++ {
|
||||
t := float64(k) / float64(steps)
|
||||
disc(int(ax+(bx-ax)*t), int(ay+(by-ay)*t), r, ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/region"
|
||||
"salty/terrain/internal/template"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// A region label is a number written on a picture, so the only thing worth asserting about it is that it lands
|
||||
// on the region it names.
|
||||
//
|
||||
// The seam is the case that fails silently. A landmass with land at x=0 and at x=W-1 has an arithmetic mean
|
||||
// halfway round the planet - on some other continent entirely - and the map would look perfectly plausible
|
||||
// while being wrong about the one thing the number is for. Everything else here is a guard on the arithmetic
|
||||
// around it: the polar pad is not painted rows, and a region too small to catch a sample still has to get a
|
||||
// position rather than a NaN.
|
||||
|
||||
const (
|
||||
labelW = 64
|
||||
labelPadY = 4
|
||||
labelPaint = 32
|
||||
)
|
||||
|
||||
// labelFixture is a planet with three regions painted onto it: a compact island in the middle, an island over
|
||||
// the seam, and a single cell small enough that the stride walks straight past it.
|
||||
func labelFixture() *Inputs {
|
||||
p := world.Planet{
|
||||
CellM: 1, W: labelW, H: labelPaint + 2*labelPadY, PadY: labelPadY,
|
||||
NoisePeriodM: float64(labelW),
|
||||
}
|
||||
n := p.W * p.H
|
||||
m := &template.Map{P: p, Class: make([]uint8, n), Sea: make([]bool, n)}
|
||||
for i := range m.Sea {
|
||||
m.Sea[i] = true
|
||||
}
|
||||
part := ®ion.Partition{P: p, MarginCells: 1, Owner: make([]int32, n)}
|
||||
for i := range part.Owner {
|
||||
part.Owner[i] = -1
|
||||
}
|
||||
|
||||
land := func(r int32, x0, x1, py0, py1 int) {
|
||||
for py := py0; py <= py1; py++ {
|
||||
for x := x0; x <= x1; x++ {
|
||||
i := (labelPadY+py)*p.W + ((x%p.W)+p.W)%p.W
|
||||
part.Owner[i], m.Sea[i] = r, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 0: a compact island around the middle of the map, centred on x = 31.5, painted row 15.5.
|
||||
land(0, 28, 35, 12, 19)
|
||||
part.Regions = append(part.Regions, region.Region{
|
||||
ID: 0, Frame: world.Frame{P: p, X0: 28, Y0: labelPadY + 12, W: 8, H: 8}, LandCells: 64,
|
||||
})
|
||||
|
||||
// 1: the same island moved onto the seam, centred on x = 63.5 - half of it at x >= 60 and half at x <= 3.
|
||||
land(1, 60, 67, 12, 19)
|
||||
part.Regions = append(part.Regions, region.Region{
|
||||
ID: 1, Frame: world.Frame{P: p, X0: 60, Y0: labelPadY + 12, W: 8, H: 8}, LandCells: 64, Seam: true,
|
||||
})
|
||||
|
||||
// 2: one cell, at coordinates the stride never samples, so this is the fallback path.
|
||||
land(2, 1, 1, 1, 1)
|
||||
part.Regions = append(part.Regions, region.Region{
|
||||
ID: 2, Frame: world.Frame{P: p, X0: 1, Y0: labelPadY + 1, W: 1, H: 1}, LandCells: 1,
|
||||
})
|
||||
|
||||
return &Inputs{P: p, Map: m, Part: part}
|
||||
}
|
||||
|
||||
// circDist is the distance between two positions round the cylinder, in fractions of a turn.
|
||||
func circDist(a, b float64) float64 {
|
||||
d := math.Abs(a - b)
|
||||
return math.Min(d, 1-d)
|
||||
}
|
||||
|
||||
func TestRegionLabelsLandOnTheirRegion(t *testing.T) {
|
||||
in := labelFixture()
|
||||
got := in.RegionLabels()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d labels for 3 regions", len(got))
|
||||
}
|
||||
|
||||
// The compact island. The stride costs up to two cells of accuracy in each axis, which is 0.03 of a turn
|
||||
// across and 0.06 down, and a label is allowed to be that far from dead centre.
|
||||
const wantU0, wantV0 = 31.5 / labelW, 15.5 / labelPaint
|
||||
if d := circDist(got[0][0], wantU0); d > 0.05 {
|
||||
t.Errorf("region 0 u = %.4f, want within 0.05 of %.4f (off by %.4f)", got[0][0], wantU0, d)
|
||||
}
|
||||
if d := math.Abs(got[0][1] - wantV0); d > 0.08 {
|
||||
t.Errorf("region 0 v = %.4f, want within 0.08 of %.4f", got[0][1], wantV0)
|
||||
}
|
||||
|
||||
// The seam island, and the reason this file exists. Its land is centred on x = 63.5, a quarter of a cell
|
||||
// short of the seam. An arithmetic mean of those columns gives 31.5, which is region 0's island: if this
|
||||
// assertion ever fails by landing near 0.49, the circular mean has been lost.
|
||||
const wantU1 = 63.5 / labelW
|
||||
if d := circDist(got[1][0], wantU1); d > 0.06 {
|
||||
t.Errorf("region 1 u = %.4f, want within 0.06 of %.4f (off by %.4f); "+
|
||||
"0.49 means the mean across is no longer circular", got[1][0], wantU1, d)
|
||||
}
|
||||
if d := math.Abs(got[1][1] - wantV0); d > 0.08 {
|
||||
t.Errorf("region 1 v = %.4f, want within 0.08 of %.4f", got[1][1], wantV0)
|
||||
}
|
||||
|
||||
// The single cell, which no sample touches: the frame centre, and in particular a v measured from the
|
||||
// painted rows rather than from the top of the polar pad.
|
||||
const wantU2, wantV2 = 1.5 / labelW, 1.5 / labelPaint
|
||||
if d := circDist(got[2][0], wantU2); d > 0.01 {
|
||||
t.Errorf("region 2 u = %.4f, want %.4f from its frame", got[2][0], wantU2)
|
||||
}
|
||||
if d := math.Abs(got[2][1] - wantV2); d > 0.01 {
|
||||
t.Errorf("region 2 v = %.4f, want %.4f from its frame; a v of %.4f would be measuring from the "+
|
||||
"top of the pad instead of the first painted row",
|
||||
got[2][1], wantV2, float64(labelPadY+1)/labelPaint)
|
||||
}
|
||||
|
||||
for i, l := range got {
|
||||
if math.IsNaN(l[0]) || math.IsNaN(l[1]) ||
|
||||
l[0] < 0 || l[0] > 1 || l[1] < 0 || l[1] > 1 {
|
||||
t.Errorf("region %d label %v is outside the drawn map", i, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hues have to be far enough apart to tell one landmass from another, which is the whole reason they
|
||||
// stopped coming out of a hash of the index. See RegionHues for the argument; this is the measurement.
|
||||
func TestRegionHuesStayApart(t *testing.T) {
|
||||
const regions = 40
|
||||
in := &Inputs{Part: ®ion.Partition{Regions: make([]region.Region, regions)}}
|
||||
hues := in.RegionHues()
|
||||
|
||||
worst, wa, wb := math.MaxFloat64, 0, 0
|
||||
for a := 0; a < regions; a++ {
|
||||
for b := a + 1; b < regions; b++ {
|
||||
dr := float64(hues[a][0]) - float64(hues[b][0])
|
||||
dg := float64(hues[a][1]) - float64(hues[b][1])
|
||||
db := float64(hues[a][2]) - float64(hues[b][2])
|
||||
if d := math.Sqrt(dr*dr + dg*dg + db*db); d < worst {
|
||||
worst, wa, wb = d, a, b
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("closest of %d hues: %.1f, between region %d and region %d", regions, worst, wa, wb)
|
||||
|
||||
// Measured: the walk gives 44.0 at twenty regions, 41.9 at twenty-six and 37.7 at forty, against 8.5 for
|
||||
// the hash this replaced. The floor is set well below what the walk achieves rather than at it, so that a
|
||||
// change to the saturation or value cycle is free to move the number a little and not free to collapse it.
|
||||
const floor = 25
|
||||
if worst < floor {
|
||||
t.Errorf("closest two hues are %.1f apart (regions %d and %d), want at least %d: "+
|
||||
"two landmasses that colour alike is the defect this walk exists to prevent", worst, wa, wb, floor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/stats"
|
||||
)
|
||||
|
||||
// What a bake writes, and why.
|
||||
//
|
||||
// Three resolutions of the same 16-bit heightmap, because the three answer different questions: the geology
|
||||
// grid is the thing the detail passes will be built on, the middle one is what fits in an image viewer, and
|
||||
// the small one is a minimap. The preview and the data maps are for judging, not for importing.
|
||||
|
||||
// Painted returns a field over the painted rows only, with the polar pad dropped. The pad is scaffolding -
|
||||
// synthetic ocean that exists so a cap touching the top of the map has a shore to drain to - and it is
|
||||
// removed before anything leaves the generator.
|
||||
func (r *Result) Painted() *field.Field {
|
||||
p := r.In.P
|
||||
out := field.New(p.W, p.PaintH(), p.CellM)
|
||||
copy(out.Data, r.Height.Data[p.PadY*p.W:(p.H-p.PadY)*p.W])
|
||||
return out
|
||||
}
|
||||
|
||||
// PaintedSea is the sea mask over the painted rows.
|
||||
func (r *Result) PaintedSea() []bool {
|
||||
p := r.In.P
|
||||
return r.Sea[p.PadY*p.W : (p.H-p.PadY)*p.W]
|
||||
}
|
||||
|
||||
// PaintedFlow is the drainage area over the painted rows.
|
||||
func (r *Result) PaintedFlow() *field.Field {
|
||||
p := r.In.P
|
||||
out := field.New(p.W, p.PaintH(), p.CellM)
|
||||
copy(out.Data, r.Flow[p.PadY*p.W:(p.H-p.PadY)*p.W])
|
||||
return out
|
||||
}
|
||||
|
||||
// Write puts the bake on disk.
|
||||
func (r *Result) Write(outDir string, mapWidth int, log func(string, ...any)) error {
|
||||
if log == nil {
|
||||
log = func(string, ...any) {}
|
||||
}
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
m := r.In.M
|
||||
h := r.Painted()
|
||||
sea := r.PaintedSea()
|
||||
flow := r.PaintedFlow()
|
||||
|
||||
// The statistics, pooled. Regions are merged in *region order* rather than in the order they finished:
|
||||
// the histograms themselves are integer counts and would not care, but the running sums are floats and
|
||||
// float addition is not associative, so a run's numbers would otherwise depend on which landmass came
|
||||
// back first. Cross-cutting rule 12, in the one place left where it could still leak.
|
||||
acc := stats.New(statsOptions(m))
|
||||
for i := range r.Regions {
|
||||
acc.Merge(r.Regions[i].stats)
|
||||
}
|
||||
// And the extent, measured once on the composited planet. A region carries an ocean margin and two
|
||||
// neighbouring margins overlap, so pooling "cells" across regions counts the same water twice and reports
|
||||
// a land fraction that means nothing; the finished cylinder is the only place the question has an answer.
|
||||
land := make([]bool, len(sea))
|
||||
for i, s := range sea {
|
||||
land[i] = !s
|
||||
}
|
||||
acc.AddExtent(h.Data, land, m.ClipCells(h.Data))
|
||||
rep := acc.Report(h.CellM)
|
||||
r.Stats = &rep
|
||||
|
||||
// The heightmap, three ways. Compression is worth paying for on the full one, which is the thing
|
||||
// anything downstream actually reads; the two overviews are rebuilt from a seed in seconds.
|
||||
levels := []struct {
|
||||
name string
|
||||
w, h int
|
||||
lvl png.CompressionLevel
|
||||
}{
|
||||
{"planet_height.png", h.W, h.H, png.DefaultCompression},
|
||||
{"planet_height_mid.png", h.W / 4, h.H / 4, png.BestSpeed},
|
||||
{"planet_height_low.png", h.W / 10, h.H / 10, png.BestSpeed},
|
||||
}
|
||||
for _, l := range levels {
|
||||
if l.w < 2 || l.h < 2 {
|
||||
continue
|
||||
}
|
||||
data := h.Data
|
||||
if l.w != h.W || l.h != h.H {
|
||||
data = boxDown(h.Data, h.W, h.H, l.w, l.h)
|
||||
}
|
||||
if err := field.WriteGray16(filepath.Join(outDir, l.name), l.w, l.h, m.Encode(data), l.lvl); err != nil {
|
||||
return err
|
||||
}
|
||||
log("wrote %-24s %d x %d at %.1f m", l.name, l.w, l.h, float64(h.W)*h.CellM/float64(l.w))
|
||||
}
|
||||
// How much of the 16-bit ramp the world actually used, which until D-64 nothing said. The clip fraction
|
||||
// is the check at the top end and it only ever catches a range too *narrow*; a range several times too
|
||||
// wide clips nothing, reports nothing, and quietly spends most of its resolution and all of its contrast
|
||||
// on elevations no cell on the planet has. A heightmap that uses a tenth of its ramp is a flat grey
|
||||
// picture in every viewer, and the ocean and the land in it are the same grey.
|
||||
span := m.ElevationM.Max - m.ElevationM.Min
|
||||
used := (rep.MaxM - rep.MinM) / span
|
||||
landUsed := (rep.LandMaxM - m.SeaLevelM) / span
|
||||
log("range %.0f..%.0f m encoded, %.0f..%.0f m used: %.0f%% of the ramp, and land is %.1f%% of it",
|
||||
m.ElevationM.Min, m.ElevationM.Max, rep.MinM, rep.MaxM, used*100, landUsed*100)
|
||||
if used < 0.5 {
|
||||
log(" tighten elevation_m to about %.0f..%.0f m and the same terrain arrives with %.0fx the "+
|
||||
"contrast and %.0fx the vertical resolution; the range is an author's choice and nothing but "+
|
||||
"this line will tell you it is wrong, because too wide never clips",
|
||||
math.Floor(rep.MinM/64)*64, math.Ceil(rep.MaxM/64)*64, 1/used, 1/used)
|
||||
}
|
||||
|
||||
topM, err := field.WritePreview(filepath.Join(outDir, "preview.png"), h, field.PreviewOptions{
|
||||
Flow: flow, Sea: sea, Snow: r.In.Map.SnowMask(), Palette: r.In.Palette,
|
||||
SeaLevelM: m.SeaLevelM, RiverKm2: 0.5, Size: mapWidth,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Say what the colours meant. The ramp is relative by default, so bare rock and snow on a preview mean
|
||||
// "the highest ground on this world", not "high ground" - and on a 47 m lowland continent those are the
|
||||
// same pixels a 2800 m range would produce. A relative picture is fine; one nobody was told is relative
|
||||
// is how a plain gets read as an alpine massif. `palette.land_top_m` makes it absolute.
|
||||
if r.In.Palette != nil && r.In.Palette.LandTopM > 0 {
|
||||
log("preview the hypsometric ramp tops out at a fixed %.0f m, so the colours mean the same thing "+
|
||||
"they would on any other world", topM)
|
||||
} else {
|
||||
log("preview the hypsometric ramp tops out at %.0f m - the %.4g%% percentile of *this* world's land, "+
|
||||
"so rock and snow mean \"the highest ground here\" and nothing about scale. Set "+
|
||||
"palette.land_top_m for an absolute ramp", topM, palPercentile(r.In.Palette))
|
||||
}
|
||||
|
||||
for _, w := range []func(string, *Inputs, int) error{
|
||||
WriteClassMap, WriteRegionMap, WriteUpliftMap, WriteErodibilityMap, WriteOverlayMap, WritePlateMap,
|
||||
} {
|
||||
if err := w(outDir, r.In, mapWidth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// The annotation layer travels with the bake, so a heightmap and the things the author placed on it are
|
||||
// never in two directories that can drift apart.
|
||||
if r.In.OverlayDoc != nil {
|
||||
if err := r.In.OverlayDoc.WriteJSON(outDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
slope := h.Slope()
|
||||
for i, v := range slope.Data {
|
||||
slope.Data[i] = float32(degrees(float64(v)))
|
||||
}
|
||||
if err := field.WriteDataMap(filepath.Join(outDir, "map_slope.png"), slope, field.DataMapOptions{
|
||||
Sea: sea, Size: mapWidth, Lo: 0, Hi: 45, Palette: field.Inferno,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := field.WriteDataMap(filepath.Join(outDir, "map_flow.png"), flow, field.DataMapOptions{
|
||||
Sea: sea, Size: mapWidth, Log: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.writeCoastMaps(outDir, mapWidth); err != nil {
|
||||
return err
|
||||
}
|
||||
log("wrote preview.png and the data maps at %d px wide", mapWidth)
|
||||
|
||||
meta := map[string]any{
|
||||
"when": time.Now().UTC().Truncate(time.Second),
|
||||
"manifest": m.Path,
|
||||
"seed": m.Source.Seed,
|
||||
"plan": r.In.Report(),
|
||||
"regions": r.Regions,
|
||||
"craters": r.Craters,
|
||||
"stats": r.Stats,
|
||||
"coast": coastStats(r),
|
||||
// The traces themselves, not just the count: a fault set is a property of the seed and the painting,
|
||||
// and "which fault made that valley" is a question somebody will ask of a finished world. Thirty
|
||||
// traces of thirty points is forty kilobytes, which is nothing against the heightmap beside it.
|
||||
"faults": r.In.Faults,
|
||||
// The tectonic model, when there is one, for the same reason and one level up: every belt and every
|
||||
// fault this planet has is a consequence of one of these lines, so "why is there a range here" is
|
||||
// answerable afterwards rather than only while the process that drew it is still running.
|
||||
"plates": r.In.Plates,
|
||||
"elapsed": r.Elapsed.Round(time.Second).String(),
|
||||
"steps": m.Pipeline.Fluvial.Steps,
|
||||
}
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(outDir, "meta.json"), append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// Summary is the verdict line, printed per region and then for the planet.
|
||||
func (r *Result) Summary() string {
|
||||
lo, hi := 1e30, -1e30
|
||||
clipWorst, clipWorstID := 0.0, -1
|
||||
total := 0.0
|
||||
for _, rr := range r.Regions {
|
||||
if rr.LandCells == 0 {
|
||||
continue
|
||||
}
|
||||
if rr.MinM < lo {
|
||||
lo = rr.MinM
|
||||
}
|
||||
if rr.MaxM > hi {
|
||||
hi = rr.MaxM
|
||||
}
|
||||
if rr.ClipFrac > clipWorst {
|
||||
clipWorst, clipWorstID = rr.ClipFrac, rr.ID
|
||||
}
|
||||
total += rr.Seconds
|
||||
}
|
||||
s := fmt.Sprintf(" %d regions solved in %s of wall time (%.0f s of solve)\n"+
|
||||
" land %.0f..%.0f m against the manifest's %.0f..%.0f m\n",
|
||||
len(r.Regions), r.Elapsed.Round(time.Second), total,
|
||||
lo, hi, r.In.M.ElevationM.Min, r.In.M.ElevationM.Max)
|
||||
if clipWorstID >= 0 && clipWorst > 0 {
|
||||
verdict := "which is a rounding"
|
||||
if clipWorst > 0.001 {
|
||||
verdict = "WHICH IS A FAILED RUN, not a rounded one: U/K is the relief knob"
|
||||
}
|
||||
s += fmt.Sprintf(" worst clip %.3f%% in region %d, %s\n", clipWorst*100, clipWorstID, verdict)
|
||||
}
|
||||
// The block Terrain-Next has called the one that matters most since D-53, and which a planet bake could
|
||||
// not print until the statistics learned to pool: map-wide medians cannot answer "are the plains plains",
|
||||
// and that is the question.
|
||||
if r.Stats != nil {
|
||||
s += "\n" + r.Stats.Summary() + "\n"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// boxDown is an area-average downsample for any ratio, integer or not: every source cell is added to the
|
||||
// bucket its centre falls in. field.Resample's mass-preserving path needs an exact integer factor on the
|
||||
// quad count, and a planet's two sides rarely share one.
|
||||
func boxDown(src []float32, w, h, dw, dh int) []float32 {
|
||||
sum := make([]float64, dw*dh)
|
||||
n := make([]int32, dw*dh)
|
||||
for y := 0; y < h; y++ {
|
||||
dy := y * dh / h
|
||||
for x := 0; x < w; x++ {
|
||||
d := dy*dw + x*dw/w
|
||||
sum[d] += float64(src[y*w+x])
|
||||
n[d]++
|
||||
}
|
||||
}
|
||||
out := make([]float32, dw*dh)
|
||||
for i := range out {
|
||||
if n[i] > 0 {
|
||||
out[i] = float32(sum[i] / float64(n[i]))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func degrees(slope float64) float64 { return math.Atan(slope) * 180 / math.Pi }
|
||||
|
||||
// BakePrefix is the directory a bake is written into, numbered upwards.
|
||||
const BakePrefix = "Bake_"
|
||||
|
||||
// NextBakeDir is the first version number not already on disk.
|
||||
//
|
||||
// Bakes are versioned for the same reason paintings are: an hour and a half is far too long to spend on a
|
||||
// change you then cannot compare against what it replaced.
|
||||
func NextBakeDir(base string) string {
|
||||
for n := 1; n < 10000; n++ {
|
||||
dir := filepath.Join(base, fmt.Sprintf("%s%03d", BakePrefix, n))
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
return filepath.Join(base, BakePrefix+"overflow")
|
||||
}
|
||||
|
||||
// palPercentile is the ramp's percentile, or the default's when the bake carries no palette of its own.
|
||||
func palPercentile(p *field.Palette) float64 {
|
||||
if p == nil {
|
||||
p = field.DefaultPalette()
|
||||
}
|
||||
return p.LandTopPercentile
|
||||
}
|
||||
|
||||
// writeCoastMaps draws the two pictures the coastal pass is judged from.
|
||||
//
|
||||
// **The change map** is the whole pass in one image: cool where the surf cut, warm where the sediment landed.
|
||||
// The sea floor is excluded from it, because the ocean goes from sea level to five hundred metres down in one
|
||||
// pass and a few hundred metres of that would swamp the few the shore processes move, which is the thing the
|
||||
// map exists to show.
|
||||
//
|
||||
// **Exposure** is drawn only within a kilometre of the water. That is not tidiness: it is measured on the
|
||||
// waterline and carried to every other cell by "the stretch of shore nearest to you", so past a few hundred
|
||||
// metres it is a map of the continent's medial axis rather than of anything coastal - the first render of it
|
||||
// on the square canvas was a sunburst of polygonal wedges meeting in the middle of a continent.
|
||||
func (r *Result) writeCoastMaps(outDir string, mapWidth int) error {
|
||||
cs := r.Coast
|
||||
if cs == nil || !r.In.M.Pipeline.Coast.Enabled {
|
||||
return nil
|
||||
}
|
||||
p := r.In.P
|
||||
lo, hi := p.PadY*p.W, (p.H-p.PadY)*p.W
|
||||
|
||||
painted := func(src *field.Field) *field.Field {
|
||||
out := field.New(p.W, p.PaintH(), p.CellM)
|
||||
copy(out.Data, src.Data[lo:hi])
|
||||
return out
|
||||
}
|
||||
|
||||
band := make([]bool, p.W*p.PaintH())
|
||||
for i, d := range cs.Geometry.Dist.Data[lo:hi] {
|
||||
band[i] = math.Abs(float64(d)) > 1000
|
||||
}
|
||||
if err := field.WriteDataMap(filepath.Join(outDir, "map_exposure.png"), painted(cs.Exposure),
|
||||
field.DataMapOptions{Sea: band, Size: mapWidth, Lo: 0, Hi: 1, Palette: field.Inferno}); err != nil {
|
||||
return err
|
||||
}
|
||||
// The sea floor masked out, so the scale belongs to the shore rather than to the shelf.
|
||||
deep := make([]bool, p.W*p.PaintH())
|
||||
for i, d := range cs.Geometry.Dist.Data[lo:hi] {
|
||||
deep[i] = float64(d) < -r.In.M.Pipeline.Coast.DepositReachM*2
|
||||
}
|
||||
if err := field.WriteDataMap(filepath.Join(outDir, "map_coast.png"), painted(cs.Change),
|
||||
field.DataMapOptions{Sea: deep, Size: mapWidth, Lo: -30, Hi: 30, Palette: field.Divergent}); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.writeExposure(outDir)
|
||||
}
|
||||
|
||||
// writeExposure carries the fetch field forward to the detail bake, at the geology grid and unmasked.
|
||||
//
|
||||
// It is data rather than a picture, which is why it is not map_exposure.png: that one is scaled to a map
|
||||
// width and blanked away from the water, both of which are right for looking at and useless for reading back.
|
||||
//
|
||||
// The detail bake needs it because it cannot compute it. Fetch is cast fifteen hundred metres in sixteen
|
||||
// directions from every waterline cell, and a tile is five kilometres across with a two hundred and fifty
|
||||
// metre margin - so a tile can see neither the far side of a bay nor the open ocean beyond a headland, and
|
||||
// whether the water in front of a beach is one or the other is the whole difference between a berm and a
|
||||
// mudflat. It is the same rule the massif threshold and the lithology split are under: a quantity measured
|
||||
// over the whole world is measured once, by the pass that has the whole world, and carried.
|
||||
//
|
||||
// Eight bits, so a stretch of shore is placed to a four-hundredth of the range. The field is a smoothed
|
||||
// fetch ratio and its own noise floor is well above that.
|
||||
func (r *Result) writeExposure(outDir string) error {
|
||||
p := r.In.P
|
||||
lo := p.PadY * p.W
|
||||
n := p.W * p.PaintH()
|
||||
px := make([]uint8, n)
|
||||
for i := 0; i < n; i++ {
|
||||
v := float64(r.Coast.Exposure.Data[lo+i])
|
||||
if v < 0 {
|
||||
v = 0
|
||||
} else if v > 1 {
|
||||
v = 1
|
||||
}
|
||||
px[i] = uint8(v*255 + 0.5)
|
||||
}
|
||||
return field.WriteGray8(filepath.Join(outDir, "coast_exposure.png"), p.W, p.PaintH(), px,
|
||||
png.DefaultCompression)
|
||||
}
|
||||
|
||||
// coastStats is the pass's own accounting for meta.json, or nil when it did not run.
|
||||
func coastStats(r *Result) any {
|
||||
if r.Coast == nil || !r.In.M.Pipeline.Coast.Enabled {
|
||||
return nil
|
||||
}
|
||||
return r.Coast.Stats
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// boxDown is what the two overview heightmaps are made with, and a planet's two sides rarely share an
|
||||
// integer factor, so it has to be right at a ratio that does not divide.
|
||||
func TestBoxDownAveragesAndKeepsTheMean(t *testing.T) {
|
||||
const w, h = 12, 7
|
||||
src := make([]float32, w*h)
|
||||
sum := 0.0
|
||||
for i := range src {
|
||||
src[i] = float32(i%5) + float32(i/w)
|
||||
sum += float64(src[i])
|
||||
}
|
||||
want := sum / float64(len(src))
|
||||
|
||||
for _, d := range [][2]int{{6, 7}, {4, 3}, {5, 3}, {12, 7}, {1, 1}} {
|
||||
out := boxDown(src, w, h, d[0], d[1])
|
||||
if len(out) != d[0]*d[1] {
|
||||
t.Fatalf("%dx%d: got %d values", d[0], d[1], len(out))
|
||||
}
|
||||
got := 0.0
|
||||
for _, v := range out {
|
||||
got += float64(v)
|
||||
}
|
||||
got /= float64(len(out))
|
||||
// Buckets do not all hold the same number of cells at a ratio that does not divide, so the mean of
|
||||
// the means drifts a little; what must not happen is a bucket left empty or a value invented.
|
||||
if math.Abs(got-want) > 0.35 {
|
||||
t.Errorf("%dx%d: mean %.3f, source mean %.3f", d[0], d[1], got, want)
|
||||
}
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, v := range out {
|
||||
lo = math.Min(lo, float64(v))
|
||||
hi = math.Max(hi, float64(v))
|
||||
}
|
||||
if lo < 0 || hi > 11 {
|
||||
t.Errorf("%dx%d: range %.2f..%.2f is outside the source's 0..10", d[0], d[1], lo, hi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxDownIsIdentityAtTheSameSize(t *testing.T) {
|
||||
src := []float32{1, 2, 3, 4, 5, 6}
|
||||
out := boxDown(src, 3, 2, 3, 2)
|
||||
for i := range src {
|
||||
if out[i] != src[i] {
|
||||
t.Fatalf("cell %d: %v, want %v", i, out[i], src[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/overlay"
|
||||
)
|
||||
|
||||
// Proposing an overlay from a finished bake.
|
||||
//
|
||||
// The annotation layer is hand-painted and starts blank, and the three things most worth putting on it -
|
||||
// woodland, settlements and the roads between them - are all consequences of ground the author cannot see
|
||||
// while painting. The classes are painted before the solve exists, and once it does exist it is a
|
||||
// seventy-six-million-cell heightmap. So this reads the bake back and hands internal/overlay the four
|
||||
// fields it needs to have an opinion: height, slope, the sea, and where the water collects.
|
||||
//
|
||||
// It reads the bake from disk rather than hooking into one, for the same reason `terrain tiles` does: the
|
||||
// geology is two hours and this is seconds, so an author can regenerate the sheet as often as they like
|
||||
// against a bake they already have.
|
||||
//
|
||||
// **It works at the template's resolution, not the geology grid's.** The overlay is registered to the
|
||||
// template and must be exactly its size, so generating anywhere else would mean resampling the output - and
|
||||
// a resampled mark is a blend of two colours, which the classifier reads as a third mark or as nothing. The
|
||||
// geology is pooled down to the template once, here, and everything downstream is at that scale.
|
||||
|
||||
// OverlayGenOptions is what the generator is pointed at.
|
||||
type OverlayGenOptions struct {
|
||||
In *Inputs
|
||||
BakeDir string
|
||||
|
||||
// Replace ignores the overlay already on disk instead of filling in around it. The default is to keep
|
||||
// every painted pixel, because regenerating must never cost an author their work; this is the flag for
|
||||
// "throw away the last generation and start again", and it says so at the call site.
|
||||
Replace bool
|
||||
|
||||
// Seed overrides the manifest's, which is what a re-roll is: the painting fixes where the land is and
|
||||
// the seed decides everything it does not - which patch of eligible ground becomes woodland, and which
|
||||
// of the equally good sites gets the town.
|
||||
Seed int64
|
||||
|
||||
// Existing overrides the overlay loaded from disk. The studio sets it, because the sheet an author is
|
||||
// looking at includes strokes they have not saved, and generating around the file instead of around the
|
||||
// screen would put marks on top of work that is visibly there.
|
||||
Existing *overlay.Raster
|
||||
|
||||
Log func(string, ...any)
|
||||
}
|
||||
|
||||
// GenerateOverlay reads a bake and proposes the marks whose legend asks for them.
|
||||
func GenerateOverlay(opt OverlayGenOptions) (*overlay.Raster, overlay.GenReport, error) {
|
||||
var none overlay.GenReport
|
||||
in := opt.In
|
||||
log := opt.Log
|
||||
if log == nil {
|
||||
log = func(string, ...any) {}
|
||||
}
|
||||
if in.Overlay == nil {
|
||||
return nil, none, fmt.Errorf("%s has no overlay legend; set planet.overlay_legend and say which "+
|
||||
"marks to generate", in.M.Path)
|
||||
}
|
||||
wants := false
|
||||
for i := range in.Overlay.Marks {
|
||||
if in.Overlay.Marks[i].Generate != nil {
|
||||
wants = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !wants {
|
||||
return nil, none, fmt.Errorf("no mark in %s has a `generate` block, so there is nothing to "+
|
||||
"generate. Generation is opt-in per mark; see the overlay section of the templates README",
|
||||
in.M.Planet.OverlayLegend)
|
||||
}
|
||||
|
||||
ow, oh := in.PaintW, in.PaintH
|
||||
cellM := in.P.CircumferenceM() / float64(ow)
|
||||
|
||||
if opt.BakeDir != "" {
|
||||
if ok, why := BakeIsOfThisPainting(opt.BakeDir, in.M); !ok {
|
||||
log("ignoring %s: %s, so its terrain is not this world's", filepath.Base(opt.BakeDir), why)
|
||||
opt.BakeDir = ""
|
||||
}
|
||||
}
|
||||
|
||||
if opt.BakeDir == "" {
|
||||
// No bake: the painting is all there is.
|
||||
//
|
||||
// Worth supporting rather than refusing, because the first thing an author wants after drawing a
|
||||
// world is to see something placed on it, and the bake is two hours away. What survives without a
|
||||
// solve is everything the *painting* knows - where the land is, where the sea is, and which class
|
||||
// each cell was painted - so the coast still shapes where towns go and the class filters still keep
|
||||
// woodland off the ice. What is lost is everything the terrain knows: there are no rivers to sit on,
|
||||
// no slope to avoid, and therefore no reason for a road to bend. Say which of the two ran; a draft
|
||||
// made this way is a sketch, and reading it as the other is how somebody concludes the generator
|
||||
// ignores the terrain.
|
||||
log("no bake: generating from the painting alone, so there are no rivers and no slope to read")
|
||||
gin := flatInputs(in, ow, oh, cellM, opt.Replace)
|
||||
opt.apply(&gin)
|
||||
return in.Overlay.Generate(gin)
|
||||
}
|
||||
|
||||
if err := CheckBake(opt.BakeDir, in.M, log); err != nil {
|
||||
return nil, none, err
|
||||
}
|
||||
hpath := filepath.Join(opt.BakeDir, "planet_height.png")
|
||||
values, gw, gh, err := field.ReadHeightmap(hpath, 0)
|
||||
if err != nil {
|
||||
return nil, none, fmt.Errorf("%s: %w (run `terrain bake` first)", hpath, err)
|
||||
}
|
||||
if gw != in.P.W || gh != in.P.PaintH() {
|
||||
return nil, none, fmt.Errorf("%s is %dx%d but the manifest describes a %dx%d planet; the bake and "+
|
||||
"the manifest have drifted apart", hpath, gw, gh, in.P.W, in.P.PaintH())
|
||||
}
|
||||
geology := in.M.Decode(values)
|
||||
log("read %s: %d x %d at %.1f m", filepath.Base(hpath), gw, gh, in.P.CellM)
|
||||
|
||||
heightM := poolTo(geology, gw, gh, ow, oh)
|
||||
sea := make([]bool, ow*oh)
|
||||
seaCells := 0
|
||||
for i, v := range heightM {
|
||||
if float64(v) < in.M.SeaLevelM {
|
||||
sea[i] = true
|
||||
seaCells++
|
||||
}
|
||||
}
|
||||
log("overlay grid %d x %d at %.1f m a pixel, %.0f%% sea",
|
||||
ow, oh, cellM, 100*float64(seaCells)/float64(ow*oh))
|
||||
|
||||
flow := drainage(heightM, sea, ow, oh, cellM, in.M.Pipeline.Fluvial.MFDExponent)
|
||||
|
||||
// The class each overlay cell was painted, so a mark can be kept off ground its author called ice or
|
||||
// desert. Nearest-sampled rather than averaged: a class is a name, and the mean of two names is not one.
|
||||
classAt := make([]uint8, ow*oh)
|
||||
for y := 0; y < oh; y++ {
|
||||
sy := y*gh/oh + in.P.PadY
|
||||
for x := 0; x < ow; x++ {
|
||||
classAt[y*ow+x] = in.Map.Class[sy*in.P.W+x*gw/ow]
|
||||
}
|
||||
}
|
||||
names := make([]string, len(in.Legend.Classes))
|
||||
for i, c := range in.Legend.Classes {
|
||||
names[i] = c.Name
|
||||
}
|
||||
|
||||
gin := overlay.GenInputs{
|
||||
W: ow, H: oh, CellM: cellM,
|
||||
HeightM: heightM, Sea: sea, FlowM2: flow,
|
||||
ClassAt: classAt, ClassNames: names,
|
||||
Seed: in.M.Source.Seed,
|
||||
}
|
||||
if !opt.Replace {
|
||||
gin.Existing = in.OverlayRaster
|
||||
}
|
||||
opt.apply(&gin)
|
||||
return in.Overlay.Generate(gin)
|
||||
}
|
||||
|
||||
// apply puts the caller's overrides onto the inputs, after the world has been read.
|
||||
func (opt OverlayGenOptions) apply(gin *overlay.GenInputs) {
|
||||
if opt.Seed != 0 {
|
||||
gin.Seed = opt.Seed
|
||||
}
|
||||
if opt.Existing != nil {
|
||||
gin.Existing = opt.Existing
|
||||
}
|
||||
}
|
||||
|
||||
// poolTo box-averages a field onto a smaller grid. The two grids cover exactly the same painted rows, so
|
||||
// this is a straight ratio in each axis with no registration to work out.
|
||||
//
|
||||
// Averaging rather than sampling, because a single sample of an 8 m grid at 12.9 m spacing would alias every
|
||||
// ridge it stepped over and put woodland in stripes.
|
||||
func poolTo(src []float32, sw, sh, dw, dh int) []float32 {
|
||||
out := make([]float32, dw*dh)
|
||||
field.Rows(dh, func(y0, y1 int) {
|
||||
for dy := y0; dy < y1; dy++ {
|
||||
sy0 := dy * sh / dh
|
||||
sy1 := (dy + 1) * sh / dh
|
||||
if sy1 <= sy0 {
|
||||
sy1 = sy0 + 1
|
||||
}
|
||||
for dx := 0; dx < dw; dx++ {
|
||||
sx0 := dx * sw / dw
|
||||
sx1 := (dx + 1) * sw / dw
|
||||
if sx1 <= sx0 {
|
||||
sx1 = sx0 + 1
|
||||
}
|
||||
sum, n := 0.0, 0
|
||||
for sy := sy0; sy < sy1 && sy < sh; sy++ {
|
||||
row := sy * sw
|
||||
for sx := sx0; sx < sx1 && sx < sw; sx++ {
|
||||
sum += float64(src[row+sx])
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
out[dy*dw+dx] = float32(sum / float64(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// drainage is the catchment area per cell at the overlay's resolution, which is what tells a settlement
|
||||
// where the water is.
|
||||
//
|
||||
// Recomputed rather than read from the bake: `map_flow.png` is a picture a few hundred pixels wide, scaled
|
||||
// for looking at, and what is wanted here is a number per overlay cell. Re-deriving it from the pooled
|
||||
// height is cheap - one fill and one multiple-flow accumulation - and it is self-consistent with the slope
|
||||
// and the sea mask beside it, which a resampled flow map would not be.
|
||||
func drainage(heightM []float32, sea []bool, w, h int, cellM, mfdExp float64) []float32 {
|
||||
g := fluvial.NewGrid(w, h, cellM, sea)
|
||||
|
||||
// The fill runs on a copy: it raises every pit to its spill level, which is right for routing water and
|
||||
// wrong for everything else here. Slope and the treeline must see the surface the bake actually made.
|
||||
filled := make([]float32, len(heightM))
|
||||
copy(filled, heightM)
|
||||
g.FillDepressions(filled, 1e-3)
|
||||
g.AccumulateMFD(filled, mfdExp)
|
||||
|
||||
out := make([]float32, len(heightM))
|
||||
copy(out, g.Area)
|
||||
return out
|
||||
}
|
||||
|
||||
// OverlaySummary is the run's report, as lines to print.
|
||||
func OverlaySummary(rep overlay.GenReport, ow, oh int) []string {
|
||||
var lines []string
|
||||
total := float64(ow * oh)
|
||||
if rep.Kept > 0 {
|
||||
lines = append(lines, fmt.Sprintf("kept %d px already painted (%.2f%% of the sheet); "+
|
||||
"generation only fills blank ground", rep.Kept, 100*float64(rep.Kept)/total))
|
||||
}
|
||||
if rep.TreelineM > 0 {
|
||||
lines = append(lines, fmt.Sprintf("treeline %.0f m, from the land's own heights", rep.TreelineM))
|
||||
}
|
||||
for _, m := range rep.Marks {
|
||||
switch m.Kind {
|
||||
case overlay.GenSettlement:
|
||||
line := fmt.Sprintf(" %-14s %d placed, %d px", m.Name, m.Pieces, m.Cells)
|
||||
if m.Wanted > m.Pieces {
|
||||
// The two things that ration settlements are the spacing and how much flat ground there is,
|
||||
// and neither is visible in the output, so the shortfall is said here rather than left to be
|
||||
// counted off the sheet.
|
||||
line += fmt.Sprintf(" (asked for %d; the spacing or the flat ground ran out)", m.Wanted)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
case overlay.GenRoad:
|
||||
lines = append(lines, fmt.Sprintf(" %-14s %d %s, %d px",
|
||||
m.Name, m.Pieces, plural(m.Pieces, "link", "links"), m.Cells))
|
||||
default:
|
||||
lines = append(lines, fmt.Sprintf(" %-14s %d px (%.2f%% of the sheet)",
|
||||
m.Name, m.Cells, 100*float64(m.Cells)/total))
|
||||
}
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("painted %d px (%.2f%% of the sheet)",
|
||||
rep.Painted, 100*float64(rep.Painted)/total))
|
||||
return lines
|
||||
}
|
||||
|
||||
func plural(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
}
|
||||
|
||||
// flatInputs builds the generator's world from the painting alone, for a planet that has not been baked.
|
||||
//
|
||||
// Height is a flat plateau on land and a flat floor at sea, which makes the slope field zero everywhere and
|
||||
// the treeline meaningless - both correct rather than approximate, because a world with no solve genuinely
|
||||
// has no relief to read. Drainage is nil rather than zero, which the generator treats as "rivers contribute
|
||||
// nothing" instead of "every cell is equally dry"; the difference matters, because a score of zero
|
||||
// everywhere would still be a score and would silently reweight the coast against it.
|
||||
func flatInputs(in *Inputs, ow, oh int, cellM float64, replace bool) overlay.GenInputs {
|
||||
n := ow * oh
|
||||
heightM := make([]float32, n)
|
||||
sea := make([]bool, n)
|
||||
classAt := make([]uint8, n)
|
||||
|
||||
// The class raster is already at the template's resolution, which is the overlay's, so this is a direct
|
||||
// read with no resampling at all.
|
||||
for i := 0; i < n && i < len(in.Raster.Class); i++ {
|
||||
c := in.Raster.Class[i]
|
||||
classAt[i] = c
|
||||
if int(c) < len(in.Legend.Classes) && in.Legend.Classes[c].Sea {
|
||||
sea[i] = true
|
||||
heightM[i] = float32(in.M.SeaLevelM - 100)
|
||||
} else {
|
||||
heightM[i] = float32(in.M.SeaLevelM + 10)
|
||||
}
|
||||
}
|
||||
|
||||
names := make([]string, len(in.Legend.Classes))
|
||||
for i, c := range in.Legend.Classes {
|
||||
names[i] = c.Name
|
||||
}
|
||||
|
||||
gin := overlay.GenInputs{
|
||||
W: ow, H: oh, CellM: cellM,
|
||||
HeightM: heightM, Sea: sea,
|
||||
ClassAt: classAt, ClassNames: names,
|
||||
Seed: in.M.Source.Seed,
|
||||
}
|
||||
if !replace {
|
||||
gin.Existing = in.OverlayRaster
|
||||
}
|
||||
return gin
|
||||
}
|
||||
|
||||
// bakeTemplate is the template a bake was made from, or "" when the bake does not record one.
|
||||
//
|
||||
// It exists because CheckBake cannot catch this. That check compares the numbers a heightmap is *encoded*
|
||||
// with - the elevation range, the circumference, the cell size, the seed - and two different paintings of
|
||||
// the same planet agree on every one of them. So a bake of one world passes every test and is then read as
|
||||
// another, and the marks come out placed against terrain that is not there: rivers in the wrong valleys,
|
||||
// towns on coasts that do not exist. Nothing in the output says so, which is what makes it worth a check of
|
||||
// its own rather than a note in a README.
|
||||
func bakeTemplate(dir string) string {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var meta struct {
|
||||
Plan struct {
|
||||
Template string `json:"template"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if json.Unmarshal(raw, &meta) != nil {
|
||||
return ""
|
||||
}
|
||||
return meta.Plan.Template
|
||||
}
|
||||
|
||||
// BakeIsOfThisPainting reports whether a bake was made from the template the manifest now names, and why not
|
||||
// when it was not. A bake that does not record its template is taken on trust, because it predates the
|
||||
// field; that is said rather than assumed.
|
||||
func BakeIsOfThisPainting(dir string, m *manifest.Manifest) (bool, string) {
|
||||
was := bakeTemplate(dir)
|
||||
if was == "" {
|
||||
return true, ""
|
||||
}
|
||||
if filepath.Base(was) == filepath.Base(m.Planet.Template) {
|
||||
return true, ""
|
||||
}
|
||||
return false, fmt.Sprintf("%s was baked from %s and this planet is painted on %s",
|
||||
filepath.Base(dir), filepath.Base(was), filepath.Base(m.Planet.Template))
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/overlay"
|
||||
)
|
||||
|
||||
// Plan reads a template and works out what baking it would involve, without eroding anything.
|
||||
//
|
||||
// It exists because two decisions can wreck an hour-long bake and both are settled before the first erosion
|
||||
// step: how the legend read the painting, and how the planet was cut into regions. Looking at them costs
|
||||
// about a minute here and an hour if the bake has to be thrown away.
|
||||
func Plan(m *manifest.Manifest, outDir string, mapWidth int, log func(string, ...any)) (*Inputs, error) {
|
||||
return PlanPainting(m, nil, outDir, mapWidth, log)
|
||||
}
|
||||
|
||||
// WriteMaps draws the four diagnostic maps. Separate from Plan because a caller that already has an Inputs
|
||||
// may want to redraw them without preparing again: the studio does, when only the legend's numbers changed
|
||||
// and so only the colouring-in can differ.
|
||||
func WriteMaps(outDir string, in *Inputs, mapWidth int) error {
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range []func(string, *Inputs, int) error{
|
||||
WriteClassMap, WriteRegionMap, WriteUpliftMap, WriteErodibilityMap, WriteOverlayMap, WritePlateMap,
|
||||
} {
|
||||
if err := w(outDir, in, mapWidth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MapNames is what WriteMaps wrote, which is what the studio lists as buttons. The overlay map is there only
|
||||
// when there is an overlay, because a map of nothing is a map nobody should be offered.
|
||||
func (in *Inputs) MapNames() []string {
|
||||
out := []string{"map_class", "map_uplift", "map_regions", "map_erodibility"}
|
||||
if in.OverlayRaster != nil {
|
||||
out = append(out, "map_overlay")
|
||||
}
|
||||
if in.Plates != nil {
|
||||
out = append(out, "map_plates")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PlanPainting is Plan over paintings already in memory. See PrepareWith.
|
||||
func PlanPainting(m *manifest.Manifest, art *Painting, outDir string, mapWidth int,
|
||||
log func(string, ...any)) (*Inputs, error) {
|
||||
|
||||
in, err := PrepareWith(m, art, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := WriteMaps(outDir, in, mapWidth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.OverlayDoc != nil {
|
||||
if err := in.OverlayDoc.WriteJSON(outDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rep := in.Report()
|
||||
data, err := json.MarshalIndent(rep, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outDir, "plan.json"), append(data, '\n'), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// Report is the machine-readable half of a plan, written to plan.json beside the maps.
|
||||
type Report struct {
|
||||
Manifest string `json:"manifest"`
|
||||
Template string `json:"template"`
|
||||
Legend string `json:"legend"`
|
||||
When time.Time `json:"when"`
|
||||
|
||||
PaintW int `json:"paint_w"`
|
||||
PaintH int `json:"paint_h"`
|
||||
|
||||
CircumferenceKm float64 `json:"circumference_km"`
|
||||
HeightKm float64 `json:"height_km"`
|
||||
AreaKm2 float64 `json:"area_km2"`
|
||||
CellM float64 `json:"cell_m"`
|
||||
GridW int `json:"grid_w"`
|
||||
GridH int `json:"grid_h"`
|
||||
PadRows int `json:"pad_rows"`
|
||||
MarginCells int `json:"margin_cells"`
|
||||
TalusDeg float64 `json:"talus_deg"`
|
||||
ClampCeilMmYr float64 `json:"clamp_ceiling_mm_yr"`
|
||||
ElevationMinM float64 `json:"elevation_min_m"`
|
||||
ElevationMaxM float64 `json:"elevation_max_m"`
|
||||
Seed int64 `json:"seed"`
|
||||
|
||||
// MassifWavelengthKm is the upland fabric's size after rounding to a whole number of lattice cells, and
|
||||
// zero when no class asked for one.
|
||||
MassifWavelengthKm float64 `json:"massif_wavelength_km,omitempty"`
|
||||
|
||||
// The two fields a seed re-rolls that the painting does not fix: the rock provinces and the fault set.
|
||||
// Zero wavelength means the planet asks for none of that field at all.
|
||||
LithologyKm float64 `json:"lithology_wavelength_km,omitempty"`
|
||||
LithologyTypes int `json:"lithology_types,omitempty"`
|
||||
FaultGrainKm float64 `json:"fault_grain_km,omitempty"`
|
||||
FaultCount int `json:"faults,omitempty"`
|
||||
|
||||
MatchFar int `json:"match_far_px"`
|
||||
MatchWorst float64 `json:"match_worst_distance"`
|
||||
MatchWorstAt [2]int `json:"match_worst_at"`
|
||||
EdgeRescued int `json:"stroke_rescued_at_poles_px"`
|
||||
Dissolved int `json:"stroke_dissolved_px"`
|
||||
WrapRows int `json:"wrap_rows"`
|
||||
WrapDiffer int `json:"wrap_differ"`
|
||||
WrapLandSea int `json:"wrap_land_against_sea"`
|
||||
WrapFarEdge int `json:"wrap_far_edge_px"`
|
||||
|
||||
Classes []ClassShare `json:"classes"`
|
||||
Regions []RegionPlan `json:"regions"`
|
||||
|
||||
// Overlay is the annotation layer's share of the plan, or nil when the planet has none. The full
|
||||
// document - every feature in world metres - goes to overlay.json beside the maps; this is the summary.
|
||||
Overlay *OverlayShare `json:"overlay,omitempty"`
|
||||
|
||||
LandCells int `json:"land_cells"`
|
||||
SolveCells int `json:"solve_cells"`
|
||||
DroppedRegions int `json:"dropped_regions"`
|
||||
DroppedCells int `json:"dropped_cells"`
|
||||
EstimateMin float64 `json:"estimate_minutes"`
|
||||
EstimatePeakGB float64 `json:"estimate_peak_gb"`
|
||||
PrepareSeconds float64 `json:"prepare_seconds"`
|
||||
}
|
||||
|
||||
type ClassShare struct {
|
||||
Name string `json:"name"`
|
||||
Sea bool `json:"sea"`
|
||||
Cells int `json:"cells"`
|
||||
Share float64 `json:"share"`
|
||||
UpliftMmYr float64 `json:"uplift_mm_yr,omitempty"`
|
||||
KMult float64 `json:"k_mult,omitempty"`
|
||||
DepthM float64 `json:"depth_m,omitempty"`
|
||||
|
||||
// DivideDeg is the hillslope angle this class's numbers imply at a divide, and Clamped says whether
|
||||
// that is past the angle of repose. See the note on divideAngle.
|
||||
DivideDeg float64 `json:"divide_deg,omitempty"`
|
||||
Clamped bool `json:"clamped,omitempty"`
|
||||
|
||||
// MedianDeg and P90Deg are what the ground actually comes out as: the median slope over the class and
|
||||
// the ninetieth percentile. See typicalFromDivide - a divide is the *steepest* place in a catchment and
|
||||
// there are very few of them, so the divide angle is about three times the ground, and an author reading
|
||||
// it as the landscape sets every rate they own two or three times too hot.
|
||||
MedianDeg float64 `json:"median_deg,omitempty"`
|
||||
P90Deg float64 `json:"p90_deg,omitempty"`
|
||||
|
||||
// ReadsAs names the ground that angle makes. It is here because an uplift rate does not look like
|
||||
// anything, and reading one as terrain is the mistake the massif field exists to undo. It is taken from
|
||||
// the median rather than from the divide, because "what does this read as" is a question about the
|
||||
// ground somebody is standing on.
|
||||
ReadsAs string `json:"reads_as,omitempty"`
|
||||
|
||||
// The massif block, when this class has one: the plain between the massifs, the angle *it* makes, and
|
||||
// how much of the class stands above the midpoint of the two. Absent for a class that is one rate all
|
||||
// over, which is what every class was before D-55.
|
||||
FloorMmYr float64 `json:"floor_mm_yr,omitempty"`
|
||||
FloorDeg float64 `json:"floor_divide_deg,omitempty"`
|
||||
FloorMedianDeg float64 `json:"floor_median_deg,omitempty"`
|
||||
FloorReadsAs string `json:"floor_reads_as,omitempty"`
|
||||
MassifFraction float64 `json:"massif_fraction,omitempty"`
|
||||
|
||||
// Faults is how many traces landed in this class's ground, and FaultThrowM the range they were drawn
|
||||
// from. Zero when the class asked for none.
|
||||
Faults int `json:"faults,omitempty"`
|
||||
FaultThrowM [2]float64 `json:"fault_throw_m,omitempty"`
|
||||
|
||||
// LithologyMix is how much of the planet's rock field this class lets through. Reported even at 1, which
|
||||
// is the default, because the useful reading is the column rather than one entry in it.
|
||||
LithologyMix float64 `json:"lithology_mix,omitempty"`
|
||||
}
|
||||
|
||||
// OverlayShare is how much of the world the annotation layer covers and what it asked for.
|
||||
type OverlayShare struct {
|
||||
Legend string `json:"legend"`
|
||||
PaintedPx int `json:"painted_px"`
|
||||
FarPx int `json:"far_px"`
|
||||
Features int `json:"features"`
|
||||
Marks []overlay.MarkShare `json:"marks"`
|
||||
}
|
||||
|
||||
type RegionPlan struct {
|
||||
ID int `json:"id"`
|
||||
X0 int `json:"x0"`
|
||||
Y0 int `json:"y0"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
WidthKm float64 `json:"width_km"`
|
||||
HeightKm float64 `json:"height_km"`
|
||||
Cells int `json:"cells"`
|
||||
LandCells int `json:"land_cells"`
|
||||
Seam bool `json:"seam"`
|
||||
EstimateMin float64 `json:"estimate_minutes"`
|
||||
EstimateGB float64 `json:"estimate_gb"`
|
||||
}
|
||||
|
||||
// Report gathers everything the plan knows.
|
||||
func (in *Inputs) Report() *Report {
|
||||
perClass, land, total := in.Map.Counts()
|
||||
r := &Report{
|
||||
Manifest: in.M.Path, Template: in.M.Planet.Template, Legend: in.M.Planet.Legend,
|
||||
When: time.Now().UTC().Truncate(time.Second),
|
||||
PaintW: in.PaintW, PaintH: in.PaintH,
|
||||
|
||||
CircumferenceKm: in.P.CircumferenceM() / 1000,
|
||||
HeightKm: in.P.HeightM() / 1000,
|
||||
AreaKm2: in.P.CircumferenceM() * in.P.HeightM() / 1e6,
|
||||
CellM: in.P.CellM,
|
||||
GridW: in.P.W, GridH: in.P.PaintH(),
|
||||
PadRows: in.P.PadY,
|
||||
MarginCells: in.MarginCells,
|
||||
TalusDeg: in.M.Pipeline.Thermal.TalusDeg,
|
||||
ClampCeilMmYr: clampCeiling(in.M.Pipeline.Fluvial.K, in.P.CellM,
|
||||
in.M.Pipeline.Fluvial.M, in.M.Pipeline.Thermal.TalusDeg),
|
||||
ElevationMinM: in.M.ElevationM.Min,
|
||||
ElevationMaxM: in.M.ElevationM.Max,
|
||||
Seed: in.M.Source.Seed,
|
||||
|
||||
MatchFar: in.Match.Far, MatchWorst: in.Match.MaxDist, MatchWorstAt: in.Match.MaxAt,
|
||||
EdgeRescued: in.EdgeRewritten, Dissolved: in.Dissolved,
|
||||
WrapRows: in.Match.WrapRows, WrapDiffer: in.Match.WrapDiffer,
|
||||
WrapLandSea: in.Match.WrapLandSea, WrapFarEdge: in.Match.WrapFarEdge,
|
||||
|
||||
LandCells: land,
|
||||
SolveCells: in.SolveCells(),
|
||||
DroppedRegions: in.Part.DroppedRegions,
|
||||
DroppedCells: in.Part.DroppedCells,
|
||||
PrepareSeconds: in.Elapsed.Seconds(),
|
||||
}
|
||||
|
||||
if in.Legend.HasMassifs() {
|
||||
r.MassifWavelengthKm = in.M.Planet.MassifWavelengthRoundedKm()
|
||||
}
|
||||
|
||||
for i, c := range in.Legend.Classes {
|
||||
if perClass[i] == 0 {
|
||||
continue
|
||||
}
|
||||
cs := ClassShare{Name: c.Name, Sea: c.Sea, Cells: perClass[i],
|
||||
Share: float64(perClass[i]) / float64(total)}
|
||||
if c.Land() {
|
||||
cs.UpliftMmYr, cs.KMult = c.UpliftMmYr, c.K()
|
||||
cs.DivideDeg = divideAngle(c.RateMYr(), in.M.Pipeline.Fluvial.K*c.K(),
|
||||
in.P.CellM, in.M.Pipeline.Fluvial.M)
|
||||
cs.Clamped = cs.DivideDeg >= in.M.Pipeline.Thermal.TalusDeg
|
||||
cs.MedianDeg, cs.P90Deg = typicalFromDivide(cs.DivideDeg)
|
||||
cs.ReadsAs = readsAs(cs.MedianDeg)
|
||||
if fr := c.MassifFraction(); fr > 0 {
|
||||
cs.MassifFraction = fr
|
||||
cs.FloorMmYr = c.Massif.FloorMmYr
|
||||
cs.FloorDeg = divideAngle(c.MassifFloorMYr(), in.M.Pipeline.Fluvial.K*c.K(),
|
||||
in.P.CellM, in.M.Pipeline.Fluvial.M)
|
||||
cs.FloorMedianDeg, _ = typicalFromDivide(cs.FloorDeg)
|
||||
cs.FloorReadsAs = readsAs(cs.FloorMedianDeg)
|
||||
}
|
||||
cs.LithologyMix = c.LithMix()
|
||||
if c.Faults != nil {
|
||||
cs.FaultThrowM = c.ThrowM()
|
||||
for _, f := range in.Faults {
|
||||
if f.Class == i {
|
||||
cs.Faults++
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cs.DepthM = c.DepthM
|
||||
}
|
||||
r.Classes = append(r.Classes, cs)
|
||||
}
|
||||
|
||||
r.FaultGrainKm = in.M.Planet.FaultGrainKm
|
||||
r.FaultCount = len(in.Faults)
|
||||
if cells := in.M.Planet.LithologyCells(); cells > 0 && in.Legend.HasLithology() {
|
||||
r.LithologyKm = in.M.Planet.NoisePeriodKm / float64(cells)
|
||||
r.LithologyTypes = len(in.M.Pipeline.Lithology.KMultipliers)
|
||||
}
|
||||
|
||||
if d := in.OverlayDoc; d != nil {
|
||||
r.Overlay = &OverlayShare{
|
||||
Legend: in.M.Planet.OverlayLegend, PaintedPx: in.OverlayMatch.Total - in.OverlayMatch.Blank,
|
||||
FarPx: in.OverlayMatch.Far, Features: len(d.Features), Marks: d.Marks,
|
||||
}
|
||||
}
|
||||
|
||||
peak := 0.0
|
||||
for _, rg := range in.Part.Regions {
|
||||
mins := in.EstimateSeconds(rg.Cells()) / 60
|
||||
gb := float64(in.EstimateBytes(rg.Cells())) / (1 << 30)
|
||||
if gb > peak {
|
||||
peak = gb
|
||||
}
|
||||
r.EstimateMin += mins
|
||||
r.Regions = append(r.Regions, RegionPlan{
|
||||
ID: rg.ID, X0: rg.Frame.X0, Y0: rg.Frame.Y0, W: rg.Frame.W, H: rg.Frame.H,
|
||||
WidthKm: float64(rg.Frame.W) * in.P.CellM / 1000,
|
||||
HeightKm: float64(rg.Frame.H) * in.P.CellM / 1000,
|
||||
Cells: rg.Cells(), LandCells: rg.LandCells, Seam: rg.Seam,
|
||||
EstimateMin: mins, EstimateGB: gb,
|
||||
})
|
||||
}
|
||||
r.EstimatePeakGB = peak
|
||||
sort.Slice(r.Regions, func(a, b int) bool { return r.Regions[a].Cells > r.Regions[b].Cells })
|
||||
return r
|
||||
}
|
||||
|
||||
// Print is the human half: the two tables worth reading before spending an hour.
|
||||
func (r *Report) Print(w *os.File) {
|
||||
p := func(format string, a ...any) { fmt.Fprintf(w, format+"\n", a...) }
|
||||
|
||||
p("")
|
||||
p(" planet %.1f x %.1f km, %.0f km2 - %d x %d cells of %.1f m (+%d rows of polar pad)",
|
||||
r.CircumferenceKm, r.HeightKm, r.AreaKm2, r.GridW, r.GridH, r.CellM, r.PadRows)
|
||||
p(" template %d x %d px, %.2f m a pixel - the paint is %s than the grid",
|
||||
r.PaintW, r.PaintH, r.CircumferenceKm*1000/float64(r.PaintW),
|
||||
coarserOrFiner(r.CircumferenceKm*1000/float64(r.PaintW), r.CellM))
|
||||
p(" classify %d px further than the warn distance from any class (worst %.0f at %d,%d)",
|
||||
r.MatchFar, r.MatchWorst, r.MatchWorstAt[0], r.MatchWorstAt[1])
|
||||
p(" %d px rescued as map-edge class, %d px of stroke dissolved",
|
||||
r.EdgeRescued, r.Dissolved)
|
||||
if r.WrapRows > 0 {
|
||||
pct := 100 * float64(r.WrapDiffer) / float64(r.WrapRows)
|
||||
p(" wrap the left and right edges are the same meridian: they disagree on %d of %d rows (%.1f%%),",
|
||||
r.WrapDiffer, r.WrapRows, pct)
|
||||
p(" %d of those land against water, and %d px in the outermost columns match no class.",
|
||||
r.WrapLandSea, r.WrapFarEdge)
|
||||
if r.WrapLandSea > r.WrapRows/50 {
|
||||
p(" THAT IS A VISIBLE SEAM. The generator wraps; the painting has to as well.")
|
||||
}
|
||||
}
|
||||
p("")
|
||||
p(" class share cells uplift mm/yr K depth m divide typical")
|
||||
clamped := 0
|
||||
for _, c := range r.Classes {
|
||||
if c.Sea {
|
||||
p(" %-12s %5.1f%% %10d - - %7.0f", c.Name, 100*c.Share, c.Cells, c.DepthM)
|
||||
continue
|
||||
}
|
||||
note := ""
|
||||
if c.Clamped {
|
||||
note = " CLAMPED"
|
||||
clamped++
|
||||
}
|
||||
p(" %-12s %5.1f%% %10d %8.3f %4.2f - %5.1f deg %6.1f deg %s%s",
|
||||
c.Name, 100*c.Share, c.Cells, c.UpliftMmYr, c.KMult, c.DivideDeg, c.MedianDeg, c.ReadsAs, note)
|
||||
if c.MassifFraction > 0 {
|
||||
p(" %-12s %s", "",
|
||||
fmt.Sprintf("massif over %.0f%% of it; the other %.0f%% is %.3f mm/yr, %.1f deg at a divide "+
|
||||
"and %.1f typical - %s",
|
||||
100*c.MassifFraction, 100*(1-c.MassifFraction), c.FloorMmYr, c.FloorDeg,
|
||||
c.FloorMedianDeg, c.FloorReadsAs))
|
||||
}
|
||||
}
|
||||
if clamped > 0 {
|
||||
p("")
|
||||
p(" %d class(es) sit past the %.0f degree angle of repose at a divide, so the repose clamp shapes",
|
||||
clamped, r.TalusDeg)
|
||||
p(" them rather than erosion does, and the ground comes out as flat polygonal facets cut along the")
|
||||
p(" eight D8 directions. Steady state is S = U/(K*A^m) applied down to a single cell, so at a %.0f m",
|
||||
r.CellM)
|
||||
p(" cell the ceiling is U = tan(talus)*K*cell = %.3f mm/yr at K x1. Above it, relief and steepness", r.ClampCeilMmYr)
|
||||
p(" are the same knob and you get talus, not mountains. See Terrain-Next 4.B1 and 4.D.3.")
|
||||
}
|
||||
p("")
|
||||
p(" `divide` is the steepest ground a rate can make and `typical` is the median over the class, which")
|
||||
p(" is about a third of it: a divide is the top of a catchment and there are very few of them. Read the")
|
||||
p(" second column. Reading the first as the landscape is how a legend ends up two or three times too")
|
||||
p(" hot everywhere, which is the defect the massif block was added to undo one size up.")
|
||||
if r.FaultCount > 0 || r.LithologyKm > 0 {
|
||||
p("")
|
||||
p(" what the seed re-rolls, and the painting does not")
|
||||
if r.LithologyKm > 0 {
|
||||
p(" lithology %d rock types over provinces of %.1f km, cut at quantiles of the *planet* so "+
|
||||
"every", r.LithologyTypes, r.LithologyKm)
|
||||
p(" region agrees; it multiplies each class's own k_mult by its lithology_mix")
|
||||
}
|
||||
if r.FaultCount > 0 {
|
||||
p(" faults %d traces, strike from a %.0f km grain field. A trace is a rate difference "+
|
||||
"across a", r.FaultCount, r.FaultGrainKm)
|
||||
p(" line, steep one side and gentle the other, which erosion carves into a scarp")
|
||||
for _, c := range r.Classes {
|
||||
if c.Faults > 0 {
|
||||
word := "traces"
|
||||
if c.Faults == 1 {
|
||||
word = "trace"
|
||||
}
|
||||
p(" %-12s %4d %-7s throw %.0f..%.0f m over the run",
|
||||
c.Name, c.Faults, word+",", c.FaultThrowM[0], c.FaultThrowM[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
p(" change source.seed, or pass --seed, and all of it moves while the painting stays put")
|
||||
}
|
||||
if r.MassifWavelengthKm > 0 {
|
||||
p("")
|
||||
p(" the massif fabric is %.1f km and is one field for the whole planet, so a highland belt and the",
|
||||
r.MassifWavelengthKm)
|
||||
p(" hills in the lowland beside it are high and low parts of the same structure. A fraction is a")
|
||||
p(" share of the planet's surface, so it is only the *expected* share of any one island: a small one")
|
||||
p(" may get all of a massif or none, which is the point of not normalising it per landmass.")
|
||||
}
|
||||
if o := r.Overlay; o != nil {
|
||||
p("")
|
||||
p(" overlay %s: %d px painted, %d features", o.Legend, o.PaintedPx, o.Features)
|
||||
if o.FarPx > 0 {
|
||||
p(" %d px are painted but match no mark and were dropped", o.FarPx)
|
||||
}
|
||||
for _, m := range o.Marks {
|
||||
line := fmt.Sprintf(" %-12s %8.2f km2 %4d %s", m.Name, m.AreaKm2, m.Pieces,
|
||||
pieces(m.Pieces))
|
||||
if m.HasJitter {
|
||||
if m.Jitter == 0 {
|
||||
line += " coastline pinned as drawn"
|
||||
} else {
|
||||
line += fmt.Sprintf(" coast jitter x%.2g", m.Jitter)
|
||||
}
|
||||
}
|
||||
if m.Kind == overlay.KindPath && m.WidthM > 0 {
|
||||
line += fmt.Sprintf(" %.0f m wide", m.WidthM)
|
||||
}
|
||||
p("%s", line)
|
||||
}
|
||||
}
|
||||
|
||||
p("")
|
||||
p(" %d regions, %d cells to solve against %d cells of painted land; margin %d cells",
|
||||
len(r.Regions), r.SolveCells, r.LandCells, r.MarginCells)
|
||||
if r.DroppedRegions > 0 {
|
||||
p(" %d specks dropped, %d land cells, below the minimum", r.DroppedRegions, r.DroppedCells)
|
||||
}
|
||||
p("")
|
||||
p(" id rect km cells land est min est GB")
|
||||
shown := r.Regions
|
||||
if len(shown) > 12 {
|
||||
shown = shown[:12]
|
||||
}
|
||||
for _, rg := range shown {
|
||||
seam := " "
|
||||
if rg.Seam {
|
||||
seam = "*"
|
||||
}
|
||||
p(" %3d%s %6.1f x %6.1f %10d %10d %8.1f %6.2f",
|
||||
rg.ID, seam, rg.WidthKm, rg.HeightKm, rg.Cells, rg.LandCells, rg.EstimateMin, rg.EstimateGB)
|
||||
}
|
||||
if len(r.Regions) > len(shown) {
|
||||
p(" ... and %d smaller", len(r.Regions)-len(shown))
|
||||
}
|
||||
p("")
|
||||
p(" estimate %.0f min of solve in total, %.2f GB at the largest region, both scaled from one measured",
|
||||
r.EstimateMin, r.EstimatePeakGB)
|
||||
p(" lowland region and to be read as a floor: steep ground costs about five times what a plain")
|
||||
p(" does per cell, because it drives the hillslope law to its full sub-step budget every step.")
|
||||
p(" prepared in %.1f s", r.PrepareSeconds)
|
||||
p("")
|
||||
}
|
||||
|
||||
func coarserOrFiner(paintM, cellM float64) string {
|
||||
if paintM > cellM {
|
||||
return "coarser"
|
||||
}
|
||||
return "finer"
|
||||
}
|
||||
|
||||
// divideAngle is the hillslope angle a class's numbers imply at a drainage divide, in degrees.
|
||||
//
|
||||
// Steady state is S = U/(K*A^m), and with critical_area_m2 at 0 that law is applied down to a single cell, so
|
||||
// at a divide A is one cell squared and A^m is just the cell size. For n = 1 the uplift rate alone therefore
|
||||
// fixes the hillslope angle - that is D-49, and it is the most useful number in the whole legend, because it
|
||||
// decides whether the ground is shaped by erosion or by landsliding.
|
||||
func divideAngle(rateMYr, k, cellM, m float64) float64 {
|
||||
if k <= 0 || cellM <= 0 {
|
||||
return 0
|
||||
}
|
||||
s := rateMYr / (k * math.Pow(cellM*cellM, m))
|
||||
return math.Atan(s) * 180 / math.Pi
|
||||
}
|
||||
|
||||
// typicalMedianFrac and typicalP90Frac turn a divide angle into the ground underneath it.
|
||||
//
|
||||
// The divide angle is exact and it is not the landscape. S = U/(K*A^m) is largest where A is smallest, which
|
||||
// is the top of a catchment; slope falls away downstream from there, and almost none of a map is divide. So
|
||||
// the number the legend hands an author is the steepest place in their world and they read it as the world.
|
||||
//
|
||||
// Measured rather than derived, on a 600 x 600 grid of 8 m cells with one coast, the manifest's own
|
||||
// constants, 1000 steps, and a uniform rate:
|
||||
//
|
||||
// U mm/yr divide median P90 over 3 deg
|
||||
// 0.012 1.7 0.58 1.00 4 %
|
||||
// 0.045 6.4 2.12 2.85 8 %
|
||||
// 0.080 11.3 3.72 4.85 75 %
|
||||
// 0.250 32.0 11.13 14.03 99 %
|
||||
//
|
||||
// In tangent the median/divide ratio is 0.34, 0.33, 0.33 and 0.32 - flat enough over a factor of twenty in
|
||||
// rate to be worth quoting as one number. The P90 ratio drifts from 0.59 to 0.40 as the ground steepens,
|
||||
// because the tail of the slope distribution is the part the repose clamp eventually binds; 0.45 is the
|
||||
// middle of it and it is the weaker of the two.
|
||||
//
|
||||
// Both are fractions of the *tangent*, not of the angle, because the steady-state law is about slope.
|
||||
const (
|
||||
typicalMedianFrac = 0.33
|
||||
typicalP90Frac = 0.45
|
||||
)
|
||||
|
||||
// typicalFromDivide is the median and P90 slope, in degrees, for a class whose divide angle is deg.
|
||||
func typicalFromDivide(deg float64) (median, p90 float64) {
|
||||
t := math.Tan(deg * math.Pi / 180)
|
||||
return math.Atan(t*typicalMedianFrac) * 180 / math.Pi,
|
||||
math.Atan(t*typicalP90Frac) * 180 / math.Pi
|
||||
}
|
||||
|
||||
func pieces(n int) string {
|
||||
if n == 1 {
|
||||
return "piece"
|
||||
}
|
||||
return "pieces"
|
||||
}
|
||||
|
||||
// readsAs names the ground a divide angle makes.
|
||||
//
|
||||
// It exists because an uplift rate does not look like anything, and the one number an author has to hand is
|
||||
// therefore the one they cannot picture. The boundaries are angles rather than rates deliberately: the mistake
|
||||
// this is here to stop is reading internal/stats' "plain below 0.1 mm/yr" as a description of terrain. It is
|
||||
// not - it is a reporting bucket calibrated for the procedural path's intraplate rates - and 0.1 mm/yr is a 14
|
||||
// degree hillslope on every divide of the map, which is hill country wherever it is painted.
|
||||
func readsAs(deg float64) string {
|
||||
switch {
|
||||
case deg < 3:
|
||||
return "plain"
|
||||
case deg < 8:
|
||||
return "rolling"
|
||||
case deg < 16:
|
||||
return "hill country"
|
||||
case deg < 28:
|
||||
return "mountain"
|
||||
default:
|
||||
return "alpine"
|
||||
}
|
||||
}
|
||||
|
||||
// clampCeiling is the uplift rate, in mm/yr, at which a divide reaches the angle of repose at K x1. Above it
|
||||
// the repose clamp does the shaping and the terrain comes out as flat polygonal facets.
|
||||
func clampCeiling(k, cellM, m, talusDeg float64) float64 {
|
||||
return math.Tan(talusDeg*math.Pi/180) * k * math.Pow(cellM*cellM, m) * 1000
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
// Package planet is the driver for a painted world: template in, terrain out.
|
||||
//
|
||||
// It owns the order of operations and nothing else. The image and its legend belong to internal/template,
|
||||
// the cylinder to internal/world, the cutting up to internal/region, and every physical process to the
|
||||
// packages that already had it. What lives here is the sequence, the reporting, and the two things that are
|
||||
// only true of a whole planet: that its open ocean is painted rather than solved, and that its statistics
|
||||
// pool across regions rather than being computed per region and averaged.
|
||||
package planet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/overlay"
|
||||
"salty/terrain/internal/plates"
|
||||
"salty/terrain/internal/region"
|
||||
"salty/terrain/internal/template"
|
||||
"salty/terrain/internal/uplift"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Inputs is everything a bake needs before a single erosion step has run: the painted map classified and
|
||||
// projected onto the cylinder, and the cylinder cut into regions.
|
||||
//
|
||||
// It is a type of its own because it is worth looking at on its own. Two decisions can wreck an hour-long
|
||||
// bake - how the legend read the paint, and how the planet was cut up - and both are settled here, in about
|
||||
// a minute. That is what the plan command exists to show.
|
||||
type Inputs struct {
|
||||
M *manifest.Manifest
|
||||
P world.Planet
|
||||
Legend *template.Legend
|
||||
Raster *template.Raster // at paint resolution
|
||||
Map *template.Map // at planet resolution
|
||||
Part *region.Partition
|
||||
|
||||
// Palette is how the preview is drawn. Never nil: the generator's own when the manifest names none.
|
||||
Palette *field.Palette
|
||||
|
||||
PaintW, PaintH int
|
||||
Match template.Match
|
||||
EdgeRewritten int
|
||||
Dissolved int
|
||||
MarginCells int
|
||||
Elapsed time.Duration
|
||||
|
||||
// The annotation layer, or nil throughout when the manifest configures none. It is prepared here rather
|
||||
// than at export time for one reason: its coast_jitter marks have to be read *before* the waterline is
|
||||
// roughened, which is the first thing that happens to the painting, so by the time a plan exists the
|
||||
// overlay has already had its say. Everything else it carries is inert - see internal/overlay.
|
||||
Overlay *overlay.Legend
|
||||
OverlayRaster *overlay.Raster
|
||||
OverlayMatch overlay.Match
|
||||
OverlayDoc *overlay.Document
|
||||
|
||||
// Faults is the planet's whole fault set, in world metres, drawn once here because placing a trace needs
|
||||
// the class raster of the *whole* cylinder - and because a set drawn per region would put a different
|
||||
// fault in every one of them, which is the defect that kept the procedural path's version from being
|
||||
// portable at all. Every region reads the same slice and filters it to its own frame.
|
||||
Faults []uplift.FaultTrace
|
||||
|
||||
// Plates is the tectonic model, or nil when the manifest asks for none. Drawn here for the same reason
|
||||
// the fault set is: a plate is a planet-wide object, and a partition computed per region would give the
|
||||
// same physical margin a different classification in every one of them.
|
||||
Plates *plates.Model
|
||||
}
|
||||
|
||||
// Painting is the two images already in memory, which is what the studio has: the pictures being edited are
|
||||
// the ones in the browser, so a plan run against the files on disk would answer a question nobody asked.
|
||||
//
|
||||
// A nil Painting, or a nil half of one, means "read what the manifest names". The two halves are separate
|
||||
// because they are edited separately: moving a road does not re-roughen the coast unless a coast_jitter mark
|
||||
// moved with it, and the studio's plan cache keys on them one at a time.
|
||||
type Painting struct {
|
||||
Class []uint8
|
||||
ClassW, ClassH int
|
||||
|
||||
Overlay []uint8
|
||||
OverlayAlpha []uint8
|
||||
OverlayW, OverlayH int
|
||||
|
||||
// The tectonic layer, RGB with no alpha: every pixel of it is some plate, so there is no "nothing" to
|
||||
// carry. Like the other two it is the authority while the studio is open.
|
||||
Plates []uint8
|
||||
PlatesW, PlatesH int
|
||||
}
|
||||
|
||||
// Prepare reads the template, classifies it, projects it onto the planet and partitions it.
|
||||
//
|
||||
// Nothing here erodes anything, and nothing here is expensive: the whole thing is a few image passes and
|
||||
// three distance transforms.
|
||||
func Prepare(m *manifest.Manifest, log func(string, ...any)) (*Inputs, error) {
|
||||
return PrepareWith(m, nil, log)
|
||||
}
|
||||
|
||||
// PrepareWith is Prepare over paintings already in memory. See Painting; nil reads what the manifest names,
|
||||
// which is what Prepare does.
|
||||
func PrepareWith(m *manifest.Manifest, art *Painting, log func(string, ...any)) (*Inputs, error) {
|
||||
|
||||
if !m.IsPlanet() {
|
||||
return nil, fmt.Errorf("%s has no planet block; this is the square canvas that `generate` builds",
|
||||
m.Path)
|
||||
}
|
||||
start := time.Now()
|
||||
if log == nil {
|
||||
log = func(string, ...any) {}
|
||||
}
|
||||
pb := m.Planet
|
||||
cell := m.GeologyCellM()
|
||||
|
||||
lg, err := template.Load(m.LegendPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log("legend %s: %d classes", m.Planet.Legend, len(lg.Classes))
|
||||
|
||||
var px []uint8
|
||||
var pw, ph int
|
||||
if art != nil {
|
||||
px, pw, ph = art.Class, art.ClassW, art.ClassH
|
||||
}
|
||||
if px == nil {
|
||||
var err error
|
||||
px, pw, ph, err = template.DecodeRGB(m.TemplatePath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log("template %s: %d x %d px", pb.Template, pw, ph)
|
||||
} else {
|
||||
log("template in memory: %d x %d px", pw, ph)
|
||||
}
|
||||
|
||||
ras, match := lg.Classify(px, pw, ph)
|
||||
px = nil
|
||||
edge, dissolved := ras.DissolveStrokes(lg)
|
||||
speckle := ras.Despeckle()
|
||||
log("classify %s; %d px rescued at the poles, %d dissolved, %d despeckled",
|
||||
match, edge, dissolved, speckle)
|
||||
|
||||
marginCells := pb.MarginCells(cell)
|
||||
p, err := world.New(pb.CircumferenceKm*1000, cell, pw, ph, marginCells, pb.NoisePeriodKm*1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log("planet %d x %d cells of %.1f m: %.1f x %.1f km, %.0f km2 (+%d rows of polar pad)",
|
||||
p.W, p.PaintH(), p.CellM, p.CircumferenceM()/1000, p.HeightM()/1000,
|
||||
p.CircumferenceM()*p.HeightM()/1e6, p.PadY)
|
||||
|
||||
padClass := lg.FirstSea()
|
||||
if pb.PadClass != "" {
|
||||
padClass = lg.Index(pb.PadClass)
|
||||
if padClass < 0 {
|
||||
return nil, fmt.Errorf("%s: planet.pad_class %q is not a class in the legend", m.Path, pb.PadClass)
|
||||
}
|
||||
if !lg.Classes[padClass].Sea {
|
||||
return nil, fmt.Errorf("%s: planet.pad_class %q is land; the pad is the ocean a polar cap "+
|
||||
"drains into", m.Path, pb.PadClass)
|
||||
}
|
||||
}
|
||||
if padClass < 0 {
|
||||
return nil, fmt.Errorf("%s: the legend has no sea class, so there is nothing to fill the polar pad "+
|
||||
"with", m.LegendPath())
|
||||
}
|
||||
|
||||
pal := field.DefaultPalette()
|
||||
if path := m.PalettePath(); path != "" {
|
||||
pal, err = field.LoadPalette(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log("palette %s", m.Planet.Palette)
|
||||
}
|
||||
|
||||
// The annotation layer, read before the coast is roughened rather than after: its coast_jitter marks are
|
||||
// the one thing on it the generator reads, and what they decide is how far the waterline may move.
|
||||
ov, ovRas, ovMatch, err := loadOverlay(m, art, pw, ph, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The painted waterline is roughened before it is projected: a drawn shore is a smooth curve and a coast
|
||||
// is not. See template/coast.go. Off by default only in the sense that an amplitude of zero is the
|
||||
// painting exactly as drawn.
|
||||
coast := template.Coast{
|
||||
AmplitudePx: pb.CoastJitterPx,
|
||||
WavelengthPx: pb.CoastJitterWavelengthPx,
|
||||
Octaves: pb.CoastJitterOctaves,
|
||||
Gain: pb.CoastJitterGain,
|
||||
Seed: m.Source.Seed,
|
||||
}
|
||||
if ov != nil && ovRas != nil {
|
||||
coast.Scale = ov.CoastScale(ovRas)
|
||||
}
|
||||
if coast.Amount() {
|
||||
ras = ras.RoughenCoast(lg, p, coast)
|
||||
masked := ""
|
||||
if coast.Scale != nil {
|
||||
masked = ", masked by the overlay"
|
||||
}
|
||||
log("coast the painted waterline roughened by up to %.0f px over %.0f px bays, %d octaves%s",
|
||||
coast.AmplitudePx, coast.WavelengthPx, coast.Octaves, masked)
|
||||
}
|
||||
|
||||
pm := ras.Project(p, lg, padClass)
|
||||
|
||||
part, err := region.Build(pm, marginCells, pb.MinLandCells)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The plates first, because the fault set is now partly a consequence of them.
|
||||
tect, err := buildPlates(m, p, pm, art, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
faults := buildFaultSet(pm, lg, pb.FaultGrainKm, m.Source.Seed)
|
||||
if len(faults) > 0 {
|
||||
log("faults %d class traces over %d classes, grain %.0f km",
|
||||
len(faults), faultClasses(lg), pb.FaultGrainKm)
|
||||
}
|
||||
if belt := buildBeltFaults(p, pm, tect, pb.Plates.Faults, m.Source.Seed, log); len(belt) > 0 {
|
||||
faults = append(faults, belt...)
|
||||
}
|
||||
|
||||
in := &Inputs{
|
||||
M: m, P: p, Legend: lg, Raster: ras, Map: pm, Part: part, Palette: pal,
|
||||
PaintW: pw, PaintH: ph, Match: match,
|
||||
EdgeRewritten: edge, Dissolved: dissolved, MarginCells: marginCells,
|
||||
Overlay: ov, OverlayRaster: ovRas, OverlayMatch: ovMatch,
|
||||
Faults: faults,
|
||||
Plates: tect,
|
||||
Elapsed: time.Since(start),
|
||||
}
|
||||
if ov != nil && ovRas != nil {
|
||||
in.OverlayDoc = ov.Describe(ovRas, ovMatch, in.OverlayScale(),
|
||||
m.Planet.Overlay, m.Planet.OverlayLegend)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// faultCandidateTarget is roughly how many strided samples of the planet the fault placement draws from. The
|
||||
// sample is only ever used for a uniform draw - the *areas* are the projection's own exact counts - so what
|
||||
// it has to be is dense enough that a small class still has somewhere to put a trace, not dense enough to
|
||||
// measure anything. Sixty-odd thousand over a 76-million-cell planet is a stride of about 34 cells, 270 m,
|
||||
// and leaves a class covering a fifth of a per cent with over a hundred candidates.
|
||||
const faultCandidateTarget = 65536
|
||||
|
||||
// buildFaultSet draws the planet's faults, or returns nil when no class asks for any.
|
||||
func buildFaultSet(pm *template.Map, lg *template.Legend, grainKm float64, seed int64) []uplift.FaultTrace {
|
||||
if !lg.HasFaults() || grainKm <= 0 {
|
||||
return nil
|
||||
}
|
||||
specs := make([]uplift.FaultSpec, len(lg.Classes))
|
||||
for i := range lg.Classes {
|
||||
f := lg.Classes[i].Faults
|
||||
if f == nil || !lg.Classes[i].Land() {
|
||||
continue
|
||||
}
|
||||
specs[i] = uplift.FaultSpec{Per1000Km2: f.Per1000Km2, ThrowM: f.ThrowM, LengthKm: f.LengthKm}
|
||||
}
|
||||
|
||||
p := pm.P
|
||||
stride := int(math.Sqrt(float64(p.W)*float64(p.PaintH())/faultCandidateTarget) + 0.5)
|
||||
if stride < 1 {
|
||||
stride = 1
|
||||
}
|
||||
candidates := make([][]int32, len(lg.Classes))
|
||||
// Painted rows only. The polar pad is synthetic ocean that no class was ever painted on, and a trace
|
||||
// placed there would be a fault in scaffolding.
|
||||
for y := p.PadY; y < p.H-p.PadY; y += stride {
|
||||
for x := 0; x < p.W; x += stride {
|
||||
i := y*p.W + x
|
||||
c := pm.Class[i]
|
||||
if specs[c].Wanted() {
|
||||
candidates[c] = append(candidates[c], int32(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
areaCells, _, _ := pm.Counts()
|
||||
return uplift.BuildFaults(p, seed, grainKm, specs, candidates, areaCells)
|
||||
}
|
||||
|
||||
// buildPlates draws the planet's tectonics, or returns nil when the manifest asks for none.
|
||||
//
|
||||
// The land mask is read through a callback at world coordinates rather than handed over as a raster, and
|
||||
// that is what keeps internal/plates ignorant of templates. What it wants from the painting is one bit per
|
||||
// position - continent or ocean - and that bit is what decides whether a plate is continental, and therefore
|
||||
// whether a margin between two of them is a collision or a subduction zone.
|
||||
func buildPlates(mf *manifest.Manifest, p world.Planet, pm *template.Map, art *Painting,
|
||||
log func(string, ...any)) (*plates.Model, error) {
|
||||
|
||||
cfg := mf.Planet.Plates
|
||||
inMemory := art != nil && art.Plates != nil
|
||||
painted := inMemory || mf.HasPaintedPlates()
|
||||
if !painted && cfg.Count <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var m *plates.Model
|
||||
var err error
|
||||
if painted {
|
||||
m, err = paintedPlates(mf, p, pm, art, cfg, log)
|
||||
} else {
|
||||
m, err = plates.Build(p, mf.Source.Seed, cfg, landAt(p, pm))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
continental := 0
|
||||
for i := range m.Plates {
|
||||
if m.Plates[i].Continental {
|
||||
continental++
|
||||
}
|
||||
}
|
||||
byKind := plates.LengthByKind(m.Boundaries)
|
||||
source := "from seed " + strconv.FormatInt(mf.Source.Seed, 10)
|
||||
if painted {
|
||||
source = "painted"
|
||||
}
|
||||
log("plates %d %s (%d continental), %d boundaries, tectonic grid %.0f m",
|
||||
len(m.Plates), source, continental, len(m.Boundaries), m.GCellM)
|
||||
log(" collision %.0f km, subduction %.0f km, rift %.0f km, ridge %.0f km, transform %.0f km",
|
||||
byKind[plates.Collision]/1000, byKind[plates.Subduction]/1000, byKind[plates.Rift]/1000,
|
||||
byKind[plates.Ridge]/1000, byKind[plates.Transform]/1000)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// landAt answers "is this world position painted land" against the projected map.
|
||||
//
|
||||
// A callback rather than the raster itself, because internal/plates and internal/uplift's belt placement both
|
||||
// want exactly this one bit and neither should know what a template is. It is also the only thing the
|
||||
// painting tells the tectonic model, which is worth being able to point at: everything else about a plate
|
||||
// comes from the seed and the manifest.
|
||||
func landAt(p world.Planet, pm *template.Map) func(xM, yM float64) bool {
|
||||
return func(xM, yM float64) bool {
|
||||
x := p.WrapX(int(math.Floor(xM / p.CellM)))
|
||||
y := p.ClampY(int(math.Floor(yM/p.CellM)) + p.PadY)
|
||||
return !pm.Sea[y*p.W+x]
|
||||
}
|
||||
}
|
||||
|
||||
// buildBeltFaults places the traces that belong to the plate margins rather than to a painted class.
|
||||
//
|
||||
// Separate from buildFaultSet and added to the same slice, because from the solve's point of view a fault is
|
||||
// a fault: both end up in Inputs.Faults, both are rasterised by the same FaultDelta, and the difference is
|
||||
// only in how they were placed. Where they came from survives in the log line and in the trace's own Class,
|
||||
// which is -1 for a belt fault because no painted colour asked for it.
|
||||
func buildBeltFaults(p world.Planet, pm *template.Map, tect *plates.Model, cfg plates.Belt,
|
||||
seed int64, log func(string, ...any)) []uplift.FaultTrace {
|
||||
|
||||
if tect == nil || !cfg.Wanted() {
|
||||
return nil
|
||||
}
|
||||
out := uplift.BuildBeltFaults(p, seed, cfg, tect.Boundaries, landAt(p, pm))
|
||||
cfg = cfg.WithDefaults()
|
||||
log(" %d belt traces, %.0f km deformation half-width at %.0f cm/yr, %.0f%% conjugate",
|
||||
len(out), cfg.ZoneKm, cfg.ReferenceCmYr, cfg.Conjugate()*100)
|
||||
return out
|
||||
}
|
||||
|
||||
// paintedPlates reads the tectonic layer and turns it into a model.
|
||||
//
|
||||
// The decoding happens here rather than in internal/plates for the same reason the land mask does: that
|
||||
// package deals in geometry and motion, and giving it a file path would give it an opinion about image
|
||||
// formats, paths and the manifest. It is handed pixels.
|
||||
func paintedPlates(mf *manifest.Manifest, p world.Planet, pm *template.Map, art *Painting,
|
||||
cfg plates.Config, log func(string, ...any)) (*plates.Model, error) {
|
||||
|
||||
legendPath := mf.PlatesLegendPath()
|
||||
lg, err := plates.LoadPaintLegend(legendPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The sheet in memory wins when there is one, for the same reason Painting exists at all: the studio is
|
||||
// editing a picture in a browser, and a plan run against the file on disk would answer a question nobody
|
||||
// asked.
|
||||
layerPath := mf.Planet.Plates.Layer
|
||||
var px []uint8
|
||||
var pw, ph int
|
||||
if art != nil && art.Plates != nil {
|
||||
px, pw, ph = art.Plates, art.PlatesW, art.PlatesH
|
||||
layerPath = "in memory"
|
||||
} else {
|
||||
path := mf.PlatesLayerPath()
|
||||
if px, pw, ph, err = template.DecodeRGB(path); err != nil {
|
||||
return nil, fmt.Errorf("tectonic layer %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
m, match, err := plates.FromPainting(p, cfg, lg, px, pw, ph, landAt(p, pm))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tectonic layer %s: %w", layerPath, err)
|
||||
}
|
||||
log("tectonic %s, %dx%d px, %d plates", layerPath, pw, ph, len(lg.Plates))
|
||||
if match.Far > 0 {
|
||||
// Reported rather than fatal, and loudly. Every pixel becomes the nearest plate whatever happens, so
|
||||
// a layer whose colours have drifted still produces a model - it just produces the wrong one, with
|
||||
// boundaries somewhere nobody put them.
|
||||
log(" %d of %d sampled cells are over %.0f from any plate colour (worst %.0f); the layer and "+
|
||||
"%s disagree", match.Far, match.Cells, lg.WarnDistance, match.MaxDistance,
|
||||
filepath.Base(legendPath))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// RebuildFaults draws the fault set again from the legend as it stands now.
|
||||
//
|
||||
// It exists for the studio's plan cache, which reuses a whole prepare when only the legend's *numbers*
|
||||
// changed - and a class's `faults` block is a number that changes the set without changing a pixel of the
|
||||
// raster the cache is keyed on. Cheap: a strided scan and a few dozen walks, against the six seconds the
|
||||
// cache is there to avoid.
|
||||
func (in *Inputs) RebuildFaults() {
|
||||
in.Faults = buildFaultSet(in.Map, in.Legend, in.M.Planet.FaultGrainKm, in.M.Source.Seed)
|
||||
// The belt set comes back too. It is not the legend's, but it is in the same slice, and a rebuild that
|
||||
// dropped it would silently unfault every margin on the planet the first time a class number changed.
|
||||
quiet := func(string, ...any) {}
|
||||
in.Faults = append(in.Faults,
|
||||
buildBeltFaults(in.P, in.Map, in.Plates, in.M.Planet.Plates.Faults, in.M.Source.Seed, quiet)...)
|
||||
}
|
||||
|
||||
// faultClasses is how many classes asked for traces, for the log line.
|
||||
func faultClasses(lg *template.Legend) int {
|
||||
n := 0
|
||||
for i := range lg.Classes {
|
||||
if lg.Classes[i].Faults != nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// OverlayScale is how an overlay pixel maps to world metres. The overlay is registered to the template and
|
||||
// shares its frame, so this is the template's scale and not the geology grid's - a pixel is 12.9 m where a
|
||||
// cell is 8.
|
||||
func (in *Inputs) OverlayScale() overlay.Scale {
|
||||
w, h := in.PaintW, in.PaintH
|
||||
if in.OverlayRaster != nil {
|
||||
w, h = in.OverlayRaster.W, in.OverlayRaster.H
|
||||
}
|
||||
circ := in.P.CircumferenceM()
|
||||
return overlay.Scale{
|
||||
MetresPerPxX: circ / float64(w),
|
||||
MetresPerPxY: in.P.HeightM() / float64(h),
|
||||
CircumferenceM: circ,
|
||||
}
|
||||
}
|
||||
|
||||
// loadOverlay reads and classifies the annotation layer, from memory when the studio has one and from disk
|
||||
// otherwise. Returns nils all round when the manifest configures none, which is not an error anywhere.
|
||||
//
|
||||
// The overlay must be the same size as the template. It is registered to it - a mark means "here", and here
|
||||
// is a place on the painting - so two different sizes is not something to resample past, it is an author who
|
||||
// exported one of the two at the wrong scale and would otherwise find their villages drifting.
|
||||
func loadOverlay(m *manifest.Manifest, art *Painting, paintW, paintH int, log func(string, ...any)) (
|
||||
*overlay.Legend, *overlay.Raster, overlay.Match, error) {
|
||||
|
||||
var none overlay.Match
|
||||
if !m.HasOverlay() {
|
||||
return nil, nil, none, nil
|
||||
}
|
||||
ov, err := overlay.Load(m.OverlayLegendPath())
|
||||
if err != nil {
|
||||
return nil, nil, none, err
|
||||
}
|
||||
|
||||
var px, alpha []uint8
|
||||
var w, h int
|
||||
if art != nil && art.Overlay != nil {
|
||||
px, alpha, w, h = art.Overlay, art.OverlayAlpha, art.OverlayW, art.OverlayH
|
||||
} else {
|
||||
path := m.OverlayPath()
|
||||
if path == "" {
|
||||
if ov.Image == "" {
|
||||
return nil, nil, none, fmt.Errorf("%s: planet.overlay_legend is set but neither it nor "+
|
||||
"planet.overlay names an image", m.Path)
|
||||
}
|
||||
path = filepath.Join(filepath.Dir(m.OverlayLegendPath()), ov.Image)
|
||||
}
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
// A configured overlay whose image is not there yet is the state the studio starts an author in:
|
||||
// the legend is written and the sheet is blank. Worth saying, not worth failing on.
|
||||
log("overlay %s: no image yet (%s); nothing is marked",
|
||||
m.Planet.OverlayLegend, filepath.Base(path))
|
||||
return ov, nil, none, nil
|
||||
}
|
||||
px, alpha, w, h, err = template.DecodeRGBA(path)
|
||||
if err != nil {
|
||||
return nil, nil, none, err
|
||||
}
|
||||
}
|
||||
if w != paintW || h != paintH {
|
||||
return nil, nil, none, fmt.Errorf("the overlay is %dx%d and the template is %dx%d; they are "+
|
||||
"registered to each other, so they have to be the same size", w, h, paintW, paintH)
|
||||
}
|
||||
ras, match := ov.Classify(px, alpha, w, h)
|
||||
log("overlay %d marks: %s", len(ov.Marks), match)
|
||||
return ov, ras, match, nil
|
||||
}
|
||||
|
||||
// SolveCells is how many cells the geology solve will actually visit, summed over regions. It is the number
|
||||
// the bake time is proportional to, and it is well above the land area because every region carries water.
|
||||
func (in *Inputs) SolveCells() int {
|
||||
n := 0
|
||||
for _, r := range in.Part.Regions {
|
||||
n += r.Cells()
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// LandCells is how many painted land cells the regions own.
|
||||
func (in *Inputs) LandCells() int {
|
||||
n := 0
|
||||
for _, r := range in.Part.Regions {
|
||||
n += r.LandCells
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// The measurement the estimates below are scaled from, and the thing it cannot know.
|
||||
//
|
||||
// Docs/Terrain.md's time budget records 256 s for 3.2 M cells over 1000 steps on the development machine's
|
||||
// 16 cores, which is 8.0e-8 s a cell-step. Measured again on a lowland region of this planet - 14.0 M cells,
|
||||
// 200 steps, 415 s - it is 1.48e-7, very nearly twice as slow, most likely because the square canvas is two
|
||||
// thirds land while a region is two thirds water: a cheap ocean cell is not a free one.
|
||||
//
|
||||
// What no single constant can capture is that **the cost per cell depends on the uplift rate, and by a lot**.
|
||||
// Measured on the same bake at 1000 steps with four regions in flight:
|
||||
//
|
||||
// lowland 0.08 mm/yr 14.0 M cells 1014 s 72 s per million cells
|
||||
// highland 0.90 mm/yr 4.0 M cells 1394 s 350 s per million cells
|
||||
// crater 1.60 mm/yr 1.4 M cells 1829 s 1278 s per million cells
|
||||
//
|
||||
// Eighteen-fold, and it is not the stream power. It is the hillslope: DiffuseNonlinear sub-steps to stay
|
||||
// stable, the count rises with the steepest slope on the grid, and it saturates at max_hillslope_substeps -
|
||||
// 24 by default. Steep ground pays all 24 every step; a plain pays one.
|
||||
//
|
||||
// So the estimate is calibrated on the plains and **badly under-predicts a mountainous template**. It is a
|
||||
// floor rather than a forecast, the printed line says so, and the practical consequence for an author is
|
||||
// that raising an uplift rate does not only change the terrain, it changes how long the bake takes.
|
||||
const secondsPerCellStep = 415.0 / (14.02e6 * 200)
|
||||
|
||||
// EstimateSeconds is how long a region's solve should take at the manifest's step count.
|
||||
func (in *Inputs) EstimateSeconds(cells int) float64 {
|
||||
return float64(cells) * float64(in.M.Pipeline.Fluvial.Steps) * secondsPerCellStep
|
||||
}
|
||||
|
||||
// bytesPerCell is what a region costs while it is being solved: the fluvial.Grid's eight int32/float32
|
||||
// arrays and three masks, plus the height, uplift and erodibility fields the solve reads. It is an estimate
|
||||
// and it is labelled as one wherever it is printed.
|
||||
const bytesPerCell = 35 + 12 + 16
|
||||
|
||||
// EstimateBytes is roughly how much memory a region's solve holds at once.
|
||||
func (in *Inputs) EstimateBytes(cells int) int64 { return int64(cells) * bytesPerCell }
|
||||
@@ -0,0 +1,686 @@
|
||||
package planet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"salty/terrain/internal/detail"
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/overlay"
|
||||
"salty/terrain/internal/thermal"
|
||||
"salty/terrain/internal/tile"
|
||||
)
|
||||
|
||||
// The detail bake: the geology grid becomes ground somebody can stand on, one tile at a time.
|
||||
//
|
||||
// It reads the heightmap a geology bake left behind rather than solving anything itself, which is what makes
|
||||
// it batchable. The geology is hours; a tile is seconds, and the islands somebody cares about can be baked
|
||||
// first and the rest later or never.
|
||||
//
|
||||
// Every pass here is local, and every hash and noise lattice in them is keyed on absolute world position, so
|
||||
// a tile's interior comes out the same as it would have in one impossible whole-world run. That is measured
|
||||
// rather than asserted: internal/detail's TestHowFarTheCutEdgeReachesIn is where the margin comes from.
|
||||
|
||||
// TileOptions steer a detail bake.
|
||||
type TileOptions struct {
|
||||
In *Inputs
|
||||
HeightM *field.Field // the geology heightmap, painted rows only, in metres
|
||||
Sea []bool // painted rows
|
||||
|
||||
// Exposure is the coastal pass's fetch field, painted rows, 0 sheltered to 1 open water, or nil when the
|
||||
// bake predates it. A tile cannot compute this - see detail.CoastalParams - so without it the coastal
|
||||
// detail pass treats every shore as fully exposed and the run says so once.
|
||||
Exposure *field.Field
|
||||
|
||||
Out string
|
||||
Prefix string
|
||||
Only [4]int // x0,y0,x1,y1 in tile indices; zero means all
|
||||
OnlySet bool
|
||||
|
||||
// NoDetail writes the tile as the geology upsampled and nothing else. It is a diagnostic and it earns
|
||||
// its place: when ground looks wrong at two metres, the first question is always whether the detail
|
||||
// passes did it or whether they are faithfully magnifying something the solve produced, and there is no
|
||||
// other way to ask.
|
||||
NoDetail bool
|
||||
|
||||
// NoShore skips pass 11b and nothing else, for the same reason NoDetail exists one level up: when a
|
||||
// coastline looks wrong the first question is whether the shore pass did it or whether it is faithfully
|
||||
// magnifying what the geology handed it, and a diff between two runs is the only way to ask.
|
||||
NoShore bool
|
||||
|
||||
Jobs int
|
||||
Log func(string, ...any)
|
||||
}
|
||||
|
||||
// TileRecord is one tile in the index.
|
||||
type TileRecord struct {
|
||||
IX int `json:"ix"`
|
||||
IY int `json:"iy"`
|
||||
File string `json:"file"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
OriginXM float64 `json:"origin_x_m"`
|
||||
OriginYM float64 `json:"origin_y_m"`
|
||||
MinM float64 `json:"min_m"`
|
||||
MaxM float64 `json:"max_m"`
|
||||
ClipFrac float64 `json:"clip_fraction"`
|
||||
Droplets int `json:"droplets"`
|
||||
Rounds int `json:"rounds"`
|
||||
LargestCutM float64 `json:"largest_cut_m"`
|
||||
LargestFillM float64 `json:"largest_fill_m"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
|
||||
// Coastal is what pass 11b moved on this tile, or nil on a tile with no shore in it.
|
||||
Coastal *detail.CoastalStats `json:"coastal,omitempty"`
|
||||
|
||||
// OverlayFile is the annotation mask beside this tile - one mark index a detail cell, zero for nothing -
|
||||
// or empty when the planet has no overlay. The key is in overlay.json in the same directory.
|
||||
OverlayFile string `json:"overlay_file,omitempty"`
|
||||
}
|
||||
|
||||
// TileIndex is tiles.json: everything a consumer needs to place the tiles back into a world.
|
||||
type TileIndex struct {
|
||||
When time.Time `json:"when"`
|
||||
Prefix string `json:"prefix"`
|
||||
CellM float64 `json:"cell_m"`
|
||||
TilePx int `json:"tile_px"`
|
||||
MarginPx int `json:"margin_px"`
|
||||
NX int `json:"nx"`
|
||||
NY int `json:"ny"`
|
||||
WrapX bool `json:"wrap_x"`
|
||||
WorldWM float64 `json:"world_w_m"`
|
||||
WorldHM float64 `json:"world_h_m"`
|
||||
ElevationM struct {
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
} `json:"elevation_m"`
|
||||
Tiles []TileRecord `json:"tiles"`
|
||||
}
|
||||
|
||||
// CheckBake refuses a detail bake whose geology was produced by a different manifest.
|
||||
//
|
||||
// The failure it exists for is silent and total. A heightmap is 16-bit samples over an elevation range, so a
|
||||
// bake made under one range and decoded under another comes out shifted - and if the shift takes the land
|
||||
// below sea level, every tile decides it is ocean, holds itself at sea level, and writes a flat zero. That
|
||||
// happened on the first run of this command and there was nothing in the output to say why.
|
||||
func CheckBake(dir string, m *manifest.Manifest, warn func(string, ...any)) error {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w (run `terrain bake` first)", filepath.Join(dir, "meta.json"), err)
|
||||
}
|
||||
// Pointers, so that a field a bake did not record is distinguishable from one it recorded as zero. An
|
||||
// absent field is a bake older than this check, which is a reason to say so and carry on; a different
|
||||
// field is a reason to stop. The first version conflated the two and refused a perfectly good bake.
|
||||
var meta struct {
|
||||
Seed *int64 `json:"seed"`
|
||||
Plan struct {
|
||||
CircumferenceKm *float64 `json:"circumference_km"`
|
||||
CellM *float64 `json:"cell_m"`
|
||||
ElevationMinM *float64 `json:"elevation_min_m"`
|
||||
ElevationMaxM *float64 `json:"elevation_max_m"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &meta); err != nil {
|
||||
return fmt.Errorf("%s: %w", filepath.Join(dir, "meta.json"), err)
|
||||
}
|
||||
if warn == nil {
|
||||
warn = func(string, ...any) {}
|
||||
}
|
||||
|
||||
unknown := 0
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
was *float64
|
||||
now float64
|
||||
}{
|
||||
{"elevation_m.min", meta.Plan.ElevationMinM, m.ElevationM.Min},
|
||||
{"elevation_m.max", meta.Plan.ElevationMaxM, m.ElevationM.Max},
|
||||
{"circumference_km", meta.Plan.CircumferenceKm, m.Planet.CircumferenceKm},
|
||||
{"the geology cell", meta.Plan.CellM, m.GeologyCellM()},
|
||||
} {
|
||||
if c.was == nil {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
if *c.was != c.now {
|
||||
return fmt.Errorf("%s was baked with %s %v and the manifest now says %v. The heightmap on disk "+
|
||||
"means something different from what this run would read it as; rebake, or put the manifest "+
|
||||
"back", dir, c.what, *c.was, c.now)
|
||||
}
|
||||
}
|
||||
if meta.Seed != nil && *meta.Seed != m.Source.Seed {
|
||||
return fmt.Errorf("%s was baked with seed %d and the manifest now says %d; the detail passes would "+
|
||||
"be hashing a different world from the one in the heightmap", dir, *meta.Seed, m.Source.Seed)
|
||||
}
|
||||
if unknown > 0 {
|
||||
warn("warning %s predates this check and does not record %d of the numbers it would be checked "+
|
||||
"against; if the manifest has moved since it was baked, the heights will be read as something "+
|
||||
"they are not", dir, unknown)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BakeTiles runs the detail passes over a rectangle of tiles and writes them.
|
||||
func BakeTiles(opt TileOptions) (*TileIndex, error) {
|
||||
log := opt.Log
|
||||
if log == nil {
|
||||
log = func(string, ...any) {}
|
||||
}
|
||||
in := opt.In
|
||||
m := in.M
|
||||
cfg := m.Pipeline
|
||||
|
||||
marginPx := detail.MarginCells(cfg.Particle)
|
||||
g, err := tile.NewGrid(in.P, cfg.GeologyFactor, cfg.Detail.TilePx, marginPx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detailCellM := in.P.CellM / float64(cfg.GeologyFactor)
|
||||
log("tiles %d x %d of %d px at %.1f m (%.2f km), margin %d px (%.0f m)",
|
||||
g.NX, g.NY, cfg.Detail.TilePx, detailCellM,
|
||||
float64(cfg.Detail.TilePx)*detailCellM/1000, g.MarginGeo*g.Factor, float64(g.MarginGeo)*in.P.CellM)
|
||||
if want := int(cfg.Detail.ClassBlendM/in.P.CellM + 0.5); want > g.MarginGeo/2 {
|
||||
log("warning class_blend_m is %.0f m, which a tile cannot reach past its own margin; it will blend "+
|
||||
"over %.0f m instead. Two passes of the blur reach twice its radius, and the margin is %.0f m",
|
||||
cfg.Detail.ClassBlendM, float64(g.MarginGeo/2)*in.P.CellM, float64(g.MarginGeo)*in.P.CellM)
|
||||
}
|
||||
if cd := cfg.CoastDetail; cd.Enabled && !opt.NoShore {
|
||||
log("shore the coastal detail pass is on: %.0f m of surf reach is %.0f cells here, a beach below "+
|
||||
"%.0f m of backshore and a cliff above %.0f", cfg.Coast.SurfReachM,
|
||||
cfg.Coast.SurfReachM/detailCellM, cd.CliffFromM, cd.CliffToM)
|
||||
} else {
|
||||
log("shore the coastal detail pass is off; the shore is the geology upsampled")
|
||||
}
|
||||
|
||||
all := g.Tiles()
|
||||
wanted := all[:0:0]
|
||||
for _, t := range all {
|
||||
if opt.OnlySet {
|
||||
if t.IX < opt.Only[0] || t.IX > opt.Only[2] || t.IY < opt.Only[1] || t.IY > opt.Only[3] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
wanted = append(wanted, t)
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return nil, fmt.Errorf("no tiles selected; the grid is %d x %d", g.NX, g.NY)
|
||||
}
|
||||
if err := os.MkdirAll(opt.Out, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefix := opt.Prefix
|
||||
if prefix == "" {
|
||||
prefix = "Planet"
|
||||
}
|
||||
jobs := opt.Jobs
|
||||
if jobs <= 0 {
|
||||
jobs = 4
|
||||
}
|
||||
if jobs > len(wanted) {
|
||||
jobs = len(wanted)
|
||||
}
|
||||
|
||||
recs := make([]TileRecord, len(wanted))
|
||||
errs := make([]error, len(wanted))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
next := make(chan int)
|
||||
go func() {
|
||||
for i := range wanted {
|
||||
next <- i
|
||||
}
|
||||
close(next)
|
||||
}()
|
||||
for w := 0; w < jobs; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range next {
|
||||
rec, err := bakeOneTile(g, wanted[i], opt, prefix)
|
||||
recs[i], errs[i] = rec, err
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
log("tile %s FAILED: %v", wanted[i].Name(prefix), err)
|
||||
} else {
|
||||
log("tile %s %d x %d %.0f..%.0f m %.3f%% clipped [%.1f s]",
|
||||
rec.File, rec.W, rec.H, rec.MinM, rec.MaxM, rec.ClipFrac*100, rec.Seconds)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
idx := &TileIndex{
|
||||
When: time.Now().UTC().Truncate(time.Second), Prefix: prefix,
|
||||
CellM: detailCellM, TilePx: cfg.Detail.TilePx, MarginPx: g.MarginGeo * g.Factor,
|
||||
NX: g.NX, NY: g.NY, WrapX: true,
|
||||
WorldWM: in.P.CircumferenceM(), WorldHM: in.P.HeightM(),
|
||||
Tiles: recs,
|
||||
}
|
||||
idx.ElevationM.Min, idx.ElevationM.Max = m.ElevationM.Min, m.ElevationM.Max
|
||||
data, err := json.MarshalIndent(idx, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(opt.Out, "tiles.json"), append(data, '\n'), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The key to every *_overlay.png, plus the features in world metres, written beside them so an importer
|
||||
// reads one directory rather than two. It is the same document the plan and the bake write; it is small,
|
||||
// it describes the whole planet, and a tile batch that did not carry it would be a folder of masks with
|
||||
// no legend.
|
||||
if in.OverlayDoc != nil {
|
||||
if err := in.OverlayDoc.WriteJSON(opt.Out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// bakeOneTile is passes 8 to 12 and 14 over one tile.
|
||||
func bakeOneTile(g *tile.Grid, t tile.Tile, opt TileOptions, prefix string) (TileRecord, error) {
|
||||
in := opt.In
|
||||
m := in.M
|
||||
cfg := m.Pipeline
|
||||
|
||||
// Pass 8: cut with the margin and upsample. UpsampleInt is exact-factor Catmull-Rom, so every geology
|
||||
// sample lands exactly on a detail sample and there is no phase error to accumulate along a tile row.
|
||||
//
|
||||
// The sea is flattened to sea level *before* the upsample, not after, and both halves of that matter. The
|
||||
// geology raster drops from the shore to the painted ocean depth in a single cell, so a Catmull-Rom
|
||||
// upsample of it rings at every coastline - hundreds of metres of overshoot in the water and a wave of it
|
||||
// back into the land. And with the sea flat, the interpolated height crosses sea level on a smooth
|
||||
// contour, so the detail land mask can be read off the height itself; taken up from the geology mask by
|
||||
// nearest neighbour instead, the coastline comes out as a staircase of 8 m blocks and it is plainly
|
||||
// visible in a hillshade.
|
||||
//
|
||||
// It is the same invariant the fluvial solve keeps, for the same reason: with the floor in place a cell at
|
||||
// the waterline stands five hundred metres above its neighbour, and thermal weathering would find the
|
||||
// whole coastline past the angle of repose and pour it into the sea.
|
||||
geo, _, _ := g.Cut(t, opt.HeightM, 0)
|
||||
geoSea := g.CutMask(t, opt.Sea, opt.HeightM.W, 0)
|
||||
floor := geo.Clone()
|
||||
for i, isSea := range geoSea {
|
||||
if isSea {
|
||||
geo.Data[i] = float32(m.SeaLevelM)
|
||||
}
|
||||
}
|
||||
|
||||
h := geo.UpsampleInt(cfg.GeologyFactor)
|
||||
land := make([]bool, len(h.Data))
|
||||
for i, v := range h.Data {
|
||||
land[i] = float64(v) > m.SeaLevelM
|
||||
}
|
||||
|
||||
f := g.Frame(t)
|
||||
periodM := m.Planet.DetailNoisePeriodKm * 1000
|
||||
|
||||
classes := blendedClasses(g, t, opt, geo, h)
|
||||
|
||||
if opt.NoDetail {
|
||||
restoreSeaFloor(h, land, geo, floor, m, cfg.GeologyFactor)
|
||||
return finishTile(g, t, opt, prefix, h, land, nil, nil)
|
||||
}
|
||||
|
||||
// Pass 9.
|
||||
detail.RunDetailNoise(h, land, detail.DetailNoiseParams{
|
||||
Cfg: cfg.Detail, Seed: m.Source.Seed, Frame: f, PeriodM: periodM, SeaLevelM: m.SeaLevelM,
|
||||
Classes: classes,
|
||||
})
|
||||
|
||||
// Pass 10 feeds pass 11 rather than standing alone: strata is hardness, and hardness is what the
|
||||
// droplets scale their cutting by, which is how a hard band ends up holding a shelf on a cut face.
|
||||
hard := detail.NewHardness(f, m.Source.Seed, m.Planet.NoisePeriodKm*1000,
|
||||
cfg.Strata.PeriodM, cfg.Strata.Contrast, classes)
|
||||
|
||||
// Pass 11.
|
||||
maps, _ := detail.RunParticle(h, land, detail.ParticleParams{
|
||||
Cfg: cfg.Particle, Seed: m.Source.Seed, Frame: f, SeaLevelM: m.SeaLevelM, Hardness: hard,
|
||||
Classes: classes,
|
||||
})
|
||||
|
||||
// Pass 12: the same mass-conserving weathering the coarse grid gets, at the cell size where scree and a
|
||||
// cliff face are actually resolved.
|
||||
fixed := make([]bool, len(land))
|
||||
for i := range land {
|
||||
fixed[i] = !land[i]
|
||||
}
|
||||
thermal.Apply(h.Data, h.W, h.H, h.CellM, thermal.TalusFromDegrees(cfg.Thermal.TalusDeg),
|
||||
cfg.Thermal.FinePasses, fixed, nil)
|
||||
|
||||
// The sea floor goes back *here*, before the shore is drawn, rather than on the way out. Pass 11b works
|
||||
// on both sides of the waterline - a foreshore is below it and a berm is above it - so a shore laid onto
|
||||
// water that is about to be overwritten would be half a shore.
|
||||
restoreSeaFloor(h, land, geo, floor, m, cfg.GeologyFactor)
|
||||
|
||||
// Pass 9b: the same texture as pass 9, under water, now that there is a sea bed to put it on. It runs
|
||||
// before the shore rather than after, so the beach the shore pass draws is smooth sand over it rather
|
||||
// than sand with noise on top.
|
||||
detail.RunSeabedNoise(h, detail.DetailNoiseParams{
|
||||
Cfg: cfg.Detail, Seed: m.Source.Seed, Frame: f, PeriodM: periodM, SeaLevelM: m.SeaLevelM,
|
||||
Classes: classes,
|
||||
})
|
||||
|
||||
// Pass 11b: the shore. It runs last of the detail passes because marine processes are the last thing to
|
||||
// act on a coast and they act faster than anything inland: a berm is rebuilt by every tide, while the
|
||||
// hillslope creep that pass 12 stands for takes the age of the cliff behind it. Running it before the
|
||||
// fine thermal would have that creep immediately relax the one face on the map that is meant to be
|
||||
// steeper than the angle of repose.
|
||||
var coastal *detail.CoastalStats
|
||||
if cfg.CoastDetail.Enabled && !opt.NoShore {
|
||||
st := detail.RunCoastal(h, land, detail.CoastalParams{
|
||||
Cfg: cfg.CoastDetail, Surf: cfg.Coast, Seed: m.Source.Seed, Frame: f, PeriodM: periodM,
|
||||
SeaLevelM: m.SeaLevelM, Exposure: cutExposure(g, t, opt, geo, h), Hardness: hard,
|
||||
})
|
||||
coastal = &st
|
||||
}
|
||||
|
||||
return finishTile(g, t, opt, prefix, h, land, maps, coastal)
|
||||
}
|
||||
|
||||
// cutExposure lifts the coastal pass's fetch field onto this tile's detail grid, or nil when the bake did not
|
||||
// carry one. Interpolated rather than nearest: it is a smooth field and a staircase in it would put a
|
||||
// staircase into the berm height along every beach.
|
||||
func cutExposure(g *tile.Grid, t tile.Tile, opt TileOptions, geo, h *field.Field) []float32 {
|
||||
if opt.Exposure == nil {
|
||||
return nil
|
||||
}
|
||||
cut, _, _ := g.Cut(t, opt.Exposure, 0)
|
||||
up := cut.UpsampleInt(opt.In.M.Pipeline.GeologyFactor)
|
||||
if len(up.Data) != len(h.Data) {
|
||||
return nil
|
||||
}
|
||||
return up.Data
|
||||
}
|
||||
|
||||
// restoreSeaFloor puts the water back after the land passes, which ran with the sea flattened to sea level.
|
||||
//
|
||||
// It used to be nearest neighbour, unconditionally, and the comment said why: the geology raster dropped from
|
||||
// the shore to the painted ocean depth in a single cell, and interpolating a five-hundred-metre step is
|
||||
// exactly what the flattening exists to avoid. The cost was a four-fold staircase over the whole sea floor,
|
||||
// which nobody could see while the shore was a cliff into five hundred metres of water.
|
||||
//
|
||||
// D-60 changed the input. There is a continental shelf now, and a surf-cut platform, and a beach, and between
|
||||
// them they carry the sea floor down from the waterline to the shelf break over kilometres rather than over
|
||||
// one cell. So the shallow water is interpolated - from the *unflattened* cut, which still holds the land
|
||||
// heights, so the surface runs across the waterline with no seam in it - and only the drop past the break is
|
||||
// still nearest. The two are blended over a depth band rather than switched between, because a hard switch
|
||||
// would put back a smaller version of the step it exists to avoid.
|
||||
func restoreSeaFloor(h *field.Field, land []bool, geo, floor *field.Field, m *manifest.Manifest, factor int) {
|
||||
breakM := m.ShelfBreakM()
|
||||
if breakM <= 0 {
|
||||
breakM = 30
|
||||
}
|
||||
// The clamp sits at the *far* end of the blend band rather than at the break, so that everywhere the blend
|
||||
// is still reading the interpolation, the interpolation is of the real sea floor. Clamped at the break
|
||||
// instead, the smooth half of the blend was a flat surface at break depth while the nearest half followed
|
||||
// the slope down, and the mixture lifted the floor by up to half the band - ten metres of invented shelf
|
||||
// in exactly the strip the blend exists to make invisible.
|
||||
const bandM = 40.0
|
||||
deepest := float32(m.SeaLevelM - (breakM + bandM))
|
||||
|
||||
shallow := floor.Clone()
|
||||
for i, v := range shallow.Data {
|
||||
if v < deepest {
|
||||
shallow.Data[i] = deepest
|
||||
}
|
||||
}
|
||||
smooth := shallow.UpsampleInt(factor)
|
||||
|
||||
// Not smoothed, and it is worth saying why not, because the first version was.
|
||||
//
|
||||
// The interpolated sea floor comes out of a hillshade covered in dotted contour lines, which look exactly
|
||||
// like an interpolation artefact and are not: measured, an eighty by hundred patch of open water takes
|
||||
// three distinct 8-bit shade values, 94 % of them the same one. It is the hillshade's own quantisation on
|
||||
// a surface that slopes at one in three hundred, it was there before and it is in the picture rather than
|
||||
// in the ground. A box blur over the floor was tried against it and changed the tile by a fifth of a
|
||||
// height quantum on average - its only real effect was to soften genuine one-cell steps in the geology,
|
||||
// which is not what it was for.
|
||||
for y := 0; y < h.H; y++ {
|
||||
sy := y / factor
|
||||
if sy >= geo.H {
|
||||
sy = geo.H - 1
|
||||
}
|
||||
for x := 0; x < h.W; x++ {
|
||||
i := y*h.W + x
|
||||
if land[i] {
|
||||
continue
|
||||
}
|
||||
sx := x / factor
|
||||
if sx >= geo.W {
|
||||
sx = geo.W - 1
|
||||
}
|
||||
near := float64(floor.Data[sy*geo.W+sx])
|
||||
depth := m.SeaLevelM - near
|
||||
t := (depth - breakM) / bandM
|
||||
if t <= 0 {
|
||||
h.Data[i] = smooth.Data[i]
|
||||
continue
|
||||
}
|
||||
if t >= 1 {
|
||||
h.Data[i] = float32(near)
|
||||
continue
|
||||
}
|
||||
w := noise.Smoothstep(t)
|
||||
h.Data[i] = float32((1-w)*float64(smooth.Data[i]) + w*near)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finishTile restores the sea floor, crops the margin away and writes everything out.
|
||||
func finishTile(g *tile.Grid, t tile.Tile, opt TileOptions, prefix string, h *field.Field, land []bool,
|
||||
maps *detail.Maps, coastal *detail.CoastalStats) (TileRecord, error) {
|
||||
|
||||
start := time.Now()
|
||||
_ = land
|
||||
m := opt.In.M
|
||||
cfg := m.Pipeline
|
||||
|
||||
// Pass 14: crop the margin away and write. Everything outside the interior was only ever there so the
|
||||
// passes above had somewhere to read from.
|
||||
ix, iy := g.MarginGeo*g.Factor, g.MarginGeo*g.Factor
|
||||
iw, ih := g.DetailW(t), g.DetailH(t)
|
||||
out := crop(h, ix, iy, iw, ih)
|
||||
|
||||
rec := TileRecord{
|
||||
IX: t.IX, IY: t.IY, File: t.Name(prefix) + ".png", W: iw, H: ih,
|
||||
OriginXM: g.OriginXM(t), OriginYM: g.OriginYM(t),
|
||||
}
|
||||
if coastal != nil && coastal.ShoreCells > 0 {
|
||||
rec.Coastal = coastal
|
||||
}
|
||||
|
||||
// The annotation layer, sampled onto this tile's interior. It is written before the height, because it is
|
||||
// the cheap one and a failure here should not leave a heightmap with no mask beside it.
|
||||
//
|
||||
// Nothing in the detail passes read it and nothing here consults it: it is the author's layer travelling
|
||||
// through to whatever builds the level. The values are mark indices, zero for nothing, and the key is in
|
||||
// overlay.json beside tiles.json.
|
||||
if ov := opt.In.OverlayRaster; ov != nil {
|
||||
marks := ov.SampleWorld(rec.OriginXM, rec.OriginYM, h.CellM, iw, ih, opt.In.OverlayScale())
|
||||
rec.OverlayFile = t.Name(prefix) + "_overlay.png"
|
||||
if err := overlay.WriteMask(filepath.Join(opt.Out, rec.OverlayFile), iw, ih, marks); err != nil {
|
||||
return rec, err
|
||||
}
|
||||
}
|
||||
lo, hi := out.MinMax()
|
||||
rec.MinM, rec.MaxM = float64(lo), float64(hi)
|
||||
rec.ClipFrac = m.ClipFraction(out.Data)
|
||||
|
||||
if err := field.WriteGray16(filepath.Join(opt.Out, rec.File), iw, ih,
|
||||
m.Encode(out.Data), png.DefaultCompression); err != nil {
|
||||
return rec, err
|
||||
}
|
||||
// A hillshade beside the heightmap, at full resolution. A 16-bit grey PNG of a hundred metres of relief
|
||||
// is a flat grey rectangle to look at, and the whole reason these passes exist is what they do to the
|
||||
// surface - which cannot be judged from a number.
|
||||
// Shaded with the water clamped at the shelf break rather than at sea level. Clamping at sea level was
|
||||
// right while the shore was a step into five hundred metres of water and there was nothing below the
|
||||
// waterline worth looking at; now there is a shore platform, a foreshore and a beach down there, and
|
||||
// they are most of what pass 11b does. The break is still clamped, because a continental slope in the
|
||||
// corner of a tile would otherwise set the whole hillshade's contrast.
|
||||
shade := out.Clone()
|
||||
shadeFloor := float32(m.SeaLevelM - m.ShelfBreakM())
|
||||
for i := range shade.Data {
|
||||
if shade.Data[i] < shadeFloor {
|
||||
shade.Data[i] = shadeFloor
|
||||
}
|
||||
}
|
||||
if err := field.WriteHillshade(filepath.Join(opt.Out, t.Name(prefix)+"_shade.png"), shade, iw, 1); err != nil {
|
||||
return rec, err
|
||||
}
|
||||
// A slice, not a map: map iteration order is randomised in Go and nothing in this generator is allowed
|
||||
// to depend on it (cross-cutting rule 12). Here it would only reorder two file writes, which is exactly
|
||||
// the kind of "it does not matter this time" that makes the rule worth keeping without exception.
|
||||
// The full-scale values are fixed constants, not percentiles of the tile.
|
||||
//
|
||||
// Field.ToUnit takes the 99th percentile of whatever it is given, which is exactly right for one map of
|
||||
// one world and exactly wrong here: it is a statistic of the tile's own extent, so two tiles would stretch
|
||||
// by different anchors and their shared valley would come out two different greys. That is the same
|
||||
// mistake the coastal pass's exposure made and had withdrawn, and the same rule - no pass computes a
|
||||
// statistic of the piece of the world it happens to be looking at.
|
||||
//
|
||||
// Flow is water-units accumulated and runs over decades, so it is log-scaled; wear and deposit are metres
|
||||
// and a metre of either is a great deal at a 2 m cell.
|
||||
flowFull := 40 * cfg.Particle.DropletsPerCell * float64(cfg.Particle.Lifetime)
|
||||
var derived []struct {
|
||||
name string
|
||||
data []float32
|
||||
full float64
|
||||
log bool
|
||||
}
|
||||
if maps != nil {
|
||||
derived = []struct {
|
||||
name string
|
||||
data []float32
|
||||
full float64
|
||||
log bool
|
||||
}{
|
||||
{"flow", maps.Flow, flowFull, true},
|
||||
{"wear", maps.Wear, 1.0, false},
|
||||
{"deposit", maps.Deposit, 1.0, false},
|
||||
}
|
||||
}
|
||||
for _, d := range derived {
|
||||
c := crop(&field.Field{W: h.W, H: h.H, CellM: h.CellM, Data: d.data}, ix, iy, iw, ih)
|
||||
if err := field.WriteGray8(filepath.Join(opt.Out, t.Name(prefix)+"_"+d.name+".png"), iw, ih,
|
||||
toBytes(normalise(c.Data, d.full, d.log)), png.BestSpeed); err != nil {
|
||||
return rec, err
|
||||
}
|
||||
}
|
||||
rec.Seconds = time.Since(start).Seconds()
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// upsampleClass takes the class raster up by an integer factor, nearest. A class index is a name and not a
|
||||
// quantity: interpolating one would invent a class that is neither of its neighbours.
|
||||
func crop(f *field.Field, x0, y0, w, h int) *field.Field {
|
||||
out := field.New(w, h, f.CellM)
|
||||
for y := 0; y < h; y++ {
|
||||
copy(out.Data[y*w:(y+1)*w], f.Data[(y0+y)*f.W+x0:(y0+y)*f.W+x0+w])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalise maps values onto 0..1 against a fixed full-scale, never a percentile of the data. See the note
|
||||
// where the constants are chosen.
|
||||
func normalise(data []float32, full float64, logScale bool) []float32 {
|
||||
if full <= 0 {
|
||||
full = 1
|
||||
}
|
||||
top := full
|
||||
if logScale {
|
||||
top = math.Log1p(full)
|
||||
}
|
||||
out := make([]float32, len(data))
|
||||
for i, v := range data {
|
||||
x := float64(v)
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
if logScale {
|
||||
x = math.Log1p(x)
|
||||
}
|
||||
out[i] = float32(x / top)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toBytes(data []float32) []uint8 {
|
||||
out := make([]uint8, len(data))
|
||||
for i, v := range data {
|
||||
x := v
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x > 1 {
|
||||
x = 1
|
||||
}
|
||||
out[i] = uint8(x*255 + 0.5)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// blendedClasses turns the painted class raster into the four numbers the detail passes read, per cell, with
|
||||
// the boundaries between classes faded rather than stepped.
|
||||
//
|
||||
// **Why the fade.** A class is a name and a name is never interpolated - the mask that travels to whatever
|
||||
// builds the level is still nearest neighbour, and it has to be. But the numbers a class stands for are
|
||||
// quantities. Kept as a lookup on the class index, a desert meeting a lowland went from seven metres of dune
|
||||
// amplitude to two, and from a fifth of the running water to all of it, in the width of one cell, along a
|
||||
// line somebody drew with a mouse. It read as what it was: a boundary in a picture rather than a change in
|
||||
// the ground. Faded over `class_blend_m`, the same boundary is a few hundred metres of one becoming the
|
||||
// other, which is what the edge of a sand sea looks like from inside it.
|
||||
//
|
||||
// **Why at the geology grid.** The class raster is a geology-resolution field, so blurring it there costs a
|
||||
// four-hundredth of blurring at detail resolution, and the upsample afterwards is the same exact-factor
|
||||
// Catmull-Rom every other field gets - so the result is smoother than a blur at detail resolution would have
|
||||
// been, not coarser.
|
||||
//
|
||||
// **Why the radius is clamped.** A blur reads outside the cell it writes, and a tile only has its margin to
|
||||
// read from. Two passes of a box blur of radius r reach 2r, so r is capped at half the margin and the run
|
||||
// says so once when the manifest asks for more. Past that cap a tile would be blending against its own cut
|
||||
// edge and two tiles would disagree about the same ground, which is the one thing the tiling may not do.
|
||||
func blendedClasses(g *tile.Grid, t tile.Tile, opt TileOptions, geo, h *field.Field) *detail.Classes {
|
||||
in := opt.In
|
||||
cfg := in.M.Pipeline
|
||||
if !in.Legend.Overrides() {
|
||||
return nil
|
||||
}
|
||||
tbl := in.Legend.DetailTables(cfg.Particle.DropletsPerCell,
|
||||
cfg.Detail.AmplitudeM.Lo(), cfg.Detail.AmplitudeM.Hi(), cfg.Strata.Contrast)
|
||||
cls := g.CutClass(t, in.Map.Class, in.P.W, in.P.PadY)
|
||||
|
||||
radius := int(cfg.Detail.ClassBlendM/in.P.CellM + 0.5)
|
||||
if max := g.MarginGeo / 2; radius > max {
|
||||
radius = max
|
||||
}
|
||||
lift := func(table []float64) []float32 {
|
||||
f := field.New(geo.W, geo.H, geo.CellM)
|
||||
for i, k := range cls {
|
||||
f.Data[i] = float32(table[k])
|
||||
}
|
||||
if radius > 0 {
|
||||
field.BoxSmooth(f.Data, f.W, f.H, radius, 2)
|
||||
}
|
||||
return f.UpsampleInt(cfg.GeologyFactor).Data
|
||||
}
|
||||
return &detail.Classes{
|
||||
Droplets: lift(tbl.Droplets), AmpLo: lift(tbl.AmpLo),
|
||||
AmpHi: lift(tbl.AmpHi), Contrast: lift(tbl.Contrast),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package plates
|
||||
|
||||
import "math"
|
||||
|
||||
// Belt is how faulted a margin's surroundings are: the deformation zone around a boundary, and how densely
|
||||
// it is broken.
|
||||
//
|
||||
// It lives beside the plates rather than beside the faults because it describes a *boundary*, not a fault.
|
||||
// How wide the ground is that a margin deforms is a property of what that margin is doing - a continental
|
||||
// collision takes up its convergence across a belt a thousand kilometres wide and a mid-ocean ridge across an
|
||||
// axis a few tens wide - and the traces are a consequence of that width, not the other way round. What
|
||||
// internal/uplift's belt_faults.go does with these numbers, and the fault map they were read off, is
|
||||
// documented there.
|
||||
type Belt struct {
|
||||
// ZoneKm is the deformation half-width of a *collision* margin closing at ReferenceCmYr, in kilometres.
|
||||
// Every other kind of margin is a fraction of it, and every margin scales with its own rate.
|
||||
ZoneKm float64 `json:"zone_km"`
|
||||
|
||||
// ReferenceCmYr is the rate ZoneKm is quoted at. Earth's big collisions run 2 to 5 cm/yr.
|
||||
ReferenceCmYr float64 `json:"reference_cm_yr"`
|
||||
|
||||
// Per1000Km2 is the trace density over the *zone*, not over the planet: a belt is as faulted as a belt is
|
||||
// wherever it happens to run, and the ground away from one is not lightly faulted, it is unfaulted.
|
||||
Per1000Km2 float64 `json:"per_1000km2"`
|
||||
|
||||
// ThrowM is the total displacement over the whole run, low to high, before the closing rate scales it -
|
||||
// the height of the scarp the fault would build if nothing eroded it.
|
||||
ThrowM [2]float64 `json:"throw_m"`
|
||||
|
||||
// LengthKm is how long a trace is, low to high, before the local zone width scales it. A wide belt
|
||||
// carries long faults and a narrow one cannot.
|
||||
LengthKm [2]float64 `json:"length_km"`
|
||||
|
||||
// StrikeSpreadDeg is how far a trace may wander off the belt's local tangent. Small on purpose: a swarm
|
||||
// being sub-parallel is the thing that makes it read as a swarm.
|
||||
//
|
||||
// ConjugateFraction is the share of traces drawn on the second, crossing direction, and ConjugateDeg is
|
||||
// the angle between the two sets. One direction alone reads as corduroy.
|
||||
//
|
||||
// The first two are pointers for the reason Config.SpinFraction is: JSON cannot tell an absent number
|
||||
// from a zero one, and both of these have a real meaning at zero - perfectly parallel traces, and no
|
||||
// second set. Read as the same thing, a block that simply did not mention them silently turned them off,
|
||||
// which is how the first painted planet came out with no conjugate set at all. Absent takes the default;
|
||||
// an explicit 0 means none.
|
||||
StrikeSpreadDeg *float64 `json:"strike_spread_deg"`
|
||||
ConjugateFraction *float64 `json:"conjugate_fraction"`
|
||||
ConjugateDeg float64 `json:"conjugate_deg"`
|
||||
}
|
||||
|
||||
// DefaultStrikeSpreadDeg and DefaultConjugateFraction are what a belt that does not mention them gets.
|
||||
const (
|
||||
DefaultStrikeSpreadDeg = 11.0
|
||||
DefaultConjugateFraction = 0.22
|
||||
)
|
||||
|
||||
// Spread is the configured strike spread, or the default when the block said nothing.
|
||||
func (b Belt) Spread() float64 {
|
||||
if b.StrikeSpreadDeg == nil {
|
||||
return DefaultStrikeSpreadDeg
|
||||
}
|
||||
return math.Max(0, *b.StrikeSpreadDeg)
|
||||
}
|
||||
|
||||
// Conjugate is the configured share of crossing traces, or the default when the block said nothing.
|
||||
func (b Belt) Conjugate() float64 {
|
||||
if b.ConjugateFraction == nil {
|
||||
return DefaultConjugateFraction
|
||||
}
|
||||
return math.Min(1, math.Max(0, *b.ConjugateFraction))
|
||||
}
|
||||
|
||||
// DefaultBelt is what a planet that asks for belt faults but says nothing else gets.
|
||||
func DefaultBelt() Belt {
|
||||
return Belt{
|
||||
ZoneKm: 6,
|
||||
ReferenceCmYr: 4,
|
||||
Per1000Km2: 90,
|
||||
ThrowM: [2]float64{80, 420},
|
||||
LengthKm: [2]float64{4, 16},
|
||||
ConjugateDeg: 32,
|
||||
// StrikeSpreadDeg and ConjugateFraction stay nil: their defaults live in Spread and Conjugate, so
|
||||
// that an explicit zero can mean none.
|
||||
}
|
||||
}
|
||||
|
||||
// Wanted reports whether this asks for anything. A zero Belt is a planet whose margins are not faulted, which
|
||||
// is what every painted planet had before this existed.
|
||||
func (b Belt) Wanted() bool {
|
||||
return b.ZoneKm > 0 && b.Per1000Km2 > 0 && b.LengthKm[1] > 0 && b.ThrowM[1] > 0
|
||||
}
|
||||
|
||||
// WithDefaults fills in the fields that have a sensible value when left out. ZoneKm, Per1000Km2, ThrowM and
|
||||
// LengthKm are deliberately not among them: those four are the feature, and defaulting them would turn
|
||||
// leaving the block out into switching the feature on.
|
||||
func (b Belt) WithDefaults() Belt {
|
||||
d := DefaultBelt()
|
||||
if b.ReferenceCmYr <= 0 {
|
||||
b.ReferenceCmYr = d.ReferenceCmYr
|
||||
}
|
||||
if b.ConjugateDeg <= 0 {
|
||||
b.ConjugateDeg = d.ConjugateDeg
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package plates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// The painted tectonic layer: a third painting beside the template and the overlay, where a colour is a
|
||||
// plate and the legend says how that plate is moving.
|
||||
//
|
||||
// **You paint the cause, not the conclusion.** A colour does not say "there is a collision here" - it says
|
||||
// "this piece of lithosphere is moving north-east at three centimetres a year", and where two of them meet,
|
||||
// what happens is worked out from the two motions and the shape of the contact. That is the whole reason to
|
||||
// paint plates rather than to paint boundary lines: a drawn line has to be told what it is, while a contact
|
||||
// between two painted plates *becomes* a collision, a transform or a rift by itself, and changes character
|
||||
// along its own length wherever it turns relative to the motion. The Alpide belt is a collision at the
|
||||
// Himalaya and a strike-slip fault through Anatolia for exactly that reason, and no author should have to
|
||||
// hand-annotate it.
|
||||
//
|
||||
// It also means the tracer needs no new code. Build's weighted Voronoi and this both produce the same thing -
|
||||
// a plate id at every cell of the tectonic grid - and everything downstream reads that.
|
||||
//
|
||||
// **Registration is by extent, not by pixel.** The layer is stretched over the painted map's own rectangle,
|
||||
// so it does not have to be the template's size. Paint plates at a quarter of it if you like: the tectonic
|
||||
// grid is a few hundred metres a cell and a plate is tens of kilometres across, so detail below that is
|
||||
// detail nothing will ever read. The polar pad has no painting under it and takes the nearest painted row,
|
||||
// which is right - a plate does not stop at the top of the author's canvas.
|
||||
|
||||
// PaintLegend is what the colours on a tectonic layer mean.
|
||||
type PaintLegend struct {
|
||||
// Comment is the legend's own note to whoever opens it next. Propose writes the conventions into it,
|
||||
// because "which way does heading 90 point" is the first thing an author needs and the last thing they
|
||||
// should have to find in a source file.
|
||||
Comment string `json:"_comment,omitempty"`
|
||||
|
||||
// Image is the layer's file name, resolved beside the legend. The manifest may name one instead.
|
||||
Image string `json:"image"`
|
||||
|
||||
// WarnDistance is how far, in RGB, a sampled pixel may sit from the nearest plate before the run says so.
|
||||
// It exists for the same reason the class template's does: a JPEG bleeds several units of each channel
|
||||
// across a painted edge, and a silent mismatch is a plate boundary in the wrong place.
|
||||
WarnDistance float64 `json:"warn_distance"`
|
||||
|
||||
Plates []PaintPlate `json:"plates"`
|
||||
}
|
||||
|
||||
// PaintPlate is one painted plate: a colour, and how that piece of lithosphere is moving.
|
||||
type PaintPlate struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
// RGB is the colour on the layer. Every sampled pixel becomes the *nearest* plate in RGB, because on a
|
||||
// tectonic layer every pixel has to be some plate - the same rule the class template uses, and the
|
||||
// opposite of the overlay's, where most of the image is deliberately nothing.
|
||||
RGB [3]int `json:"rgb"`
|
||||
|
||||
// SpeedCmYr and HeadingDeg are the plate's drift. The heading is a compass bearing over the map: 0 points
|
||||
// at the top of the image, 90 to the right, 180 to the bottom. Earth's plates run 1 to 10 cm/yr, and what
|
||||
// matters at a margin is the *difference* between two of these, so two plates both drifting east at 4 are
|
||||
// a boundary doing nothing at all.
|
||||
SpeedCmYr float64 `json:"speed_cm_yr"`
|
||||
HeadingDeg float64 `json:"heading_deg"`
|
||||
|
||||
// SpinDegMyr turns the plate about its own centre, in degrees per million years, positive clockwise on
|
||||
// the map.
|
||||
//
|
||||
// It is worth setting on at least one plate. A planet of plates that only drift has margins that are the
|
||||
// same all the way along, because the relative velocity is then one constant vector and the only thing
|
||||
// that varies is where the contact happens to point. A little spin is what makes one end of a margin
|
||||
// collide while the other slides - which is the Anatolia case, and the most useful thing a tectonic map
|
||||
// can give a fault set.
|
||||
SpinDegMyr float64 `json:"spin_deg_myr"`
|
||||
|
||||
// Continental overrides what the painting says. Left out - which is the usual case - a plate is
|
||||
// continental when enough of its painted area is land, so the template decides and the two paintings
|
||||
// cannot contradict each other. Set it when they should: an oceanic plate carrying a chain of islands, or
|
||||
// a continental fragment currently underwater.
|
||||
Continental *bool `json:"continental,omitempty"`
|
||||
}
|
||||
|
||||
// rgbOneLine finds an indented colour triple so MarshalLegend can put it back on one line.
|
||||
var rgbOneLine = regexp.MustCompile(`"rgb": \[\s*(\d+),\s*(\d+),\s*(\d+)\s*\]`)
|
||||
|
||||
// MarshalLegend writes a legend as JSON somebody will want to edit.
|
||||
//
|
||||
// json.MarshalIndent puts every colour on five lines, because Indent reformats every array whatever a custom
|
||||
// marshaller does, and a seven-plate legend then runs to ninety lines of mostly punctuation. Putting the
|
||||
// triples back on one line each is cosmetic and it is worth the ten lines: this file is meant to be opened
|
||||
// and changed by hand, beside the painting, and a legend nobody can read at a glance is a legend nobody
|
||||
// keeps in step with the picture.
|
||||
func MarshalLegend(lg *PaintLegend) ([]byte, error) {
|
||||
data, err := json.MarshalIndent(lg, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(rgbOneLine.ReplaceAll(data, []byte(`"rgb": [$1, $2, $3]`)), '\n'), nil
|
||||
}
|
||||
|
||||
// LoadPaintLegend reads a tectonic layer's legend.
|
||||
func LoadPaintLegend(path string) (*PaintLegend, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lg PaintLegend
|
||||
if err := json.Unmarshal(data, &lg); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if err := lg.validate(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lg, nil
|
||||
}
|
||||
|
||||
func (l *PaintLegend) validate(path string) error {
|
||||
if len(l.Plates) < 2 {
|
||||
return fmt.Errorf("%s: %d plate(s); a planet in one plate has no boundaries", path, len(l.Plates))
|
||||
}
|
||||
if l.WarnDistance <= 0 {
|
||||
l.WarnDistance = 60
|
||||
}
|
||||
seen := map[[3]int]string{}
|
||||
for i := range l.Plates {
|
||||
p := &l.Plates[i]
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("%s: plate %d has no name", path, i)
|
||||
}
|
||||
for c := range 3 {
|
||||
if p.RGB[c] < 0 || p.RGB[c] > 255 {
|
||||
return fmt.Errorf("%s: plate %q has rgb %v", path, p.Name, p.RGB)
|
||||
}
|
||||
}
|
||||
if prev, dup := seen[p.RGB]; dup {
|
||||
return fmt.Errorf("%s: plates %q and %q are both rgb %v; a colour is one plate",
|
||||
path, prev, p.Name, p.RGB)
|
||||
}
|
||||
seen[p.RGB] = p.Name
|
||||
if p.SpeedCmYr < 0 {
|
||||
return fmt.Errorf("%s: plate %q moves at %v cm/yr; speed is a magnitude and the heading is "+
|
||||
"where it points", path, p.Name, p.SpeedCmYr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PaintMatch is how well the painting matched the legend, reported the way the class template's match is: a
|
||||
// layer whose colours have drifted is a tectonic model quietly built on the wrong plates.
|
||||
type PaintMatch struct {
|
||||
Cells int `json:"cells"`
|
||||
Far int `json:"far"`
|
||||
MaxDistance float64 `json:"max_distance"`
|
||||
}
|
||||
|
||||
// FromPainting builds a tectonic model from a painted layer instead of from a seed.
|
||||
//
|
||||
// px is the layer decoded to RGB triples, pw by ph. Decoding happens in the caller so that this package keeps
|
||||
// knowing nothing about files or image formats - the same reason land is a callback.
|
||||
func FromPainting(p world.Planet, cfg Config, lg *PaintLegend, px []uint8, pw, ph int,
|
||||
land func(xM, yM float64) bool) (*Model, PaintMatch, error) {
|
||||
|
||||
var match PaintMatch
|
||||
if lg == nil || len(lg.Plates) < 2 {
|
||||
return nil, match, fmt.Errorf("a tectonic layer needs at least two plates")
|
||||
}
|
||||
if pw <= 0 || ph <= 0 || len(px) < pw*ph*3 {
|
||||
return nil, match, fmt.Errorf("the tectonic layer is %dx%d with %d bytes", pw, ph, len(px))
|
||||
}
|
||||
|
||||
cfg = cfg.withDefaults()
|
||||
circ := p.CircumferenceM()
|
||||
gw := int(circ/cfg.ResolutionM + 0.5)
|
||||
if gw < 8 {
|
||||
gw = 8
|
||||
}
|
||||
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, Cell: make([]int16, gw*gh)}
|
||||
m.Plates = make([]Plate, len(lg.Plates))
|
||||
for i := range m.Plates {
|
||||
m.Plates[i] = Plate{ID: i, Weight: 1}
|
||||
}
|
||||
|
||||
heightM := p.HeightM()
|
||||
for gy := range gh {
|
||||
yM := m.GridYM(gy)
|
||||
// The painted map covers 0..heightM; the polar pad above and below it takes the nearest painted row.
|
||||
v := clamp01(yM / heightM)
|
||||
py := int(v * float64(ph-1))
|
||||
row := gy * gw
|
||||
for gx := range gw {
|
||||
xM := m.GridXM(gx)
|
||||
pxi := int(xM / circ * float64(pw))
|
||||
if pxi >= pw {
|
||||
pxi = pw - 1
|
||||
}
|
||||
o := (py*pw + pxi) * 3
|
||||
id, dist := nearestPlate(lg.Plates, px[o], px[o+1], px[o+2])
|
||||
m.Cell[row+gx] = int16(id)
|
||||
|
||||
match.Cells++
|
||||
if dist > lg.WarnDistance {
|
||||
match.Far++
|
||||
}
|
||||
if dist > match.MaxDistance {
|
||||
match.MaxDistance = dist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range m.Plates {
|
||||
pl := &m.Plates[i]
|
||||
src := lg.Plates[i]
|
||||
// Compass bearing over the map: 0 points at the top of the image, which is -Y, and 90 to the right.
|
||||
speed := src.SpeedCmYr / 100
|
||||
bearing := src.HeadingDeg * math.Pi / 180
|
||||
pl.TransXM = speed * math.Sin(bearing)
|
||||
pl.TransYM = -speed * math.Cos(bearing)
|
||||
// Positive spin is clockwise on the map: with Y running down the image, v = T + omega x r sends the
|
||||
// point east of the centre southwards. The centre itself comes from measure, below.
|
||||
pl.OmegaRadYr = src.SpinDegMyr * math.Pi / 180 / 1e6
|
||||
}
|
||||
|
||||
// measure fills in the area, the land fraction and the centre of each plate - and the centre is the pole
|
||||
// every one of them turns about, so nothing has a usable velocity field until this has run.
|
||||
m.measure(land)
|
||||
for i := range m.Plates {
|
||||
// A painted plate has no Voronoi site. Its centre of area is the only position it has, and it is what
|
||||
// the map and the reports point at.
|
||||
m.Plates[i].SiteXM = m.Plates[i].CentroidXM
|
||||
m.Plates[i].SiteYM = m.Plates[i].CentroidYM
|
||||
}
|
||||
// The painting has the last word where it asks for one, after measure has read the template's land.
|
||||
for i := range m.Plates {
|
||||
if c := lg.Plates[i].Continental; c != nil {
|
||||
m.Plates[i].Continental = *c
|
||||
}
|
||||
}
|
||||
|
||||
m.Boundaries = m.buildBoundaries()
|
||||
return m, match, nil
|
||||
}
|
||||
|
||||
// nearestPlate is the legend entry closest to a colour, and how far away it was.
|
||||
//
|
||||
// Nearest rather than exact, and unlike the overlay there is no "no plate" answer: every pixel of a tectonic
|
||||
// layer is some piece of lithosphere, so a colour that matches nothing is a painting mistake to report rather
|
||||
// than a hole to leave. WarnDistance is what reports it.
|
||||
func nearestPlate(ps []PaintPlate, r, g, b uint8) (id int, dist float64) {
|
||||
best, bestID := math.Inf(1), 0
|
||||
for i := range ps {
|
||||
dr := float64(int(r) - ps[i].RGB[0])
|
||||
dg := float64(int(g) - ps[i].RGB[1])
|
||||
db := float64(int(b) - ps[i].RGB[2])
|
||||
if d := dr*dr + dg*dg + db*db; d < best {
|
||||
best, bestID = d, i
|
||||
}
|
||||
}
|
||||
return bestID, math.Sqrt(best)
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Propose turns a generated model into a painting and a legend to start from.
|
||||
//
|
||||
// An author should not face a blank canvas for this. Seven plates with plausible motions is a minute's work
|
||||
// for the Voronoi and an afternoon's by hand, and what an author actually wants to do is move two of them and
|
||||
// change a heading - which is editing, not authoring from nothing.
|
||||
//
|
||||
// The returned pixels are the layer at the given width, and the legend has one entry per plate carrying the
|
||||
// motion the generator drew. Writing them out is the caller's job.
|
||||
func (m *Model) Propose(width int) (px []uint8, w, h int, lg *PaintLegend) {
|
||||
if width < 64 {
|
||||
width = 64
|
||||
}
|
||||
h = int(float64(width) * m.P.HeightM() / m.P.CircumferenceM())
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
w = width
|
||||
|
||||
lg = &PaintLegend{
|
||||
Comment: "A painted tectonic layer: one colour per plate, and how that plate is moving. " +
|
||||
"heading_deg is a compass bearing over the map - 0 points at the top of the image, 90 to the " +
|
||||
"right, 180 to the bottom. speed_cm_yr is drift; what happens at a margin is the difference " +
|
||||
"between the two plates either side of it, so two plates drifting the same way are a boundary " +
|
||||
"doing nothing. spin_deg_myr turns a plate about its own centre, positive clockwise, and it is " +
|
||||
"worth setting on at least one: without it every margin is the same all the way along, and " +
|
||||
"with it one end collides while the other slides. Paint the plates, not the mountains - where " +
|
||||
"two of these meet, the collision, the belt and its faults are worked out from the motions. " +
|
||||
"Repaint the blobs freely; only the colours have to keep matching this file.",
|
||||
WarnDistance: 60,
|
||||
Plates: make([]PaintPlate, len(m.Plates)),
|
||||
}
|
||||
colours := make([][3]uint8, len(m.Plates))
|
||||
for i := range m.Plates {
|
||||
pl := &m.Plates[i]
|
||||
// Hues walked by the golden ratio, so that neighbouring ids are not neighbouring colours and an
|
||||
// author can tell two touching plates apart at a glance.
|
||||
c := hsvBytes(math.Mod(float64(i)*0.61803398875, 1)*360, 0.62, 0.86)
|
||||
colours[i] = c
|
||||
|
||||
speed := math.Hypot(pl.TransXM, pl.TransYM) * 100 // m/yr to cm/yr
|
||||
// Back to a compass bearing: 0 at the top of the image, 90 to the right.
|
||||
bearing := math.Atan2(pl.TransXM, -pl.TransYM) * 180 / math.Pi
|
||||
if bearing < 0 {
|
||||
bearing += 360
|
||||
}
|
||||
lg.Plates[i] = PaintPlate{
|
||||
Name: fmt.Sprintf("plate_%d", i),
|
||||
RGB: [3]int{int(c[0]), int(c[1]), int(c[2])},
|
||||
SpeedCmYr: math.Round(speed*10) / 10,
|
||||
HeadingDeg: math.Round(bearing),
|
||||
SpinDegMyr: math.Round(pl.OmegaRadYr*180/math.Pi*1e6*100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
px = make([]uint8, w*h*3)
|
||||
for y := range h {
|
||||
yM := m.P.HeightM() * (float64(y) + 0.5) / float64(h)
|
||||
for x := range w {
|
||||
xM := m.P.CircumferenceM() * (float64(x) + 0.5) / float64(w)
|
||||
c := colours[m.PlateAt(xM, yM)]
|
||||
o := (y*w + x) * 3
|
||||
px[o], px[o+1], px[o+2] = c[0], c[1], c[2]
|
||||
}
|
||||
}
|
||||
return px, w, h, lg
|
||||
}
|
||||
|
||||
// hsvBytes is a hue in degrees, saturation and value in 0..1, as an RGB triple.
|
||||
func hsvBytes(hue, sat, val float64) [3]uint8 {
|
||||
hue = math.Mod(math.Mod(hue, 360)+360, 360) / 60
|
||||
i := math.Floor(hue)
|
||||
f := hue - i
|
||||
p := val * (1 - sat)
|
||||
q := val * (1 - sat*f)
|
||||
t := val * (1 - sat*(1-f))
|
||||
var r, g, b float64
|
||||
switch int(i) % 6 {
|
||||
case 0:
|
||||
r, g, b = val, t, p
|
||||
case 1:
|
||||
r, g, b = q, val, p
|
||||
case 2:
|
||||
r, g, b = p, val, t
|
||||
case 3:
|
||||
r, g, b = p, q, val
|
||||
case 4:
|
||||
r, g, b = t, p, val
|
||||
default:
|
||||
r, g, b = val, p, q
|
||||
}
|
||||
return [3]uint8{byte(r*255 + 0.5), byte(g*255 + 0.5), byte(b*255 + 0.5)}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package plates
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A painted layer and its legend, built by hand: two plates split at a quarter and three quarters of the way
|
||||
// round, driven into each other along X with no spin.
|
||||
func paintedStripes(w, h int) ([]uint8, *PaintLegend) {
|
||||
lg := &PaintLegend{
|
||||
WarnDistance: 60,
|
||||
Plates: []PaintPlate{
|
||||
{Name: "west", RGB: [3]int{200, 60, 60}, SpeedCmYr: 2, HeadingDeg: 90}, // due east
|
||||
{Name: "east", RGB: [3]int{60, 60, 200}, SpeedCmYr: 2, HeadingDeg: 270}, // due west
|
||||
},
|
||||
}
|
||||
px := make([]uint8, w*h*3)
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
id := 0
|
||||
if x >= w/4 && x < 3*w/4 {
|
||||
id = 1
|
||||
}
|
||||
o := (y*w + x) * 3
|
||||
c := lg.Plates[id].RGB
|
||||
px[o], px[o+1], px[o+2] = uint8(c[0]), uint8(c[1]), uint8(c[2])
|
||||
}
|
||||
}
|
||||
return px, lg
|
||||
}
|
||||
|
||||
func TestAPaintedLayerBecomesAPlanet(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
px, lg := paintedStripes(400, 200)
|
||||
|
||||
m, match, err := FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
|
||||
if err != nil {
|
||||
t.Fatalf("from painting: %v", err)
|
||||
}
|
||||
if match.Far != 0 {
|
||||
t.Errorf("%d of %d sampled cells did not match a plate colour", match.Far, match.Cells)
|
||||
}
|
||||
if len(m.Plates) != 2 {
|
||||
t.Fatalf("%d plates from a two-colour legend", len(m.Plates))
|
||||
}
|
||||
if len(m.Boundaries) != 2 {
|
||||
t.Fatalf("%d boundaries; two stripes on a cylinder make two contacts", len(m.Boundaries))
|
||||
}
|
||||
|
||||
// The same invariant the generated path has: with a pure translation one margin closes and the other
|
||||
// opens, by the same amount. Two plates at 2 cm/yr closing head-on give 4 cm/yr.
|
||||
means := sortedMeans(m.Boundaries)
|
||||
if means[0] >= 0 || means[1] <= 0 {
|
||||
t.Fatalf("closing rates %.4g and %.4g; one of each is the only arrangement possible", means[0], means[1])
|
||||
}
|
||||
if got := math.Abs(means[1]); math.Abs(got-0.04) > 1e-3 {
|
||||
t.Errorf("painted plates at 2 cm/yr each close at %.4g m/yr, want 0.04", got)
|
||||
}
|
||||
// Both painted as land, so both are continental and the closing margin is a collision.
|
||||
if got, want := kinds(m.Boundaries), []string{"collision", "rift"}; !sameStrings(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func allLandAt(xM, yM float64) bool { return true }
|
||||
|
||||
func TestAHeadingIsACompassBearing(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
px, lg := paintedStripes(400, 200)
|
||||
// 0 points at the top of the image, which is -Y; 90 to the right, which is +X.
|
||||
lg.Plates[0].HeadingDeg = 0
|
||||
lg.Plates[1].HeadingDeg = 90
|
||||
|
||||
m, _, err := FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
|
||||
if err != nil {
|
||||
t.Fatalf("from painting: %v", err)
|
||||
}
|
||||
north, east := m.Plates[0], m.Plates[1]
|
||||
if math.Abs(north.TransXM) > 1e-9 || north.TransYM >= 0 {
|
||||
t.Errorf("heading 0 gives (%.4g, %.4g); it should point at the top of the map",
|
||||
north.TransXM, north.TransYM)
|
||||
}
|
||||
if math.Abs(east.TransYM) > 1e-9 || east.TransXM <= 0 {
|
||||
t.Errorf("heading 90 gives (%.4g, %.4g); it should point to the right of the map",
|
||||
east.TransXM, east.TransYM)
|
||||
}
|
||||
if got := math.Hypot(east.TransXM, east.TransYM); math.Abs(got-0.02) > 1e-9 {
|
||||
t.Errorf("2 cm/yr came out as %.4g m/yr", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePaintingCanOverruleTheLandMask(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
px, lg := paintedStripes(400, 200)
|
||||
|
||||
// Every cell is land, so both plates are continental and the closing margin is a collision.
|
||||
m, _, err := FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
|
||||
if err != nil {
|
||||
t.Fatalf("from painting: %v", err)
|
||||
}
|
||||
if !m.Plates[0].Continental || !m.Plates[1].Continental {
|
||||
t.Fatal("a planet of land has an oceanic plate on it")
|
||||
}
|
||||
|
||||
// The legend says otherwise about one of them, and a legend that bothers to say so wins.
|
||||
oceanic := false
|
||||
lg.Plates[1].Continental = &oceanic
|
||||
m, _, err = FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
|
||||
if err != nil {
|
||||
t.Fatalf("from painting: %v", err)
|
||||
}
|
||||
if m.Plates[1].Continental {
|
||||
t.Error("the legend called plate 1 oceanic and the land mask overruled it")
|
||||
}
|
||||
// And the consequence is the point of the override: the same margin is now a subduction zone.
|
||||
if got, want := kinds(m.Boundaries), []string{"ridge", "subduction"}; !sameStrings(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACentroidIsMeasuredTheShortWayRound(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
const w, h = 400, 200
|
||||
lg := &PaintLegend{
|
||||
WarnDistance: 60,
|
||||
Plates: []PaintPlate{
|
||||
{Name: "seam", RGB: [3]int{200, 60, 60}, SpeedCmYr: 2, HeadingDeg: 90},
|
||||
{Name: "rest", RGB: [3]int{60, 60, 200}, SpeedCmYr: 2, HeadingDeg: 270},
|
||||
},
|
||||
}
|
||||
// Plate 0 is painted across the meridian: the left eighth and the right eighth of the image. Its centre
|
||||
// is the seam, and an arithmetic mean of those columns would put it on the far side of the planet - and
|
||||
// with it the pole it spins about.
|
||||
px := make([]uint8, w*h*3)
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
id := 1
|
||||
if x < w/8 || x >= 7*w/8 {
|
||||
id = 0
|
||||
}
|
||||
o := (y*w + x) * 3
|
||||
c := lg.Plates[id].RGB
|
||||
px[o], px[o+1], px[o+2] = uint8(c[0]), uint8(c[1]), uint8(c[2])
|
||||
}
|
||||
}
|
||||
|
||||
m, _, err := FromPainting(p, Default(), lg, px, w, h, allLandAt)
|
||||
if err != nil {
|
||||
t.Fatalf("from painting: %v", err)
|
||||
}
|
||||
circ := p.CircumferenceM()
|
||||
got := m.Plates[0].SiteXM
|
||||
// Near the meridian, measured the short way round: either just above 0 or just below the circumference.
|
||||
if d := math.Abs(wrapDelta(got, circ)); d > circ/16 {
|
||||
t.Errorf("the seam-straddling plate's centre is at %.0f m of %.0f; it should be near the meridian, "+
|
||||
"and the arithmetic mean would have put it near %.0f", got, circ, circ/2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProposalReadsBackAsTheSamePlanet(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
cfg := Default()
|
||||
cfg.Count = 6
|
||||
land := func(xM, yM float64) bool { return yM > 4000 && yM < 14000 }
|
||||
|
||||
made, err := Build(p, 3630, cfg, land)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
// The round trip is what makes Propose worth having: what it writes has to be a layer that comes back as
|
||||
// the planet it was written from, or an author's first edit starts from something that was never true.
|
||||
px, w, h, lg := made.Propose(1600)
|
||||
read, match, err := FromPainting(p, cfg, lg, px, w, h, land)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if match.Far != 0 {
|
||||
t.Errorf("%d of %d cells of its own proposal did not match its own legend", match.Far, match.Cells)
|
||||
}
|
||||
if len(read.Plates) != len(made.Plates) {
|
||||
t.Fatalf("%d plates written, %d read back", len(made.Plates), len(read.Plates))
|
||||
}
|
||||
|
||||
// The motions survive the trip through the legend's cm/yr and degrees. Rounded when written - a tenth of
|
||||
// a cm/yr and a whole degree - so the tolerance is the rounding, not a fudge.
|
||||
for i := range made.Plates {
|
||||
a, b := made.Plates[i], read.Plates[i]
|
||||
if d := math.Hypot(a.TransXM-b.TransXM, a.TransYM-b.TransYM); d > 0.0006 {
|
||||
t.Errorf("plate %d drifts %.5g m/yr differently after the round trip", i, d)
|
||||
}
|
||||
if a.Continental != b.Continental {
|
||||
t.Errorf("plate %d was %v continental and reads back %v", i, a.Continental, b.Continental)
|
||||
}
|
||||
}
|
||||
|
||||
// And the tectonics: the same margins doing the same things. Not vertex-for-vertex - the proposal is a
|
||||
// raster at 1600 px and the model was traced at the tectonic grid - but the same boundaries by count and
|
||||
// by what each one is.
|
||||
if len(read.Boundaries) != len(made.Boundaries) {
|
||||
t.Errorf("%d boundaries written, %d read back", len(made.Boundaries), len(read.Boundaries))
|
||||
}
|
||||
if got, want := kinds(read.Boundaries), kinds(made.Boundaries); !sameStrings(got, want) {
|
||||
t.Errorf("margins read back as %v, were %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALegendThatCannotBeAPlanetIsRefused(t *testing.T) {
|
||||
one := &PaintLegend{Plates: []PaintPlate{{Name: "only", RGB: [3]int{1, 2, 3}}}}
|
||||
if err := one.validate("test"); err == nil {
|
||||
t.Error("a planet in one plate was accepted; it has no boundaries")
|
||||
}
|
||||
dup := &PaintLegend{Plates: []PaintPlate{
|
||||
{Name: "a", RGB: [3]int{1, 2, 3}},
|
||||
{Name: "b", RGB: [3]int{1, 2, 3}},
|
||||
}}
|
||||
if err := dup.validate("test"); err == nil {
|
||||
t.Error("two plates sharing a colour were accepted; a colour is one plate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
package plates
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// testPlanet is a small cylinder with the same shape of arithmetic as a real one: a whole number of columns
|
||||
// and a noise period that divides the circumference.
|
||||
func testPlanet(t *testing.T) world.Planet {
|
||||
t.Helper()
|
||||
p, err := world.New(40000, 8, 100, 50, 0, 40000)
|
||||
if err != nil {
|
||||
t.Fatalf("planet: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// handModel builds a Model with a raster set by the caller, so a test can ask about a boundary in a known
|
||||
// place. Nothing here goes through Build: the point is to control the partition rather than the seed.
|
||||
func handModel(t *testing.T, p world.Planet, gw, gh int, cell []int16, ps []Plate) *Model {
|
||||
t.Helper()
|
||||
return &Model{
|
||||
P: p, Cfg: Default(), Plates: ps,
|
||||
GW: gw, GH: gh, GCellM: p.CircumferenceM() / float64(gw),
|
||||
Cell: cell,
|
||||
}
|
||||
}
|
||||
|
||||
// stripes paints two vertical bands: plate 1 from column lo up to hi, plate 0 everywhere else.
|
||||
//
|
||||
// That is **two** contacts, not one, and it is worth saying why every test here is written in pairs. A
|
||||
// cylinder cut into two strips has a margin at each end of each strip, and under a pure translation the
|
||||
// plates are closing at one of them and opening at the other by exactly the same amount. There is no way to
|
||||
// arrange two plates on a cylinder that only collide. The invariant is the test.
|
||||
func stripes(gw, gh, lo, hi int) []int16 {
|
||||
cell := make([]int16, gw*gh)
|
||||
for gy := range gh {
|
||||
for gx := range gw {
|
||||
if gx >= lo && gx < hi {
|
||||
cell[gy*gw+gx] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
// rollX moves every column east by n, wrapping. Rolling the map is the whole seam test: the cylinder has no
|
||||
// preferred meridian, so a partition and the same partition rolled must produce the same tectonics.
|
||||
func rollX(cell []int16, gw, gh, n int) []int16 {
|
||||
out := make([]int16, len(cell))
|
||||
for gy := range gh {
|
||||
for gx := range gw {
|
||||
out[gy*gw+((gx+n)%gw)] = cell[gy*gw+gx]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// closingPair drives plate 0 east and plate 1 west, with no spin.
|
||||
func closingPair() []Plate {
|
||||
return []Plate{
|
||||
{ID: 0, TransXM: 0.02, Continental: true, AreaCells: 1},
|
||||
{ID: 1, TransXM: -0.02, Continental: true, AreaCells: 1},
|
||||
}
|
||||
}
|
||||
|
||||
// meanClosing is a boundary's average closing rate, in metres a year.
|
||||
func meanClosing(b Boundary) float64 {
|
||||
if len(b.V) == 0 {
|
||||
return 0
|
||||
}
|
||||
total := 0.0
|
||||
for _, v := range b.V {
|
||||
total += v.ClosingMYr
|
||||
}
|
||||
return total / float64(len(b.V))
|
||||
}
|
||||
|
||||
// sortedMeans is every boundary's mean closing rate, in order: the signature of a whole planet's tectonics,
|
||||
// independent of which order the boundaries happened to be found in.
|
||||
func sortedMeans(bs []Boundary) []float64 {
|
||||
out := make([]float64, len(bs))
|
||||
for i, b := range bs {
|
||||
out[i] = meanClosing(b)
|
||||
}
|
||||
sort.Float64s(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func kinds(bs []Boundary) []string {
|
||||
out := make([]string, len(bs))
|
||||
for i, b := range bs {
|
||||
out[i] = b.Dominant().String()
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestABoundaryAcrossTheSeamIsOneBoundary(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
const gw, gh = 200, 100
|
||||
m := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair())
|
||||
bs := m.buildBoundaries()
|
||||
|
||||
if len(bs) != 2 {
|
||||
t.Fatalf("two vertical contacts on a cylinder, got %d boundaries", len(bs))
|
||||
}
|
||||
// Roll the partition so one contact sits exactly on the meridian. If the seam were special the boundary
|
||||
// through it would come back cut in half - two chains of half the length - or with a whole circumference
|
||||
// of jump in the middle of it.
|
||||
rolled := handModel(t, p, gw, gh, rollX(m.Cell, gw, gh, 50), closingPair())
|
||||
rbs := rolled.buildBoundaries()
|
||||
if len(rbs) != 2 {
|
||||
t.Fatalf("after rolling the map onto the seam: %d boundaries, want 2", len(rbs))
|
||||
}
|
||||
|
||||
for _, b := range rbs {
|
||||
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)
|
||||
if d > maxGapCells*m.GCellM*1.01 {
|
||||
t.Fatalf("a %.0f m step between neighbouring vertices: X was wrapped, not unwrapped", d)
|
||||
}
|
||||
}
|
||||
if got, want := b.LengthM(), bs[0].LengthM(); math.Abs(got-want) > m.GCellM {
|
||||
t.Errorf("rolled boundary is %.0f m, unrolled %.0f m", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The tectonics have to be the same planet, not just the same shape: the rolled map's margins close at
|
||||
// the same rates as the unrolled map's.
|
||||
before, after := sortedMeans(bs), sortedMeans(rbs)
|
||||
for i := range before {
|
||||
if math.Abs(before[i]-after[i]) > 1e-9 {
|
||||
t.Errorf("closing rate %d is %.6g before the roll and %.6g after", i, before[i], after[i])
|
||||
}
|
||||
}
|
||||
|
||||
circ := p.CircumferenceM()
|
||||
crossed := false
|
||||
for _, b := range rbs {
|
||||
for _, v := range b.V {
|
||||
if v.XM < 0 || v.XM >= circ {
|
||||
crossed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !crossed {
|
||||
t.Error("no vertex outside 0..circumference, so nothing was unwrapped and the roll tested nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosingIsTheSameWhicheverPlateIsCalledA(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
const gw, gh = 200, 100
|
||||
|
||||
forward := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair())
|
||||
// Swap which stripe belongs to which plate, and swap the motions with it. Physically nothing has moved:
|
||||
// the same two materials are being driven together at the same margin. Every closing rate must therefore
|
||||
// come back identical, because the normal flips and the relative velocity flips with it.
|
||||
swapped := stripes(gw, gh, 50, 150)
|
||||
for i := range swapped {
|
||||
swapped[i] = 1 - swapped[i]
|
||||
}
|
||||
reverse := handModel(t, p, gw, gh, swapped, []Plate{
|
||||
{ID: 0, TransXM: -0.02, Continental: true, AreaCells: 1},
|
||||
{ID: 1, TransXM: 0.02, Continental: true, AreaCells: 1},
|
||||
})
|
||||
|
||||
fm, rm := sortedMeans(forward.buildBoundaries()), sortedMeans(reverse.buildBoundaries())
|
||||
if len(fm) != len(rm) {
|
||||
t.Fatalf("%d boundaries one way round and %d the other", len(fm), len(rm))
|
||||
}
|
||||
for i := range fm {
|
||||
if math.Abs(fm[i]-rm[i]) > 1e-9 {
|
||||
t.Errorf("closing rate %d is %.6g one way round and %.6g the other; the sign convention is not "+
|
||||
"symmetric", i, fm[i], rm[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneMarginClosesAndTheOtherOpens(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
const gw, gh = 200, 100
|
||||
bs := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair()).buildBoundaries()
|
||||
if len(bs) != 2 {
|
||||
t.Fatalf("got %d boundaries, want 2", len(bs))
|
||||
}
|
||||
|
||||
means := sortedMeans(bs)
|
||||
if means[0] >= 0 || means[1] <= 0 {
|
||||
t.Fatalf("closing rates %.4g and %.4g; two strips on a cylinder give one of each", means[0], means[1])
|
||||
}
|
||||
// Equal and opposite, because a pure translation is the same relative velocity at both margins and the
|
||||
// only thing that differs is which way the normal points.
|
||||
if math.Abs(means[0]+means[1]) > 1e-9 {
|
||||
t.Errorf("closing rates %.6g and %.6g are not equal and opposite", means[0], means[1])
|
||||
}
|
||||
if got := math.Abs(means[1]); math.Abs(got-0.04) > 1e-9 {
|
||||
t.Errorf("plates at 2 cm/yr each close at %.4g m/yr; 0.04 is the sum of the two speeds", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinentsCollideAndOceansSubduct(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
const gw, gh = 200, 100
|
||||
cell := stripes(gw, gh, 50, 150)
|
||||
|
||||
both := handModel(t, p, gw, gh, cell, closingPair()).buildBoundaries()
|
||||
if got, want := kinds(both), []string{"collision", "rift"}; !sameStrings(got, want) {
|
||||
t.Errorf("two continental plates give %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// The same geometry with one side oceanic: the closing margin is a subduction zone and the arc belongs
|
||||
// to the continent, because continental crust is too buoyant to go down.
|
||||
ps := closingPair()
|
||||
ps[1].Continental = false
|
||||
oceanic := handModel(t, p, gw, gh, cell, ps).buildBoundaries()
|
||||
if got, want := kinds(oceanic), []string{"ridge", "subduction"}; !sameStrings(got, want) {
|
||||
t.Errorf("continent against ocean gives %v, want %v", got, want)
|
||||
}
|
||||
for _, b := range oceanic {
|
||||
for _, v := range b.V {
|
||||
if v.Kind == Subduction && v.Over != 0 {
|
||||
t.Fatalf("the overriding plate is %d, but plate 1 is the oceanic one", v.Over)
|
||||
}
|
||||
if v.Kind != Subduction && v.Over != -1 {
|
||||
t.Fatalf("a %q vertex carries an overriding plate", v.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sameStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestMotionAlongTheLineIsATransform(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
const gw, gh = 200, 100
|
||||
// Vertical contacts, both plates sliding north and south: the relative motion is entirely along the line,
|
||||
// so neither margin closes or opens.
|
||||
m := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), []Plate{
|
||||
{ID: 0, TransYM: 0.02, Continental: true, AreaCells: 1},
|
||||
{ID: 1, TransYM: -0.02, Continental: true, AreaCells: 1},
|
||||
})
|
||||
for _, b := range m.buildBoundaries() {
|
||||
if got := b.Dominant(); got != Transform {
|
||||
t.Errorf("plates sliding past each other give %q, want %q", got, Transform)
|
||||
}
|
||||
if got := math.Abs(meanClosing(b)); got > 1e-9 {
|
||||
t.Errorf("a transform margin closes at %.3g m/yr", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindOf(t *testing.T) {
|
||||
oblique := 60 * math.Pi / 180
|
||||
cases := []struct {
|
||||
name string
|
||||
closing, slip float64
|
||||
bothContinental bool
|
||||
want Kind
|
||||
}{
|
||||
{"head-on continental", 1, 0, true, Collision},
|
||||
{"head-on with an ocean", 1, 0, false, Subduction},
|
||||
{"opening continental", -1, 0, true, Rift},
|
||||
{"opening with an ocean", -1, 0, false, Ridge},
|
||||
{"pure slip", 0, 1, true, Transform},
|
||||
{"oblique but still closing", 1, 1.5, true, Collision},
|
||||
{"slip has taken over", 1, 2, true, Transform},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := kindOf(c.closing, c.slip, oblique, c.bothContinental); got != c.want {
|
||||
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpinMakesAMarginChangeAlongItsLength(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
const gw, gh = 200, 100
|
||||
cell := stripes(gw, gh, 50, 150)
|
||||
|
||||
// Pure translation: the relative velocity is the same everywhere, so a straight margin closes at one rate
|
||||
// from end to end. That uniformity is exactly what the in-plane pole exists to break.
|
||||
flat := handModel(t, p, gw, gh, cell, closingPair()).buildBoundaries()
|
||||
if spread := worstSpread(flat); spread > 1e-9 {
|
||||
t.Errorf("without spin a margin varies by %.3g m/yr along its own length; it should not", spread)
|
||||
}
|
||||
|
||||
// The same plates with one rotating about a pole off to the side close at one end and slide at the other.
|
||||
spun := closingPair()
|
||||
spun[0].PoleXM, spun[0].PoleYM = 0, 0
|
||||
spun[0].OmegaRadYr = 2e-6
|
||||
spinning := handModel(t, p, gw, gh, cell, spun).buildBoundaries()
|
||||
if spread := worstSpread(spinning); spread < 1e-3 {
|
||||
t.Errorf("with spin a margin varies by only %.3g m/yr; the rotation is not reaching the boundary",
|
||||
spread)
|
||||
}
|
||||
}
|
||||
|
||||
// worstSpread is the largest range of closing rates found *within* a single boundary. Within, not across:
|
||||
// two margins of the same pair legitimately differ, and measuring across them would report that difference
|
||||
// as variation along a line.
|
||||
func worstSpread(bs []Boundary) float64 {
|
||||
worst := 0.0
|
||||
for _, b := range bs {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, v := range b.V {
|
||||
lo = math.Min(lo, v.ClosingMYr)
|
||||
hi = math.Max(hi, v.ClosingMYr)
|
||||
}
|
||||
if !math.IsInf(lo, 1) && hi-lo > worst {
|
||||
worst = hi - lo
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
func TestVelocityIsContinuousAcrossTheSeam(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
circ := p.CircumferenceM()
|
||||
// A plate whose pole sits just east of the meridian. Measured without wrapping, the lever arm a metre
|
||||
// west of the seam would be a whole circumference long, and the plate would spin the wrong way there.
|
||||
pl := Plate{ID: 0, PoleXM: 10, PoleYM: 0, OmegaRadYr: 1e-6, TransXM: 0.01}
|
||||
|
||||
ax, ay := pl.VelocityAt(p, circ-1, 0)
|
||||
bx, by := pl.VelocityAt(p, 1, 0)
|
||||
acrossSeam := math.Hypot(ax-bx, ay-by)
|
||||
|
||||
// The same two-metre gap in open map, away from the meridian: the seam must cost nothing extra.
|
||||
cx, cy := pl.VelocityAt(p, circ/2-1, 0)
|
||||
dx, dy := pl.VelocityAt(p, circ/2+1, 0)
|
||||
elsewhere := math.Hypot(cx-dx, cy-dy)
|
||||
|
||||
if math.Abs(acrossSeam-elsewhere) > 1e-12 {
|
||||
t.Errorf("velocity changes by %.3g m/yr over two metres at the meridian and %.3g m/yr over two "+
|
||||
"metres anywhere else", acrossSeam, elsewhere)
|
||||
}
|
||||
// And the failure this guards against is enormous, not subtle: an unwrapped lever arm would be a whole
|
||||
// circumference and give a jump of omega*circ.
|
||||
if acrossSeam > pl.OmegaRadYr*circ/100 {
|
||||
t.Errorf("velocity jumps by %.3g m/yr at the meridian; the lever arm was not wrapped", acrossSeam)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCoversThePlanetAndReadsTheLandMask(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
cfg := Default()
|
||||
cfg.Count = 6
|
||||
|
||||
allSea, err := Build(p, 7, cfg, func(xM, yM float64) bool { return false })
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
total := 0
|
||||
for _, pl := range allSea.Plates {
|
||||
total += pl.AreaCells
|
||||
if pl.Continental {
|
||||
t.Errorf("plate %d is continental on a planet with no land", pl.ID)
|
||||
}
|
||||
}
|
||||
if total != allSea.GW*allSea.GH {
|
||||
t.Errorf("plates cover %d cells of %d; the partition has holes", total, allSea.GW*allSea.GH)
|
||||
}
|
||||
if len(allSea.Boundaries) == 0 {
|
||||
t.Fatal("six plates and no boundaries between them")
|
||||
}
|
||||
for _, b := range allSea.Boundaries {
|
||||
if b.A >= b.B {
|
||||
t.Errorf("boundary pair (%d, %d) is not ordered", b.A, b.B)
|
||||
}
|
||||
for _, v := range b.V {
|
||||
if v.Kind == Collision || v.Kind == Rift {
|
||||
t.Errorf("a %q on a planet with no continental plate at all", v.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allLand, err := Build(p, 7, cfg, func(xM, yM float64) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
for _, pl := range allLand.Plates {
|
||||
if !pl.Continental {
|
||||
t.Errorf("plate %d is oceanic on a planet that is all land", pl.ID)
|
||||
}
|
||||
}
|
||||
for _, b := range allLand.Boundaries {
|
||||
for _, v := range b.V {
|
||||
if v.Kind == Subduction || v.Kind == Ridge {
|
||||
t.Errorf("a %q with no oceanic plate to make it", v.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSameSeedGivesTheSamePlanet(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
cfg := Default()
|
||||
land := func(xM, yM float64) bool { return yM > 5000 && yM < 12000 }
|
||||
|
||||
a, err := Build(p, 9342, cfg, land)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
b, err := Build(p, 9342, cfg, land)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
if len(a.Boundaries) != len(b.Boundaries) {
|
||||
t.Fatalf("%d boundaries then %d; the set is not deterministic", len(a.Boundaries), len(b.Boundaries))
|
||||
}
|
||||
for i := range a.Boundaries {
|
||||
if a.Boundaries[i].A != b.Boundaries[i].A || a.Boundaries[i].B != b.Boundaries[i].B {
|
||||
t.Fatalf("boundary %d is a different pair on the second run", i)
|
||||
}
|
||||
if math.Abs(a.Boundaries[i].LengthM()-b.Boundaries[i].LengthM()) > 1e-9 {
|
||||
t.Fatalf("boundary %d is a different length on the second run", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWarpBendsMarginsAndTooMuchOfItBreaksThem(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
|
||||
// Sinuosity - the line's own length over the distance between its ends - is what "a margin is not a
|
||||
// ruled line" means as a number. A weighted Voronoi edge is a circular arc even at no warp, so the
|
||||
// baseline is a little over 1 rather than exactly 1.
|
||||
measure := func(warp float64) (sinuosity float64, boundaries int) {
|
||||
cfg := Default()
|
||||
cfg.Count = 7
|
||||
cfg.WarpFraction = warp
|
||||
m, err := Build(p, 3630, cfg, func(xM, yM float64) bool { return false })
|
||||
if err != nil {
|
||||
t.Fatalf("warp %.2f: %v", warp, err)
|
||||
}
|
||||
total, n := 0.0, 0
|
||||
for _, b := range m.Boundaries {
|
||||
if len(b.V) < 10 {
|
||||
continue
|
||||
}
|
||||
last := b.V[len(b.V)-1]
|
||||
if straight := math.Hypot(last.XM-b.V[0].XM, last.YM-b.V[0].YM); straight > 0 {
|
||||
total += b.LengthM() / straight
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, len(m.Boundaries)
|
||||
}
|
||||
return total / float64(n), len(m.Boundaries)
|
||||
}
|
||||
|
||||
straight, straightCount := measure(0)
|
||||
warped, warpedCount := measure(Default().WarpFraction)
|
||||
if straight > 1.05 {
|
||||
t.Errorf("an unwarped partition already has a sinuosity of %.3f; it should be close to a polygon net",
|
||||
straight)
|
||||
}
|
||||
if warped <= straight*1.03 {
|
||||
t.Errorf("the warp takes sinuosity from %.3f to %.3f, which is no bend at all", straight, warped)
|
||||
}
|
||||
|
||||
// Past about half a plate spacing the displacement folds back on itself and the partition grows islands
|
||||
// of one plate inside another, which the tracer faithfully chains into extra rings. The count is the
|
||||
// symptom, and this is the bound warpFineGain and the default are set under.
|
||||
_, tooMuch := measure(0.5)
|
||||
if tooMuch <= warpedCount {
|
||||
t.Skipf("no fragmentation at warp 0.5 on this seed (%d boundaries against %d); the bound still holds "+
|
||||
"but this seed does not show it", tooMuch, warpedCount)
|
||||
}
|
||||
if warpedCount != straightCount {
|
||||
t.Errorf("the default warp changed the boundary count from %d to %d; it should bend margins, not "+
|
||||
"create them", straightCount, warpedCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// Package region cuts a planet into the pieces the geology solve runs on.
|
||||
//
|
||||
// Docs/Terrain-Next.md 3.3 says the fluvial solve cannot be tiled, and that is right: drainage area is an
|
||||
// integral over the whole upstream catchment and the priority-flood needs global connectivity, so a river
|
||||
// crossing a tile boundary would need the next tile's catchment to know how big it is.
|
||||
//
|
||||
// It can be decomposed per landmass, though, and that is a different statement. Ocean cells are held fixed
|
||||
// at sea level for the entire solve - fluvial.ComputeReceivers makes every outlet its own receiver, so a
|
||||
// receiver chain starting on land terminates the moment it steps into water, and StreamPower, both
|
||||
// diffusions, the repose clamp and thermal all skip a fixed cell. No flow path crosses open water, and every
|
||||
// basin is contained in one eight-connected land component. So solving a landmass in a box of its own is not
|
||||
// an approximation of solving the planet whole: on land it is the same answer.
|
||||
//
|
||||
// What that buys is memory. The whole planet at once is a fluvial.Grid of about 35 bytes a cell plus the
|
||||
// dozen full-size fields uplift builds, which at 78 million cells is several gigabytes before anything has
|
||||
// been eroded. Landmasses plus a thin margin are a fraction of that area and are solved one at a time.
|
||||
//
|
||||
// What it costs is that the decomposition becomes part of the world's identity: the priority-flood's epsilon
|
||||
// ladder across a flat depends on the flood's traversal order, which depends on the box it is flooding. The
|
||||
// seed alone no longer names a world - the seed and the margin do - so the margin lives in the manifest and
|
||||
// is recorded in meta.json.
|
||||
//
|
||||
// Note what is NOT decomposed. The coastal pass runs once on the whole cylinder, because it is cheap (tens
|
||||
// of nanoseconds a cell, against tens of nanoseconds a cell *per step* for the solve) and because cutting it
|
||||
// up would truncate the fetch across every strait, split the sediment budget whose conservation is the one
|
||||
// thing in that pass not derived from something already measured, and leave the shoreline length and the
|
||||
// exposure percentiles as statistics that do not pool. Decompose the solve, not the map.
|
||||
package region
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"salty/terrain/internal/dt"
|
||||
"salty/terrain/internal/template"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Region is one piece of the planet: a landmass, or a cluster of landmasses close enough that they shelter
|
||||
// each other, plus a margin of ocean on every side.
|
||||
type Region struct {
|
||||
ID int
|
||||
Frame world.Frame
|
||||
|
||||
LandCells int // painted land cells this region owns
|
||||
SetCells int // cells in the dilated set, which the frame is the bounding box of
|
||||
Seam bool // the frame straddles x = 0
|
||||
}
|
||||
|
||||
// Cells is the size of the grid the solve will run on, margin included.
|
||||
func (r Region) Cells() int { return r.Frame.Cells() }
|
||||
|
||||
// Partition is a planet cut into regions, and the map from planet cell to owning region.
|
||||
type Partition struct {
|
||||
P world.Planet
|
||||
MarginCells int
|
||||
|
||||
// Owner is the region id for every planet cell, or -1 for water that belongs to no region. A cell
|
||||
// inside one region's frame may be owned by another region or by nobody, which is what keeps two
|
||||
// regions from both solving the same island.
|
||||
Owner []int32
|
||||
Regions []Region
|
||||
|
||||
// Dropped counts the specks: components with less painted land than the minimum, returned to the sea.
|
||||
DroppedRegions, DroppedCells int
|
||||
}
|
||||
|
||||
// Build partitions a classified planet.
|
||||
//
|
||||
// The land mask is dilated by the margin with one exact distance transform, and the connected components of
|
||||
// the dilated mask are the regions. That is the whole rule, and it is deliberately not a bounding-box
|
||||
// overlap test: dilated boxes are transitively closed and one long thin landmass has an enormous box, so on
|
||||
// a real template box clustering collapses most of the map into a single region. Dilating the mask itself
|
||||
// groups exactly those landmasses that come within a margin of each other.
|
||||
//
|
||||
// The bounding box of a dilated component is the region's frame, and its edges are ocean by construction: a
|
||||
// land cell dilates to reach margin cells further out, so the outermost column and row of the dilated set
|
||||
// are at least margin cells from any land in that component. That is the invariant TestBorderIsAlwaysOcean
|
||||
// asserts about the square canvas, and the solve depends on it - a border cell is an outlet, and land
|
||||
// sitting on one would freeze at its initial relief while the interior eroded out from under it.
|
||||
func Build(m *template.Map, marginCells, minLandCells int) (*Partition, error) {
|
||||
p := m.P
|
||||
n := p.W * p.H
|
||||
if marginCells < 1 {
|
||||
return nil, fmt.Errorf("margin is %d cells; a region needs at least one ring of ocean", marginCells)
|
||||
}
|
||||
if p.PadY < marginCells {
|
||||
return nil, fmt.Errorf("the polar pad is %d rows against a %d cell margin; a cap touching the top "+
|
||||
"of the painted map would not get a full margin of ocean", p.PadY, marginCells)
|
||||
}
|
||||
|
||||
part := &Partition{P: p, MarginCells: marginCells, Owner: minusOne(n)}
|
||||
|
||||
land := make([]bool, n)
|
||||
anyLand := false
|
||||
for i := range m.Sea {
|
||||
land[i] = !m.Sea[i]
|
||||
anyLand = anyLand || land[i]
|
||||
}
|
||||
if !anyLand {
|
||||
return part, nil
|
||||
}
|
||||
|
||||
near := dilate(land, p, marginCells)
|
||||
|
||||
comp := make([]int32, n)
|
||||
for i := range comp {
|
||||
comp[i] = -1
|
||||
}
|
||||
var regionOfComp []int32 // one entry per component: the region index, or -1 when it was dropped
|
||||
var stack []int32
|
||||
cols := make([]bool, p.W)
|
||||
|
||||
for start := 0; start < n; start++ {
|
||||
if !near[start] || comp[start] >= 0 {
|
||||
continue
|
||||
}
|
||||
id := int32(len(regionOfComp))
|
||||
regionOfComp = append(regionOfComp, -1)
|
||||
comp[start] = id
|
||||
stack = append(stack[:0], int32(start))
|
||||
|
||||
for i := range cols {
|
||||
cols[i] = false
|
||||
}
|
||||
minY, maxY := p.H, -1
|
||||
setCells, landCells := 0, 0
|
||||
|
||||
for len(stack) > 0 {
|
||||
c := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
cx, cy := int(c)%p.W, int(c)/p.W
|
||||
setCells++
|
||||
cols[cx] = true
|
||||
if cy < minY {
|
||||
minY = cy
|
||||
}
|
||||
if cy > maxY {
|
||||
maxY = cy
|
||||
}
|
||||
if land[c] {
|
||||
landCells++
|
||||
}
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
ny := cy + dy
|
||||
if ny < 0 || ny >= p.H {
|
||||
continue
|
||||
}
|
||||
base := ny * p.W
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue
|
||||
}
|
||||
ni := int32(base + p.WrapX(cx+dx))
|
||||
if near[ni] && comp[ni] < 0 {
|
||||
comp[ni] = id
|
||||
stack = append(stack, ni)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if landCells < minLandCells {
|
||||
// A speck: a stray paint pixel, or a lone cell the classifier left behind. Solving it would
|
||||
// spend a whole region on a rock, so it goes back to the sea and is counted.
|
||||
part.DroppedRegions++
|
||||
part.DroppedCells += landCells
|
||||
continue
|
||||
}
|
||||
|
||||
x0, width := span(cols, p.W)
|
||||
if width >= p.W {
|
||||
return nil, fmt.Errorf("a landmass reaches all the way round the planet: %d of %d columns once "+
|
||||
"the %d cell margin is added. It cannot be flattened into a rectangle with ocean on both "+
|
||||
"sides, and the solve needs that, because a grid edge is an outlet. Break it with a strait, "+
|
||||
"or reduce the margin", width, p.W, marginCells)
|
||||
}
|
||||
|
||||
regionOfComp[id] = int32(len(part.Regions))
|
||||
part.Regions = append(part.Regions, Region{
|
||||
ID: len(part.Regions),
|
||||
Frame: world.Frame{P: p, X0: x0, Y0: minY, W: width, H: maxY - minY + 1},
|
||||
LandCells: landCells,
|
||||
SetCells: setCells,
|
||||
Seam: x0+width > p.W,
|
||||
})
|
||||
}
|
||||
|
||||
for i, c := range comp {
|
||||
if c >= 0 {
|
||||
part.Owner[i] = regionOfComp[c]
|
||||
}
|
||||
}
|
||||
return part, nil
|
||||
}
|
||||
|
||||
// dilate marks every cell within margin cells of a seed, on the cylinder.
|
||||
func dilate(seed []bool, p world.Planet, margin int) []bool {
|
||||
d2 := dt.Distance2(seed, p.W, p.H, true)
|
||||
reach := float32(margin * margin)
|
||||
out := make([]bool, len(d2))
|
||||
for i, d := range d2 {
|
||||
out[i] = d <= reach
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Cut is the region's own view of the world: the class raster and the land mask for its frame, with every
|
||||
// cell belonging to another region - or to no region - forced to sea.
|
||||
//
|
||||
// Forcing them is right rather than convenient. A neighbouring island inside this frame is a separate
|
||||
// landmass with its own basins, and no flow path connects the two, so leaving it as land would solve it
|
||||
// twice and let its relief leak into this region's statistics. As water it is exactly what it is to this
|
||||
// region's rivers: base level.
|
||||
func (p *Partition) Cut(m *template.Map, r Region) (class []uint8, land []bool) {
|
||||
sea := uint8(0)
|
||||
if i := m.L.FirstSea(); i >= 0 {
|
||||
sea = uint8(i)
|
||||
}
|
||||
class = make([]uint8, r.Frame.Cells())
|
||||
land = make([]bool, r.Frame.Cells())
|
||||
for y := 0; y < r.Frame.H; y++ {
|
||||
for x := 0; x < r.Frame.W; x++ {
|
||||
pi := r.Frame.PlanetIdx(x, y)
|
||||
o := y*r.Frame.W + x
|
||||
mine := p.Owner[pi] == int32(r.ID)
|
||||
if mine && !m.Sea[pi] {
|
||||
class[o] = m.Class[pi]
|
||||
land[o] = true
|
||||
continue
|
||||
}
|
||||
if m.Sea[pi] {
|
||||
class[o] = m.Class[pi] // keep the painted water class: its depth is read later
|
||||
} else {
|
||||
class[o] = sea // somebody else's land, which to this region is open water
|
||||
}
|
||||
}
|
||||
}
|
||||
return class, land
|
||||
}
|
||||
|
||||
// Composite writes a region's solved land back into the planet raster.
|
||||
//
|
||||
// Only cells the region owns and that are painted land are written. Everything else in the frame is water,
|
||||
// and the sea floor is the planetary coastal pass's to lay afterwards - a region must not write it, or two
|
||||
// overlapping frames would disagree about the same stretch of shelf.
|
||||
func (p *Partition) Composite(dst []float32, m *template.Map, r Region, src []float32) int {
|
||||
written := 0
|
||||
for y := 0; y < r.Frame.H; y++ {
|
||||
for x := 0; x < r.Frame.W; x++ {
|
||||
pi := r.Frame.PlanetIdx(x, y)
|
||||
if p.Owner[pi] != int32(r.ID) || m.Sea[pi] {
|
||||
continue
|
||||
}
|
||||
dst[pi] = src[y*r.Frame.W+x]
|
||||
written++
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
// span finds the shortest run of columns covering every occupied one, going round the cylinder. The largest
|
||||
// gap decides: the run starts just after it.
|
||||
func span(cols []bool, w int) (x0, width int) {
|
||||
occupied := make([]int, 0, w)
|
||||
for x, on := range cols {
|
||||
if on {
|
||||
occupied = append(occupied, x)
|
||||
}
|
||||
}
|
||||
if len(occupied) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
if len(occupied) == w {
|
||||
return 0, w
|
||||
}
|
||||
bestGap, bestAt := -1, 0
|
||||
for i := range occupied {
|
||||
var gap int
|
||||
if i == len(occupied)-1 {
|
||||
gap = occupied[0] + w - occupied[i]
|
||||
} else {
|
||||
gap = occupied[i+1] - occupied[i]
|
||||
}
|
||||
if gap > bestGap {
|
||||
bestGap, bestAt = gap, (i+1)%len(occupied)
|
||||
}
|
||||
}
|
||||
return occupied[bestAt], w - bestGap + 1
|
||||
}
|
||||
|
||||
func minusOne(n int) []int32 {
|
||||
out := make([]int32, n)
|
||||
for i := range out {
|
||||
out[i] = -1
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package region
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/template"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
const legendJSON = `{"classes":[
|
||||
{"name":"sea","rgb":[0,0,255],"sea":true,"depth_m":100},
|
||||
{"name":"land","rgb":[0,255,0],"uplift_mm_yr":0.5}
|
||||
]}`
|
||||
|
||||
// testMap builds a planet from a picture of its painted rows. '#' is land, '.' is sea; the polar pad of
|
||||
// synthetic ocean is added above and below.
|
||||
func testMap(t *testing.T, rows []string, pad int) *template.Map {
|
||||
t.Helper()
|
||||
l, err := template.Parse([]byte(legendJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := len(rows[0])
|
||||
p := world.Planet{CellM: 1, W: w, H: len(rows) + 2*pad, PadY: pad, NoisePeriodM: float64(w)}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &template.Map{P: p, L: l, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)}
|
||||
for i := range m.Class {
|
||||
m.Class[i], m.Sea[i] = 0, true
|
||||
}
|
||||
for y, row := range rows {
|
||||
if len(row) != w {
|
||||
t.Fatalf("row %d is %d wide, want %d", y, len(row), w)
|
||||
}
|
||||
for x, r := range row {
|
||||
if r == '#' {
|
||||
i := (y+pad)*p.W + x
|
||||
m.Class[i], m.Sea[i] = 1, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// assertBordersAreWater is the invariant the whole solve depends on, and the direct analogue of
|
||||
// TestBorderIsAlwaysOcean: a grid edge is an outlet, so land on one would freeze at its initial relief while
|
||||
// the interior eroded out from under it.
|
||||
func assertBordersAreWater(t *testing.T, part *Partition, m *template.Map) {
|
||||
t.Helper()
|
||||
for _, r := range part.Regions {
|
||||
_, land := part.Cut(m, r)
|
||||
for x := 0; x < r.Frame.W; x++ {
|
||||
if land[x] {
|
||||
t.Errorf("region %d: land on the top edge at column %d", r.ID, x)
|
||||
}
|
||||
if land[(r.Frame.H-1)*r.Frame.W+x] {
|
||||
t.Errorf("region %d: land on the bottom edge at column %d", r.ID, x)
|
||||
}
|
||||
}
|
||||
for y := 0; y < r.Frame.H; y++ {
|
||||
if land[y*r.Frame.W] {
|
||||
t.Errorf("region %d: land on the left edge at row %d", r.ID, y)
|
||||
}
|
||||
if land[y*r.Frame.W+r.Frame.W-1] {
|
||||
t.Errorf("region %d: land on the right edge at row %d", r.ID, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The crater island in the template this was written for straddles x = 0. If the partitioner split it in
|
||||
// two, half of it would be solved against a shore that does not exist.
|
||||
func TestSeamStraddlingLandmassIsOneRegion(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"#.........#",
|
||||
"#.........#",
|
||||
"...........",
|
||||
}, 2)
|
||||
part, err := Build(m, 2, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) != 1 {
|
||||
t.Fatalf("got %d regions, want 1: the landmass wraps", len(part.Regions))
|
||||
}
|
||||
r := part.Regions[0]
|
||||
if !r.Seam {
|
||||
t.Error("the region does not report that it straddles the seam")
|
||||
}
|
||||
if r.LandCells != 4 {
|
||||
t.Errorf("LandCells = %d, want 4", r.LandCells)
|
||||
}
|
||||
// Land occupies columns 10 and 0, which are neighbours on an 11-column cylinder; a 2-cell margin on
|
||||
// each side makes the frame six columns wide starting at column 8.
|
||||
if r.Frame.W != 6 {
|
||||
t.Errorf("frame width = %d, want 6", r.Frame.W)
|
||||
}
|
||||
if r.Frame.X0 != 8 {
|
||||
t.Errorf("frame X0 = %d, want 8", r.Frame.X0)
|
||||
}
|
||||
assertBordersAreWater(t, part, m)
|
||||
}
|
||||
|
||||
// Landmasses close enough to shelter each other are solved together; further apart they are not. The
|
||||
// alternative that was rejected - overlapping dilated bounding boxes - is transitively closed and one long
|
||||
// landmass has an enormous box, so on a real template it collapses most of the map into a single region.
|
||||
func TestClusteringFollowsDistanceNotBoundingBoxes(t *testing.T) {
|
||||
near := testMap(t, []string{
|
||||
"..............................",
|
||||
".####....#....................",
|
||||
".####....#....................",
|
||||
"..............................",
|
||||
}, 3)
|
||||
part, err := Build(near, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) != 1 {
|
||||
t.Fatalf("got %d regions, want 1: a four-cell gap closes under a three-cell margin on each side",
|
||||
len(part.Regions))
|
||||
}
|
||||
|
||||
far := testMap(t, []string{
|
||||
"..............................",
|
||||
".####.........#...............",
|
||||
".####.........#...............",
|
||||
"..............................",
|
||||
}, 3)
|
||||
part, err = Build(far, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) != 2 {
|
||||
t.Fatalf("got %d regions, want 2: a nine-cell gap does not", len(part.Regions))
|
||||
}
|
||||
assertBordersAreWater(t, part, far)
|
||||
}
|
||||
|
||||
func TestEveryRegionBorderIsWater(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"..###...........................................................",
|
||||
"..###.........####.............####.............##..............",
|
||||
"..............####.............####.............##..............",
|
||||
"..............####.............####.............................",
|
||||
"................................................................",
|
||||
"................................................................",
|
||||
}, 3)
|
||||
part, err := Build(m, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) < 2 {
|
||||
t.Fatalf("got %d regions; the picture has several separate landmasses", len(part.Regions))
|
||||
}
|
||||
assertBordersAreWater(t, part, m)
|
||||
}
|
||||
|
||||
// A polar cap touches the top row of the painted map. The synthetic ocean pad is what gives it a shore, so
|
||||
// that fluvial.isOutlet - which treats every top-row cell as an outlet - is answering about water.
|
||||
func TestAPolarCapGetsAMarginOfOcean(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"###############...............",
|
||||
"########......................",
|
||||
"..............................",
|
||||
"..............................",
|
||||
}, 3)
|
||||
part, err := Build(m, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) != 1 {
|
||||
t.Fatalf("got %d regions, want 1", len(part.Regions))
|
||||
}
|
||||
r := part.Regions[0]
|
||||
if r.Frame.Y0 != 0 {
|
||||
t.Errorf("frame Y0 = %d, want 0: the cap reaches into the pad", r.Frame.Y0)
|
||||
}
|
||||
assertBordersAreWater(t, part, m)
|
||||
}
|
||||
|
||||
// Every painted land cell belongs to exactly one region, and a round trip through Cut and Composite
|
||||
// reproduces it. If two regions owned the same cell, one would silently overwrite the other.
|
||||
func TestCutAndCompositeCoverEveryLandCellOnce(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"#..####...................#",
|
||||
"#..####...................#",
|
||||
"...........................",
|
||||
"..........##.....##........",
|
||||
"..........##.....##........",
|
||||
"...........................",
|
||||
}, 3)
|
||||
part, err := Build(m, 2, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) < 2 {
|
||||
t.Fatalf("got %d regions; the picture has several separate landmasses", len(part.Regions))
|
||||
}
|
||||
|
||||
dst := make([]float32, m.P.W*m.P.H)
|
||||
hits := make([]int, len(dst))
|
||||
total := 0
|
||||
for _, r := range part.Regions {
|
||||
_, land := part.Cut(m, r)
|
||||
src := make([]float32, r.Frame.Cells())
|
||||
for i := range src {
|
||||
if land[i] {
|
||||
src[i] = float32(r.ID + 1)
|
||||
}
|
||||
}
|
||||
total += part.Composite(dst, m, r, src)
|
||||
for y := 0; y < r.Frame.H; y++ {
|
||||
for x := 0; x < r.Frame.W; x++ {
|
||||
pi := r.Frame.PlanetIdx(x, y)
|
||||
if part.Owner[pi] == int32(r.ID) && !m.Sea[pi] {
|
||||
hits[pi]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
painted := 0
|
||||
for i := range m.Sea {
|
||||
if !m.Sea[i] {
|
||||
painted++
|
||||
}
|
||||
}
|
||||
if total != painted {
|
||||
t.Errorf("composited %d land cells, but %d are painted", total, painted)
|
||||
}
|
||||
for i, n := range hits {
|
||||
if m.Sea[i] {
|
||||
if n != 0 {
|
||||
t.Fatalf("water cell %d was written %d times", i, n)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("land cell %d was written %d times, want exactly 1", i, n)
|
||||
}
|
||||
if dst[i] == 0 {
|
||||
t.Fatalf("land cell %d came back zero", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A landmass that rings the planet cannot be flattened into a rectangle with water on both sides, and the
|
||||
// solve needs that. Better a clear refusal than a silently frozen coastline.
|
||||
func TestALandmassRingingThePlanetIsRefused(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"##########",
|
||||
"..........",
|
||||
"..........",
|
||||
"..........",
|
||||
}, 2)
|
||||
_, err := Build(m, 2, 1)
|
||||
if err == nil {
|
||||
t.Fatal("accepted a landmass that goes all the way round")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "all the way round") {
|
||||
t.Errorf("error %q does not say why", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A stray paint pixel should not cost a whole region.
|
||||
func TestSpecksAreDroppedAndCounted(t *testing.T) {
|
||||
m := testMap(t, []string{
|
||||
"####......................",
|
||||
"####...............#......",
|
||||
"####......................",
|
||||
"..........................",
|
||||
}, 2)
|
||||
part, err := Build(m, 2, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) != 1 {
|
||||
t.Fatalf("got %d regions, want 1", len(part.Regions))
|
||||
}
|
||||
if part.DroppedRegions != 1 || part.DroppedCells != 1 {
|
||||
t.Errorf("dropped %d regions / %d cells, want 1 and 1", part.DroppedRegions, part.DroppedCells)
|
||||
}
|
||||
// And the speck is not owned by anything, so nothing solves it.
|
||||
for i := range m.Sea {
|
||||
if !m.Sea[i] && part.Owner[i] < 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("the speck is still owned by a region")
|
||||
}
|
||||
|
||||
func TestTheMarginMustFitInsideThePad(t *testing.T) {
|
||||
m := testMap(t, []string{"####......", ".........."}, 1)
|
||||
if _, err := Build(m, 4, 1); err == nil {
|
||||
t.Fatal("accepted a margin wider than the polar pad")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpanWrapsTheShortWay(t *testing.T) {
|
||||
mark := func(w int, on ...int) []bool {
|
||||
c := make([]bool, w)
|
||||
for _, x := range on {
|
||||
c[x] = true
|
||||
}
|
||||
return c
|
||||
}
|
||||
cases := []struct {
|
||||
cols []bool
|
||||
w int
|
||||
x0, wanted int
|
||||
}{
|
||||
{mark(10, 0, 1, 2), 10, 0, 3},
|
||||
{mark(10, 8, 9, 0, 1), 10, 8, 4},
|
||||
{mark(10, 5), 10, 5, 1},
|
||||
{mark(10, 0, 5), 10, 5, 6},
|
||||
}
|
||||
for _, c := range cases {
|
||||
x0, w := span(c.cols, c.w)
|
||||
if x0 != c.x0 || w != c.wanted {
|
||||
t.Errorf("span = %d+%d, want %d+%d", x0, w, c.x0, c.wanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// Gathering a world's statistics one piece at a time.
|
||||
//
|
||||
// The geology is solved one landmass at a time (D-53) and a planet's regions never exist together, so a
|
||||
// planet-wide statistic has to be assembled rather than computed. Terrain.md's rule for that is "statistics
|
||||
// pool across regions rather than being computed per region and averaged", and until now it was a rule with
|
||||
// no implementation: the whole package took a grid and sorted it, so a planet bake printed its elevation
|
||||
// range and nothing else - no slope distribution, no per-uplift-class breakdown, no drainage density. The
|
||||
// block the documentation calls the one that matters most was the one that could not be afforded.
|
||||
//
|
||||
// An Accumulator is what makes the rule true. Every quantity in it is either a counter, an exact running
|
||||
// extreme, or a Histogram, and all three are **additive**: merging two regions and reading the result gives
|
||||
// exactly what one pass over both would have. See histogram.go for why that is the whole design and not an
|
||||
// implementation detail.
|
||||
//
|
||||
// Add takes a grid. It does not care whether that grid is one region of a planet or the whole square canvas,
|
||||
// which is the other half of the point: `generate` and `bake` now compute their statistics with the same
|
||||
// code, so a number measured on one is comparable with the same number measured on the other.
|
||||
|
||||
// Options are the constants a world is judged against. They have to be the same for every region of a planet,
|
||||
// which is why they live on the accumulator rather than being passed to each Add.
|
||||
type Options struct {
|
||||
// ElevMin and ElevMax bound the elevation histogram, and they are the manifest's encoding range on
|
||||
// purpose rather than the data's own extremes. A histogram's bounds have to be known before the first
|
||||
// value arrives, or two regions would bin against different scales and could not be merged - and the
|
||||
// encoding range is the one bound that is a property of the world rather than of whatever happens to be
|
||||
// in front of it. Anything outside is counted as out of range, which is also what the clip fraction is
|
||||
// about.
|
||||
ElevMin, ElevMax float64
|
||||
|
||||
TalusDeg float64 // the angle of repose, for the "pinned against the clamp" share
|
||||
ReliefWindowM float64 // the side of the square local relief is taken over
|
||||
ChannelM2 float64 // drainage area at which a cell counts as a channel
|
||||
K, M, N float64 // the stream-power constants, for the slope-area normalisation
|
||||
}
|
||||
|
||||
// slopeBins and elevBins are the resolutions. A twentieth of a degree and a metre or two of elevation are far
|
||||
// finer than any verdict in Summary turns on, and the whole structure is a few tens of kilobytes either way.
|
||||
const (
|
||||
slopeBins = 2048
|
||||
elevBins = 4096
|
||||
logSABins = 1024
|
||||
)
|
||||
|
||||
// bucketAcc is one uplift class's share of the accumulator.
|
||||
type bucketAcc struct {
|
||||
slope, relief, elev *Histogram
|
||||
near, total int64
|
||||
}
|
||||
|
||||
// saBin is one decade-fraction of drainage area in the slope-area plot.
|
||||
type saBin struct{ norm, raw *Histogram }
|
||||
|
||||
// Accumulator gathers one world's statistics, a grid at a time.
|
||||
type Accumulator struct {
|
||||
opt Options
|
||||
|
||||
Cells, Land, Clip int64
|
||||
MinM, MaxM float64 // the whole field, sea floor included: what the 16-bit encoding has to hold
|
||||
|
||||
elev *Histogram // land only
|
||||
slope *Histogram // land only, degrees
|
||||
|
||||
buckets []bucketAcc
|
||||
sa map[int]*saBin
|
||||
|
||||
saChannels int64
|
||||
channelCells int64
|
||||
leafCells int64
|
||||
}
|
||||
|
||||
// New returns an empty accumulator.
|
||||
func New(opt Options) *Accumulator {
|
||||
if opt.ElevMax <= opt.ElevMin {
|
||||
opt.ElevMin, opt.ElevMax = -1024, 2048
|
||||
}
|
||||
a := &Accumulator{
|
||||
opt: opt,
|
||||
MinM: math.Inf(1), MaxM: math.Inf(-1),
|
||||
elev: NewHistogram(opt.ElevMin, opt.ElevMax, elevBins),
|
||||
slope: NewHistogram(0, 90, slopeBins),
|
||||
sa: map[int]*saBin{},
|
||||
}
|
||||
span := opt.ElevMax - opt.ElevMin
|
||||
a.buckets = make([]bucketAcc, len(bucketDefs))
|
||||
for i := range a.buckets {
|
||||
a.buckets[i] = bucketAcc{
|
||||
slope: NewHistogram(0, 90, slopeBins),
|
||||
relief: NewHistogram(0, span, elevBins),
|
||||
elev: NewHistogram(opt.ElevMin, opt.ElevMax, elevBins),
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Input is one grid and everything known about it. Everything but H and Land is optional; a caller with no
|
||||
// flow topology gets the statistics that do not need one.
|
||||
type Input struct {
|
||||
H *field.Field
|
||||
Land []bool // nil means every cell is land
|
||||
|
||||
// WrapX says whether this grid's left and right edges are the same meridian. A region of a planet is a
|
||||
// rectangle cut out of the cylinder with water all round it, so it does *not* wrap; the whole square
|
||||
// canvas does not either. It is here because the local relief window is the one thing that reads
|
||||
// neighbours, and being wrong about it would put a seam in one column of the relief map.
|
||||
WrapX bool
|
||||
|
||||
UpliftMYr []float32 // per cell; without it there is no per-class breakdown
|
||||
KLocal []float32 // the lithology multiplier, for the slope-area normalisation
|
||||
|
||||
// The flow topology, for slope-area and drainage density. All three or none.
|
||||
Area []float32
|
||||
Receiver []int32
|
||||
Length []float32
|
||||
}
|
||||
|
||||
// AddExtent records what a *finished* grid covers: how many cells, how many of them are land, how many fall
|
||||
// outside the encoding range, and the extremes over everything including the sea floor.
|
||||
//
|
||||
// It is separate from Add because on a planet the two are measured in different places, and measuring them in
|
||||
// the wrong one is silently wrong rather than obviously so. A region is a rectangle cut out of the cylinder
|
||||
// with an ocean margin round it, and neighbouring regions' margins overlap - so pooling "cells" across regions
|
||||
// counts the same water more than once and reports a land fraction that means nothing. The extent is a
|
||||
// property of the composited planet and is measured once, on it. Land statistics are the opposite: they are
|
||||
// per landmass, disjoint by construction, and never see the finished cylinder at all.
|
||||
func (a *Accumulator) AddExtent(data []float32, land []bool, clipCells int64) {
|
||||
a.Clip += clipCells
|
||||
for i, v := range data {
|
||||
a.Cells++
|
||||
f := float64(v)
|
||||
if f < a.MinM {
|
||||
a.MinM = f
|
||||
}
|
||||
if f > a.MaxM {
|
||||
a.MaxM = f
|
||||
}
|
||||
if land == nil || land[i] {
|
||||
a.Land++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add folds one grid's land statistics in. It reads only the cells the mask calls land, and it deliberately
|
||||
// records nothing about the grid's extent - see AddExtent.
|
||||
func (a *Accumulator) Add(in Input) {
|
||||
h := in.H
|
||||
if h == nil || len(h.Data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Local relief first, because it is the one quantity that needs a neighbourhood and therefore a whole
|
||||
// field of its own. Two sliding passes, O(1) a cell whatever the window: the loop this replaces was
|
||||
// 1.1e11 comparisons on a planet, which is why no planet bake has ever printed this block.
|
||||
var relief *field.Field
|
||||
if a.opt.ReliefWindowM > 0 && in.UpliftMYr != nil {
|
||||
r := int(math.Round(a.opt.ReliefWindowM / h.CellM / 2))
|
||||
if r < 1 {
|
||||
r = 1
|
||||
}
|
||||
relief = field.LocalRelief(h, r, in.WrapX)
|
||||
}
|
||||
|
||||
inv := 1.0 / (2.0 * h.CellM)
|
||||
cellArea := h.CellM * h.CellM
|
||||
for y := 0; y < h.H; y++ {
|
||||
for x := 0; x < h.W; x++ {
|
||||
i := y*h.W + x
|
||||
if in.Land != nil && !in.Land[i] {
|
||||
continue
|
||||
}
|
||||
v := float64(h.Data[i])
|
||||
a.elev.Add(v)
|
||||
|
||||
// The slope inline rather than through Field.Slope: that allocates a whole field, which at
|
||||
// planet scale is 300 MB per call and there would be two of them.
|
||||
gx := float64(h.AtClamped(x+1, y)-h.AtClamped(x-1, y)) * inv
|
||||
gy := float64(h.AtClamped(x, y+1)-h.AtClamped(x, y-1)) * inv
|
||||
deg := math.Atan(math.Hypot(gx, gy)) * 180 / math.Pi
|
||||
a.slope.Add(deg)
|
||||
|
||||
if in.UpliftMYr != nil {
|
||||
if b := bucketOf(float64(in.UpliftMYr[i]) * 1000); b >= 0 {
|
||||
acc := &a.buckets[b]
|
||||
acc.total++
|
||||
acc.slope.Add(deg)
|
||||
acc.elev.Add(v)
|
||||
if relief != nil {
|
||||
acc.relief.Add(float64(relief.Data[i]))
|
||||
}
|
||||
if deg >= a.opt.TalusDeg-2 { // pinned against the clamp rather than shaped by erosion
|
||||
acc.near++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if in.Area == nil {
|
||||
continue
|
||||
}
|
||||
if float64(in.Area[i]) >= a.opt.ChannelM2 {
|
||||
a.channelCells++
|
||||
}
|
||||
// A leaf is a cell that drains nothing but itself, and what it measures is the router when the
|
||||
// ground is smooth - not the landscape when it is finished. On a planar ramp with no erosion at
|
||||
// all, D8 leaves 29.5 % of the grid draining nothing, because a cell either sits on one of its
|
||||
// parallel flow lines or it does not; multiple-flow leaves 0.4 %, which is the strict local
|
||||
// maxima. After three hundred steps of solving the same ramp both come back near 8 %: the
|
||||
// terrain has dissected itself by then and its own divides dominate the count. So read this on
|
||||
// young ground, on a stage dump, or against another run of the same age, and do not read it as a
|
||||
// verdict on a mature one. It is a count, so it pools across regions exactly.
|
||||
if float64(in.Area[i]) <= cellArea*1.001 {
|
||||
a.leafCells++
|
||||
}
|
||||
a.addSlopeArea(in, i, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addSlopeArea records one channel cell in the slope-area plot.
|
||||
//
|
||||
// S is the gradient *along the flow path*, not the magnitude of the topographic gradient: on a valley floor
|
||||
// the central difference is dominated by the walls across the channel, which reads far steeper than the water
|
||||
// actually runs and bends the fitted exponent well past -m/n. And the slope is normalised by (U/K)^(1/n) with
|
||||
// the *local* K, because erodibility correlates with drainage area by construction - soft rock is cut down,
|
||||
// sits low and collects flow - so one global K mis-corrects the large-A end systematically.
|
||||
func (a *Accumulator) addSlopeArea(in Input, i int, h *field.Field) {
|
||||
if in.Receiver == nil || in.Length == nil || in.UpliftMYr == nil {
|
||||
return
|
||||
}
|
||||
r := in.Receiver[i]
|
||||
if int(r) == i { // a root drains to itself and has no gradient to measure
|
||||
return
|
||||
}
|
||||
area := float64(in.Area[i])
|
||||
s := float64(h.Data[i]-h.Data[r]) / float64(in.Length[i])
|
||||
if area < a.opt.ChannelM2 || s <= 1e-6 {
|
||||
return
|
||||
}
|
||||
u := float64(in.UpliftMYr[i])
|
||||
kk := a.opt.K
|
||||
if in.KLocal != nil {
|
||||
kk *= float64(in.KLocal[i])
|
||||
}
|
||||
if u <= 0 || kk <= 0 || a.opt.N <= 0 {
|
||||
return // no steady state to normalise against
|
||||
}
|
||||
a.saChannels++
|
||||
key := int(math.Floor(math.Log10(area) * binsPerDecade))
|
||||
b := a.sa[key]
|
||||
if b == nil {
|
||||
b = &saBin{norm: NewHistogram(-8, 4, logSABins), raw: NewHistogram(-8, 4, logSABins)}
|
||||
a.sa[key] = b
|
||||
}
|
||||
b.norm.Add(math.Log10(s / math.Pow(u/kk, 1/a.opt.N)))
|
||||
b.raw.Add(math.Log10(s))
|
||||
}
|
||||
|
||||
const binsPerDecade = 4
|
||||
|
||||
// Merge folds another accumulator in. Every field is additive by construction; see histogram.go.
|
||||
func (a *Accumulator) Merge(o *Accumulator) {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
a.Cells += o.Cells
|
||||
a.Land += o.Land
|
||||
a.Clip += o.Clip
|
||||
a.saChannels += o.saChannels
|
||||
a.channelCells += o.channelCells
|
||||
a.leafCells += o.leafCells
|
||||
a.MinM = math.Min(a.MinM, o.MinM)
|
||||
a.MaxM = math.Max(a.MaxM, o.MaxM)
|
||||
a.elev.Merge(o.elev)
|
||||
a.slope.Merge(o.slope)
|
||||
for i := range a.buckets {
|
||||
if i >= len(o.buckets) {
|
||||
break
|
||||
}
|
||||
a.buckets[i].slope.Merge(o.buckets[i].slope)
|
||||
a.buckets[i].relief.Merge(o.buckets[i].relief)
|
||||
a.buckets[i].elev.Merge(o.buckets[i].elev)
|
||||
a.buckets[i].near += o.buckets[i].near
|
||||
a.buckets[i].total += o.buckets[i].total
|
||||
}
|
||||
// Sorted, because Go randomises map iteration and cross-cutting rule 12 says the answer must not depend
|
||||
// on it. Here it would only change the order two float sums happen in, which is exactly the sort of "it
|
||||
// does not matter this time" the rule exists to refuse.
|
||||
keys := make([]int, 0, len(o.sa))
|
||||
for k := range o.sa {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
for _, k := range keys {
|
||||
b := a.sa[k]
|
||||
if b == nil {
|
||||
b = &saBin{norm: NewHistogram(-8, 4, logSABins), raw: NewHistogram(-8, 4, logSABins)}
|
||||
a.sa[k] = b
|
||||
}
|
||||
b.norm.Merge(o.sa[k].norm)
|
||||
b.raw.Merge(o.sa[k].raw)
|
||||
}
|
||||
}
|
||||
|
||||
// bucketDefs are the uplift classes the breakdown splits on.
|
||||
//
|
||||
// Absolute rather than percentiles of this map's own field: the point is to compare one run against the next,
|
||||
// and a percentile split would redefine "plain" every time the uplift field was retuned. They are reporting
|
||||
// buckets and not a description of terrain - 0.1 mm/yr is a fourteen-degree hillslope at an 8 m cell, which
|
||||
// is hill country wherever it is painted, and reading this axis as guidance is how a legend once ended up ten
|
||||
// times too hot (D-55).
|
||||
var bucketDefs = []struct {
|
||||
name string
|
||||
lo, hi float64
|
||||
}{
|
||||
{"plain", 0, 0.1},
|
||||
{"rolling", 0.1, 0.5},
|
||||
// The top bound is finite rather than +Inf only because the report is marshalled to meta.json and
|
||||
// encoding/json refuses an infinity. 100 mm/yr is an order of magnitude above anything on Earth.
|
||||
{"mountain", 0.5, 100},
|
||||
}
|
||||
|
||||
func bucketOf(mmYr float64) int {
|
||||
for i, d := range bucketDefs {
|
||||
if mmYr >= d.lo && mmYr < d.hi {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Report turns everything gathered into the numbers a run is judged by.
|
||||
func (a *Accumulator) Report(cellM float64) Report {
|
||||
r := Report{
|
||||
MinM: a.MinM, MaxM: a.MaxM, ReliefM: a.MaxM - a.MinM,
|
||||
}
|
||||
if a.Cells > 0 {
|
||||
r.LandFraction = float64(a.Land) / float64(a.Cells)
|
||||
r.ClipFraction = float64(a.Clip) / float64(a.Cells)
|
||||
}
|
||||
r.LandCells = a.Land
|
||||
r.MeasuredLandCells = a.elev.Count
|
||||
if a.elev.Count == 0 {
|
||||
return r
|
||||
}
|
||||
r.LandMinM, r.LandMaxM = a.elev.MinV, a.elev.MaxV
|
||||
r.LandReliefM = r.LandMaxM - r.LandMinM
|
||||
|
||||
r.Slopes = Slopes{
|
||||
Under15Deg: a.slope.FracBelow(15),
|
||||
Under30Deg: a.slope.FracBelow(30),
|
||||
Over50Deg: 1 - a.slope.FracBelow(50),
|
||||
MedianDeg: a.slope.Quantile(0.5),
|
||||
}
|
||||
|
||||
// The hypsometric integral is a *mean* of the normalised elevation, so it comes off the exact running sum
|
||||
// rather than out of the bins: (sum - n*lo) / (n*span). The curve is the binned part, which is what it
|
||||
// should be - it is eleven fractions and nobody reads the third decimal of one.
|
||||
if span := r.LandMaxM - r.LandMinM; span > 1e-6 {
|
||||
r.Hypsometry.Integral = (a.elev.Sum - float64(a.elev.Count)*r.LandMinM) /
|
||||
(float64(a.elev.Count) * span)
|
||||
curve := make([]float64, 11)
|
||||
for i := 0; i <= 10; i++ {
|
||||
curve[i] = 1 - a.elev.FracBelow(r.LandMinM+span*float64(i)/10)
|
||||
}
|
||||
r.Hypsometry.Curve = curve
|
||||
}
|
||||
|
||||
// Channel length over the area the channels were *counted* in, which is the land Add walked and not the
|
||||
// land the planet has. On a full bake the two are the same number. On a partial one - `bake --only` - the
|
||||
// extent is still the whole cylinder while the land statistics cover three islands, and dividing one by
|
||||
// the other would report a drainage density an order of magnitude low with nothing to say it had.
|
||||
if measured := a.elev.Count; measured > 0 && (a.channelCells > 0 || a.saChannels > 0) {
|
||||
lengthKm := float64(a.channelCells) * cellM / 1000
|
||||
areaKm2 := float64(measured) * cellM * cellM / 1e6
|
||||
if areaKm2 > 0 {
|
||||
r.DrainageDensity = lengthKm / areaKm2
|
||||
}
|
||||
}
|
||||
if measured := a.elev.Count; measured > 0 {
|
||||
r.LeafFraction = float64(a.leafCells) / float64(measured)
|
||||
}
|
||||
r.SlopeArea = a.slopeArea()
|
||||
r.Buckets = a.bucketReport(cellM)
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *Accumulator) slopeArea() SlopeArea {
|
||||
out := SlopeArea{Expected: expectedGradient, Channels: int(a.saChannels),
|
||||
ThreshKm2: a.opt.ChannelM2 / 1e6}
|
||||
keys := make([]int, 0, len(a.sa))
|
||||
for k := range a.sa {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
|
||||
var xs, normYs, rawYs []float64
|
||||
for _, key := range keys {
|
||||
b := a.sa[key]
|
||||
if b.norm.Count < 8 { // a bin with a handful of cells is noise, not a data point
|
||||
continue
|
||||
}
|
||||
logA := (float64(key) + 0.5) / binsPerDecade
|
||||
med := b.norm.Quantile(0.5)
|
||||
out.Bins = append(out.Bins, Bin{LogA: logA, LogS: med, N: int(b.norm.Count)})
|
||||
xs = append(xs, logA)
|
||||
normYs = append(normYs, med)
|
||||
rawYs = append(rawYs, b.raw.Quantile(0.5))
|
||||
}
|
||||
out.Exponent, out.R2 = fitLine(xs, normYs)
|
||||
out.RawExponent, out.RawR2 = fitLine(xs, rawYs)
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Accumulator) bucketReport(cellM float64) []UpliftBucket {
|
||||
total := int64(0)
|
||||
for i := range a.buckets {
|
||||
total += a.buckets[i].total
|
||||
}
|
||||
if total == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]UpliftBucket, 0, len(bucketDefs))
|
||||
for i, d := range bucketDefs {
|
||||
b := &a.buckets[i]
|
||||
if b.total == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, UpliftBucket{
|
||||
Name: d.name, LoMmYr: d.lo, HiMmYr: d.hi,
|
||||
LandFrac: float64(b.total) / float64(total),
|
||||
MedianDeg: b.slope.Quantile(0.5),
|
||||
P90Deg: b.slope.Quantile(0.9),
|
||||
MedianRelM: b.relief.Quantile(0.5),
|
||||
WindowM: a.opt.ReliefWindowM,
|
||||
NearTalus: float64(b.near) / float64(b.total),
|
||||
MedianElevM: b.elev.Quantile(0.5),
|
||||
Cells: int(b.total),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// A small world with real structure in it: a coast, a range, a plain, and a sea the statistics have to leave
|
||||
// out. Deterministic, so both halves of every comparison see the same ground.
|
||||
func testWorld(t *testing.T, w, h int, cellM float64) (*field.Field, []bool, []float32) {
|
||||
t.Helper()
|
||||
r := rand.New(rand.NewPCG(11, 13))
|
||||
f := field.New(w, h, cellM)
|
||||
land := make([]bool, w*h)
|
||||
up := make([]float32, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if x < w/8 {
|
||||
f.Data[i] = -40 // the sea, which must not appear in any land statistic
|
||||
continue
|
||||
}
|
||||
land[i] = true
|
||||
t := float64(x) / float64(w)
|
||||
// A range towards the east, a plain in the middle, and enough noise to give the slopes a spread.
|
||||
// The 40 m base keeps every land cell above sea level, so "the land minimum is positive" is a
|
||||
// statement about the mask rather than about this formula.
|
||||
f.Data[i] = float32(40 + 300*t*t + 18*math.Sin(float64(x)/9)*math.Cos(float64(y)/7) +
|
||||
r.NormFloat64()*3)
|
||||
up[i] = float32((0.02 + 0.9*t*t*t) / 1000)
|
||||
}
|
||||
}
|
||||
return f, land, up
|
||||
}
|
||||
|
||||
func testOptions() Options {
|
||||
return Options{ElevMin: -1024, ElevMax: 2048, TalusDeg: 35, ReliefWindowM: 500,
|
||||
ChannelM2: 1e6, K: 5e-5, M: 0.5, N: 1}
|
||||
}
|
||||
|
||||
// The histograms replaced sorts, and the whole point is that nothing a run is judged by moved. This is the
|
||||
// same data through both: the old implementation is reproduced here as the reference, so that a future change
|
||||
// to the fast path has something to be wrong against.
|
||||
func TestTheHistogramsAgreeWithSorting(t *testing.T) {
|
||||
const w, h, cellM = 220, 160, 8.0
|
||||
f, land, up := testWorld(t, w, h, cellM)
|
||||
|
||||
acc := New(testOptions())
|
||||
acc.Add(Input{H: f, Land: land, UpliftMYr: up})
|
||||
acc.AddExtent(f.Data, land, 0)
|
||||
got := acc.Report(cellM)
|
||||
|
||||
// --- the reference, by sorting, exactly as the package used to do it -----------------------------
|
||||
slope := f.Slope()
|
||||
var degs, elevs []float64
|
||||
for i := range f.Data {
|
||||
if !land[i] {
|
||||
continue
|
||||
}
|
||||
degs = append(degs, math.Atan(float64(slope.Data[i]))*180/math.Pi)
|
||||
elevs = append(elevs, float64(f.Data[i]))
|
||||
}
|
||||
sort.Float64s(degs)
|
||||
sort.Float64s(elevs)
|
||||
frac := func(v []float64, limit float64) float64 {
|
||||
return float64(sort.SearchFloat64s(v, limit)) / float64(len(v))
|
||||
}
|
||||
|
||||
const slopeTol = 90.0 / slopeBins // one bin: the whole error budget of a histogram quantile
|
||||
if d := math.Abs(got.Slopes.MedianDeg - degs[len(degs)/2]); d > slopeTol {
|
||||
t.Errorf("median slope %.4f against %.4f", got.Slopes.MedianDeg, degs[len(degs)/2])
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
got float64
|
||||
want float64
|
||||
}{
|
||||
{"under 15", got.Slopes.Under15Deg, frac(degs, 15)},
|
||||
{"under 30", got.Slopes.Under30Deg, frac(degs, 30)},
|
||||
{"over 50", got.Slopes.Over50Deg, 1 - frac(degs, 50)},
|
||||
} {
|
||||
if math.Abs(c.got-c.want) > 0.002 {
|
||||
t.Errorf("slopes %s: %.4f against %.4f", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// The hypsometric integral is a mean and is carried exactly, so it has to match to the bit of a float sum.
|
||||
lo, hi := elevs[0], elevs[len(elevs)-1]
|
||||
var sum float64
|
||||
for _, v := range elevs {
|
||||
sum += (v - lo) / (hi - lo)
|
||||
}
|
||||
if d := math.Abs(got.Hypsometry.Integral - sum/float64(len(elevs))); d > 1e-9 {
|
||||
t.Errorf("hypsometric integral %.6f against %.6f", got.Hypsometry.Integral, sum/float64(len(elevs)))
|
||||
}
|
||||
if got.LandMinM != lo || got.LandMaxM != hi {
|
||||
t.Errorf("land range %.3f..%.3f against %.3f..%.3f", got.LandMinM, got.LandMaxM, lo, hi)
|
||||
}
|
||||
|
||||
// And the per-class breakdown, which is the block that matters most.
|
||||
for _, b := range got.Buckets {
|
||||
var bdeg []float64
|
||||
for i := range f.Data {
|
||||
if !land[i] {
|
||||
continue
|
||||
}
|
||||
mm := float64(up[i]) * 1000
|
||||
if mm < b.LoMmYr || mm >= b.HiMmYr {
|
||||
continue
|
||||
}
|
||||
bdeg = append(bdeg, math.Atan(float64(slope.Data[i]))*180/math.Pi)
|
||||
}
|
||||
if len(bdeg) != b.Cells {
|
||||
t.Errorf("bucket %s holds %d cells, the reference found %d", b.Name, b.Cells, len(bdeg))
|
||||
}
|
||||
sort.Float64s(bdeg)
|
||||
if d := math.Abs(b.MedianDeg - bdeg[len(bdeg)/2]); d > slopeTol {
|
||||
t.Errorf("bucket %s median %.4f against %.4f", b.Name, b.MedianDeg, bdeg[len(bdeg)/2])
|
||||
}
|
||||
p90 := bdeg[min(len(bdeg)*9/10, len(bdeg)-1)]
|
||||
if d := math.Abs(b.P90Deg - p90); d > slopeTol {
|
||||
t.Errorf("bucket %s P90 %.4f against %.4f", b.Name, b.P90Deg, p90)
|
||||
}
|
||||
}
|
||||
if len(got.Buckets) < 2 {
|
||||
t.Fatalf("only %d buckets came out; this test measured almost nothing", len(got.Buckets))
|
||||
}
|
||||
}
|
||||
|
||||
// The property the planet depends on: a world cut into pieces and accumulated piece by piece has to report
|
||||
// what one pass over the whole thing would. Everything here is additive by construction, and this is the
|
||||
// assertion that says so end to end rather than one histogram at a time.
|
||||
func TestPoolingPiecesMatchesOnePass(t *testing.T) {
|
||||
const w, h, cellM = 240, 120, 8.0
|
||||
f, land, up := testWorld(t, w, h, cellM)
|
||||
|
||||
whole := New(testOptions())
|
||||
whole.Add(Input{H: f, Land: land, UpliftMYr: up})
|
||||
whole.AddExtent(f.Data, land, 0)
|
||||
|
||||
// The same ground in three horizontal strips. Slope and relief read neighbours, so a strip's own edge
|
||||
// rows differ from the whole - which is exactly the seam a region has, and the reason the comparison
|
||||
// below is on the *distributions* rather than cell by cell.
|
||||
pooled := New(testOptions())
|
||||
for _, band := range [][2]int{{0, 40}, {40, 80}, {80, 120}} {
|
||||
sub := field.New(w, band[1]-band[0], cellM)
|
||||
subLand := make([]bool, w*(band[1]-band[0]))
|
||||
subUp := make([]float32, len(subLand))
|
||||
copy(sub.Data, f.Data[band[0]*w:band[1]*w])
|
||||
copy(subLand, land[band[0]*w:band[1]*w])
|
||||
copy(subUp, up[band[0]*w:band[1]*w])
|
||||
pooled.Add(Input{H: sub, Land: subLand, UpliftMYr: subUp})
|
||||
pooled.AddExtent(sub.Data, subLand, 0)
|
||||
}
|
||||
|
||||
a, b := whole.Report(cellM), pooled.Report(cellM)
|
||||
if a.LandFraction != b.LandFraction {
|
||||
t.Errorf("land fraction %.6f pooled against %.6f whole", b.LandFraction, a.LandFraction)
|
||||
}
|
||||
if a.LandMinM != b.LandMinM || a.LandMaxM != b.LandMaxM {
|
||||
t.Errorf("land range %.3f..%.3f pooled against %.3f..%.3f",
|
||||
b.LandMinM, b.LandMaxM, a.LandMinM, a.LandMaxM)
|
||||
}
|
||||
// Elevation does not read neighbours at all, so it has to pool to the bit.
|
||||
if math.Abs(a.Hypsometry.Integral-b.Hypsometry.Integral) > 1e-12 {
|
||||
t.Errorf("hypsometric integral %.9f pooled against %.9f", b.Hypsometry.Integral, a.Hypsometry.Integral)
|
||||
}
|
||||
// Slope reads one cell either side, so six rows of a 120-row world are clamped differently. The
|
||||
// distribution has to survive that; a tenth of a degree is far inside anything Summary turns on.
|
||||
if d := math.Abs(a.Slopes.MedianDeg - b.Slopes.MedianDeg); d > 0.1 {
|
||||
t.Errorf("median slope %.3f pooled against %.3f", b.Slopes.MedianDeg, a.Slopes.MedianDeg)
|
||||
}
|
||||
for i := range a.Buckets {
|
||||
if i >= len(b.Buckets) {
|
||||
t.Fatalf("pooling lost a bucket: %d against %d", len(b.Buckets), len(a.Buckets))
|
||||
}
|
||||
if a.Buckets[i].Cells != b.Buckets[i].Cells {
|
||||
t.Errorf("bucket %s: %d cells pooled against %d", a.Buckets[i].Name,
|
||||
b.Buckets[i].Cells, a.Buckets[i].Cells)
|
||||
}
|
||||
if d := math.Abs(a.Buckets[i].MedianDeg - b.Buckets[i].MedianDeg); d > 0.2 {
|
||||
t.Errorf("bucket %s median %.3f pooled against %.3f", a.Buckets[i].Name,
|
||||
b.Buckets[i].MedianDeg, a.Buckets[i].MedianDeg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge is the other way pieces arrive - a planet's regions are accumulated separately and folded together -
|
||||
// and it has to be the same as adding them to one accumulator.
|
||||
func TestMergeMatchesAddingToOne(t *testing.T) {
|
||||
const w, h, cellM = 160, 60, 8.0
|
||||
f, land, up := testWorld(t, w, h, cellM)
|
||||
|
||||
one := New(testOptions())
|
||||
one.Add(Input{H: f, Land: land, UpliftMYr: up})
|
||||
one.AddExtent(f.Data, land, 0)
|
||||
one.Add(Input{H: f, Land: land, UpliftMYr: up})
|
||||
one.AddExtent(f.Data, land, 0)
|
||||
|
||||
a, b := New(testOptions()), New(testOptions())
|
||||
a.Add(Input{H: f, Land: land, UpliftMYr: up})
|
||||
a.AddExtent(f.Data, land, 0)
|
||||
b.Add(Input{H: f, Land: land, UpliftMYr: up})
|
||||
b.AddExtent(f.Data, land, 0)
|
||||
a.Merge(b)
|
||||
|
||||
x, y := one.Report(cellM), a.Report(cellM)
|
||||
if x.LandFraction != y.LandFraction || x.Slopes.MedianDeg != y.Slopes.MedianDeg ||
|
||||
x.LandMinM != y.LandMinM || x.LandMaxM != y.LandMaxM {
|
||||
t.Errorf("merged report differs from one built by adding twice:\n %+v\n %+v", x.Slopes, y.Slopes)
|
||||
}
|
||||
for i := range x.Buckets {
|
||||
if x.Buckets[i].Cells != y.Buckets[i].Cells || x.Buckets[i].MedianDeg != y.Buckets[i].MedianDeg {
|
||||
t.Errorf("bucket %s differs after a merge", x.Buckets[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sea cells are counted for the land fraction and for the encoding range, and are in nothing else. A single
|
||||
// -40 m sea floor in a land statistic would flatter every relief number by forty metres for free.
|
||||
func TestTheSeaIsNotLand(t *testing.T) {
|
||||
const w, h, cellM = 120, 80, 8.0
|
||||
f, land, up := testWorld(t, w, h, cellM)
|
||||
acc := New(testOptions())
|
||||
acc.Add(Input{H: f, Land: land, UpliftMYr: up})
|
||||
acc.AddExtent(f.Data, land, 0)
|
||||
r := acc.Report(cellM)
|
||||
|
||||
if r.LandMinM < 0 {
|
||||
t.Errorf("land minimum is %.1f m; the sea got into the land statistics", r.LandMinM)
|
||||
}
|
||||
if r.MinM > -39 {
|
||||
t.Errorf("whole-field minimum is %.1f m; the sea should still bound the encoding range", r.MinM)
|
||||
}
|
||||
wantLand := 0
|
||||
for _, v := range land {
|
||||
if v {
|
||||
wantLand++
|
||||
}
|
||||
}
|
||||
if got := int(r.LandFraction*float64(w*h) + 0.5); got != wantLand {
|
||||
t.Errorf("land fraction says %d cells, the mask has %d", got, wantLand)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// The whole reason this package was rewritten: the sorts could not be afforded at planet scale. 3000 x 3000
|
||||
// is about what region 12 of the 100 km template is - 9 M cells, the largest single landmass - so this is the
|
||||
// cost of the biggest piece a bake ever hands over, and the planet is the sum of twenty of them.
|
||||
//
|
||||
// A benchmark rather than a test: it measures rather than asserts, and nothing here should fail a build.
|
||||
//
|
||||
// go test ./internal/stats/ -bench Region -benchtime 1x
|
||||
func BenchmarkRegionSizedAccumulate(b *testing.B) {
|
||||
const w, h, cellM = 3000, 3000, 8.0
|
||||
f := field.New(w, h, cellM)
|
||||
land := make([]bool, w*h)
|
||||
up := make([]float32, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
land[i] = true
|
||||
t := float64(x) / float64(w)
|
||||
f.Data[i] = float32(40 + 300*t*t + 18*math.Sin(float64(x)/9)*math.Cos(float64(y)/7))
|
||||
up[i] = float32((0.02 + 0.9*t*t*t) / 1000)
|
||||
}
|
||||
}
|
||||
in := Input{H: f, Land: land, UpliftMYr: up}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
a := New(testOptions())
|
||||
a.Add(in)
|
||||
a.AddExtent(f.Data, land, 0)
|
||||
_ = a.Report(cellM)
|
||||
}
|
||||
b.ReportMetric(float64(w*h)/1e6, "Mcells")
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package stats
|
||||
|
||||
import "math"
|
||||
|
||||
// A fixed-bin histogram, which is what lets a planet be judged at all.
|
||||
//
|
||||
// Every statistic in this package used to be a sort: `ComputeHypsometry` copies every land cell into a
|
||||
// `[]float64` and sorts it, `ComputeSlopes` does the same with slopes, and `UpliftBuckets` does it three
|
||||
// times per bucket. On the square canvas that is a few megabytes and nobody noticed. On a planet it is
|
||||
// **28 million land cells**, so the copies alone are several gigabytes before a single number comes out, and
|
||||
// that is why a planet bake has never printed anything but its elevation range - the block that matters most
|
||||
// was the block that could not be afforded.
|
||||
//
|
||||
// A histogram replaces all of it. One pass, no allocation per cell, a quantile out of a running sum, and the
|
||||
// error is bounded by the bin width rather than by anything to do with the data.
|
||||
//
|
||||
// **And it pools, which is the property that actually matters here.** The geology is solved one landmass at a
|
||||
// time (D-53), so a planet-wide statistic has to be assembled from per-region pieces - and a histogram is
|
||||
// *additive*: summing two regions' bins and taking the quantile of the sum gives exactly the number a single
|
||||
// pass over both would have given. A median of medians would not; a mean of means weighted by area would be
|
||||
// right for a mean and wrong for everything else. This is the one structure that makes "statistics pool
|
||||
// across regions rather than being computed per region and averaged" true rather than aspirational.
|
||||
type Histogram struct {
|
||||
Lo, Hi float64 `json:"-"`
|
||||
Bins []int64 `json:"-"`
|
||||
|
||||
// Count, Sum, Min and Max are exact rather than binned. The mean and the extremes cost nothing to carry
|
||||
// and they are the numbers a bin width would spoil - the hypsometric integral is a mean, and reading it
|
||||
// off bin centres would make it a property of the bin count.
|
||||
Count int64 `json:"count"`
|
||||
Sum float64 `json:"sum"`
|
||||
MinV float64 `json:"min"`
|
||||
MaxV float64 `json:"max"`
|
||||
Under int64 `json:"under"` // values below Lo
|
||||
Over int64 `json:"over"` // values at or above Hi
|
||||
}
|
||||
|
||||
// NewHistogram covers lo..hi in n bins. Values outside are counted rather than clamped: a quantile that
|
||||
// silently piled everything on the end bin would be a quantile that lied about a field whose range had
|
||||
// been set wrong.
|
||||
func NewHistogram(lo, hi float64, n int) *Histogram {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if hi <= lo {
|
||||
hi = lo + 1
|
||||
}
|
||||
return &Histogram{Lo: lo, Hi: hi, Bins: make([]int64, n),
|
||||
MinV: math.Inf(1), MaxV: math.Inf(-1)}
|
||||
}
|
||||
|
||||
// Add records one value.
|
||||
func (h *Histogram) Add(v float64) {
|
||||
h.Count++
|
||||
h.Sum += v
|
||||
if v < h.MinV {
|
||||
h.MinV = v
|
||||
}
|
||||
if v > h.MaxV {
|
||||
h.MaxV = v
|
||||
}
|
||||
b := int((v - h.Lo) / (h.Hi - h.Lo) * float64(len(h.Bins)))
|
||||
switch {
|
||||
case b < 0:
|
||||
h.Under++
|
||||
case b >= len(h.Bins):
|
||||
h.Over++
|
||||
default:
|
||||
h.Bins[b]++
|
||||
}
|
||||
}
|
||||
|
||||
// Merge folds another histogram of the same shape into this one. Two histograms with different bounds cannot
|
||||
// be merged and the caller is the one place that knows it, so this refuses silently rather than inventing an
|
||||
// answer: every merge in this package is between histograms built by the same constructor.
|
||||
func (h *Histogram) Merge(o *Histogram) {
|
||||
if o == nil || o.Count == 0 || len(o.Bins) != len(h.Bins) || o.Lo != h.Lo || o.Hi != h.Hi {
|
||||
return
|
||||
}
|
||||
for i, n := range o.Bins {
|
||||
h.Bins[i] += n
|
||||
}
|
||||
h.Count += o.Count
|
||||
h.Sum += o.Sum
|
||||
h.Under += o.Under
|
||||
h.Over += o.Over
|
||||
if o.MinV < h.MinV {
|
||||
h.MinV = o.MinV
|
||||
}
|
||||
if o.MaxV > h.MaxV {
|
||||
h.MaxV = o.MaxV
|
||||
}
|
||||
}
|
||||
|
||||
// Mean is exact, not binned.
|
||||
func (h *Histogram) Mean() float64 {
|
||||
if h.Count == 0 {
|
||||
return 0
|
||||
}
|
||||
return h.Sum / float64(h.Count)
|
||||
}
|
||||
|
||||
// Quantile is the value below which p of the distribution sits, interpolated within the bin it lands in.
|
||||
//
|
||||
// The out-of-range counts are part of the walk rather than ignored: a quantile that fell among values below
|
||||
// Lo returns Lo, which is honest, where skipping them would shift every quantile above by however many there
|
||||
// were.
|
||||
func (h *Histogram) Quantile(p float64) float64 {
|
||||
if h.Count == 0 {
|
||||
return 0
|
||||
}
|
||||
if p <= 0 {
|
||||
return h.MinV
|
||||
}
|
||||
if p >= 1 {
|
||||
return h.MaxV
|
||||
}
|
||||
want := p * float64(h.Count)
|
||||
run := float64(h.Under)
|
||||
if run >= want {
|
||||
return h.Lo
|
||||
}
|
||||
width := (h.Hi - h.Lo) / float64(len(h.Bins))
|
||||
for i, n := range h.Bins {
|
||||
if run+float64(n) >= want {
|
||||
frac := 0.0
|
||||
if n > 0 {
|
||||
frac = (want - run) / float64(n)
|
||||
}
|
||||
return h.Lo + (float64(i)+frac)*width
|
||||
}
|
||||
run += float64(n)
|
||||
}
|
||||
return h.Hi
|
||||
}
|
||||
|
||||
// FracBelow is the share of the distribution strictly below x, which is what every "how much of the land is
|
||||
// under fifteen degrees" question is asking.
|
||||
func (h *Histogram) FracBelow(x float64) float64 {
|
||||
if h.Count == 0 {
|
||||
return 0
|
||||
}
|
||||
if x <= h.Lo {
|
||||
return float64(h.Under) / float64(h.Count)
|
||||
}
|
||||
if x >= h.Hi {
|
||||
return float64(h.Count-h.Over) / float64(h.Count)
|
||||
}
|
||||
width := (h.Hi - h.Lo) / float64(len(h.Bins))
|
||||
full := int((x - h.Lo) / width)
|
||||
run := h.Under
|
||||
for i := 0; i < full && i < len(h.Bins); i++ {
|
||||
run += h.Bins[i]
|
||||
}
|
||||
// The part-bin, spread evenly across its own width. Without it every threshold would snap to a bin edge,
|
||||
// which at a bin width of a twentieth of a degree does not matter and at a coarse one would.
|
||||
if full < len(h.Bins) {
|
||||
frac := (x - h.Lo - float64(full)*width) / width
|
||||
run += int64(float64(h.Bins[full]) * frac)
|
||||
}
|
||||
return float64(run) / float64(h.Count)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A histogram has to answer the same questions a sort did, closely enough that no verdict changes. The bound
|
||||
// is the bin width, so the test is against a real sort of the same data.
|
||||
func TestHistogramMatchesASort(t *testing.T) {
|
||||
r := rand.New(rand.NewPCG(7, 11))
|
||||
vals := make([]float64, 200000)
|
||||
h := NewHistogram(0, 90, 2048)
|
||||
for i := range vals {
|
||||
// A slope-like distribution: mostly gentle, with a tail.
|
||||
v := math.Abs(r.NormFloat64()) * 7
|
||||
if v > 89.9 {
|
||||
v = 89.9
|
||||
}
|
||||
vals[i] = v
|
||||
h.Add(v)
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
q := func(p float64) float64 { return vals[int(p*float64(len(vals)-1))] }
|
||||
|
||||
width := 90.0 / 2048
|
||||
for _, p := range []float64{0.1, 0.25, 0.5, 0.75, 0.9, 0.99} {
|
||||
got, want := h.Quantile(p), q(p)
|
||||
if math.Abs(got-want) > width {
|
||||
t.Errorf("quantile %.2f: histogram %.4f, sort %.4f, wider than one %.4f bin", p, got, want, width)
|
||||
}
|
||||
}
|
||||
for _, x := range []float64{1, 5, 15, 30, 50} {
|
||||
want := float64(sort.SearchFloat64s(vals, x)) / float64(len(vals))
|
||||
if got := h.FracBelow(x); math.Abs(got-want) > 0.002 {
|
||||
t.Errorf("fraction below %.0f: histogram %.4f, sort %.4f", x, got, want)
|
||||
}
|
||||
}
|
||||
// The mean and the extremes are carried exactly, not read off bins.
|
||||
var sum float64
|
||||
for _, v := range vals {
|
||||
sum += v
|
||||
}
|
||||
if math.Abs(h.Mean()-sum/float64(len(vals))) > 1e-9 {
|
||||
t.Errorf("mean %v against %v", h.Mean(), sum/float64(len(vals)))
|
||||
}
|
||||
if h.MinV != vals[0] || h.MaxV != vals[len(vals)-1] {
|
||||
t.Errorf("extremes %v..%v against %v..%v", h.MinV, h.MaxV, vals[0], vals[len(vals)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// The property the whole per-region design rests on: summing two regions' bins and taking the quantile of the
|
||||
// sum is exactly the quantile of the two together. A median of medians would not be, which is why the
|
||||
// histogram is here and not a smaller summary.
|
||||
func TestMergingIsExactlyPooling(t *testing.T) {
|
||||
r := rand.New(rand.NewPCG(3, 5))
|
||||
a, b, both := NewHistogram(0, 90, 512), NewHistogram(0, 90, 512), NewHistogram(0, 90, 512)
|
||||
for i := 0; i < 30000; i++ {
|
||||
v := r.Float64() * 40
|
||||
a.Add(v)
|
||||
both.Add(v)
|
||||
}
|
||||
for i := 0; i < 70000; i++ {
|
||||
// A different distribution, so a mean of the two would not do.
|
||||
v := 50 + r.Float64()*30
|
||||
b.Add(v)
|
||||
both.Add(v)
|
||||
}
|
||||
a.Merge(b)
|
||||
if a.Count != both.Count {
|
||||
t.Fatalf("merged count %d against %d", a.Count, both.Count)
|
||||
}
|
||||
for i := range a.Bins {
|
||||
if a.Bins[i] != both.Bins[i] {
|
||||
t.Fatalf("bin %d: merged %d against %d", i, a.Bins[i], both.Bins[i])
|
||||
}
|
||||
}
|
||||
for _, p := range []float64{0.1, 0.5, 0.9} {
|
||||
if got, want := a.Quantile(p), both.Quantile(p); got != want {
|
||||
t.Errorf("quantile %.1f: merged %v, together %v", p, got, want)
|
||||
}
|
||||
}
|
||||
// The extremes pool exactly; the mean is a float sum and so is associativity-bound, which is a
|
||||
// 1e-16 effect and not a property worth asserting to the bit.
|
||||
if a.MinV != both.MinV || a.MaxV != both.MaxV {
|
||||
t.Errorf("the extremes did not pool: %v..%v against %v..%v", a.MinV, a.MaxV, both.MinV, both.MaxV)
|
||||
}
|
||||
if rel := math.Abs(a.Mean()-both.Mean()) / both.Mean(); rel > 1e-12 {
|
||||
t.Errorf("the mean did not pool: %v against %v", a.Mean(), both.Mean())
|
||||
}
|
||||
}
|
||||
|
||||
// Out of range is counted, not clamped: a range set wrong has to be visible rather than piling up on an end
|
||||
// bin and quietly moving every quantile.
|
||||
func TestOutOfRangeIsCountedRatherThanClamped(t *testing.T) {
|
||||
h := NewHistogram(0, 10, 10)
|
||||
for _, v := range []float64{-5, -1, 3, 3, 3, 12, 20} {
|
||||
h.Add(v)
|
||||
}
|
||||
if h.Under != 2 || h.Over != 2 {
|
||||
t.Fatalf("under %d over %d, want 2 and 2", h.Under, h.Over)
|
||||
}
|
||||
if h.Count != 7 {
|
||||
t.Fatalf("count %d", h.Count)
|
||||
}
|
||||
if h.MinV != -5 || h.MaxV != 20 {
|
||||
t.Errorf("extremes %v..%v", h.MinV, h.MaxV)
|
||||
}
|
||||
// Three of seven are below 4, plus the two under the bottom: five sevenths.
|
||||
if got := h.FracBelow(4); math.Abs(got-5.0/7) > 1e-9 {
|
||||
t.Errorf("FracBelow(4) = %v, want %v", got, 5.0/7)
|
||||
}
|
||||
// An empty histogram answers zero rather than dividing by nothing.
|
||||
e := NewHistogram(0, 1, 4)
|
||||
if e.Quantile(0.5) != 0 || e.Mean() != 0 || e.FracBelow(0.5) != 0 {
|
||||
t.Error("an empty histogram should answer zero everywhere")
|
||||
}
|
||||
}
|
||||
|
||||
// Merging refuses a mismatch rather than inventing an answer, because every real merge here is between
|
||||
// histograms one constructor made.
|
||||
func TestMergeRefusesADifferentShape(t *testing.T) {
|
||||
a := NewHistogram(0, 10, 10)
|
||||
a.Add(5)
|
||||
for _, b := range []*Histogram{NewHistogram(0, 10, 20), NewHistogram(0, 20, 10), nil} {
|
||||
if b != nil {
|
||||
b.Add(5)
|
||||
}
|
||||
a.Merge(b)
|
||||
}
|
||||
if a.Count != 1 {
|
||||
t.Errorf("a mismatched merge changed the histogram: count %d", a.Count)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,6 @@ package stats
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
type Bin struct {
|
||||
@@ -74,93 +71,22 @@ type Report struct {
|
||||
Hypsometry Hypsometry `json:"hypsometry"`
|
||||
DrainageDensity float64 `json:"drainage_density_per_km"`
|
||||
|
||||
// Buckets is the whole-map aggregates split by the uplift class that caused them; see UpliftBuckets.
|
||||
// LeafFraction is the share of land cells that drain nothing but themselves. See accumulate.go: it is
|
||||
// the one number that separates a drainage network from a comb of parallel non-converging flow lines.
|
||||
LeafFraction float64 `json:"leaf_fraction"`
|
||||
|
||||
// LandCells is how much land the world has and MeasuredLandCells how much of it the land statistics
|
||||
// below actually walked. They differ only on a partial run - `bake --only` leaves most of a planet at sea
|
||||
// level - and when they do, every distribution here describes the part that was solved while the extent
|
||||
// above describes the whole cylinder. Summary says so rather than leaving the two to be compared.
|
||||
LandCells int64 `json:"land_cells"`
|
||||
MeasuredLandCells int64 `json:"measured_land_cells"`
|
||||
|
||||
// Buckets is the whole-map aggregates split by the uplift class that caused them.
|
||||
// The map-wide median above cannot tell a mountain belt from a plain, and that is the question.
|
||||
Buckets []UpliftBucket `json:"uplift_buckets"`
|
||||
}
|
||||
|
||||
// ComputeSlopeArea bins channel cells by log10 drainage area and takes the median slope in each bin, which
|
||||
// is far more robust than the mean: one cliff cell in a bin drags a mean and leaves a median alone.
|
||||
//
|
||||
// S is the gradient *along the flow path*, (h - h_receiver) / L, not the magnitude of the topographic
|
||||
// gradient. The difference is not pedantic: for a cell on a valley floor the central difference is dominated
|
||||
// by the valley walls across the channel, which reads as a far steeper slope than the water actually runs
|
||||
// down, and it bends the fitted exponent well past -m/n. The receiver gradient is the quantity the
|
||||
// stream-power law is written in, so it is the quantity the plot has to use.
|
||||
// kLocal is the per-cell erodibility multiplier from the lithology pass, and passing it matters as much as
|
||||
// passing the uplift. Erodibility correlates with drainage area by construction: soft rock is cut down, so it
|
||||
// sits low and collects flow, while hard rock stands up as ridges and drains little. Normalising every cell by
|
||||
// one global K therefore mis-corrects the large-A end systematically and bends the fitted exponent — it read
|
||||
// -1.23 against a true -0.50 on a landscape the solver had built correctly. Steady state is written in the
|
||||
// local K, so the normalisation has to be too.
|
||||
func ComputeSlopeArea(h *field.Field, area []float32, receiver []int32, length []float32, land []bool,
|
||||
upliftMYr, kLocal []float32, k, n float64, thresholdM2 float64) SlopeArea {
|
||||
const binsPerDecade = 4
|
||||
type acc struct{ norm, raw []float64 }
|
||||
bins := map[int]*acc{}
|
||||
count := 0
|
||||
for i := range h.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
r := receiver[i]
|
||||
if int(r) == i { // a root drains to itself and has no gradient to measure
|
||||
continue
|
||||
}
|
||||
a := float64(area[i])
|
||||
s := float64(h.Data[i]-h.Data[r]) / float64(length[i])
|
||||
if a < thresholdM2 || s <= 1e-6 {
|
||||
continue
|
||||
}
|
||||
u := 0.0
|
||||
if upliftMYr != nil {
|
||||
u = float64(upliftMYr[i])
|
||||
}
|
||||
kk := k
|
||||
if kLocal != nil {
|
||||
kk *= float64(kLocal[i])
|
||||
}
|
||||
if u <= 0 || kk <= 0 || n <= 0 {
|
||||
continue // no steady state to normalise against
|
||||
}
|
||||
count++
|
||||
key := int(math.Floor(math.Log10(a) * binsPerDecade))
|
||||
b := bins[key]
|
||||
if b == nil {
|
||||
b = &acc{}
|
||||
bins[key] = b
|
||||
}
|
||||
b.norm = append(b.norm, math.Log10(s/math.Pow(u/kk, 1/n)))
|
||||
b.raw = append(b.raw, math.Log10(s))
|
||||
}
|
||||
// Map iteration is randomised in Go, so the keys are sorted before anything reads them. Determinism is
|
||||
// cross-cutting rule 12 and this is exactly where it would leak.
|
||||
keys := make([]int, 0, len(bins))
|
||||
for k := range bins {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
|
||||
out := SlopeArea{Expected: expectedGradient, Channels: count, ThreshKm2: thresholdM2 / 1e6}
|
||||
var xs, normYs, rawYs []float64
|
||||
for _, key := range keys {
|
||||
b := bins[key]
|
||||
if len(b.norm) < 8 { // a bin with a handful of cells is noise, not a data point
|
||||
continue
|
||||
}
|
||||
sort.Float64s(b.norm)
|
||||
sort.Float64s(b.raw)
|
||||
logA := (float64(key) + 0.5) / binsPerDecade
|
||||
out.Bins = append(out.Bins, Bin{LogA: logA, LogS: b.norm[len(b.norm)/2], N: len(b.norm)})
|
||||
xs = append(xs, logA)
|
||||
normYs = append(normYs, b.norm[len(b.norm)/2])
|
||||
rawYs = append(rawYs, b.raw[len(b.raw)/2])
|
||||
}
|
||||
out.Exponent, out.R2 = fitLine(xs, normYs)
|
||||
out.RawExponent, out.RawR2 = fitLine(xs, rawYs)
|
||||
return out
|
||||
}
|
||||
|
||||
// expectedGradient is the -m/n the theory predicts, kept in one place so the verdict compares the fit against
|
||||
// the exponents the run was actually configured with rather than against the defaults.
|
||||
var expectedGradient = -0.5
|
||||
@@ -204,85 +130,6 @@ func fitLine(x, y []float64) (float64, float64) {
|
||||
return grad, 1 - ssRes/ssTot
|
||||
}
|
||||
|
||||
func ComputeHypsometry(h *field.Field, land []bool) Hypsometry {
|
||||
vals := make([]float64, 0, len(h.Data))
|
||||
for i, v := range h.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
vals = append(vals, float64(v))
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return Hypsometry{}
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
lo, hi := vals[0], vals[len(vals)-1]
|
||||
span := hi - lo
|
||||
if span < 1e-6 {
|
||||
return Hypsometry{Integral: 0}
|
||||
}
|
||||
var sum float64
|
||||
for _, v := range vals {
|
||||
sum += (v - lo) / span
|
||||
}
|
||||
curve := make([]float64, 11)
|
||||
for i := 0; i <= 10; i++ {
|
||||
target := lo + span*float64(i)/10
|
||||
// Fraction of land standing above this elevation.
|
||||
idx := sort.SearchFloat64s(vals, target)
|
||||
curve[i] = 1 - float64(idx)/float64(len(vals))
|
||||
}
|
||||
return Hypsometry{Integral: sum / float64(len(vals)), Curve: curve}
|
||||
}
|
||||
|
||||
func ComputeSlopes(h *field.Field, land []bool) Slopes {
|
||||
slope := h.Slope()
|
||||
degs := make([]float64, 0, len(slope.Data))
|
||||
for i, s := range slope.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
degs = append(degs, math.Atan(float64(s))*180/math.Pi)
|
||||
}
|
||||
if len(degs) == 0 {
|
||||
return Slopes{}
|
||||
}
|
||||
sort.Float64s(degs)
|
||||
frac := func(limit float64) float64 {
|
||||
return float64(sort.SearchFloat64s(degs, limit)) / float64(len(degs))
|
||||
}
|
||||
return Slopes{
|
||||
Under15Deg: frac(15),
|
||||
Under30Deg: frac(30),
|
||||
Over50Deg: 1 - frac(50),
|
||||
MedianDeg: degs[len(degs)/2],
|
||||
}
|
||||
}
|
||||
|
||||
// DrainageDensity is channel length over basin area, per kilometre. Real landscapes sit around 1 to 10 /km;
|
||||
// a value near zero means the solve never organised into channels at all.
|
||||
func DrainageDensity(area []float32, land []bool, cellM float64, thresholdM2 float64) float64 {
|
||||
var channels, total int
|
||||
for i, a := range area {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if float64(a) >= thresholdM2 {
|
||||
channels++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
lengthKm := float64(channels) * cellM / 1000
|
||||
areaKm2 := float64(total) * cellM * cellM / 1e6
|
||||
if areaKm2 == 0 {
|
||||
return 0
|
||||
}
|
||||
return lengthKm / areaKm2
|
||||
}
|
||||
|
||||
// Summary is the one block a run prints. Written so the numbers that decide whether the run was any good are
|
||||
// the ones you see without asking.
|
||||
func (r Report) Summary() string {
|
||||
@@ -305,19 +152,31 @@ func (r Report) Summary() string {
|
||||
case r.Hypsometry.Integral < 0.35:
|
||||
hyp = "concave: over-eroded"
|
||||
}
|
||||
// A partial run measures the whole cylinder's extent and only the solved landmasses' ground, and the two
|
||||
// sitting next to each other invite exactly the wrong comparison. Say so, rather than leave somebody to
|
||||
// work out afterwards why the drainage density looked impossible.
|
||||
partial := ""
|
||||
if r.LandCells > 0 && r.MeasuredLandCells > 0 && r.MeasuredLandCells < r.LandCells {
|
||||
partial = fmt.Sprintf(
|
||||
" PARTIAL: the line above is the whole world; everything below is the %.0f%% of its land that\n"+
|
||||
" was actually solved (%d of %d cells). The two are not comparable.\n",
|
||||
100*float64(r.MeasuredLandCells)/float64(r.LandCells), r.MeasuredLandCells, r.LandCells)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
" field %.0f..%.0f m; land %.0f..%.0f m (relief %.0f m), %.0f%% land, %.2f%% clipped\n"+
|
||||
"%s"+
|
||||
" slopes: %.0f%% under 15 deg, %.0f%% under 30, %.1f%% over 50, median %.1f deg\n"+
|
||||
" slope-area: exponent %.3f (expect %.3f), R2 %.3f over %d bins, %d channel cells above %.2f km2\n"+
|
||||
" unnormalised %.3f, R2 %.3f (heterogeneous uplift, so this one is expected to be worse)\n"+
|
||||
" %s\n"+
|
||||
" hypsometric integral %.3f (%s); drainage density %.2f /km\n"+
|
||||
" hypsometric integral %.3f (%s); drainage density %.2f /km; %.1f%% of land drains nothing\n"+
|
||||
"%s",
|
||||
r.MinM, r.MaxM, r.LandMinM, r.LandMaxM, r.LandReliefM, r.LandFraction*100, r.ClipFraction*100,
|
||||
partial,
|
||||
r.Slopes.Under15Deg*100, r.Slopes.Under30Deg*100, r.Slopes.Over50Deg*100, r.Slopes.MedianDeg,
|
||||
sa.Exponent, sa.Expected, sa.R2, len(sa.Bins), sa.Channels, sa.ThreshKm2,
|
||||
sa.RawExponent, sa.RawR2,
|
||||
verdict, r.Hypsometry.Integral, hyp, r.DrainageDensity,
|
||||
verdict, r.Hypsometry.Integral, hyp, r.DrainageDensity, r.LeafFraction*100,
|
||||
BucketSummary(r.Buckets))
|
||||
}
|
||||
|
||||
@@ -349,110 +208,6 @@ type UpliftBucket struct {
|
||||
Cells int `json:"cells"`
|
||||
}
|
||||
|
||||
// UpliftBuckets splits the land by rock uplift rate and reports slope, local relief and how much of each
|
||||
// bucket is pinned against the repose clamp. The last of those is the diagnostic: a bucket where most cells
|
||||
// sit within two degrees of talus is not being shaped by erosion at all, it is being shaped by the clamp,
|
||||
// and no amount of tuning downstream of that will change what it looks like.
|
||||
//
|
||||
// reliefWindowM is the side of the square the local relief is taken over; 500 m is the usual choice and is
|
||||
// what the caller passes.
|
||||
func UpliftBuckets(h *field.Field, upliftMYr []float32, land []bool, talusDeg, reliefWindowM float64) []UpliftBucket {
|
||||
// The class boundaries are in mm/yr and are deliberately absolute rather than percentiles of this map's
|
||||
// own field: the point is to compare one run against the next, and a percentile split would redefine
|
||||
// "plain" every time the uplift field was retuned.
|
||||
defs := []struct {
|
||||
name string
|
||||
lo, hi float64
|
||||
}{
|
||||
{"plain", 0, 0.1},
|
||||
{"rolling", 0.1, 0.5},
|
||||
// The top bound is finite rather than +Inf only because the report is marshalled to meta.json and
|
||||
// encoding/json refuses an infinity. 100 mm/yr is an order of magnitude above anything on Earth.
|
||||
{"mountain", 0.5, 100},
|
||||
}
|
||||
if upliftMYr == nil {
|
||||
return nil
|
||||
}
|
||||
slope := h.Slope()
|
||||
radius := int(math.Round(reliefWindowM / h.CellM / 2))
|
||||
if radius < 1 {
|
||||
radius = 1
|
||||
}
|
||||
type acc struct {
|
||||
deg, rel, elev []float64
|
||||
near, total int
|
||||
}
|
||||
accs := make([]acc, len(defs))
|
||||
landCells := 0
|
||||
for i := range h.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
landCells++
|
||||
u := float64(upliftMYr[i]) * 1000 // mm/yr
|
||||
b := -1
|
||||
for j, d := range defs {
|
||||
if u >= d.lo && u < d.hi {
|
||||
b = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if b < 0 {
|
||||
continue
|
||||
}
|
||||
a := &accs[b]
|
||||
deg := math.Atan(float64(slope.Data[i])) * 180 / math.Pi
|
||||
a.deg = append(a.deg, deg)
|
||||
a.elev = append(a.elev, float64(h.Data[i]))
|
||||
a.rel = append(a.rel, localRelief(h, i%h.W, i/h.W, radius))
|
||||
a.total++
|
||||
if deg >= talusDeg-2 { // pinned against the clamp rather than shaped by erosion
|
||||
a.near++
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]UpliftBucket, 0, len(defs))
|
||||
for j, d := range defs {
|
||||
a := &accs[j]
|
||||
if a.total == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Float64s(a.deg)
|
||||
sort.Float64s(a.rel)
|
||||
sort.Float64s(a.elev)
|
||||
out = append(out, UpliftBucket{
|
||||
Name: d.name, LoMmYr: d.lo, HiMmYr: d.hi,
|
||||
LandFrac: float64(a.total) / float64(max(landCells, 1)),
|
||||
MedianDeg: a.deg[len(a.deg)/2],
|
||||
P90Deg: a.deg[min(len(a.deg)*9/10, len(a.deg)-1)],
|
||||
MedianRelM: a.rel[len(a.rel)/2],
|
||||
WindowM: float64(radius*2) * h.CellM,
|
||||
NearTalus: float64(a.near) / float64(a.total),
|
||||
MedianElevM: a.elev[len(a.elev)/2],
|
||||
Cells: a.total,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// localRelief is max minus min over a square window, the standard field measure of how rugged a place is.
|
||||
// Slope alone cannot tell a 5 m hummock from a 500 m mountainside, because both can stand at 30 degrees.
|
||||
func localRelief(h *field.Field, cx, cy, radius int) float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for y := cy - radius; y <= cy+radius; y++ {
|
||||
for x := cx - radius; x <= cx+radius; x++ {
|
||||
v := float64(h.AtClamped(x, y))
|
||||
if v < lo {
|
||||
lo = v
|
||||
}
|
||||
if v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return hi - lo
|
||||
}
|
||||
|
||||
// BucketSummary is the block the buckets print. Kept separate from Summary so a run that has no uplift field
|
||||
// to hand still prints the rest.
|
||||
func BucketSummary(bs []UpliftBucket) string {
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/planet"
|
||||
)
|
||||
|
||||
// Watching a bake, which is the part `terrain bake` on a terminal cannot do.
|
||||
//
|
||||
// A bake is two hours, and for most of that the only thing on screen is a percentage. The question an author
|
||||
// actually has - *is this the world I meant* - is answerable long before the end, because the solve is
|
||||
// decomposed per landmass (D-53) and each one comes out whole. So the studio hangs a hook on the composite:
|
||||
// every time a region's land is written back, the planet as it stands is rendered to a preview, and the world
|
||||
// fills in one landmass at a time while the rest of it is still running. If the first continent out is wrong,
|
||||
// the other seventeen do not need to finish.
|
||||
//
|
||||
// Two consequences worth stating. The hook holds the composite lock, so every worker is stopped while it
|
||||
// draws - about a second a region against a run measured in hours, which is the right trade for being able to
|
||||
// see it at all. And a bake can be **cancelled**, because a two-hour job with no way out is not a button
|
||||
// anybody should press: the solve checks once a step, so the longest wait is one step of the biggest region,
|
||||
// and a cancelled result is for looking at rather than for writing out.
|
||||
|
||||
// bakeRun is the one bake a studio will run at a time.
|
||||
type bakeRun struct {
|
||||
mu sync.Mutex
|
||||
|
||||
running bool
|
||||
finished bool
|
||||
cancelCh chan struct{}
|
||||
|
||||
started time.Time
|
||||
steps int
|
||||
total int // regions this run will solve
|
||||
done []planet.RegionResult
|
||||
lines []string
|
||||
stamp int64 // bumped every time a new preview lands, so the browser knows to re-fetch
|
||||
outDir string
|
||||
err string
|
||||
note string
|
||||
}
|
||||
|
||||
// maxBakeLines caps the log the browser is shown. A thousand-step bake over eighteen regions prints a couple
|
||||
// of hundred lines; the cap is only so that a pathological run cannot grow without bound.
|
||||
const maxBakeLines = 400
|
||||
|
||||
func (b *bakeRun) logf(format string, a ...any) {
|
||||
line := fmt.Sprintf(format, a...)
|
||||
b.mu.Lock()
|
||||
b.lines = append(b.lines, line)
|
||||
if len(b.lines) > maxBakeLines {
|
||||
b.lines = b.lines[len(b.lines)-maxBakeLines:]
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
type bakeStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Finished bool `json:"finished"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
Steps int `json:"steps"`
|
||||
Total int `json:"total"`
|
||||
Done []planet.RegionResult `json:"done"`
|
||||
Lines []string `json:"lines"`
|
||||
Stamp int64 `json:"stamp"`
|
||||
OutDir string `json:"out_dir"`
|
||||
Err string `json:"err"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBake(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.bake.mu.Lock()
|
||||
// Both slices start empty rather than nil, because a nil slice marshals to `null` and the page does
|
||||
// `j.done.length` on it - which is fine for every poll after the first region lands and throws on
|
||||
// every poll before it, which is exactly the window a person watches most closely.
|
||||
st := bakeStatus{
|
||||
Running: s.bake.running, Finished: s.bake.finished,
|
||||
Steps: s.bake.steps, Total: s.bake.total,
|
||||
Done: append(make([]planet.RegionResult, 0, len(s.bake.done)), s.bake.done...),
|
||||
Lines: append(make([]string, 0, len(s.bake.lines)), s.bake.lines...),
|
||||
Stamp: s.bake.stamp,
|
||||
OutDir: s.bake.outDir, Err: s.bake.err, Note: s.bake.note,
|
||||
}
|
||||
if !s.bake.started.IsZero() {
|
||||
st.Seconds = time.Since(s.bake.started).Seconds()
|
||||
}
|
||||
s.bake.mu.Unlock()
|
||||
writeJSON(w, st)
|
||||
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Only []int `json:"only"`
|
||||
Steps int `json:"steps"`
|
||||
Jobs int `json:"jobs"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := s.startBake(req.Only, req.Steps, req.Jobs); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
|
||||
default:
|
||||
http.Error(w, "GET or POST", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleBakeCancel(w http.ResponseWriter, r *http.Request) {
|
||||
s.bake.mu.Lock()
|
||||
if s.bake.running && s.bake.cancelCh != nil {
|
||||
select {
|
||||
case <-s.bake.cancelCh: // already asked
|
||||
default:
|
||||
close(s.bake.cancelCh)
|
||||
s.bake.note = "cancelling: regions in flight stop at the end of their current step"
|
||||
}
|
||||
}
|
||||
s.bake.mu.Unlock()
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleBakePreview(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeFile(w, r, filepath.Join(s.planDir, bakePreviewName))
|
||||
}
|
||||
|
||||
const bakePreviewName = "bake_preview.png"
|
||||
|
||||
// bakePreviewWidth is what the live preview is drawn at. Small on purpose: it is redrawn with every worker
|
||||
// stopped, so it is charged against the bake's wall time, and 1400 px is enough to answer "is this the world
|
||||
// I meant" while costing well under a second.
|
||||
const bakePreviewWidth = 1400
|
||||
|
||||
// startBake takes a snapshot of everything the run needs and hands it to a goroutine.
|
||||
//
|
||||
// A snapshot rather than a reference, because the whole point of the studio is that the painting keeps being
|
||||
// edited: a bake is of the world as it was when the button was pressed, and it says so.
|
||||
func (s *Server) startBake(only []int, steps, jobs int) error {
|
||||
s.bake.mu.Lock()
|
||||
if s.bake.running {
|
||||
s.bake.mu.Unlock()
|
||||
return fmt.Errorf("a bake is already running; cancel it first")
|
||||
}
|
||||
s.bake.running, s.bake.finished = true, false
|
||||
s.bake.cancelCh = make(chan struct{})
|
||||
s.bake.started = time.Now()
|
||||
s.bake.done, s.bake.lines, s.bake.err, s.bake.outDir, s.bake.note = nil, nil, "", "", ""
|
||||
s.bake.total, s.bake.steps = 0, steps
|
||||
cancel := s.bake.cancelCh
|
||||
s.bake.mu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
// Copied rather than shared: a bake is hours and the author keeps painting through it, so what it solves
|
||||
// has to be the world as it was when they pressed the button.
|
||||
art := &planet.Painting{
|
||||
Class: append([]uint8(nil), s.paint...),
|
||||
ClassW: s.paintW, ClassH: s.paintH,
|
||||
}
|
||||
if s.ov != nil {
|
||||
art.Overlay = append([]uint8(nil), s.ovPaint...)
|
||||
art.OverlayAlpha = append([]uint8(nil), s.ovAlpha...)
|
||||
art.OverlayW, art.OverlayH = s.paintW, s.paintH
|
||||
}
|
||||
mPath := s.manifestPath
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.runBake(art, mPath, only, steps, jobs, cancel)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) runBake(art *planet.Painting, mPath string, only []int, steps, jobs int,
|
||||
cancel chan struct{}) {
|
||||
|
||||
fail := func(err error) {
|
||||
s.bake.mu.Lock()
|
||||
s.bake.err = err.Error()
|
||||
s.bake.running, s.bake.finished = false, true
|
||||
s.bake.mu.Unlock()
|
||||
}
|
||||
|
||||
// Loaded fresh rather than reusing the server's copy: a bake is long enough that the manifest may be
|
||||
// edited while it runs, and it should be of the numbers that were in force when it started.
|
||||
m, err := manifest.Load(mPath)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
s.bake.logf("preparing")
|
||||
in, err := planet.PrepareWith(m, art, s.bake.logf)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
wanted := len(in.Part.Regions)
|
||||
if len(only) > 0 {
|
||||
wanted = len(only)
|
||||
}
|
||||
s.bake.mu.Lock()
|
||||
s.bake.total = wanted
|
||||
s.bake.mu.Unlock()
|
||||
|
||||
res, err := planet.Bake(in, planet.BakeOptions{
|
||||
Only: only, Steps: steps, Jobs: jobs, Log: s.bake.logf, Cancel: cancel,
|
||||
OnRegion: func(res *planet.Result, rr planet.RegionResult) {
|
||||
s.writeBakePreview(res)
|
||||
s.bake.mu.Lock()
|
||||
s.bake.done = append(s.bake.done, rr)
|
||||
s.bake.stamp = time.Now().UnixNano()
|
||||
s.bake.mu.Unlock()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
|
||||
note := ""
|
||||
out := ""
|
||||
if res.Cancelled() {
|
||||
// A cancelled run is not worthless, and the first version of this threw it away, which was wrong: the
|
||||
// solve is per landmass, so a region that *finished* is finished - only the one or two still in
|
||||
// flight stopped mid-step. Cancelling an eighteen-region bake after fifteen of them had landed and
|
||||
// getting nothing for it is exactly the outcome a cancel button should not have.
|
||||
//
|
||||
// So it is written, under a name of its own. Not Bake_NNN, because a directory that looked like
|
||||
// every other bake while holding sea level where three continents should be is a trap for whatever
|
||||
// reads it next, and `tiles --bake` picks the newest Bake_NNN by default.
|
||||
done := 0
|
||||
for _, rr := range res.Regions {
|
||||
if rr.Seconds > 0 {
|
||||
done++
|
||||
}
|
||||
}
|
||||
if done == 0 {
|
||||
note = "cancelled before any region finished; nothing to write"
|
||||
} else {
|
||||
out = nextPartialDir(filepath.Dir(mPath))
|
||||
if err := res.Write(out, 3000, s.bake.logf); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
note = fmt.Sprintf("cancelled after %d region(s); those are complete and written to %s. "+
|
||||
"Everything else in it is still at sea level, which is why it is not a Bake_NNN", done, out)
|
||||
}
|
||||
} else {
|
||||
out = planet.NextBakeDir(filepath.Dir(mPath))
|
||||
if err := res.Write(out, 3000, s.bake.logf); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
s.writeBakePreview(res)
|
||||
note = "wrote " + out
|
||||
}
|
||||
|
||||
s.bake.mu.Lock()
|
||||
s.bake.running, s.bake.finished = false, true
|
||||
s.bake.outDir, s.bake.note = out, note
|
||||
s.bake.stamp = time.Now().UnixNano()
|
||||
s.bake.mu.Unlock()
|
||||
}
|
||||
|
||||
// writeBakePreview draws the planet as it currently stands.
|
||||
//
|
||||
// The height and flow fields are *views* over the result's own arrays rather than copies: Painted() allocates
|
||||
// three hundred megabytes, and doing that once a region while every worker is stopped is a cost with nothing
|
||||
// to show for it. Safe because this only ever runs holding the composite lock.
|
||||
func (s *Server) writeBakePreview(res *planet.Result) {
|
||||
p := res.In.P
|
||||
lo, hi := p.PadY*p.W, (p.H-p.PadY)*p.W
|
||||
h := &field.Field{W: p.W, H: p.PaintH(), CellM: p.CellM, Data: res.Height.Data[lo:hi]}
|
||||
flow := &field.Field{W: p.W, H: p.PaintH(), CellM: p.CellM, Data: res.Flow[lo:hi]}
|
||||
|
||||
// The painted sea, not the baked one: res.Sea is only computed once the whole run is over, and the
|
||||
// painting already knows which cells are water.
|
||||
sea := res.In.Map.Sea[lo:hi]
|
||||
|
||||
_, err := field.WritePreview(filepath.Join(s.planDir, bakePreviewName), h, field.PreviewOptions{
|
||||
Flow: flow, Sea: sea, Snow: res.In.Map.SnowMask(), Palette: res.In.Palette,
|
||||
SeaLevelM: res.In.M.SeaLevelM, RiverKm2: 0.5, Size: bakePreviewWidth,
|
||||
})
|
||||
if err != nil {
|
||||
s.bake.logf("preview failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// partialPrefix is deliberately outside the Bake_NNN namespace: latestBakeDir parses the suffix after
|
||||
// "Bake_" as an integer, so this could never be mistaken for a finished bake even by accident, and a person
|
||||
// reading the directory listing can see which is which without opening anything.
|
||||
const partialPrefix = "Partial_"
|
||||
|
||||
func nextPartialDir(base string) string {
|
||||
for n := 1; n < 10000; n++ {
|
||||
dir := filepath.Join(base, fmt.Sprintf("%s%03d", partialPrefix, n))
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
return filepath.Join(base, partialPrefix+"overflow")
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"salty/terrain/internal/overlay"
|
||||
"salty/terrain/internal/planet"
|
||||
)
|
||||
|
||||
// Generating the annotation layer from inside the studio.
|
||||
//
|
||||
// `terrain overlay` already does this from the command line, and the reason to have it here as well is that
|
||||
// generation is not a step in a pipeline - it is a *draft*. An author presses it, looks at where the towns
|
||||
// landed, presses it again, and keeps the third one. That loop only works where the sheet is already on
|
||||
// screen and editable, which is here.
|
||||
//
|
||||
// Two things it does that the command does not, and both exist because a button is pressed repeatedly:
|
||||
//
|
||||
// - **Every press is a new seed.** The painting fixes where the land is; the seed decides everything it
|
||||
// does not. So the button is a re-roll by construction, which is what makes looking at three drafts
|
||||
// cheap.
|
||||
// - **It replaces the last draft rather than piling on top of it.** The generator never overwrites a
|
||||
// painted pixel, and after one press its own output *is* painted pixels - so a second press would
|
||||
// generate around the first and the sheet would silt up. The server remembers exactly which pixels the
|
||||
// last generation put down and clears those, and only those, before generating again. Hand-painted work
|
||||
// is never in that set and so is never touched.
|
||||
//
|
||||
// It uses the newest bake when there is one and the painting alone when there is not, and says which. The
|
||||
// difference is not cosmetic: without a solve there are no rivers to sit on and no slope to avoid, so the
|
||||
// draft is a sketch.
|
||||
|
||||
// bakePrefix matches the command's. A directory is a bake if it is this plus an integer.
|
||||
const bakePrefix = "Bake_"
|
||||
|
||||
// latestBake is the newest Bake_NNN beside the manifest, or "" when the planet has never been baked.
|
||||
//
|
||||
// Newest by *number* rather than by modification time: the numbers are the order the bakes were made, and a
|
||||
// directory touched by a backup tool is not a newer bake.
|
||||
func latestBake(base string) string {
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
best, bestN := "", -1
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !strings.HasPrefix(e.Name(), bakePrefix) {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(e.Name(), bakePrefix))
|
||||
if err != nil || n <= bestN {
|
||||
continue
|
||||
}
|
||||
// A directory that has no heightmap in it is a bake that was interrupted, and reading one would fail
|
||||
// later with a worse message than simply not choosing it.
|
||||
if _, err := os.Stat(filepath.Join(base, e.Name(), "planet_height.png")); err != nil {
|
||||
continue
|
||||
}
|
||||
best, bestN = filepath.Join(base, e.Name()), n
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
type genReply struct {
|
||||
OK bool `json:"ok"`
|
||||
Seed int64 `json:"seed"`
|
||||
Bake string `json:"bake"`
|
||||
FromBake bool `json:"from_bake"`
|
||||
Lines []string `json:"lines"`
|
||||
Marks []string `json:"marks"`
|
||||
}
|
||||
|
||||
// handleOverlayGenerate fills the annotation sheet in from the world, and hands the page back a summary.
|
||||
func (s *Server) handleOverlayGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Seed int64 `json:"seed"`
|
||||
}
|
||||
// An empty body is allowed: it means "pick a seed for me", which is what the button sends.
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.ov == nil {
|
||||
http.Error(w, "this planet has no overlay legend; set planet.overlay_legend first",
|
||||
http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
wants := false
|
||||
for i := range s.ov.Marks {
|
||||
if s.ov.Marks[i].Generate != nil {
|
||||
wants = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !wants {
|
||||
http.Error(w, "no mark in the overlay legend has a `generate` block, so there is nothing to "+
|
||||
"generate. Generation is opt-in per mark; see the overlay section of the templates README",
|
||||
http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
seed := req.Seed
|
||||
if seed == 0 {
|
||||
seed = int64(rand.Uint64()>>16) + 1
|
||||
}
|
||||
|
||||
// The plan's prepare, which is where the class raster and the projected map come from. Reused when it is
|
||||
// warm - the usual case, because an author plans before they look at anything - and built when it is not.
|
||||
in := s.cache
|
||||
if in == nil || s.cacheKey != s.planKey() {
|
||||
var err error
|
||||
in, err = planet.Prepare(s.m, func(string, ...any) {})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.cache, s.cacheKey = in, s.planKey()
|
||||
}
|
||||
|
||||
// The sheet as it is on screen, with the last generation taken back out of it. Everything an author
|
||||
// painted stays; everything the previous press put down goes, which is what makes this a re-roll rather
|
||||
// than an accumulation.
|
||||
existing := s.overlayRasterLocked()
|
||||
cleared := 0
|
||||
for i, m := range s.ovGen {
|
||||
if m != overlay.Blank && i < len(existing.Mark) && existing.Mark[i] == m {
|
||||
existing.Mark[i] = overlay.Blank
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
|
||||
// The newest bake, but only if it is a bake of *this* painting: two paintings of the same planet encode
|
||||
// their heightmaps identically, so nothing else would catch it and the draft would be placed against
|
||||
// terrain from another world. Decided here as well as inside the generator so that the line the page
|
||||
// prints says which path actually ran.
|
||||
bake := latestBake(filepath.Dir(s.manifestPath))
|
||||
if bake != "" {
|
||||
if ok, _ := planet.BakeIsOfThisPainting(bake, s.m); !ok {
|
||||
bake = ""
|
||||
}
|
||||
}
|
||||
var lines []string
|
||||
log := func(format string, a ...any) { lines = append(lines, fmt.Sprintf(format, a...)) }
|
||||
|
||||
ras, rep, err := planet.GenerateOverlay(planet.OverlayGenOptions{
|
||||
In: in, BakeDir: bake, Seed: seed, Existing: existing, Log: log,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Back onto the sheet the author is looking at. The raster is the whole truth here - it already contains
|
||||
// the pixels that were kept - so this is a straight encode rather than a merge.
|
||||
px, alpha := s.ov.Encode(ras)
|
||||
copy(s.ovPaint, px)
|
||||
copy(s.ovAlpha, alpha)
|
||||
s.ovDirty = true
|
||||
|
||||
// What this generation put down, so the next press can take it back out again. Only the cells that were
|
||||
// blank before it ran: a mark sitting where the author painted one is theirs, not ours.
|
||||
if s.ovGen == nil {
|
||||
s.ovGen = make([]uint8, s.paintW*s.paintH)
|
||||
}
|
||||
for i := range s.ovGen {
|
||||
if i < len(existing.Mark) && existing.Mark[i] == overlay.Blank && ras.Mark[i] != overlay.Blank {
|
||||
s.ovGen[i] = ras.Mark[i]
|
||||
} else {
|
||||
s.ovGen[i] = overlay.Blank
|
||||
}
|
||||
}
|
||||
|
||||
reply := genReply{OK: true, Seed: seed, FromBake: bake != "", Lines: lines}
|
||||
if bake != "" {
|
||||
reply.Bake = filepath.Base(bake)
|
||||
}
|
||||
if cleared > 0 {
|
||||
reply.Lines = append(reply.Lines, fmt.Sprintf("re-rolled: %d px of the last draft cleared first", cleared))
|
||||
}
|
||||
reply.Marks = planet.OverlaySummary(rep, ras.W, ras.H)
|
||||
writeJSON(w, reply)
|
||||
}
|
||||
|
||||
// overlayRasterLocked classifies the live sheet into marks. s.mu must be held.
|
||||
func (s *Server) overlayRasterLocked() *overlay.Raster {
|
||||
ras, _ := s.ov.Classify(s.ovPaint, s.ovAlpha, s.paintW, s.paintH)
|
||||
return ras
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Editing a legend in place, without reformatting it.
|
||||
//
|
||||
// The obvious way to save a legend the studio has changed is to unmarshal it, set the fields and marshal it
|
||||
// back. That destroys the file. A legend is mostly *commentary* - the `_comment_massif` block on `lowland` is
|
||||
// four lines explaining why its floor is a tenth of its rate - and unmarshalling into the Class struct drops
|
||||
// every underscore key on the floor. Unmarshalling into map[string]any keeps them and loses the order
|
||||
// instead, because encoding/json sorts map keys, so the hand-laid table comes back alphabetised with every
|
||||
// comment moved away from the thing it was commenting on.
|
||||
//
|
||||
// The same argument the palette writer already makes, one file over: this repository does not let
|
||||
// MarshalIndent near a file a person wrote. So the studio patches the *text*. It finds the object for a named
|
||||
// class and replaces one key's value inside it, or inserts the key if it is not there, and every byte it did
|
||||
// not deliberately change comes out identical. That also means a legend edited here still diffs usefully,
|
||||
// which for a file under review is most of the point.
|
||||
|
||||
// patchClassNumber sets one numeric key on one class, adding it if it is absent. The returned text is the
|
||||
// input with exactly that value changed.
|
||||
func patchClassNumber(src, class, key string, value float64) (string, error) {
|
||||
start, end, err := classObject(src, class)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := src[start:end]
|
||||
num := formatNumber(value)
|
||||
|
||||
if ks, ke, ok := keyValue(body, key); ok {
|
||||
return src[:start] + body[:ks] + fmt.Sprintf("%q: %s", key, num) + body[ke:] + src[end:], nil
|
||||
}
|
||||
// Not present: put it after the class's name, which is where a reader looks for it and which every class
|
||||
// is guaranteed to have.
|
||||
ns, ne, ok := keyValue(body, "name")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("class %q has no name key to insert %q after", class, key)
|
||||
}
|
||||
_ = ns
|
||||
ins := fmt.Sprintf(", %q: %s", key, num)
|
||||
return src[:start] + body[:ne] + ins + body[ne:] + src[end:], nil
|
||||
}
|
||||
|
||||
// patchClassRemove deletes one key from one class, taking its separating comma with it. Absent is not an
|
||||
// error: the studio sends "this mark no longer says anything about the coast" whether or not it ever did.
|
||||
func patchClassRemove(src, class, key string) (string, error) {
|
||||
start, end, err := classObject(src, class)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := src[start:end]
|
||||
ks, ke, ok := keyValue(body, key)
|
||||
if !ok {
|
||||
return src, nil
|
||||
}
|
||||
// A key goes with exactly one of the two separators around it, and which one depends on where it sits.
|
||||
// The test is a round trip: adding a key and taking it away again has to give the file back byte for
|
||||
// byte, or every later diff carries the scar of a setting somebody tried once.
|
||||
s, e := ks, ke
|
||||
j := e
|
||||
for j < len(body) && isJSONSpace(body[j]) {
|
||||
j++
|
||||
}
|
||||
if j < len(body) && body[j] == ',' {
|
||||
// Not the last key: take the comma after it, and the space that followed that comma in place of the
|
||||
// one that preceded this key.
|
||||
e = j + 1
|
||||
if e < len(body) && body[e] == ' ' && s > 0 && body[s-1] == ' ' {
|
||||
e++
|
||||
}
|
||||
} else {
|
||||
// The last key in the object: there is no comma after it, so take the one before - and nothing
|
||||
// forward, or the space in front of the closing brace goes with it.
|
||||
for s > 0 && isJSONSpace(body[s-1]) {
|
||||
s--
|
||||
}
|
||||
if s > 0 && body[s-1] == ',' {
|
||||
s--
|
||||
}
|
||||
}
|
||||
return src[:start] + body[:s] + body[e:] + src[end:], nil
|
||||
}
|
||||
|
||||
func isJSONSpace(c byte) bool { return c == ' ' || c == '\n' || c == '\r' || c == '\t' }
|
||||
|
||||
// patchClassObject sets one object-valued key on one class - the massif block - or removes it when nil.
|
||||
func patchClassObject(src, class, key string, fields map[string]float64, order []string) (string, error) {
|
||||
start, end, err := classObject(src, class)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := src[start:end]
|
||||
|
||||
var lit string
|
||||
if fields != nil {
|
||||
parts := make([]string, 0, len(order))
|
||||
for _, k := range order {
|
||||
v, ok := fields[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%q: %s", k, formatNumber(v)))
|
||||
}
|
||||
lit = fmt.Sprintf("%q: { %s }", key, strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
if ks, ke, ok := keyValue(body, key); ok {
|
||||
if lit == "" {
|
||||
// Removing it: take the separating comma with it, whichever side it is on.
|
||||
s, e := ks, ke
|
||||
for e < len(body) && (body[e] == ' ' || body[e] == '\n' || body[e] == '\r' || body[e] == '\t') {
|
||||
e++
|
||||
}
|
||||
if e < len(body) && body[e] == ',' {
|
||||
e++
|
||||
} else {
|
||||
for s > 0 && (body[s-1] == ' ' || body[s-1] == '\n' || body[s-1] == '\r' || body[s-1] == '\t') {
|
||||
s--
|
||||
}
|
||||
if s > 0 && body[s-1] == ',' {
|
||||
s--
|
||||
}
|
||||
}
|
||||
return src[:start] + body[:s] + body[e:] + src[end:], nil
|
||||
}
|
||||
return src[:start] + body[:ks] + lit + body[ke:] + src[end:], nil
|
||||
}
|
||||
if lit == "" {
|
||||
return src, nil // asked to remove something that is not there
|
||||
}
|
||||
// Inserted at the end of the class object, on a line of its own. Straight after the name would read
|
||||
// better in a one-line class and reads badly in exactly the ones that matter: a class carrying
|
||||
// commentary is written over several lines, and splicing into the middle of the first one leaves the
|
||||
// rest of that line dangling behind the insertion.
|
||||
brace := len(body) - 1
|
||||
for brace > 0 && body[brace] != '}' {
|
||||
brace--
|
||||
}
|
||||
head := strings.TrimRight(body[:brace], " \t\r\n")
|
||||
return src[:start] + head + ",\n " + lit + "\n " + body[brace:] + src[end:], nil
|
||||
}
|
||||
|
||||
// patchTopNumber sets a numeric key inside a named top-level object, such as the manifest's planet block.
|
||||
func patchTopNumber(src, object, key string, value float64) (string, error) {
|
||||
os, oe, err := objectAfterKey(src, object, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := src[os:oe]
|
||||
num := formatNumber(value)
|
||||
if ks, ke, ok := keyValue(body, key); ok {
|
||||
return src[:os] + body[:ks] + fmt.Sprintf("%q: %s", key, num) + body[ke:] + src[oe:], nil
|
||||
}
|
||||
// Insert just inside the opening brace, on its own line.
|
||||
return src[:os+1] + fmt.Sprintf("\n %q: %s,", key, num) + src[os+1:], nil
|
||||
}
|
||||
|
||||
// patchTopString is patchTopNumber for a string value.
|
||||
func patchTopString(src, object, key, value string) (string, error) {
|
||||
os, oe, err := objectAfterKey(src, object, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := src[os:oe]
|
||||
if ks, ke, ok := keyValue(body, key); ok {
|
||||
return src[:os] + body[:ks] + fmt.Sprintf("%q: %q", key, value) + body[ke:] + src[oe:], nil
|
||||
}
|
||||
return src[:os+1] + fmt.Sprintf("\n %q: %q,", key, value) + src[os+1:], nil
|
||||
}
|
||||
|
||||
// classObject is the byte range of the object in the classes array whose "name" is the one asked for,
|
||||
// from its opening brace to just past its closing one.
|
||||
func classObject(src, class string) (start, end int, err error) {
|
||||
want := fmt.Sprintf("%q", class)
|
||||
from := 0
|
||||
for {
|
||||
i := indexKeyValue(src, "name", want, from)
|
||||
if i < 0 {
|
||||
return 0, 0, fmt.Errorf("no class named %q in the legend", class)
|
||||
}
|
||||
// Walk back to the opening brace of the object this key sits in.
|
||||
depth := 0
|
||||
j := i
|
||||
for ; j >= 0; j-- {
|
||||
switch src[j] {
|
||||
case '}':
|
||||
depth++
|
||||
case '{':
|
||||
if depth == 0 {
|
||||
s, e, ok := matchBrace(src, j)
|
||||
if ok {
|
||||
return s, e, nil
|
||||
}
|
||||
return 0, 0, fmt.Errorf("class %q: unbalanced braces", class)
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
from = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// objectAfterKey is the byte range of the object that is the value of the given key.
|
||||
func objectAfterKey(src, key string, from int) (start, end int, err error) {
|
||||
i := indexKey(src, key, from)
|
||||
if i < 0 {
|
||||
return 0, 0, fmt.Errorf("no %q object", key)
|
||||
}
|
||||
j := i
|
||||
for j < len(src) && src[j] != '{' {
|
||||
if src[j] == ',' || src[j] == '}' {
|
||||
return 0, 0, fmt.Errorf("%q is not an object", key)
|
||||
}
|
||||
j++
|
||||
}
|
||||
if j >= len(src) {
|
||||
return 0, 0, fmt.Errorf("%q is not an object", key)
|
||||
}
|
||||
s, e, ok := matchBrace(src, j)
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("%q: unbalanced braces", key)
|
||||
}
|
||||
return s, e, nil
|
||||
}
|
||||
|
||||
// keyValue finds "key": value inside a body and returns the range covering both, value included.
|
||||
func keyValue(body, key string) (start, end int, ok bool) {
|
||||
i := indexKey(body, key, 0)
|
||||
if i < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Past the colon, then over the value.
|
||||
j := i
|
||||
for j < len(body) && body[j] != ':' {
|
||||
j++
|
||||
}
|
||||
j++
|
||||
for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\n' || body[j] == '\r') {
|
||||
j++
|
||||
}
|
||||
if j >= len(body) {
|
||||
return 0, 0, false
|
||||
}
|
||||
switch body[j] {
|
||||
case '{':
|
||||
_, e, ok := matchBrace(body, j)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return i, e, true
|
||||
case '[':
|
||||
depth, k := 0, j
|
||||
for ; k < len(body); k++ {
|
||||
if body[k] == '[' {
|
||||
depth++
|
||||
} else if body[k] == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i, k + 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
case '"':
|
||||
k := j + 1
|
||||
for ; k < len(body); k++ {
|
||||
if body[k] == '\\' {
|
||||
k++
|
||||
continue
|
||||
}
|
||||
if body[k] == '"' {
|
||||
return i, k + 1, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
default:
|
||||
k := j
|
||||
for k < len(body) && body[k] != ',' && body[k] != '}' && body[k] != '\n' {
|
||||
k++
|
||||
}
|
||||
// A bare number or keyword ends where the scan stopped, but the scan does not stop on a space, so
|
||||
// `"width_m": 8 }` would otherwise hand back the space in front of the brace as part of the value -
|
||||
// and every edit of a last-in-object key would quietly close it up to `8}`.
|
||||
for k > j && isJSONSpace(body[k-1]) {
|
||||
k--
|
||||
}
|
||||
return i, k, true
|
||||
}
|
||||
}
|
||||
|
||||
// indexKey finds the offset of a "key" token at object level, skipping any inside a string value.
|
||||
func indexKey(s, key string, from int) int {
|
||||
needle := fmt.Sprintf("%q", key)
|
||||
for i := from; ; {
|
||||
j := strings.Index(s[i:], needle)
|
||||
if j < 0 {
|
||||
return -1
|
||||
}
|
||||
at := i + j
|
||||
// It is a key only if the next non-space character is a colon.
|
||||
k := at + len(needle)
|
||||
for k < len(s) && (s[k] == ' ' || s[k] == '\t') {
|
||||
k++
|
||||
}
|
||||
if k < len(s) && s[k] == ':' {
|
||||
return at
|
||||
}
|
||||
i = at + len(needle)
|
||||
}
|
||||
}
|
||||
|
||||
// indexKeyValue finds a "key": "value" pair and returns the offset of the key.
|
||||
func indexKeyValue(s, key, quotedValue string, from int) int {
|
||||
for i := from; ; {
|
||||
at := indexKey(s, key, i)
|
||||
if at < 0 {
|
||||
return -1
|
||||
}
|
||||
_, e, ok := keyValue(s[at:], key)
|
||||
if ok {
|
||||
seg := strings.TrimSpace(s[at : at+e])
|
||||
if strings.HasSuffix(seg, quotedValue) {
|
||||
return at
|
||||
}
|
||||
}
|
||||
i = at + 1
|
||||
}
|
||||
}
|
||||
|
||||
// matchBrace returns the range of the object opening at i.
|
||||
func matchBrace(s string, i int) (start, end int, ok bool) {
|
||||
depth := 0
|
||||
inStr := false
|
||||
for j := i; j < len(s); j++ {
|
||||
c := s[j]
|
||||
if inStr {
|
||||
if c == '\\' {
|
||||
j++
|
||||
} else if c == '"' {
|
||||
inStr = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"':
|
||||
inStr = true
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i, j + 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// formatNumber writes a number the way a person would: no exponent, no trailing zeros, and never bare "0."
|
||||
func formatNumber(v float64) string {
|
||||
s := strconv.FormatFloat(v, 'f', -1, 64)
|
||||
if s == "-0" {
|
||||
return "0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const legendSrc = `{
|
||||
"_comment": "what the colours mean",
|
||||
"image": "Map3.jpg",
|
||||
"classes": [
|
||||
{ "name": "ocean", "rgb": [91, 175, 185], "sea": true, "depth_m": 512 },
|
||||
|
||||
{ "_comment_plain": "why the floor is a tenth of the rate, at length",
|
||||
"name": "lowland", "rgb": [153, 204, 102], "uplift_mm_yr": 0.08, "k_mult": 1.0,
|
||||
"massif": { "floor_mm_yr": 0.012, "fraction": 0.16 },
|
||||
"coastal_plain_km": 1.0 },
|
||||
|
||||
{ "name": "highland", "rgb": [68, 170, 102], "uplift_mm_yr": 0.25, "k_mult": 1.0 }
|
||||
]
|
||||
}`
|
||||
|
||||
// The whole reason this is text surgery and not MarshalIndent: a legend is mostly commentary, and the
|
||||
// commentary has to survive a save byte for byte.
|
||||
func TestPatchingKeepsEverythingItDidNotChange(t *testing.T) {
|
||||
out, err := patchClassNumber(legendSrc, "lowland", "uplift_mm_yr", 0.12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"uplift_mm_yr": 0.12`) {
|
||||
t.Error("the new value is not there")
|
||||
}
|
||||
if strings.Contains(out, `"uplift_mm_yr": 0.08`) {
|
||||
t.Error("the old value is still there")
|
||||
}
|
||||
if !strings.Contains(out, `"_comment_plain": "why the floor is a tenth of the rate, at length"`) {
|
||||
t.Error("the comment was dropped")
|
||||
}
|
||||
if !strings.Contains(out, `"uplift_mm_yr": 0.25`) {
|
||||
t.Error("the other class's rate was touched")
|
||||
}
|
||||
// And nothing else moved: the only difference from the original is those four characters.
|
||||
if a, b := strings.Replace(out, "0.12", "0.08", 1), legendSrc; a != b {
|
||||
t.Errorf("the file changed somewhere else:\n--- got\n%s\n--- want\n%s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchingAddsAKeyThatIsNotThere(t *testing.T) {
|
||||
out, err := patchClassNumber(legendSrc, "highland", "coastal_plain_km", 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"coastal_plain_km": 4`) {
|
||||
t.Fatalf("the key was not added:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"name": "highland", "coastal_plain_km": 4`) {
|
||||
t.Errorf("it did not go in after the name:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchingAMassifBlock(t *testing.T) {
|
||||
order := []string{"floor_mm_yr", "fraction"}
|
||||
out, err := patchClassObject(legendSrc, "lowland", "massif",
|
||||
map[string]float64{"floor_mm_yr": 0.02, "fraction": 0.25}, order)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"massif": { "floor_mm_yr": 0.02, "fraction": 0.25 }`) {
|
||||
t.Fatalf("the massif block was not rewritten:\n%s", out)
|
||||
}
|
||||
|
||||
// Adding one to a class that has none.
|
||||
out, err = patchClassObject(legendSrc, "highland", "massif",
|
||||
map[string]float64{"floor_mm_yr": 0.045, "fraction": 0.3}, order)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"massif": { "floor_mm_yr": 0.045, "fraction": 0.3 }`) {
|
||||
t.Fatalf("the massif block was not added:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovingAMassifBlock(t *testing.T) {
|
||||
out, err := patchClassObject(legendSrc, "lowland", "massif", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "massif") {
|
||||
t.Fatalf("the massif block is still there:\n%s", out)
|
||||
}
|
||||
// The class has to still parse: no doubled or dangling comma where it was.
|
||||
if strings.Contains(out, ",,") || strings.Contains(out, ", }") && !strings.Contains(legendSrc, ", }") {
|
||||
t.Errorf("the comma was left in a bad state:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchingARefusesAClassThatIsNotThere(t *testing.T) {
|
||||
if _, err := patchClassNumber(legendSrc, "tundra", "uplift_mm_yr", 0.1); err == nil {
|
||||
t.Fatal("it accepted a class the legend does not have")
|
||||
}
|
||||
}
|
||||
|
||||
const manifestSrc = `{
|
||||
"level": "/Game/Maps/L_Planet",
|
||||
"planet": {
|
||||
"_comment_scale": "why the cell is eight metres",
|
||||
"template": "Templates/Map3.jpg",
|
||||
"circumference_km": 100,
|
||||
"coast_jitter_px": 48
|
||||
}
|
||||
}`
|
||||
|
||||
func TestPatchingTheManifestPlanetBlock(t *testing.T) {
|
||||
out, err := patchTopNumber(manifestSrc, "planet", "coast_jitter_px", 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"coast_jitter_px": 64`) {
|
||||
t.Fatalf("not patched:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"_comment_scale"`) {
|
||||
t.Error("the comment was dropped")
|
||||
}
|
||||
|
||||
out, err = patchTopNumber(manifestSrc, "planet", "massif_wavelength_km", 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"massif_wavelength_km": 7`) {
|
||||
t.Fatalf("a missing key was not added:\n%s", out)
|
||||
}
|
||||
|
||||
out, err = patchTopString(manifestSrc, "planet", "template", "Templates/Map3.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"template": "Templates/Map3.png"`) {
|
||||
t.Fatalf("the template path was not repointed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The base map is an input a person made by hand and there is no undo for it outside this process. Saving
|
||||
// versions rather than overwriting is the whole contract, and the second half of it is that saving twice
|
||||
// gives _001 and _002 rather than _001 and _001_002.
|
||||
func TestSavingNeverOverwritesAndNumbersUpwards(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "Map3.jpg")
|
||||
if err := os.WriteFile(base, []byte("the base map"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first, err := nextVersion(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(first) != "Map3_001.png" {
|
||||
t.Errorf("first save went to %q, want Map3_001.png", filepath.Base(first))
|
||||
}
|
||||
if err := os.WriteFile(first, []byte("v1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Asked again from the *versioned* path, which is what the manifest now points at.
|
||||
second, err := nextVersion(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(second) != "Map3_002.png" {
|
||||
t.Errorf("second save went to %q, want Map3_002.png", filepath.Base(second))
|
||||
}
|
||||
|
||||
// And the base map is still exactly what it was.
|
||||
if b, err := os.ReadFile(base); err != nil || string(b) != "the base map" {
|
||||
t.Errorf("the base map was touched: %q %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The overlay legend is patched by the same three functions - a mark is an object with a "name" like a class
|
||||
// is - so the thing worth testing separately is the one that is new: removing a key.
|
||||
const overlaySrc = `{
|
||||
"image": "Map3.overlay.png",
|
||||
"marks": [
|
||||
{ "_comment": "why this shore is pinned, at length",
|
||||
"name": "drawn_coast", "rgb": [255, 0, 255], "coast_jitter": 0 },
|
||||
{ "name": "forest", "rgb": [0, 128, 0] },
|
||||
{ "name": "road", "rgb": [90, 60, 30], "kind": "path", "width_m": 8 }
|
||||
]
|
||||
}`
|
||||
|
||||
func TestRemovingAKeyLeavesNoTrace(t *testing.T) {
|
||||
// Add one, then take it away: the file has to come back exactly as it started, or every later diff
|
||||
// carries the scar of a setting somebody tried once.
|
||||
with, err := patchClassNumber(overlaySrc, "forest", "coast_jitter", 0.5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(with, `"coast_jitter": 0.5`) {
|
||||
t.Fatalf("the key was not added:\n%s", with)
|
||||
}
|
||||
back, err := patchClassRemove(with, "forest", "coast_jitter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back != overlaySrc {
|
||||
t.Errorf("a round trip changed the file:\n--- want ---\n%s\n--- got ---\n%s", overlaySrc, back)
|
||||
}
|
||||
|
||||
// Removing one that is not there is not an error: the studio sends "this mark says nothing about the
|
||||
// coast" whether or not it ever did.
|
||||
same, err := patchClassRemove(overlaySrc, "road", "coast_jitter")
|
||||
if err != nil || same != overlaySrc {
|
||||
t.Errorf("removing an absent key should be a no-op; err=%v changed=%v", err, same != overlaySrc)
|
||||
}
|
||||
|
||||
// And the one that is there, on a mark carrying commentary.
|
||||
out, err := patchClassRemove(overlaySrc, "drawn_coast", "coast_jitter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "coast_jitter") {
|
||||
t.Error("the key is still there")
|
||||
}
|
||||
if !strings.Contains(out, `"_comment": "why this shore is pinned, at length"`) {
|
||||
t.Error("the comment went with it")
|
||||
}
|
||||
if !strings.Contains(out, `"name": "drawn_coast", "rgb": [255, 0, 255] }`) {
|
||||
t.Errorf("the trailing comma and its space were not cleaned up:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"salty/terrain/internal/plates"
|
||||
"salty/terrain/internal/template"
|
||||
)
|
||||
|
||||
// The tectonic layer in the studio: a third sheet beside the geology and the annotation.
|
||||
//
|
||||
// It is held, served and saved exactly as the other two are, and the one thing worth writing down is why it
|
||||
// is resampled on the way in.
|
||||
//
|
||||
// A tectonic layer does not have to be the template's size. plates.FromPainting registers it by *extent*, and
|
||||
// the layer `terrain plan --propose-plates` writes is a few thousand pixels wide because a plate is tens of
|
||||
// kilometres across and nothing downstream reads finer than the 250 m tectonic grid. The studio's canvas, its
|
||||
// brush, its undo and its tile upload all assume every sheet is the template's size, though - D-61's whole
|
||||
// design rests on one geometry shared by every layer - and generalising them to three resolutions would be a
|
||||
// great deal of code for a picture of seven blobs. So the layer is upsampled to the template's size on the
|
||||
// way in and saved at that size. It costs nothing on disk: it is a handful of flat colours, and PNG stores
|
||||
// that in a few kilobytes however large the canvas is.
|
||||
//
|
||||
// **A blank tectonic layer is not empty, it is one plate.** The overlay starts transparent because most of an
|
||||
// annotation is nothing; here every pixel is some piece of lithosphere, so a sheet that has never been
|
||||
// painted starts as the legend's first plate all over. That is the class template's rule rather than the
|
||||
// overlay's, and it is the same rule plates.nearestPlate follows when it refuses to leave a pixel unassigned.
|
||||
|
||||
// loadPlates reads the tectonic layer, or starts a blank one the right size.
|
||||
func (s *Server) loadPlates() error {
|
||||
if s.m.PlatesLegendPath() == "" {
|
||||
return nil
|
||||
}
|
||||
lg, err := plates.LoadPaintLegend(s.m.PlatesLegendPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.pl = lg
|
||||
|
||||
if path := s.platesImagePath(); path != "" {
|
||||
if _, statErr := os.Stat(path); statErr == nil {
|
||||
px, w, h, err := template.DecodeRGB(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.plPaint = resampleRGB(px, w, h, s.paintW, s.paintH)
|
||||
s.plOnDisk = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
s.plPaint = blankPlates(lg, s.paintW*s.paintH)
|
||||
return nil
|
||||
}
|
||||
|
||||
// blankPlates is a sheet of the legend's first plate: see the note above about a blank layer being one plate
|
||||
// rather than nothing.
|
||||
func blankPlates(lg *plates.PaintLegend, cells int) []uint8 {
|
||||
out := make([]uint8, cells*3)
|
||||
if len(lg.Plates) == 0 {
|
||||
return out
|
||||
}
|
||||
c := lg.Plates[0].RGB
|
||||
for i := 0; i < cells; i++ {
|
||||
out[i*3], out[i*3+1], out[i*3+2] = uint8(c[0]), uint8(c[1]), uint8(c[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resampleRGB scales a sheet to a new size by nearest neighbour.
|
||||
//
|
||||
// Nearest, never interpolated. Every pixel of this layer is a plate id wearing a colour, and a blend of two
|
||||
// plate colours is a third plate as far as nearestPlate is concerned - so a bilinear resample would paint a
|
||||
// one-pixel ribbon of some unrelated plate down every margin on the planet.
|
||||
func resampleRGB(src []uint8, sw, sh, dw, dh int) []uint8 {
|
||||
if sw == dw && sh == dh {
|
||||
out := make([]uint8, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
out := make([]uint8, dw*dh*3)
|
||||
for y := 0; y < dh; y++ {
|
||||
sy := y * sh / dh
|
||||
for x := 0; x < dw; x++ {
|
||||
sx := x * sw / dw
|
||||
s := (sy*sw + sx) * 3
|
||||
d := (y*dw + x) * 3
|
||||
out[d], out[d+1], out[d+2] = src[s], src[s+1], src[s+2]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// platesImagePath is the layer the manifest names, or the one its legend names beside itself.
|
||||
func (s *Server) platesImagePath() string {
|
||||
if p := s.m.PlatesLayerPath(); p != "" {
|
||||
return p
|
||||
}
|
||||
if s.pl != nil && s.pl.Image != "" {
|
||||
return filepath.Join(filepath.Dir(s.m.PlatesLegendPath()), s.pl.Image)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) handlePlatesPNG(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
if s.pl == nil {
|
||||
s.mu.Unlock()
|
||||
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
img := rgbaFrom(s.plPaint, s.paintW, s.paintH)
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = (&png.Encoder{CompressionLevel: png.BestSpeed}).Encode(w, img)
|
||||
}
|
||||
|
||||
// rgbaFrom turns an RGB sheet into an opaque image ready to encode.
|
||||
func rgbaFrom(px []uint8, w, h int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for i, n := 0, w*h; i < n; i++ {
|
||||
img.Pix[i*4] = px[i*3]
|
||||
img.Pix[i*4+1] = px[i*3+1]
|
||||
img.Pix[i*4+2] = px[i*3+2]
|
||||
img.Pix[i*4+3] = 255
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// handlePlatesPost takes the browser's tectonic canvas back, and saves it when asked.
|
||||
func (s *Server) handlePlatesPost(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<20))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "the body is not a PNG: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
b := img.Bounds()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.pl == nil {
|
||||
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if b.Dx() != s.paintW || b.Dy() != s.paintH {
|
||||
http.Error(w, fmt.Sprintf("the tectonic canvas is %dx%d and the template is %dx%d",
|
||||
b.Dx(), b.Dy(), s.paintW, s.paintH), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
for y := 0; y < s.paintH; y++ {
|
||||
for x := 0; x < s.paintW; x++ {
|
||||
cr, cg, cb, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
|
||||
o := (y*s.paintW + x) * 3
|
||||
r8, g8, b8 := uint8(cr>>8), uint8(cg>>8), uint8(cb>>8)
|
||||
if s.plPaint[o] != r8 || s.plPaint[o+1] != g8 || s.plPaint[o+2] != b8 {
|
||||
changed = true
|
||||
}
|
||||
s.plPaint[o], s.plPaint[o+1], s.plPaint[o+2] = r8, g8, b8
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.plSeq++
|
||||
s.plDirty = true
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("save") == "1" {
|
||||
path, repointed, err := s.savePlates()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.plDirty, s.plOnDisk = false, true
|
||||
writeJSON(w, map[string]any{"saved": path, "repointed": repointed})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// savePlates writes the layer to the next free numbered PNG and points the manifest at it. Same rule as the
|
||||
// other two sheets and for the same reason: it never overwrites, because there is no undo for a painting
|
||||
// outside this process. A layer that has never existed is written at the name already asked for, which is not
|
||||
// an overwrite because nothing is there.
|
||||
func (s *Server) savePlates() (path string, repointed bool, err error) {
|
||||
src := s.platesImagePath()
|
||||
if src == "" {
|
||||
return "", false, fmt.Errorf("%s names no tectonic layer and its legend names none either; set "+
|
||||
"planet.plates.layer or the legend's \"image\"", s.manifestPath)
|
||||
}
|
||||
if _, statErr := os.Stat(src); os.IsNotExist(statErr) {
|
||||
path = src
|
||||
} else {
|
||||
if path, err = nextVersion(src); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
enc := png.Encoder{CompressionLevel: png.BestCompression}
|
||||
if err := enc.Encode(&buf, rgbaFrom(s.plPaint, s.paintW, s.paintH)); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
rel := filepath.ToSlash(filepath.Join(filepath.Dir(s.m.Planet.Plates.Legend), filepath.Base(path)))
|
||||
if rel != s.m.Planet.Plates.Layer {
|
||||
if err := s.patchManifest(func(text string) (string, error) {
|
||||
return patchTopString(text, "plates", "layer", rel)
|
||||
}); err != nil {
|
||||
return path, false, err
|
||||
}
|
||||
repointed = true
|
||||
}
|
||||
return path, repointed, nil
|
||||
}
|
||||
|
||||
// plateEdit is one plate's motion as the page sends it back.
|
||||
type plateEdit struct {
|
||||
Plate string `json:"plate"`
|
||||
SpeedCmYr *float64 `json:"speed_cm_yr"`
|
||||
HeadingDeg *float64 `json:"heading_deg"`
|
||||
SpinDegMyr *float64 `json:"spin_deg_myr"`
|
||||
}
|
||||
|
||||
// handlePlatesLegend writes the tectonic legend by patching its text, the same way the class and overlay
|
||||
// legends are written: the commentary at the top of that file is the only place the heading convention is
|
||||
// written down, and marshalling the struct back would delete it.
|
||||
func (s *Server) handlePlatesLegend(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
serveJSONFile(w, s.m.PlatesLegendPath())
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var edits []plateEdit
|
||||
if err := json.NewDecoder(r.Body).Decode(&edits); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.m.PlatesLegendPath()
|
||||
if path == "" || s.pl == nil {
|
||||
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
text, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := string(text)
|
||||
for _, e := range edits {
|
||||
for key, v := range map[string]*float64{
|
||||
"speed_cm_yr": e.SpeedCmYr,
|
||||
"heading_deg": e.HeadingDeg,
|
||||
"spin_deg_myr": e.SpinDegMyr,
|
||||
} {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if out, err = patchClassNumber(out, e.Plate, key, *v); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.reload(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"saved": path})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Sharing the studio's files with another tool on the same machine.
|
||||
//
|
||||
// World Orogen (Tools/Orogen, D-66) reads the same painting and the same legends this studio edits, and until
|
||||
// now the only way to get them there was a file picker: choose the PNG, choose the legend, choose the overlay,
|
||||
// choose its legend, retype the manifest's numbers. The studio already holds every one of those - the painting
|
||||
// in memory, exactly as the next plan will read it - so it serves them, and Orogen loads a planet with one
|
||||
// button.
|
||||
//
|
||||
// Two rules keep this from turning the studio into something a web page can drive:
|
||||
//
|
||||
// - **Only GET is shared.** The CORS header goes on GET responses and nothing else, and no preflight is ever
|
||||
// answered. A cross-origin POST with a JSON body needs a preflight, so every endpoint that paints, saves,
|
||||
// plans or bakes stays reachable from this page and from nothing else. The studio listens on loopback, but
|
||||
// a browser on the same machine visits other origins all day, and "any tab can start a two-hour bake" is
|
||||
// not a property to give away for a convenience.
|
||||
// - **The files are served as they are on disk.** The legend and the manifest are text somebody wrote, with
|
||||
// commentary; Orogen reads the same keys the plan does and ignores the rest. Nothing is re-marshalled, so
|
||||
// what Orogen sees is byte for byte what `terrain plan` will see.
|
||||
|
||||
// readOnlyCORS lets any origin *read* the API and touches nothing else.
|
||||
func readOnlyCORS(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// serveJSONFile answers a GET for one of the planet's JSON files, or says there is none. The POST handlers
|
||||
// call it first and return, so an endpoint that edits a file also hands the file out.
|
||||
func serveJSONFile(w http.ResponseWriter, path string) {
|
||||
if path == "" {
|
||||
http.Error(w, "the manifest names no such file", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The one property share.go promises: another origin can read, and cannot do anything else.
|
||||
func TestCORSIsReadOnly(t *testing.T) {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
h := readOnlyCORS(inner)
|
||||
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
want string
|
||||
}{
|
||||
{http.MethodGet, "*"},
|
||||
{http.MethodHead, "*"},
|
||||
{http.MethodPost, ""},
|
||||
{http.MethodOptions, ""},
|
||||
{http.MethodDelete, ""},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(tc.method, "/api/legend", nil))
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != tc.want {
|
||||
t.Errorf("%s: Access-Control-Allow-Origin = %q, want %q", tc.method, got, tc.want)
|
||||
}
|
||||
// No preflight is answered: a browser needs Allow-Methods to send a cross-origin POST, and it never
|
||||
// gets one.
|
||||
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "" {
|
||||
t.Errorf("%s: Access-Control-Allow-Methods = %q, want none", tc.method, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeJSONFileIsTheFileOnDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "legend.json")
|
||||
// Commentary and formatting are the point: what Orogen reads is the text the author wrote.
|
||||
text := "{\n \"_comment\": \"kept\",\n \"classes\": []\n}\n"
|
||||
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
serveJSONFile(rec, path)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != text {
|
||||
t.Errorf("body changed:\n%s\nwant\n%s", got, text)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type %q", ct)
|
||||
}
|
||||
|
||||
// A planet with no overlay legend has "" for its path, and that is a 404 rather than a read of "".
|
||||
rec = httptest.NewRecorder()
|
||||
serveJSONFile(rec, "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("empty path: status %d, want 404", rec.Code)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
serveJSONFile(rec, filepath.Join(dir, "missing.json"))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("missing file: status %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// Raster is a class index per pixel, row-major. X wraps; Y does not.
|
||||
type Raster struct {
|
||||
W, H int
|
||||
Class []uint8
|
||||
}
|
||||
|
||||
// At reads a pixel, wrapping X and clamping Y, which is the convention every cylindrical map in this
|
||||
// tree follows: the left and right edges are the same meridian, the top and bottom are the poles.
|
||||
func (r *Raster) At(x, y int) uint8 {
|
||||
x = ((x % r.W) + r.W) % r.W
|
||||
if y < 0 {
|
||||
y = 0
|
||||
} else if y >= r.H {
|
||||
y = r.H - 1
|
||||
}
|
||||
return r.Class[y*r.W+x]
|
||||
}
|
||||
|
||||
// Match is what the classifier saw, and it is the first thing to read when a template comes out wrong.
|
||||
//
|
||||
// Every pixel is assigned to its nearest class, so a colour the legend has never heard of does not fail
|
||||
// the run - it quietly becomes whatever it happens to be closest to. Far and MaxDist are what make that
|
||||
// visible.
|
||||
type Match struct {
|
||||
Total int
|
||||
Counts []int // per class
|
||||
Far int // pixels further than the legend's WarnDistance from every class
|
||||
MaxDist float64
|
||||
MaxAt [2]int // where the worst one was
|
||||
|
||||
// The wrap: how well the painting's left and right edges agree. They are the same meridian, so a
|
||||
// template that does not wrap produces a real discontinuity down one line of the world and there is no
|
||||
// way to see it by looking at the picture - the two edges are as far apart on screen as they can be.
|
||||
WrapRows int // rows compared
|
||||
WrapDiffer int // rows where the two edges classify differently
|
||||
WrapLandSea int // rows where one edge is land and the other water: the visible kind
|
||||
WrapFarEdge int // pixels in the first or last two columns that no class is near
|
||||
}
|
||||
|
||||
func (m Match) String() string {
|
||||
return fmt.Sprintf("%d px, %d further than the warn distance from any class (worst %.0f at %d,%d)",
|
||||
m.Total, m.Far, m.MaxDist, m.MaxAt[0], m.MaxAt[1])
|
||||
}
|
||||
|
||||
// WrapReport is the one-line verdict on whether the painting is a cylinder.
|
||||
func (m Match) WrapReport() string {
|
||||
if m.WrapRows == 0 {
|
||||
return "wrap not measured"
|
||||
}
|
||||
return fmt.Sprintf("the edges disagree on %d of %d rows (%.1f%%), %d of them land against water; "+
|
||||
"%d px in the outermost columns match no class",
|
||||
m.WrapDiffer, m.WrapRows, 100*float64(m.WrapDiffer)/float64(m.WrapRows), m.WrapLandSea, m.WrapFarEdge)
|
||||
}
|
||||
|
||||
// measureWrap compares the first and last columns, which are the same meridian.
|
||||
func (l *Legend) measureWrap(px []uint8, w, h int, r *Raster, m *Match) {
|
||||
if w < 2 {
|
||||
return
|
||||
}
|
||||
m.WrapRows = h
|
||||
warn2 := l.WarnDistance * l.WarnDistance
|
||||
for y := 0; y < h; y++ {
|
||||
a := r.Class[y*w]
|
||||
b := r.Class[y*w+w-1]
|
||||
if a != b {
|
||||
m.WrapDiffer++
|
||||
if l.Classes[a].Sea != l.Classes[b].Sea {
|
||||
m.WrapLandSea++
|
||||
}
|
||||
}
|
||||
// The outermost columns are where a lossy encoder leaves its halo, and a halo on the seam is a
|
||||
// stripe of the wrong class down the one line of the world where it cannot be hidden.
|
||||
for _, x := range [4]int{0, 1, w - 2, w - 1} {
|
||||
o := (y*w + x) * 3
|
||||
if float64(l.nearestDist2(int(px[o]), int(px[o+1]), int(px[o+2]))) > warn2 {
|
||||
m.WrapFarEdge++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nearestDist2 is the squared RGB distance to the closest painted class.
|
||||
func (l *Legend) nearestDist2(r, g, b int) int {
|
||||
best := 1 << 30
|
||||
for ci := range l.Classes {
|
||||
c := &l.Classes[ci]
|
||||
if c.Derived {
|
||||
continue
|
||||
}
|
||||
dr, dg, db := r-c.RGB[0], g-c.RGB[1], b-c.RGB[2]
|
||||
if d := dr*dr + dg*dg + db*db; d < best {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Classify assigns every pixel to the nearest class in RGB.
|
||||
//
|
||||
// Nearest rather than within-a-tolerance, so the result is total: there is no unclassified pixel to
|
||||
// decide what to do with later, and a stray artefact - a JPEG ringing overshoot, the one black pixel in
|
||||
// the left column of the template this was written for - lands on something sensible instead of
|
||||
// punching a hole in the world. The Match report is what says it happened.
|
||||
func (l *Legend) Classify(px []uint8, w, h int) (*Raster, Match) {
|
||||
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
|
||||
|
||||
// Reduction into pre-allocated indexed slots, never a channel drain: the result must not depend on
|
||||
// which goroutine finished first (cross-cutting rule 12).
|
||||
partial := make([]Match, field.BandCount(h))
|
||||
for i := range partial {
|
||||
partial[i].Counts = make([]int, len(l.Classes))
|
||||
}
|
||||
warn2 := l.WarnDistance * l.WarnDistance
|
||||
|
||||
field.RowsIndexed(h, func(band, y0, y1 int) {
|
||||
p := &partial[band]
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
o := (y*w + x) * 3
|
||||
cr, cg, cb := int(px[o]), int(px[o+1]), int(px[o+2])
|
||||
best, bestD := -1, 1<<30
|
||||
for ci := range l.Classes {
|
||||
c := &l.Classes[ci]
|
||||
if c.Derived {
|
||||
continue // never painted, so never matched
|
||||
}
|
||||
dr := cr - c.RGB[0]
|
||||
dg := cg - c.RGB[1]
|
||||
db := cb - c.RGB[2]
|
||||
d := dr*dr + dg*dg + db*db
|
||||
if d < bestD {
|
||||
bestD, best = d, ci
|
||||
}
|
||||
}
|
||||
r.Class[y*w+x] = uint8(best)
|
||||
p.Total++
|
||||
p.Counts[best]++
|
||||
if float64(bestD) > warn2 {
|
||||
p.Far++
|
||||
}
|
||||
if float64(bestD) > p.MaxDist {
|
||||
p.MaxDist = float64(bestD)
|
||||
p.MaxAt = [2]int{x, y}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
out := Match{Counts: make([]int, len(l.Classes))}
|
||||
out.MaxAt = [2]int{-1, -1}
|
||||
for i := range partial {
|
||||
p := &partial[i]
|
||||
out.Total += p.Total
|
||||
out.Far += p.Far
|
||||
for c, n := range p.Counts {
|
||||
out.Counts[c] += n
|
||||
}
|
||||
// The tie-break keeps the report itself independent of GOMAXPROCS, which changes how many bands
|
||||
// there are: without it two pixels at the same distance could be reported in either order.
|
||||
if p.Total > 0 && (p.MaxDist > out.MaxDist ||
|
||||
(p.MaxDist == out.MaxDist && earlier(p.MaxAt, out.MaxAt))) {
|
||||
out.MaxDist = p.MaxDist
|
||||
out.MaxAt = p.MaxAt
|
||||
}
|
||||
}
|
||||
out.MaxDist = math.Sqrt(out.MaxDist) // kept squared through the loops; reported as a distance
|
||||
l.measureWrap(px, w, h, r, &out)
|
||||
return r, out
|
||||
}
|
||||
|
||||
func earlier(a, b [2]int) bool {
|
||||
if b[1] < 0 {
|
||||
return true
|
||||
}
|
||||
if a[1] != b[1] {
|
||||
return a[1] < b[1]
|
||||
}
|
||||
return a[0] < b[0]
|
||||
}
|
||||
|
||||
// DissolveStrokes removes the decoration an artist drew and leaves only classes that mean something.
|
||||
//
|
||||
// Two rules, in this order:
|
||||
//
|
||||
// 1. A stroke region that touches the top or bottom row of the map is not a stroke. It becomes the
|
||||
// class its edge_class names. This is what tells a polar ice cap from the white outline drawn
|
||||
// around every island when both are painted the same white, and it is the whole reason the rule
|
||||
// exists.
|
||||
// 2. Every remaining stroke pixel takes the class of the nearest pixel that is not a stroke, measured
|
||||
// outwards from all of them at once. A ring sitting between land and water is therefore split down
|
||||
// its middle rather than given wholly to one side, which is the only answer that does not move the
|
||||
// coastline by the width of the artist's brush.
|
||||
//
|
||||
// Returns how many pixels each rule rewrote.
|
||||
func (r *Raster) DissolveStrokes(l *Legend) (edge, dissolved int) {
|
||||
stroke := make([]bool, len(l.Classes))
|
||||
any := false
|
||||
for i := range l.Classes {
|
||||
stroke[i] = l.Classes[i].Stroke
|
||||
any = any || stroke[i]
|
||||
}
|
||||
if !any {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
edge = r.rewriteEdgeStrokes(l, stroke)
|
||||
|
||||
// Rule 2. Seed from every non-stroke pixel that touches a stroke pixel, then walk outwards through
|
||||
// stroke pixels only. Seeds are pushed in raster order and the queue is FIFO, so the result does not
|
||||
// depend on anything but the image.
|
||||
queue := make([]int32, 0, 1<<16)
|
||||
filled := make([]bool, len(r.Class))
|
||||
for y := 0; y < r.H; y++ {
|
||||
for x := 0; x < r.W; x++ {
|
||||
i := y*r.W + x
|
||||
if stroke[r.Class[i]] {
|
||||
continue
|
||||
}
|
||||
if r.hasStrokeNeighbour(x, y, stroke) {
|
||||
queue = append(queue, int32(i))
|
||||
filled[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for head := 0; head < len(queue); head++ {
|
||||
i := int(queue[head])
|
||||
c := r.Class[i]
|
||||
x, y := i%r.W, i/r.W
|
||||
for _, n := range r.neighbours(x, y) {
|
||||
if n < 0 || filled[n] || !stroke[r.Class[n]] {
|
||||
continue
|
||||
}
|
||||
r.Class[n] = c
|
||||
filled[n] = true
|
||||
dissolved++
|
||||
queue = append(queue, int32(n))
|
||||
}
|
||||
}
|
||||
return edge, dissolved
|
||||
}
|
||||
|
||||
// rewriteEdgeStrokes applies rule 1: flood each stroke class inwards from the poles.
|
||||
func (r *Raster) rewriteEdgeStrokes(l *Legend, stroke []bool) int {
|
||||
n := 0
|
||||
stack := make([]int32, 0, 1<<16)
|
||||
for ci := range l.Classes {
|
||||
if !stroke[ci] {
|
||||
continue
|
||||
}
|
||||
to := l.EdgeIndex(ci)
|
||||
if to < 0 {
|
||||
continue
|
||||
}
|
||||
want := uint8(ci)
|
||||
become := uint8(to)
|
||||
stack = stack[:0]
|
||||
push := func(x, y int) {
|
||||
i := y*r.W + x
|
||||
if r.Class[i] == want {
|
||||
r.Class[i] = become
|
||||
n++
|
||||
stack = append(stack, int32(i))
|
||||
}
|
||||
}
|
||||
for x := 0; x < r.W; x++ {
|
||||
push(x, 0)
|
||||
push(x, r.H-1)
|
||||
}
|
||||
for len(stack) > 0 {
|
||||
i := int(stack[len(stack)-1])
|
||||
stack = stack[:len(stack)-1]
|
||||
x, y := i%r.W, i/r.W
|
||||
for _, m := range r.neighbours(x, y) {
|
||||
if m >= 0 && r.Class[m] == want {
|
||||
r.Class[m] = become
|
||||
n++
|
||||
stack = append(stack, int32(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// neighbours is the eight-connected neighbourhood with X wrapped and Y bounded. -1 means off the map,
|
||||
// which only ever happens past a pole.
|
||||
func (r *Raster) neighbours(x, y int) [8]int {
|
||||
var out [8]int
|
||||
k := 0
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
ny := y + dy
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue
|
||||
}
|
||||
if ny < 0 || ny >= r.H {
|
||||
out[k] = -1
|
||||
k++
|
||||
continue
|
||||
}
|
||||
nx := x + dx
|
||||
if nx < 0 {
|
||||
nx += r.W
|
||||
} else if nx >= r.W {
|
||||
nx -= r.W
|
||||
}
|
||||
out[k] = ny*r.W + nx
|
||||
k++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Raster) hasStrokeNeighbour(x, y int, stroke []bool) bool {
|
||||
for _, n := range r.neighbours(x, y) {
|
||||
if n >= 0 && stroke[r.Class[n]] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/dt"
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// A painted coastline is a drawn line, and a coastline is not a drawn line.
|
||||
//
|
||||
// This is the Richardson paradox with a brush in it. An author draws a shore as a smooth curve, because that
|
||||
// is what a hand and a bezier tool produce; a real coast has bays inside bays inside bays and the length you
|
||||
// measure depends on the ruler you measure it with. Projected straight, the painting's own smoothness
|
||||
// survives all the way to the heightmap, and the result reads as exactly what it is - a shape somebody drew -
|
||||
// however good the erosion downstream is. `internal/coast` does this job on the square canvas, where the
|
||||
// outline is noise to begin with; the painted planet had a manifest key for it, `coast_jitter_px`, which
|
||||
// until now nothing anywhere read.
|
||||
//
|
||||
// **It is a mask on the waterline, not a warp of the painting.** That distinction was measured rather than
|
||||
// reasoned. Displacing the point each cell asks the painting about - a domain warp - was tried first and it
|
||||
// cannot cut a bay: a smooth warp of a smooth boundary is another smooth boundary, just wigglier, and at an
|
||||
// amplitude large enough to fold it back on itself it drags every inland class boundary the same distance.
|
||||
// What produces bays and headlands is thresholding a *signed distance field*: how far is this cell from the
|
||||
// waterline, add fractal noise to that distance in metres, and ask again which side of zero it is on. Land
|
||||
// juts out where the noise is positive and the sea reaches in where it is negative, at every scale the
|
||||
// octaves cover, and nothing away from the shore moves at all.
|
||||
//
|
||||
// Two things follow from doing it this way, and both are the reason to:
|
||||
//
|
||||
// - A cell that changes sides needs a class, and the distance transform already knows which one: it
|
||||
// returns the nearest seed cell as well as the distance to it, so new land takes the class of the land
|
||||
// it grew from and new sea takes the class of the water that came in. Sea eaten out of a shore becomes
|
||||
// the surf that was lying against it rather than deep ocean.
|
||||
// - Small islands have to survive. An islet thirty pixels across, under a noise field whose wavelength is
|
||||
// four hundred, sees very nearly a constant - so it either sits still or vanishes whole, and vanishing
|
||||
// whole is how an archipelago disappears between two runs. The amplitude is therefore capped per cell at
|
||||
// a fraction of the widest land within reach of it, which is a sliding maximum of the land distance.
|
||||
// A continent sees the full amplitude; an islet gets nibbled instead of deleted.
|
||||
|
||||
// Pass indices for the coast mask's noise, above the painted uplift path's 20..25 so neither can reshuffle
|
||||
// the other.
|
||||
const (
|
||||
srcCoastMask = 30
|
||||
)
|
||||
|
||||
// islandGuard is how much of the widest land within reach the mask may eat. Two thirds leaves an islet
|
||||
// recognisably itself while still giving it a ragged edge; at 1 it can take the whole thing.
|
||||
const islandGuard = 0.66
|
||||
|
||||
// Coast is how the painted waterline is roughened before the painting is projected.
|
||||
type Coast struct {
|
||||
// AmplitudePx is the furthest, in template pixels, that the shoreline may move. Zero switches the whole
|
||||
// thing off and the painting is used exactly as drawn.
|
||||
AmplitudePx float64
|
||||
|
||||
// WavelengthPx is the coarsest octave: the width of the biggest bay it can cut. Octaves halve from
|
||||
// there, so the finest detail is this over 2^(Octaves-1). Bays come out about this wide and up to
|
||||
// AmplitudePx deep, so the ratio of the two is what decides whether the coast reads as a rough line or
|
||||
// as a fjord coast.
|
||||
WavelengthPx float64
|
||||
|
||||
// Octaves and Gain are the fractal structure. A gain near 0.5 makes each scale about as prominent as the
|
||||
// last, which is the property a real coastline has and a single wobble does not.
|
||||
Octaves int
|
||||
Gain float64
|
||||
|
||||
// Scale is a per-pixel multiplier on AmplitudePx at the raster's own resolution, from the annotation
|
||||
// layer's coast_jitter marks. Nil is one everywhere, which is every world before D-57.
|
||||
//
|
||||
// It is what makes a hand-drawn coastline hold. The roughening exists because a drawn shore is smooth and
|
||||
// a real one is not, which is true of a shore nobody thought about and false of one somebody traced off a
|
||||
// map on purpose; a zero here pins that stretch exactly as painted while the rest of the world is still
|
||||
// roughened. Above one chews harder, which is the same knob pointed the other way - a fjord coast wants
|
||||
// more than the planet's own amplitude, not less.
|
||||
//
|
||||
// **A negative entry means the pixel carries no instruction**, which is not the same as one. A mark is a
|
||||
// stroke an author drew along a coastline and it lands on whichever side of the waterline their hand was
|
||||
// on; if an unmarked cell took the default, a stroke painted on the land would leave the water beside it
|
||||
// free to march inland anyway and the coast would move regardless. So an uninstructed cell takes the
|
||||
// instruction from the nearest cell on the other side of the waterline, which the distance transform
|
||||
// below has already found for a different reason. Painting either side is then enough, and painting over
|
||||
// the line - which is what a brush does - is enough twice over.
|
||||
Scale []float32
|
||||
|
||||
Seed int64
|
||||
}
|
||||
|
||||
// Amount reports whether this mask does anything.
|
||||
func (c Coast) Amount() bool {
|
||||
return c.AmplitudePx > 0 && c.Octaves > 0 && c.WavelengthPx > 0 && c.Gain > 0
|
||||
}
|
||||
|
||||
// maxScale is the largest multiplier any mark asks for, and at least 1. Uninstructed entries are negative and
|
||||
// do not count; an unmarked world has no Scale at all and gets 1.
|
||||
func (c Coast) maxScale() float64 {
|
||||
m := 1.0
|
||||
for _, v := range c.Scale {
|
||||
if float64(v) > m {
|
||||
m = float64(v)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// RoughenCoast returns the painting with its waterline displaced by fractal noise. The receiver is not
|
||||
// modified: a caller that wants both keeps both, which is what the studio's preview does.
|
||||
//
|
||||
// It runs at the paint's own resolution rather than the planet's. That is a third of the cells, the mask's
|
||||
// scales are quoted in template pixels anyway, and the thing being roughened is the painting - so a template
|
||||
// re-exported at a different size is the one case where the coast moves, and that is already true of every
|
||||
// other thing the painting decides.
|
||||
func (r *Raster) RoughenCoast(l *Legend, p world.Planet, c Coast) *Raster {
|
||||
if !c.Amount() || r.W == 0 || r.H == 0 {
|
||||
return r
|
||||
}
|
||||
sea := make([]bool, len(l.Classes))
|
||||
for i := range l.Classes {
|
||||
sea[i] = l.Classes[i].Sea
|
||||
}
|
||||
|
||||
isSea := make([]bool, len(r.Class))
|
||||
anySea, anyLand := false, false
|
||||
for i, cl := range r.Class {
|
||||
isSea[i] = sea[cl]
|
||||
if isSea[i] {
|
||||
anySea = true
|
||||
} else {
|
||||
anyLand = true
|
||||
}
|
||||
}
|
||||
if !anySea || !anyLand {
|
||||
return r // nothing to move: the painting is all one or all the other
|
||||
}
|
||||
|
||||
// Signed distance to the waterline, positive on land, in template pixels, plus the index of the nearest
|
||||
// cell on the other side - which is where a cell that changes sides gets its class from.
|
||||
signed := make([]float32, len(r.Class))
|
||||
other := make([]int32, len(r.Class))
|
||||
|
||||
// Seeded on land: for every sea cell, how far to land and which land cell.
|
||||
d2, near := dt.Transform(invert(isSea), r.W, r.H, true)
|
||||
for i := range signed {
|
||||
if isSea[i] {
|
||||
signed[i] = -float32(math.Sqrt(float64(d2[i])))
|
||||
other[i] = near[i]
|
||||
}
|
||||
}
|
||||
// Seeded on sea: for every land cell, how far to water and which water cell. Released in turn so the
|
||||
// two transforms are never both alive - at 29 million pixels each one is a quarter of a gigabyte.
|
||||
d2, near = dt.Transform(isSea, r.W, r.H, true)
|
||||
for i := range signed {
|
||||
if !isSea[i] {
|
||||
signed[i] = float32(math.Sqrt(float64(d2[i])))
|
||||
other[i] = near[i]
|
||||
}
|
||||
}
|
||||
d2, near = nil, nil
|
||||
|
||||
// How wide the land is near each cell, so an islet cannot be eaten whole. Only land contributes, so a
|
||||
// lone islet reports its own half-width and not the open water around it.
|
||||
landOnly := field.New(r.W, r.H, 1)
|
||||
for i := range signed {
|
||||
if signed[i] > 0 {
|
||||
landOnly.Data[i] = signed[i]
|
||||
}
|
||||
}
|
||||
// The window is the furthest the shore could move *anywhere*, which is no longer the plain amplitude: a
|
||||
// mark asking for more than the planet's own can reach past it, and a guard measured over too small a
|
||||
// window would under-report how wide the land is and let an islet inside such a mark be eaten whole -
|
||||
// the one failure this guard exists to stop.
|
||||
reach := int(c.AmplitudePx*c.maxScale() + 0.5)
|
||||
widest := field.SlidingMax(landOnly, reach, true)
|
||||
|
||||
// The noise, on world coordinates so it wraps at the seam and two runs of the same world agree.
|
||||
// noise.Lattice wraps modulo its cell count, so the lattice has to be a whole number of cells in the
|
||||
// noise period; the wavelength is quoted in template pixels and converts through the paint's own scale.
|
||||
metresPerPx := p.CircumferenceM() / float64(r.W)
|
||||
cells := int(p.NoisePeriodM/(c.WavelengthPx*metresPerPx) + 0.5)
|
||||
if cells < 1 {
|
||||
cells = 1
|
||||
}
|
||||
u, v := noise.WorldUV(r.W, r.H, metresPerPx, 0, 0, p.NoisePeriodM)
|
||||
n := noise.FBMAt(u, v, noise.NewSource(c.Seed, srcCoastMask),
|
||||
noise.Params{BaseCells: cells, Octaves: c.Octaves, Gain: c.Gain})
|
||||
|
||||
// Stretched to its own full range before it is used, so the amplitude means what it says. An fBm stack
|
||||
// is normalised by the sum of its octave amplitudes, which is the value it would take if every octave
|
||||
// agreed at once - they never do, so the realised spread is far narrower than 0..1 and a nominal 48 px
|
||||
// was moving the shore about ten. The same trap as the massif fabric's threshold, and the same fix:
|
||||
// measure the distribution rather than assume it. Here it is one pass of min/max over the whole painting
|
||||
// - legitimate because the mask runs once on the whole map and not per region, so there is no second
|
||||
// caller to disagree with.
|
||||
n.Normalise()
|
||||
|
||||
out := &Raster{W: r.W, H: r.H, Class: make([]uint8, len(r.Class))}
|
||||
copy(out.Class, r.Class)
|
||||
|
||||
field.Rows(r.H, func(y0, y1 int) {
|
||||
for i := y0 * r.W; i < y1*r.W; i++ {
|
||||
amp := c.AmplitudePx
|
||||
if c.Scale != nil {
|
||||
sc := c.Scale[i]
|
||||
if sc < 0 {
|
||||
// Uninstructed: take the instruction from the far side of the waterline. See Coast.Scale.
|
||||
if j := other[i]; j >= 0 {
|
||||
sc = c.Scale[j]
|
||||
}
|
||||
}
|
||||
if sc >= 0 {
|
||||
amp *= float64(sc)
|
||||
}
|
||||
}
|
||||
if g := islandGuard * float64(widest.Data[i]); g < amp {
|
||||
amp = g
|
||||
}
|
||||
if amp <= 0 {
|
||||
continue
|
||||
}
|
||||
d := float64(signed[i]) + amp*(float64(n.Data[i])*2-1)
|
||||
nowLand := d > 0
|
||||
if nowLand == !isSea[i] {
|
||||
continue // this cell did not change sides
|
||||
}
|
||||
// It did. Take the class of the nearest cell on the side it has joined, which the transform
|
||||
// already found: land grows out of the land beside it, and water comes in as the water that
|
||||
// was lying against the shore.
|
||||
if j := other[i]; j >= 0 {
|
||||
out.Class[i] = r.Class[j]
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func invert(b []bool) []bool {
|
||||
out := make([]bool, len(b))
|
||||
for i, v := range b {
|
||||
out[i] = !v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package template
|
||||
|
||||
import "salty/terrain/internal/field"
|
||||
|
||||
// Despeckle removes the hairline of a class nobody painted that appears along a boundary between two others.
|
||||
//
|
||||
// It exists because of a measured failure, and the arithmetic is worth keeping because it will happen to
|
||||
// anybody who exports a lossy image. Classification gives every pixel its nearest class in RGB, and a codec
|
||||
// blends across every boundary it finds. On the first template the antialiased edge between `surf`
|
||||
// (221,238,238) and `lowland` (153,204,102) comes out at about (186,219,174), whose distance to `desert`
|
||||
// (238,221,153) is **53.8** against **77.9** to either of the colours it was actually mixed from. So every
|
||||
// temperate coast on the map gained a one-pixel ribbon of desert - 1607 pixels of it nowhere near the real
|
||||
// desert - and it was invisible until the coast mask made those strays the nearest *land* to a stretch of
|
||||
// open water and handed their class to every cell it turned into shore. An eleven-pixel band of desert
|
||||
// appeared along a green continent, and the mask was blamed for it first.
|
||||
//
|
||||
// **The test is spatial, and it has to be.** The obvious fix is colorimetric - notice that the pixel lies on
|
||||
// the line between two class colours and give it to the nearer one - and it was built, measured and thrown
|
||||
// away, because it cannot work in general and this legend is the proof: `shelf` (153,204,221) sits 10 units
|
||||
// from the line between `ocean` and `surf`, so a real shelf pixel with a little codec noise on it and a
|
||||
// genuine ocean/surf blend are the same point in colour space. That rule reclassified 943 000 painted shelf
|
||||
// pixels. What actually distinguishes a stray is *where* it is: a class nobody painted here occupies a line
|
||||
// one pixel wide with two other classes on either side of it, and no painted feature at 12.9 m a pixel is
|
||||
// one pixel wide - the one thing that was, the decorative stroke, has a pass of its own.
|
||||
//
|
||||
// Hence a five by five window rather than three by three. A one-pixel ribbon running through the middle of a
|
||||
// 3x3 holds three of its nine cells and the two classes it divides hold three each, so nothing has a
|
||||
// majority and the rule cannot fire; over 5x5 the ribbon holds five of twenty-five against ten and ten, which
|
||||
// is the signature being looked for. A feature two pixels wide already holds ten and is left alone.
|
||||
func (r *Raster) Despeckle() int {
|
||||
if r.W < despeckleWindow || r.H < despeckleWindow {
|
||||
return 0
|
||||
}
|
||||
out := make([]uint8, len(r.Class))
|
||||
copy(out, r.Class)
|
||||
|
||||
const rad = despeckleWindow / 2
|
||||
counts := make([]int32, field.BandCount(r.H)*256)
|
||||
changed := make([]int, field.BandCount(r.H))
|
||||
|
||||
field.RowsIndexed(r.H, func(band, y0, y1 int) {
|
||||
c := counts[band*256 : band*256+256]
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := 0; x < r.W; x++ {
|
||||
self := r.Class[y*r.W+x]
|
||||
for dy := -rad; dy <= rad; dy++ {
|
||||
for dx := -rad; dx <= rad; dx++ {
|
||||
c[r.At(x+dx, y+dy)]++
|
||||
}
|
||||
}
|
||||
best, bestN := self, int32(0)
|
||||
for i := range c {
|
||||
if c[i] > bestN || (c[i] == bestN && uint8(i) < best) {
|
||||
best, bestN = uint8(i), c[i]
|
||||
}
|
||||
}
|
||||
selfN := c[self]
|
||||
for i := range c {
|
||||
c[i] = 0
|
||||
}
|
||||
if selfN <= despeckleThin && bestN >= despeckleMajority && best != self {
|
||||
out[y*r.W+x] = best
|
||||
changed[band]++
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
total := 0
|
||||
for _, n := range changed {
|
||||
total += n
|
||||
}
|
||||
r.Class = out
|
||||
return total
|
||||
}
|
||||
|
||||
const (
|
||||
// despeckleWindow is five: see the note above on why three is too small to see a ribbon at all.
|
||||
despeckleWindow = 5
|
||||
|
||||
// despeckleThin is how little of its own window a class may hold and still be called a stray. Five of
|
||||
// twenty-five is a line one pixel wide straight through the middle; a feature two pixels wide holds ten.
|
||||
despeckleThin = 5
|
||||
|
||||
// despeckleMajority is how much of the window the replacement has to hold. Eight of twenty-five means
|
||||
// there is something clearly there to join, so a pixel in genuinely mixed country is left as it is.
|
||||
despeckleMajority = 8
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package template reads a painted world map and turns it into the fields the geology solve needs.
|
||||
//
|
||||
// The map is an image; the legend beside it says what each colour means. The image is cylindrical: X
|
||||
// wraps, Y does not, so the left and right edges are the same meridian and the top and bottom rows are
|
||||
// the poles. Nothing here knows how big the world is - that is the planet package's job. This package
|
||||
// only answers "what did the author paint here".
|
||||
//
|
||||
// The one rule that governs the whole design is Docs/Terrain-Next.md 3.2: paint the uplift, never the
|
||||
// height. A painted heightmap is handed to a solver that erodes it into something else and throws away
|
||||
// the drainage network, which is the reason the generator exists. So a class carries an uplift rate and
|
||||
// an erodibility, and the solve makes the terrain.
|
||||
package template
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
|
||||
// Registered for image.Decode. JPEG is here because the first template anyone painted was a JPEG;
|
||||
// PNG is what a template should be, because JPEG bleeds colour across every class boundary and the
|
||||
// classifier then has to clean up after it.
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
)
|
||||
|
||||
// DecodeRGB reads an image and returns tightly packed 8-bit RGB, three bytes a pixel, row-major.
|
||||
//
|
||||
// field.ReadHeightmap cannot be used for this and deliberately is not extended: it decodes PNG only, and
|
||||
// its fallback branch collapses colour to luma, which is right for a DEM and destroys a painted map -
|
||||
// two different classes can share a luma and here several nearly do.
|
||||
func DecodeRGB(path string) (px []uint8, w, h int, err error) {
|
||||
px, _, w, h, err = decode(path, false)
|
||||
return px, w, h, err
|
||||
}
|
||||
|
||||
// DecodeRGBA is DecodeRGB with the alpha channel kept alongside.
|
||||
//
|
||||
// It exists for the annotation layer and only for it. A class template is opaque by definition - every pixel
|
||||
// is some class - so throwing alpha away there costs nothing. An overlay is the opposite: it is a transparent
|
||||
// sheet with strokes on it, most of it is nothing, and "nothing" is exactly what alpha records. An image with
|
||||
// no alpha comes back fully opaque, which is the right reading of a flattened export.
|
||||
func DecodeRGBA(path string) (px, alpha []uint8, w, h int, err error) {
|
||||
return decode(path, true)
|
||||
}
|
||||
|
||||
func decode(path string, wantAlpha bool) (px, alpha []uint8, w, h int, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, nil, 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
img, _, err := image.Decode(bufio.NewReaderSize(f, 1<<20))
|
||||
if err != nil {
|
||||
return nil, nil, 0, 0, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
w, h = b.Dx(), b.Dy()
|
||||
if w <= 0 || h <= 0 {
|
||||
return nil, nil, 0, 0, fmt.Errorf("%s: empty image", path)
|
||||
}
|
||||
px = make([]uint8, w*h*3)
|
||||
if wantAlpha {
|
||||
alpha = make([]uint8, w*h)
|
||||
for i := range alpha {
|
||||
alpha[i] = 255
|
||||
}
|
||||
}
|
||||
|
||||
// The fast paths matter: a 7738x3761 template is 29 million pixels, and going through the At()
|
||||
// interface for every one of them costs seconds rather than milliseconds.
|
||||
switch src := img.(type) {
|
||||
case *image.RGBA:
|
||||
// Premultiplied: the RGB bytes are already scaled by alpha, so a half-transparent red reads as a
|
||||
// darker red. Nothing here un-multiplies it, because every consumer that cares about alpha treats a
|
||||
// non-opaque pixel as blank and never looks at its colour.
|
||||
for y := 0; y < h; y++ {
|
||||
row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride:]
|
||||
off := (b.Min.X - src.Rect.Min.X) * 4
|
||||
for x := 0; x < w; x++ {
|
||||
o := (y*w + x) * 3
|
||||
px[o], px[o+1], px[o+2] = row[off+x*4], row[off+x*4+1], row[off+x*4+2]
|
||||
if alpha != nil {
|
||||
alpha[y*w+x] = row[off+x*4+3]
|
||||
}
|
||||
}
|
||||
}
|
||||
case *image.NRGBA:
|
||||
for y := 0; y < h; y++ {
|
||||
row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride:]
|
||||
off := (b.Min.X - src.Rect.Min.X) * 4
|
||||
for x := 0; x < w; x++ {
|
||||
o := (y*w + x) * 3
|
||||
px[o], px[o+1], px[o+2] = row[off+x*4], row[off+x*4+1], row[off+x*4+2]
|
||||
if alpha != nil {
|
||||
alpha[y*w+x] = row[off+x*4+3]
|
||||
}
|
||||
}
|
||||
}
|
||||
case *image.YCbCr:
|
||||
// What image/jpeg returns.
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
yi := src.YOffset(b.Min.X+x, b.Min.Y+y)
|
||||
ci := src.COffset(b.Min.X+x, b.Min.Y+y)
|
||||
r, g, bl := ycbcrToRGB(src.Y[yi], src.Cb[ci], src.Cr[ci])
|
||||
o := (y*w + x) * 3
|
||||
px[o], px[o+1], px[o+2] = r, g, bl
|
||||
}
|
||||
}
|
||||
default:
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
r, g, bl, a := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
|
||||
o := (y*w + x) * 3
|
||||
px[o], px[o+1], px[o+2] = uint8(r>>8), uint8(g>>8), uint8(bl>>8)
|
||||
if alpha != nil {
|
||||
alpha[y*w+x] = uint8(a >> 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return px, alpha, w, h, nil
|
||||
}
|
||||
|
||||
// ycbcrToRGB is image/color's conversion, inlined so the YCbCr path does not allocate a color.Color per
|
||||
// pixel. Same arithmetic, same rounding.
|
||||
func ycbcrToRGB(y, cb, cr uint8) (uint8, uint8, uint8) {
|
||||
yy := int32(y) * 0x10101
|
||||
cb1 := int32(cb) - 128
|
||||
cr1 := int32(cr) - 128
|
||||
|
||||
r := yy + 91881*cr1
|
||||
if uint32(r)&0xff000000 == 0 {
|
||||
r >>= 16
|
||||
} else {
|
||||
r = ^(r >> 31) & 0xffff >> 8
|
||||
}
|
||||
g := yy - 22554*cb1 - 46802*cr1
|
||||
if uint32(g)&0xff000000 == 0 {
|
||||
g >>= 16
|
||||
} else {
|
||||
g = ^(g >> 31) & 0xffff >> 8
|
||||
}
|
||||
b := yy + 116130*cb1
|
||||
if uint32(b)&0xff000000 == 0 {
|
||||
b >>= 16
|
||||
} else {
|
||||
b = ^(b >> 31) & 0xffff >> 8
|
||||
}
|
||||
return uint8(r), uint8(g), uint8(b)
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// Class is one painted colour and everything it means.
|
||||
//
|
||||
// A class says whether the author painted sea or land, and for land it carries the two numbers the
|
||||
// solve actually reads: the rock uplift rate, which is what produces relief, and a multiplier on the
|
||||
// stream-power erodibility, which is what makes one range read differently from the next. Neither is a
|
||||
// height. See the package comment.
|
||||
type Class struct {
|
||||
Name string `json:"name"`
|
||||
RGB [3]int `json:"rgb"`
|
||||
|
||||
Sea bool `json:"sea"`
|
||||
|
||||
// DepthM is how deep this water is, in metres below sea level, positive. It is scenery: the coastal
|
||||
// pass owns the sea floor within its reach of a shore and lays a derived shelf there, so this only
|
||||
// decides the open ocean beyond it. Sea only.
|
||||
DepthM float64 `json:"depth_m"`
|
||||
|
||||
// UpliftMmYr is rock uplift in millimetres a year, which is the field everything else is a
|
||||
// consequence of. The reporting buckets in internal/stats read plain below 0.1, rolling to 0.5 and
|
||||
// mountain above, so those are the numbers to think in. Land only.
|
||||
UpliftMmYr float64 `json:"uplift_mm_yr"`
|
||||
|
||||
// KMult multiplies the stream-power erodibility K. Soft rock above 1, hard rock below. Land only;
|
||||
// zero is read as 1, because an erodibility of zero is never what anyone means.
|
||||
KMult float64 `json:"k_mult"`
|
||||
|
||||
// Stroke marks a colour that is decoration rather than data - the white outline an artist draws
|
||||
// around every island. A stroke is dissolved into whichever real class is nearest, so it never
|
||||
// becomes a ring of land or a moat of water.
|
||||
Stroke bool `json:"stroke"`
|
||||
|
||||
// Snow marks land that is permanently under ice or snow. It is a *display and material* hint and nothing
|
||||
// else - it changes no height and enters no pass - but the preview needs it, because the hypsometric ramp
|
||||
// tops out at snow by elevation and a polar cap at fifty metres therefore comes out the same green as a
|
||||
// meadow. An ice sheet that reads as a meadow is a map that lies about the one thing it is for.
|
||||
Snow bool `json:"snow"`
|
||||
|
||||
// CoastalPlainKm puts the range inland.
|
||||
//
|
||||
// For n = 1 the uplift rate alone fixes the hillslope angle (D-49), so a uniformly painted island sits at
|
||||
// the angle of repose everywhere, the shore included: the rivers cut down to sea level but the ground
|
||||
// between them does not care how far from the coast it is. Real coasts have a plain in front of the
|
||||
// range. This ramps the rate from CoastalFloorMmYr at the waterline up to the class rate over this
|
||||
// distance inland, so the first few kilometres are plain and the range stands behind them.
|
||||
//
|
||||
// It is deliberately opt-in and deliberately not a taper to zero, which is the distinction from D-52:
|
||||
// that was a *hidden* taper - a side effect of multiplying by the continent mask - and it flattened the
|
||||
// hundred-metre strip the surf works in, moving every cliff inland. This is an author saying where their
|
||||
// range starts, and the waterline keeps a real rate.
|
||||
CoastalPlainKm float64 `json:"coastal_plain_km"`
|
||||
|
||||
// CoastalFloorMmYr is the rate at the waterline. Zero means the default, and it is never raised above the
|
||||
// class rate - a plain in front of a plain is still a plain.
|
||||
CoastalFloorMmYr float64 `json:"coastal_floor_mm_yr"`
|
||||
|
||||
// Massif breaks this class into plain and upland instead of one rate over every cell of it.
|
||||
Massif *Massif `json:"massif"`
|
||||
|
||||
// LithologyMix is how much of the planet's rock field shows through on this class's ground, 0 to 1.
|
||||
//
|
||||
// The rock field is one low-frequency pattern over the whole planet, cut into the manifest's
|
||||
// `pipeline.lithology` types, and it multiplies K on top of this class's own `k_mult`. At 1 the class
|
||||
// takes all of it; at 0 it is one uniform rock, which is what every painted class was before D-58 and
|
||||
// what a polar cap or a crater floor should stay - there is no bedrock province showing through an ice
|
||||
// sheet. A pointer, so "not set" is 1 and "set to zero" is uniform; those are different answers.
|
||||
LithologyMix *float64 `json:"lithology_mix"`
|
||||
|
||||
// Faults places traces in the ground this class was painted on. Absent means none, which is right for a
|
||||
// plain: faults belong to orogens, and an author saying which classes are faulted is saying where the
|
||||
// orogens are. See internal/uplift's painted_faults.go for what a trace then does.
|
||||
Faults *ClassFaults `json:"faults"`
|
||||
|
||||
// Crater reshapes this class's painted blobs into rim and floor, *after* the solve.
|
||||
Crater *Crater `json:"crater"`
|
||||
|
||||
// Detail overrides what the detail passes do on this class's ground. Optional; every field left out
|
||||
// keeps the manifest's pipeline value.
|
||||
//
|
||||
// It exists because at the geology grid a class is only an uplift rate and an erodibility, and those two
|
||||
// numbers cannot tell a desert from a wet lowland - both are "low ground". The difference is at two
|
||||
// metres: a desert has sparse sharp wadis instead of a dendritic gully network, it holds its mesas and
|
||||
// ledges because there is no soil creep to round them off, and a good deal of it is dunes.
|
||||
Detail *ClassDetail `json:"detail"`
|
||||
|
||||
// Derived marks a class that is never painted: it takes part in no colour matching and exists only as
|
||||
// something another class turns into. Its rgb, if it has one, is for the diagnostic maps alone.
|
||||
//
|
||||
// The case it exists for is the one every hand-painted world map has. White is drawn twice - as the
|
||||
// polar caps and as the outline stroke around every island - so exactly one class can own that colour,
|
||||
// and it has to be the stroke, because the stroke is the one that needs to be recognised everywhere it
|
||||
// appears. What the caps become is then a class with no colour of its own.
|
||||
Derived bool `json:"derived"`
|
||||
|
||||
// EdgeClass rescues the ambiguous case, which in practice is always white: the same colour is the
|
||||
// polar ice cap and the outline stroke. A stroke region that touches the top or bottom row of the
|
||||
// map is not a stroke at all; it becomes the class named here. Everything else dissolves.
|
||||
EdgeClass string `json:"edge_class"`
|
||||
}
|
||||
|
||||
// ClassDetail is what the detail passes do differently on one class's ground.
|
||||
type ClassDetail struct {
|
||||
// DropletsPerCell is how much running water this ground sees. The single most useful number here: drop it
|
||||
// and the dendritic gully network thins out to isolated channels, which is the difference between a
|
||||
// rain-fed landscape and an arid one. Zero keeps the pipeline value.
|
||||
DropletsPerCell float64 `json:"droplets_per_cell"`
|
||||
|
||||
// AmplitudeM is the detail noise, low end to high end by slope. Raise it for dune fields - flat desert
|
||||
// ground with tens of metres of relief on it is a sand sea, and flat ground with two metres is a plain.
|
||||
AmplitudeM *[2]float64 `json:"amplitude_m"`
|
||||
|
||||
// StrataContrast is how hard the hard bands are. Ledges and mesas come from here, and they survive in a
|
||||
// desert because there is nothing wearing them round.
|
||||
StrataContrast float64 `json:"strata_contrast"`
|
||||
}
|
||||
|
||||
// Massif breaks one painted colour into plain and upland, which is the difference between a landmass and a
|
||||
// landscape.
|
||||
//
|
||||
// The reason it has to exist is arithmetic. For n = 1 the steady-state divide slope is U/(K*cell^2m), so a
|
||||
// class's uplift rate *is* its hillslope angle - 0.08 mm/yr is 11.3 degrees at an 8 m cell and K 5e-5 - and a
|
||||
// class is one rate over every cell an author painted with it. A uniformly painted landmass therefore comes
|
||||
// out uniformly dissected from the waterline to the summit at whatever angle its rate names, with no flat
|
||||
// ground anywhere on it, and that is what the first painted planet looked like. Europe away from the Alps is
|
||||
// not that. It is a plain at a fraction of a degree with isolated massifs standing out of it, and what
|
||||
// separates the two is not the rate, it is that the rate is not the same everywhere.
|
||||
//
|
||||
// So the class rate is re-read as the rate a *massif* reaches, FloorMmYr is the plain between them, and
|
||||
// Fraction is how much of the ground rises above the halfway point. The cut is made in one field - the
|
||||
// planet's upland fabric, whose size is planet.massif_wavelength_km - so a highland belt and the hills in the
|
||||
// lowland next door are outliers of one structure rather than two unrelated noises, which is how a foreland
|
||||
// works on Earth.
|
||||
//
|
||||
// Fraction is a share of the *planet's surface*, and because the fabric knows nothing about the painting it
|
||||
// is also, in expectation, the share of any one class. The difference is the variance, and the variance is
|
||||
// the point: a small island may get all of a massif or none of it, exactly as it would if it were a real
|
||||
// island that happened to sit on or off an orogen. Normalising per landmass would hand every island its
|
||||
// quota of hills, which is the thing this exists to stop.
|
||||
type Massif struct {
|
||||
// FloorMmYr is the plain: the rate everywhere the fabric is low. It is the number that decides whether
|
||||
// this class has flat ground at all, and it wants to be about a tenth of the class rate - 0.012 mm/yr is
|
||||
// a 1.7 degree hillslope, which is a plain a player can build on, where 0.08 is continuous hill country.
|
||||
FloorMmYr float64 `json:"floor_mm_yr"`
|
||||
|
||||
// Fraction is how much of this class stands above the midpoint between floor and class rate. Half that
|
||||
// again reaches the class rate outright and half again above that is off the plain at all, so 0.15 means
|
||||
// roughly a seventh upland, a quarter touched, and the rest plain.
|
||||
Fraction float64 `json:"fraction"`
|
||||
}
|
||||
|
||||
// ClassFaults is a class's fault set: how many, how long, and how much they throw.
|
||||
//
|
||||
// A density rather than a count, because a class covers whatever an author painted it over and a count would
|
||||
// mean something different on every template. The throw is the *total displacement over the whole run*, which
|
||||
// the solve turns into a rate - so it is the height of the scarp the fault would build if nothing eroded it,
|
||||
// which is a number an author can picture, unlike millimetres a year.
|
||||
type ClassFaults struct {
|
||||
Per1000Km2 float64 `json:"per_1000km2"`
|
||||
|
||||
// ThrowM and LengthKm are low-to-high ranges the seed picks between, so one class's faults are not all
|
||||
// the same size.
|
||||
ThrowM [2]float64 `json:"throw_m"`
|
||||
LengthKm [2]float64 `json:"length_km"`
|
||||
}
|
||||
|
||||
// Crater is an impact, stamped onto the finished terrain rather than solved.
|
||||
//
|
||||
// It is not an uplift rate and it cannot be one, for a reason worth writing down: a closed basin does not
|
||||
// survive the fluvial solve. The priority-flood runs every step and *raises* every depression to its spill
|
||||
// level, so a crater built out of negative uplift would be filled in before the run was a hundred steps old.
|
||||
// It is also the wrong model. A crater is an event, not a rate - it postdates the landscape it sits in, which
|
||||
// is exactly what a pass running after the solve expresses.
|
||||
//
|
||||
// The shape is derived from the painted blob rather than drawn: distance inward from the blob's own boundary,
|
||||
// normalised by its widest point, gives a coordinate that is 0 at the shore and 1 at the centre whatever size
|
||||
// and shape the author painted.
|
||||
type Crater struct {
|
||||
// RimM is the crest height above sea level and FloorM the basin floor, also above sea level. The
|
||||
// difference is the depth; real simple craters run about a fifth of their diameter deep.
|
||||
RimM float64 `json:"rim_m"`
|
||||
FloorM float64 `json:"floor_m"`
|
||||
|
||||
// RimAt is where the crest sits as a fraction of the way in from the shore, and WallAt where the inner
|
||||
// wall has finished falling to the floor. Everything past WallAt is floor.
|
||||
RimAt float64 `json:"rim_at"`
|
||||
WallAt float64 `json:"wall_at"`
|
||||
}
|
||||
|
||||
// Land is the complement of Sea, spelled out because it is read far more often than it is written.
|
||||
func (c Class) Land() bool { return !c.Sea }
|
||||
|
||||
// RateMYr is the uplift rate in metres a year, which is the unit the solve works in.
|
||||
func (c Class) RateMYr() float64 { return c.UpliftMmYr / 1000 }
|
||||
|
||||
// PlainFloorMYr is the uplift rate at the waterline in metres a year, never above the class's own rate.
|
||||
func (c Class) PlainFloorMYr() float64 {
|
||||
floor := c.CoastalFloorMmYr
|
||||
if floor <= 0 {
|
||||
floor = defaultCoastalFloorMmYr
|
||||
}
|
||||
if floor > c.UpliftMmYr {
|
||||
floor = c.UpliftMmYr
|
||||
}
|
||||
return floor / 1000
|
||||
}
|
||||
|
||||
// MassifFloorMYr is the plain's uplift rate in metres a year, or the class rate when this class has no
|
||||
// massif and is therefore one rate all over.
|
||||
func (c Class) MassifFloorMYr() float64 {
|
||||
if c.Massif == nil {
|
||||
return c.RateMYr()
|
||||
}
|
||||
return c.Massif.FloorMmYr / 1000
|
||||
}
|
||||
|
||||
// MassifFraction is how much of this class stands above the midpoint between its floor and its rate. Zero
|
||||
// means no massif field is built for it at all.
|
||||
func (c Class) MassifFraction() float64 {
|
||||
if c.Massif == nil {
|
||||
return 0
|
||||
}
|
||||
return c.Massif.Fraction
|
||||
}
|
||||
|
||||
// LithMix is how much of the planet's rock field this class takes, with "not set" read as all of it.
|
||||
func (c Class) LithMix() float64 {
|
||||
if c.LithologyMix == nil {
|
||||
return 1
|
||||
}
|
||||
return *c.LithologyMix
|
||||
}
|
||||
|
||||
// ThrowM is this class's fault throw range, or zeroes when it has no faults.
|
||||
func (c Class) ThrowM() [2]float64 {
|
||||
if c.Faults == nil {
|
||||
return [2]float64{}
|
||||
}
|
||||
return c.Faults.ThrowM
|
||||
}
|
||||
|
||||
// K is KMult with the zero value read as 1.
|
||||
func (c Class) K() float64 {
|
||||
if c.KMult == 0 {
|
||||
return 1
|
||||
}
|
||||
return c.KMult
|
||||
}
|
||||
|
||||
// Legend is a template's colours and their meanings. It lives beside the image as JSON so that tuning a
|
||||
// world is a text edit and a rerun rather than a repaint.
|
||||
type Legend struct {
|
||||
// Image is the painted map, relative to the legend file unless it is absolute.
|
||||
Image string `json:"image"`
|
||||
|
||||
// WarnDistance is how far, in RGB, a pixel may sit from the nearest class before the run says so.
|
||||
// Every pixel is always assigned to its nearest class - there is no unclassified - so this is the
|
||||
// only thing that catches a colour the legend forgot. Zero means the default.
|
||||
WarnDistance float64 `json:"warn_distance"`
|
||||
|
||||
Classes []Class `json:"classes"`
|
||||
|
||||
edge []int // per class: the resolved EdgeClass index, or -1
|
||||
}
|
||||
|
||||
// defaultCoastalFloorMmYr is a plain, and it is 0.02 rather than the 0.06 it was first written as because
|
||||
// 0.06 is not one. The old number came from reading internal/stats' "plain below 0.1 mm/yr" as a description
|
||||
// of terrain; it is not, it is a reporting bucket calibrated for the procedural path's intraplate rates. What
|
||||
// decides how ground reads is the divide angle, and at an 8 m cell and K 5e-5 that is tan(angle) = 2.5 * the
|
||||
// rate in mm/yr: 0.06 is an 8.5 degree hillslope on every divide, which is hill country, and 0.02 is 2.9
|
||||
// degrees, which is a coastal plain. See `terrain plan`, which prints the angle and what it reads as.
|
||||
const defaultCoastalFloorMmYr = 0.02
|
||||
|
||||
// MaxMassifFraction is the largest share of a class that may stand above the midpoint. See the ramp in
|
||||
// internal/uplift: it opens at 1 - 1.5*fraction in probability, so above two thirds it would run off the
|
||||
// bottom of the distribution and the number would stop meaning what it says.
|
||||
const MaxMassifFraction = 0.6
|
||||
|
||||
// DefaultWarnDistance is generous on purpose. A JPEG bleeds several units of each channel across a
|
||||
// boundary and a hand-mixed colour is rarely the one in the legend to the unit; a class the legend has
|
||||
// never heard of is usually tens of units away from everything.
|
||||
const DefaultWarnDistance = 60
|
||||
|
||||
// Load reads a legend from JSON.
|
||||
func Load(path string) (*Legend, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l, err := Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// Parse reads a legend from JSON already in memory.
|
||||
//
|
||||
// Unknown fields are refused, which is unusual for this project and deliberate here: a legend is a table of
|
||||
// numbers an author edits by hand, and a misspelt key that is silently ignored is a class quietly running on
|
||||
// the default rather than on what they wrote. Keys beginning with an underscore are the exception, because
|
||||
// that is how every manifest in this repository carries its commentary.
|
||||
func Parse(data []byte) (*Legend, error) {
|
||||
clean, err := field.StripJSONComments(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var l Legend
|
||||
dec := json.NewDecoder(bytes.NewReader(clean))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := l.resolve(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// Index is the class with this name, or -1.
|
||||
func (l *Legend) Index(name string) int {
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// EdgeIndex is the class a stroke at the map edge becomes, or -1 when it has none.
|
||||
func (l *Legend) EdgeIndex(i int) int { return l.edge[i] }
|
||||
|
||||
// FirstSea is the index of the first sea class, or -1. It is the default fill for the polar pad: those rows
|
||||
// are synthetic ocean that exists only so a cap touching the top of the painted map has a shore to drain to,
|
||||
// and they are discarded before anything is written out.
|
||||
func (l *Legend) FirstSea() int {
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].Sea {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// HasCraters reports whether any class is stamped as an impact.
|
||||
func (l *Legend) HasCraters() bool {
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].Crater != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasCoastalPlains reports whether any class puts its range inland.
|
||||
func (l *Legend) HasCoastalPlains() bool {
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].CoastalPlainKm > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasFaults reports whether any class asks for fault traces. When none does, the planet's grain field is
|
||||
// never built and no trace is ever drawn, so a legend that does not ask for them pays nothing.
|
||||
func (l *Legend) HasFaults() bool {
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].Faults != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasLithology reports whether any class lets the rock field through.
|
||||
func (l *Legend) HasLithology() bool {
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].Land() && l.Classes[i].LithMix() > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasMassifs reports whether any class breaks into plain and upland. When none does, the planet's upland
|
||||
// fabric is never built and never sampled, so a legend that does not ask for it pays nothing.
|
||||
func (l *Legend) HasMassifs() bool {
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].MassifFraction() > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolve fills the derived fields and refuses a legend that cannot mean anything.
|
||||
func (l *Legend) resolve() error {
|
||||
if len(l.Classes) == 0 {
|
||||
return fmt.Errorf("legend has no classes")
|
||||
}
|
||||
if len(l.Classes) > 255 {
|
||||
return fmt.Errorf("legend has %d classes; the raster holds 255", len(l.Classes))
|
||||
}
|
||||
if l.WarnDistance <= 0 {
|
||||
l.WarnDistance = DefaultWarnDistance
|
||||
}
|
||||
|
||||
seen := make(map[string]int, len(l.Classes))
|
||||
byRGB := make(map[[3]int]string, len(l.Classes))
|
||||
nonStroke, painted := 0, 0
|
||||
for i := range l.Classes {
|
||||
c := &l.Classes[i]
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("class %d has no name", i)
|
||||
}
|
||||
if c.Derived && c.Stroke {
|
||||
return fmt.Errorf("class %q is both derived and a stroke; a derived class is never painted, "+
|
||||
"so there is nothing of it to dissolve", c.Name)
|
||||
}
|
||||
if j, dup := seen[c.Name]; dup {
|
||||
return fmt.Errorf("classes %d and %d are both named %q", j, i, c.Name)
|
||||
}
|
||||
seen[c.Name] = i
|
||||
for k, v := range c.RGB {
|
||||
if v < 0 || v > 255 {
|
||||
return fmt.Errorf("class %q: rgb[%d] is %d, outside 0..255", c.Name, k, v)
|
||||
}
|
||||
}
|
||||
if !c.Derived {
|
||||
if other, dup := byRGB[c.RGB]; dup {
|
||||
return fmt.Errorf("classes %q and %q share the colour %v; nothing could tell them apart",
|
||||
other, c.Name, c.RGB)
|
||||
}
|
||||
byRGB[c.RGB] = c.Name
|
||||
painted++
|
||||
}
|
||||
if c.Sea {
|
||||
if c.DepthM < 0 {
|
||||
return fmt.Errorf("class %q: depth_m is %.1f; it is metres below sea level, so positive",
|
||||
c.Name, c.DepthM)
|
||||
}
|
||||
if c.UpliftMmYr != 0 || c.KMult != 0 {
|
||||
return fmt.Errorf("class %q is sea but carries uplift or erodibility; the solve holds "+
|
||||
"every sea cell at base level and would never read them", c.Name)
|
||||
}
|
||||
if c.Crater != nil || c.CoastalPlainKm != 0 || c.Snow || c.Detail != nil || c.Massif != nil ||
|
||||
c.Faults != nil || c.LithologyMix != nil {
|
||||
return fmt.Errorf("class %q is sea but carries a land property (crater, coastal plain, snow, "+
|
||||
"massif, faults, lithology or detail); the solve holds every sea cell at base level and "+
|
||||
"would never read them", c.Name)
|
||||
}
|
||||
} else {
|
||||
if c.UpliftMmYr < 0 {
|
||||
return fmt.Errorf("class %q: uplift_mm_yr is %.3f; subsidence is not modelled",
|
||||
c.Name, c.UpliftMmYr)
|
||||
}
|
||||
if c.KMult < 0 {
|
||||
return fmt.Errorf("class %q: k_mult is %.3f", c.Name, c.KMult)
|
||||
}
|
||||
if c.DepthM != 0 {
|
||||
return fmt.Errorf("class %q is land but carries depth_m", c.Name)
|
||||
}
|
||||
if c.CoastalPlainKm < 0 {
|
||||
return fmt.Errorf("class %q: coastal_plain_km is %v", c.Name, c.CoastalPlainKm)
|
||||
}
|
||||
if d := c.Detail; d != nil {
|
||||
if d.DropletsPerCell < 0 {
|
||||
return fmt.Errorf("class %q: detail.droplets_per_cell is %v", c.Name, d.DropletsPerCell)
|
||||
}
|
||||
if d.StrataContrast < 0 || d.StrataContrast > 1 {
|
||||
return fmt.Errorf("class %q: detail.strata_contrast is %v, outside 0..1",
|
||||
c.Name, d.StrataContrast)
|
||||
}
|
||||
if a := d.AmplitudeM; a != nil && (a[0] < 0 || a[1] < a[0]) {
|
||||
return fmt.Errorf("class %q: detail.amplitude_m is %v", c.Name, *a)
|
||||
}
|
||||
}
|
||||
if ms := c.Massif; ms != nil {
|
||||
if ms.FloorMmYr < 0 {
|
||||
return fmt.Errorf("class %q: massif.floor_mm_yr is %.4f; subsidence is not modelled",
|
||||
c.Name, ms.FloorMmYr)
|
||||
}
|
||||
if ms.FloorMmYr >= c.UpliftMmYr {
|
||||
return fmt.Errorf("class %q: massif.floor_mm_yr is %.4f and uplift_mm_yr is %.4f; the "+
|
||||
"floor is the plain between the massifs, so it has to be below the rate they reach",
|
||||
c.Name, ms.FloorMmYr, c.UpliftMmYr)
|
||||
}
|
||||
// Above two thirds the ramp would start below the bottom of the distribution and the
|
||||
// fraction would stop meaning what it says. A class that is two thirds upland is not a
|
||||
// plain with hills in it anyway; paint it as its own colour.
|
||||
if ms.Fraction <= 0 || ms.Fraction > MaxMassifFraction {
|
||||
return fmt.Errorf("class %q: massif.fraction is %.3f; it is the share of this class "+
|
||||
"standing above the midpoint and must be over 0 and at most %.2f",
|
||||
c.Name, ms.Fraction, MaxMassifFraction)
|
||||
}
|
||||
}
|
||||
if c.LithologyMix != nil && (*c.LithologyMix < 0 || *c.LithologyMix > 1) {
|
||||
return fmt.Errorf("class %q: lithology_mix is %v, outside 0..1; it is the share of the "+
|
||||
"planet's rock field this class takes", c.Name, *c.LithologyMix)
|
||||
}
|
||||
if fa := c.Faults; fa != nil {
|
||||
if fa.Per1000Km2 <= 0 {
|
||||
return fmt.Errorf("class %q: faults.per_1000km2 is %v; leave the block out to have no "+
|
||||
"faults rather than asking for none", c.Name, fa.Per1000Km2)
|
||||
}
|
||||
if fa.LengthKm[0] <= 0 || fa.LengthKm[1] < fa.LengthKm[0] {
|
||||
return fmt.Errorf("class %q: faults.length_km is %v; it is a low-to-high range in "+
|
||||
"kilometres", c.Name, fa.LengthKm)
|
||||
}
|
||||
if fa.ThrowM[0] <= 0 || fa.ThrowM[1] < fa.ThrowM[0] {
|
||||
return fmt.Errorf("class %q: faults.throw_m is %v; it is a low-to-high range of total "+
|
||||
"displacement over the run, in metres", c.Name, fa.ThrowM)
|
||||
}
|
||||
}
|
||||
if cr := c.Crater; cr != nil {
|
||||
if cr.FloorM >= cr.RimM {
|
||||
return fmt.Errorf("class %q: a crater's floor (%.0f m) must be below its rim (%.0f m)",
|
||||
c.Name, cr.FloorM, cr.RimM)
|
||||
}
|
||||
if cr.RimAt <= 0 || cr.RimAt >= cr.WallAt || cr.WallAt > 1 {
|
||||
return fmt.Errorf("class %q: a crater needs 0 < rim_at < wall_at <= 1, got %.2f and %.2f",
|
||||
c.Name, cr.RimAt, cr.WallAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !c.Stroke {
|
||||
nonStroke++
|
||||
}
|
||||
}
|
||||
if nonStroke == 0 {
|
||||
return fmt.Errorf("every class is a stroke; there is nothing for them to dissolve into")
|
||||
}
|
||||
if painted == 0 {
|
||||
return fmt.Errorf("every class is derived; nothing in the legend can match a painted pixel")
|
||||
}
|
||||
|
||||
l.edge = make([]int, len(l.Classes))
|
||||
for i := range l.Classes {
|
||||
l.edge[i] = -1
|
||||
c := &l.Classes[i]
|
||||
if c.EdgeClass == "" {
|
||||
continue
|
||||
}
|
||||
if !c.Stroke {
|
||||
return fmt.Errorf("class %q sets edge_class but is not a stroke; only a stroke is rewritten "+
|
||||
"at the map edge", c.Name)
|
||||
}
|
||||
j := l.Index(c.EdgeClass)
|
||||
if j < 0 {
|
||||
return fmt.Errorf("class %q: edge_class %q is not a class", c.Name, c.EdgeClass)
|
||||
}
|
||||
if l.Classes[j].Stroke {
|
||||
return fmt.Errorf("class %q: edge_class %q is itself a stroke", c.Name, c.EdgeClass)
|
||||
}
|
||||
l.edge[i] = j
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Map is a classified template projected onto a planet grid: one legend index per planet cell, including
|
||||
// the polar pad.
|
||||
type Map struct {
|
||||
P world.Planet
|
||||
L *Legend
|
||||
Class []uint8
|
||||
Sea []bool
|
||||
}
|
||||
|
||||
// Project resamples a paint-resolution raster onto the planet grid by nearest neighbour, and fills the
|
||||
// polar pad with padClass.
|
||||
//
|
||||
// Nearest neighbour is not a shortcut, it is the only correct choice: a class index is a name, not a
|
||||
// quantity, and interpolating between "desert" and "ocean" would invent a class that is neither. The blend
|
||||
// rule in Docs/Terrain-Next.md 3.2 - the painted map owns the wavelengths above its pixel size and noise
|
||||
// owns those below - is honoured downstream, where the continuous fields the classes stand for are smoothed
|
||||
// and then given sub-pixel variation. Doing it here instead would smear the coastline, which is the one
|
||||
// thing in the whole template an author draws deliberately.
|
||||
func (r *Raster) Project(p world.Planet, l *Legend, padClass int) *Map {
|
||||
m := &Map{P: p, L: l, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)}
|
||||
|
||||
sea := make([]bool, len(l.Classes))
|
||||
for i := range l.Classes {
|
||||
sea[i] = l.Classes[i].Sea
|
||||
}
|
||||
|
||||
pad := uint8(padClass)
|
||||
paintH := p.PaintH()
|
||||
field.Rows(p.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
if p.InPad(y) {
|
||||
for x := 0; x < p.W; x++ {
|
||||
i := y*p.W + x
|
||||
m.Class[i] = pad
|
||||
m.Sea[i] = sea[pad]
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Sample at the cell's centre, so a run of planet cells maps evenly across the paint rather
|
||||
// than favouring its left edge.
|
||||
py := (2*(y-p.PadY) + 1) * r.H / (2 * paintH)
|
||||
if py >= r.H {
|
||||
py = r.H - 1
|
||||
}
|
||||
for x := 0; x < p.W; x++ {
|
||||
px := (2*x + 1) * r.W / (2 * p.W)
|
||||
if px >= r.W {
|
||||
px = r.W - 1
|
||||
}
|
||||
i := y*p.W + x
|
||||
c := r.Class[py*r.W+px]
|
||||
m.Class[i] = c
|
||||
m.Sea[i] = sea[c]
|
||||
}
|
||||
}
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
// Counts is how many planet cells each class covers, and how many of them are land. The pad is excluded,
|
||||
// because it is not part of anybody's world.
|
||||
func (m *Map) Counts() (perClass []int, land, total int) {
|
||||
perClass = make([]int, len(m.L.Classes))
|
||||
for y := m.P.PadY; y < m.P.H-m.P.PadY; y++ {
|
||||
for x := 0; x < m.P.W; x++ {
|
||||
i := y*m.P.W + x
|
||||
perClass[m.Class[i]]++
|
||||
total++
|
||||
if !m.Sea[i] {
|
||||
land++
|
||||
}
|
||||
}
|
||||
}
|
||||
return perClass, land, total
|
||||
}
|
||||
|
||||
// Rates is the uplift rate in metres a year for every class, indexed by class. Sea classes are zero: the
|
||||
// solve holds an ocean cell at base level for its whole run and never reads the rate there.
|
||||
func (l *Legend) Rates() []float32 {
|
||||
out := make([]float32, len(l.Classes))
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].Land() {
|
||||
out[i] = float32(l.Classes[i].RateMYr())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Erodibilities is the multiplier on stream-power K for every class. Sea classes get 1 rather than 0, so
|
||||
// that a field built from this never carries a zero into a division.
|
||||
func (l *Legend) Erodibilities() []float32 {
|
||||
out := make([]float32, len(l.Classes))
|
||||
for i := range l.Classes {
|
||||
out[i] = 1
|
||||
if l.Classes[i].Land() {
|
||||
out[i] = float32(l.Classes[i].K())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CoastalPlains is, per class, how far inland the rate ramps up to its full value, in metres, and the rate
|
||||
// it starts from at the waterline.
|
||||
func (l *Legend) CoastalPlains() (plainM []float64, floor []float32) {
|
||||
plainM = make([]float64, len(l.Classes))
|
||||
floor = make([]float32, len(l.Classes))
|
||||
for i := range l.Classes {
|
||||
c := l.Classes[i]
|
||||
if c.Land() && c.CoastalPlainKm > 0 {
|
||||
plainM[i] = c.CoastalPlainKm * 1000
|
||||
floor[i] = float32(c.PlainFloorMYr())
|
||||
}
|
||||
}
|
||||
return plainM, floor
|
||||
}
|
||||
|
||||
// Massifs is, per class, the plain's uplift rate in metres a year and the share of the class that stands
|
||||
// above the midpoint between that floor and the class rate. A class with no massif reports a zero fraction,
|
||||
// which is what internal/uplift reads as "one rate all over", and its floor is then its own rate.
|
||||
func (l *Legend) Massifs() (floor []float32, fraction []float64) {
|
||||
floor = make([]float32, len(l.Classes))
|
||||
fraction = make([]float64, len(l.Classes))
|
||||
for i := range l.Classes {
|
||||
c := l.Classes[i]
|
||||
if !c.Land() {
|
||||
continue
|
||||
}
|
||||
floor[i] = float32(c.MassifFloorMYr())
|
||||
fraction[i] = c.MassifFraction()
|
||||
}
|
||||
return floor, fraction
|
||||
}
|
||||
|
||||
// LithologyMixes is, per class, how much of the planet's rock field shows through. Sea is zero: the solve
|
||||
// holds every sea cell at base level and never reads K there, and leaving it at 1 would put rock provinces on
|
||||
// the diagnostic map out in the open ocean.
|
||||
func (l *Legend) LithologyMixes() []float64 {
|
||||
out := make([]float64, len(l.Classes))
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].Land() {
|
||||
out[i] = l.Classes[i].LithMix()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Snow is, per class, whether it is permanently under ice. A display and material hint; no pass reads it.
|
||||
func (l *Legend) Snow() []bool {
|
||||
out := make([]bool, len(l.Classes))
|
||||
for i := range l.Classes {
|
||||
out[i] = l.Classes[i].Snow
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SnowMask marks every planet cell whose class is permanently under ice, painted rows only.
|
||||
func (m *Map) SnowMask() []bool {
|
||||
snow := m.L.Snow()
|
||||
any := false
|
||||
for _, s := range snow {
|
||||
any = any || s
|
||||
}
|
||||
if !any {
|
||||
return nil
|
||||
}
|
||||
p := m.P
|
||||
out := make([]bool, p.W*p.PaintH())
|
||||
for i := range out {
|
||||
out[i] = snow[m.Class[p.PadY*p.W+i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Depths is how far below sea level the open water of each class sits, in metres, positive. Land is zero.
|
||||
func (l *Legend) Depths() []float32 {
|
||||
out := make([]float32, len(l.Classes))
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].Sea {
|
||||
out[i] = float32(l.Classes[i].DepthM)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ClassDetailTables are the per-class detail overrides, resolved against the pipeline's own numbers so a pass
|
||||
// can index them without asking whether a class overrode anything.
|
||||
type ClassDetailTables struct {
|
||||
Droplets []float64
|
||||
AmpLo []float64
|
||||
AmpHi []float64
|
||||
Contrast []float64
|
||||
}
|
||||
|
||||
// DetailTables resolves every class against the pipeline defaults it is given.
|
||||
func (l *Legend) DetailTables(droplets, ampLo, ampHi, contrast float64) ClassDetailTables {
|
||||
n := len(l.Classes)
|
||||
t := ClassDetailTables{
|
||||
Droplets: make([]float64, n), AmpLo: make([]float64, n),
|
||||
AmpHi: make([]float64, n), Contrast: make([]float64, n),
|
||||
}
|
||||
for i := range l.Classes {
|
||||
t.Droplets[i], t.AmpLo[i], t.AmpHi[i], t.Contrast[i] = droplets, ampLo, ampHi, contrast
|
||||
d := l.Classes[i].Detail
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
if d.DropletsPerCell > 0 {
|
||||
t.Droplets[i] = d.DropletsPerCell
|
||||
}
|
||||
if d.AmplitudeM != nil {
|
||||
t.AmpLo[i], t.AmpHi[i] = d.AmplitudeM[0], d.AmplitudeM[1]
|
||||
}
|
||||
if d.StrataContrast > 0 {
|
||||
t.Contrast[i] = d.StrataContrast
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Overrides reports whether any class asks the detail passes for anything different, so a caller can skip
|
||||
// carrying a class raster through them when nothing would read it.
|
||||
func (l *Legend) Overrides() bool {
|
||||
for i := range l.Classes {
|
||||
if l.Classes[i].Detail != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
const goodLegend = `{
|
||||
"image": "x.png",
|
||||
"classes": [
|
||||
{ "name": "ocean", "rgb": [0, 0, 255], "sea": true, "depth_m": 500 },
|
||||
{ "name": "land", "rgb": [0, 255, 0], "uplift_mm_yr": 0.5, "k_mult": 2 },
|
||||
{ "name": "ice", "rgb": [200, 200, 200], "uplift_mm_yr": 0.05 },
|
||||
{ "name": "white", "rgb": [255, 255, 255], "stroke": true, "edge_class": "ice" },
|
||||
{ "name": "outline", "rgb": [255, 0, 255], "stroke": true }
|
||||
]
|
||||
}`
|
||||
|
||||
func mustLegend(t *testing.T, src string) *Legend {
|
||||
t.Helper()
|
||||
l, err := Parse([]byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestLegendResolves(t *testing.T) {
|
||||
l := mustLegend(t, goodLegend)
|
||||
if l.WarnDistance != DefaultWarnDistance {
|
||||
t.Errorf("WarnDistance = %v, want the default %v", l.WarnDistance, DefaultWarnDistance)
|
||||
}
|
||||
if got := l.Index("land"); got != 1 {
|
||||
t.Errorf("Index(land) = %d, want 1", got)
|
||||
}
|
||||
if got := l.EdgeIndex(3); got != 2 {
|
||||
t.Errorf("EdgeIndex(white) = %d, want 2 (ice)", got)
|
||||
}
|
||||
if got := l.EdgeIndex(1); got != -1 {
|
||||
t.Errorf("EdgeIndex(land) = %d, want -1", got)
|
||||
}
|
||||
if got := l.Classes[1].K(); got != 2 {
|
||||
t.Errorf("land K = %v, want 2", got)
|
||||
}
|
||||
if got := l.Classes[2].K(); got != 1 {
|
||||
t.Errorf("ice K = %v, want 1 (zero reads as one)", got)
|
||||
}
|
||||
if got := l.Classes[1].RateMYr(); got != 0.0005 {
|
||||
t.Errorf("land rate = %v m/yr, want 0.0005", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegendRefusesTheImpossible(t *testing.T) {
|
||||
cases := []struct{ name, src, want string }{
|
||||
{"no classes", `{"classes":[]}`, "no classes"},
|
||||
{"duplicate colour", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[1,2,3]}]}`, "share the colour"},
|
||||
{"duplicate name", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3]},{"name":"a","rgb":[4,5,6]}]}`, "both named"},
|
||||
{"sea with uplift", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"sea":true,"uplift_mm_yr":1}]}`, "would never read them"},
|
||||
{"land with depth", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"depth_m":10}]}`, "carries depth_m"},
|
||||
{"negative depth", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"sea":true,"depth_m":-10}]}`, "so positive"},
|
||||
{"edge on a non-stroke", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[4,5,6],"edge_class":"a"}]}`, "is not a stroke"},
|
||||
{"edge names nothing", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[4,5,6],"stroke":true,"edge_class":"z"}]}`,
|
||||
"is not a class"},
|
||||
{"everything is a stroke", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"stroke":true}]}`, "nothing for them to dissolve into"},
|
||||
{"rgb out of range", `{"classes":[{"name":"a","rgb":[1,2,300]}]}`, "outside 0..255"},
|
||||
{"sea with a massif", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"sea":true,"massif":{"floor_mm_yr":0.01,"fraction":0.2}}]}`,
|
||||
"carries a land property"},
|
||||
{"massif floor at or above the rate", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":0.08,"fraction":0.2}}]}`,
|
||||
"below the rate they reach"},
|
||||
{"negative massif floor", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":-0.01,"fraction":0.2}}]}`,
|
||||
"subsidence is not modelled"},
|
||||
{"massif fraction of nothing", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":0.01,"fraction":0}}]}`,
|
||||
"must be over 0"},
|
||||
{"massif fraction past the ramp", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":0.01,"fraction":0.8}}]}`,
|
||||
"must be over 0"},
|
||||
{"misspelt massif key", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor":0.01,"fraction":0.2}}]}`,
|
||||
"unknown field"},
|
||||
{"sea with faults", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"sea":true,"faults":{"per_1000km2":5,"throw_m":[100,200],
|
||||
"length_km":[4,8]}}]}`, "carries a land property"},
|
||||
{"sea with a lithology mix", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"sea":true,"lithology_mix":0.5}]}`, "carries a land property"},
|
||||
{"a fault block asking for nothing", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"faults":{"per_1000km2":0,"throw_m":[100,200],
|
||||
"length_km":[4,8]}}]}`, "leave the block out"},
|
||||
{"fault length the wrong way round", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"faults":{"per_1000km2":5,"throw_m":[100,200],
|
||||
"length_km":[8,4]}}]}`, "low-to-high range in kilometres"},
|
||||
{"fault throw the wrong way round", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"faults":{"per_1000km2":5,"throw_m":[200,100],
|
||||
"length_km":[4,8]}}]}`, "low-to-high range of total"},
|
||||
{"lithology mix past one", `{"classes":[
|
||||
{"name":"a","rgb":[1,2,3],"lithology_mix":1.5}]}`, "outside 0..1"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := Parse([]byte(c.src))
|
||||
if err == nil {
|
||||
t.Fatalf("accepted %s", c.name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("error %q does not mention %q", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// build an RGB buffer from a small picture written as one rune per pixel.
|
||||
func picture(t *testing.T, l *Legend, rows []string) ([]uint8, int, int) {
|
||||
t.Helper()
|
||||
h := len(rows)
|
||||
w := len(rows[0])
|
||||
px := make([]uint8, w*h*3)
|
||||
for y, row := range rows {
|
||||
if len(row) != w {
|
||||
t.Fatalf("row %d is %d wide, want %d", y, len(row), w)
|
||||
}
|
||||
for x, r := range row {
|
||||
var ci int
|
||||
switch r {
|
||||
case 'o':
|
||||
ci = l.Index("ocean")
|
||||
case 'L':
|
||||
ci = l.Index("land")
|
||||
case 'i':
|
||||
ci = l.Index("ice")
|
||||
case 'W':
|
||||
ci = l.Index("white")
|
||||
case 'X':
|
||||
ci = l.Index("outline")
|
||||
case '?':
|
||||
ci = -1
|
||||
default:
|
||||
t.Fatalf("unknown pixel %q", r)
|
||||
}
|
||||
o := (y*w + x) * 3
|
||||
if ci < 0 {
|
||||
px[o], px[o+1], px[o+2] = 0, 0, 0 // the stray black pixel a real template had
|
||||
continue
|
||||
}
|
||||
c := l.Classes[ci]
|
||||
px[o], px[o+1], px[o+2] = uint8(c.RGB[0]), uint8(c.RGB[1]), uint8(c.RGB[2])
|
||||
}
|
||||
}
|
||||
return px, w, h
|
||||
}
|
||||
|
||||
func render(l *Legend, r *Raster) []string {
|
||||
sym := map[string]rune{"ocean": 'o', "land": 'L', "ice": 'i', "white": 'W', "outline": 'X'}
|
||||
out := make([]string, r.H)
|
||||
for y := 0; y < r.H; y++ {
|
||||
var b strings.Builder
|
||||
for x := 0; x < r.W; x++ {
|
||||
b.WriteRune(sym[l.Classes[r.Class[y*r.W+x]].Name])
|
||||
}
|
||||
out[y] = b.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestClassifyIsTotalAndReportsTheStrays(t *testing.T) {
|
||||
l := mustLegend(t, goodLegend)
|
||||
px, w, h := picture(t, l, []string{
|
||||
"ooLL",
|
||||
"oo?L",
|
||||
})
|
||||
r, m := l.Classify(px, w, h)
|
||||
if m.Total != 8 {
|
||||
t.Errorf("Total = %d, want 8", m.Total)
|
||||
}
|
||||
// Black is nearest to ocean here, and nothing is unclassified - but it must be reported as far.
|
||||
if m.Far != 1 {
|
||||
t.Errorf("Far = %d, want 1: the black pixel", m.Far)
|
||||
}
|
||||
if m.MaxAt != [2]int{2, 1} {
|
||||
t.Errorf("MaxAt = %v, want the black pixel at 2,1", m.MaxAt)
|
||||
}
|
||||
if m.MaxDist < 100 {
|
||||
t.Errorf("MaxDist = %.1f, want it large", m.MaxDist)
|
||||
}
|
||||
if got := render(l, r)[0]; got != "ooLL" {
|
||||
t.Errorf("row 0 = %q", got)
|
||||
}
|
||||
if n := m.Counts[l.Index("land")]; n != 3 {
|
||||
t.Errorf("land count = %d, want 3", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteAtThePoleIsIceAndWhiteAroundAnIslandIsNot(t *testing.T) {
|
||||
l := mustLegend(t, goodLegend)
|
||||
px, w, h := picture(t, l, []string{
|
||||
"WWWW", // the cap: touches row 0, so it is ice
|
||||
"WWWW",
|
||||
"oooo",
|
||||
"oWWo", // an island's outline: touches nothing, so it dissolves
|
||||
"oWLo",
|
||||
"oooo",
|
||||
})
|
||||
r, _ := l.Classify(px, w, h)
|
||||
edge, dissolved := r.DissolveStrokes(l)
|
||||
if edge != 8 {
|
||||
t.Errorf("edge rewrites = %d, want 8", edge)
|
||||
}
|
||||
if dissolved != 3 {
|
||||
t.Errorf("dissolved = %d, want 3", dissolved)
|
||||
}
|
||||
got := render(l, r)
|
||||
want := []string{"iiii", "iiii", "oooo", "oooo", "ooLo", "oooo"}
|
||||
for y := range want {
|
||||
if got[y] != want[y] {
|
||||
t.Errorf("row %d = %q, want %q (whole picture %v)", y, got[y], want[y], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stroke lying between land and water is split down the middle. Giving it wholly to one side would
|
||||
// move the coastline by the width of the artist's brush, which on a real template is hundreds of metres.
|
||||
func TestStrokeSplitsDownItsMiddle(t *testing.T) {
|
||||
l := mustLegend(t, goodLegend)
|
||||
px, w, h := picture(t, l, []string{
|
||||
"LLL",
|
||||
"LLL",
|
||||
"WWW",
|
||||
"WWW",
|
||||
"ooo",
|
||||
"ooo",
|
||||
})
|
||||
r, _ := l.Classify(px, w, h)
|
||||
if _, n := r.DissolveStrokes(l); n != 6 {
|
||||
t.Errorf("dissolved = %d, want 6", n)
|
||||
}
|
||||
got := render(l, r)
|
||||
want := []string{"LLL", "LLL", "LLL", "ooo", "ooo", "ooo"}
|
||||
for y := range want {
|
||||
if got[y] != want[y] {
|
||||
t.Errorf("row %d = %q, want %q (whole picture %v)", y, got[y], want[y], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The map is a cylinder: a stroke on the left edge is reached by land on the right edge.
|
||||
func TestDissolveWrapsInX(t *testing.T) {
|
||||
l := mustLegend(t, goodLegend)
|
||||
// One row, and a stroke class with no edge_class so the polar rescue never applies. The stroke at
|
||||
// x=0 has land only at x=5, on the far side of the seam; if X did not wrap it would take the ocean
|
||||
// in the middle instead.
|
||||
px, w, h := picture(t, l, []string{"XXoXXL"})
|
||||
r, _ := l.Classify(px, w, h)
|
||||
r.DissolveStrokes(l)
|
||||
got := render(l, r)
|
||||
want := []string{"LoooLL"}
|
||||
for y := range want {
|
||||
if got[y] != want[y] {
|
||||
t.Errorf("row %d = %q, want %q", y, got[y], want[y])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRasterAtWrapsXAndClampsY(t *testing.T) {
|
||||
r := &Raster{W: 3, H: 2, Class: []uint8{1, 2, 3, 4, 5, 6}}
|
||||
if got := r.At(-1, 0); got != 3 {
|
||||
t.Errorf("At(-1,0) = %d, want 3", got)
|
||||
}
|
||||
if got := r.At(3, 0); got != 1 {
|
||||
t.Errorf("At(3,0) = %d, want 1", got)
|
||||
}
|
||||
if got := r.At(0, -1); got != 1 {
|
||||
t.Errorf("At(0,-1) = %d, want 1 (clamped to the pole)", got)
|
||||
}
|
||||
if got := r.At(0, 9); got != 4 {
|
||||
t.Errorf("At(0,9) = %d, want 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectIsNearestNeighbourAndPadsThePoles(t *testing.T) {
|
||||
l := mustLegend(t, goodLegend)
|
||||
// A 4x2 paint: land on the right half, ocean on the left.
|
||||
px, w, h := picture(t, l, []string{
|
||||
"ooLL",
|
||||
"ooLL",
|
||||
})
|
||||
r, _ := l.Classify(px, w, h)
|
||||
|
||||
// 8 columns of 10 m is an 80 m circumference; the paint's 4:2 aspect gives 4 painted rows, plus 1 of
|
||||
// pad at each end.
|
||||
p, err := world.New(80, 10, w, h, 1, 80)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.W != 8 || p.PaintH() != 4 || p.H != 6 {
|
||||
t.Fatalf("planet is %dx%d with %d painted rows, want 8x6 with 4", p.W, p.H, p.PaintH())
|
||||
}
|
||||
|
||||
m := r.Project(p, l, l.Index("ocean"))
|
||||
|
||||
for x := 0; x < p.W; x++ {
|
||||
if !m.Sea[0*p.W+x] || !m.Sea[(p.H-1)*p.W+x] {
|
||||
t.Fatalf("pad row is not sea at column %d", x)
|
||||
}
|
||||
}
|
||||
// Every painted row upsamples the same way: four ocean cells then four land cells, and no third class
|
||||
// has been invented in between.
|
||||
for y := p.PadY; y < p.H-p.PadY; y++ {
|
||||
for x := 0; x < p.W; x++ {
|
||||
wantSea := x < 4
|
||||
if m.Sea[y*p.W+x] != wantSea {
|
||||
t.Fatalf("cell (%d,%d): sea = %v, want %v", x, y, m.Sea[y*p.W+x], wantSea)
|
||||
}
|
||||
name := l.Classes[m.Class[y*p.W+x]].Name
|
||||
if name != "ocean" && name != "land" {
|
||||
t.Fatalf("cell (%d,%d) is %q; projection invented a class", x, y, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perClass, land, total := m.Counts()
|
||||
if total != p.W*p.PaintH() {
|
||||
t.Errorf("Counts total = %d, want %d (the pad is not part of the world)", total, p.W*p.PaintH())
|
||||
}
|
||||
if land != 16 {
|
||||
t.Errorf("land = %d, want 16", land)
|
||||
}
|
||||
if perClass[l.Index("land")] != 16 {
|
||||
t.Errorf("land class count = %d, want 16", perClass[l.Index("land")])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerClassTables(t *testing.T) {
|
||||
l := mustLegend(t, goodLegend)
|
||||
rates := l.Rates()
|
||||
if got := rates[l.Index("land")]; got != 0.0005 {
|
||||
t.Errorf("land rate = %v, want 0.0005 m/yr", got)
|
||||
}
|
||||
if got := rates[l.Index("ocean")]; got != 0 {
|
||||
t.Errorf("ocean rate = %v, want 0", got)
|
||||
}
|
||||
ks := l.Erodibilities()
|
||||
if got := ks[l.Index("land")]; got != 2 {
|
||||
t.Errorf("land K = %v, want 2", got)
|
||||
}
|
||||
if got := ks[l.Index("ocean")]; got != 1 {
|
||||
t.Errorf("ocean K = %v, want 1: a zero would be carried into a division", got)
|
||||
}
|
||||
if got := l.Depths()[l.Index("ocean")]; got != 500 {
|
||||
t.Errorf("ocean depth = %v, want 500", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A class with no massif block is one rate all over, and the tables have to say so in the way internal/uplift
|
||||
// reads them: a zero fraction, which is what switches the fabric off, and a floor that is the class's own rate
|
||||
// so that nothing can read a plain out of a class that never asked for one.
|
||||
func TestAClassWithNoMassifIsOneRateAllOver(t *testing.T) {
|
||||
l := mustLegend(t, goodLegend)
|
||||
floor, fraction := l.Massifs()
|
||||
i := l.Index("land")
|
||||
if fraction[i] != 0 {
|
||||
t.Errorf("fraction = %v, want 0 for a class with no massif block", fraction[i])
|
||||
}
|
||||
if got := floor[i]; got != 0.0005 {
|
||||
t.Errorf("floor = %v, want the class rate 0.0005 m/yr", got)
|
||||
}
|
||||
if l.HasMassifs() {
|
||||
t.Error("HasMassifs is true for a legend with no massif block anywhere")
|
||||
}
|
||||
}
|
||||
|
||||
// And a class that asks for one reports the numbers the fabric is cut with.
|
||||
func TestAMassifClassReportsItsFloorAndFraction(t *testing.T) {
|
||||
l := mustLegend(t, `{"classes":[
|
||||
{"name":"ocean","rgb":[0,0,255],"sea":true,"depth_m":500},
|
||||
{"name":"land","rgb":[0,255,0],"uplift_mm_yr":0.08,
|
||||
"massif":{"floor_mm_yr":0.012,"fraction":0.16}}]}`)
|
||||
if !l.HasMassifs() {
|
||||
t.Fatal("HasMassifs is false for a legend that has one")
|
||||
}
|
||||
floor, fraction := l.Massifs()
|
||||
i := l.Index("land")
|
||||
if got, want := float64(floor[i]), 0.000012; math.Abs(got-want) > 1e-12 {
|
||||
t.Errorf("floor = %v m/yr, want %v", got, want)
|
||||
}
|
||||
if fraction[i] != 0.16 {
|
||||
t.Errorf("fraction = %v, want 0.16", fraction[i])
|
||||
}
|
||||
// Sea classes carry neither, and the fraction has to be zero rather than inherited: a sea cell is held at
|
||||
// base level for the whole run and a fabric there would be a field nobody reads.
|
||||
if j := l.Index("ocean"); floor[j] != 0 || fraction[j] != 0 {
|
||||
t.Errorf("ocean carries floor %v fraction %v, want both zero", floor[j], fraction[j])
|
||||
}
|
||||
}
|
||||
|
||||
// White is drawn twice on a hand-painted world map: the polar caps and the stroke around every island. Only
|
||||
// one class can own that colour, and it has to be the stroke - so what the caps become is a class with no
|
||||
// colour of its own.
|
||||
func TestADerivedClassIsNeverMatched(t *testing.T) {
|
||||
const src = `{"classes":[
|
||||
{"name":"sea","rgb":[0,0,255],"sea":true},
|
||||
{"name":"ice","derived":true,"uplift_mm_yr":0.05},
|
||||
{"name":"white","rgb":[238,238,238],"stroke":true,"edge_class":"ice"}
|
||||
]}`
|
||||
l := mustLegend(t, src)
|
||||
|
||||
// A pixel near white must become the stroke, not the derived ice, however close ice's zero colour is.
|
||||
px := []uint8{236, 236, 236}
|
||||
r, m := l.Classify(px, 1, 1)
|
||||
if got := l.Classes[r.Class[0]].Name; got != "white" {
|
||||
t.Errorf("a near-white pixel classified as %q, want the painted stroke", got)
|
||||
}
|
||||
if m.Counts[l.Index("ice")] != 0 {
|
||||
t.Error("the derived class matched a pixel")
|
||||
}
|
||||
}
|
||||
|
||||
// A derived class may carry a colour, and it is display only: the diagnostic maps need something to draw it
|
||||
// with, and without one the polar caps came out as black holes in map_class.png.
|
||||
func TestADerivedClassColourIsDisplayOnly(t *testing.T) {
|
||||
l := mustLegend(t, `{"classes":[
|
||||
{"name":"sea","rgb":[0,0,255],"sea":true},
|
||||
{"name":"ice","derived":true,"rgb":[250,250,250]},
|
||||
{"name":"white","rgb":[238,238,238],"stroke":true,"edge_class":"ice"}
|
||||
]}`)
|
||||
// 245,245,245 is nearer to ice's display colour than to the painted stroke, and must still be the stroke.
|
||||
r, _ := l.Classify([]uint8{245, 245, 245}, 1, 1)
|
||||
if got := l.Classes[r.Class[0]].Name; got != "white" {
|
||||
t.Errorf("classified as %q, want the painted stroke: a derived colour must not match", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegendRefusesAllDerived(t *testing.T) {
|
||||
_, err := Parse([]byte(`{"classes":[{"name":"a","derived":true}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "every class is derived") {
|
||||
t.Fatalf("error = %v, want a refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The mask is opt-in: zero amplitude has to leave the painting exactly as drawn, because every template
|
||||
// written before it existed was drawn against that contract.
|
||||
func TestNoCoastMaskLeavesThePaintingExactly(t *testing.T) {
|
||||
p := testCylinder(t, 512, 288)
|
||||
l := mustLegend(t, goodLegend)
|
||||
r := stripeRaster(512, 288, l)
|
||||
|
||||
out := r.RoughenCoast(l, p, Coast{AmplitudePx: 0, WavelengthPx: 64, Octaves: 4, Gain: 0.5})
|
||||
for i := range r.Class {
|
||||
if out.Class[i] != r.Class[i] {
|
||||
t.Fatalf("pixel %d changed with the mask switched off", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What it is for: a ruled painted coastline has to come back with bays in it. Measured as the spread of the
|
||||
// waterline's row along the map - zero for a drawn line, tens of pixels for a coast.
|
||||
func TestTheCoastMaskCutsBaysIntoARuledShore(t *testing.T) {
|
||||
p := testCylinder(t, 1024, 512)
|
||||
l := mustLegend(t, goodLegend)
|
||||
r := stripeRaster(1024, 512, l) // land above the halfway row, ocean below
|
||||
|
||||
if lo, hi := shoreSpread(r, l); hi-lo != 0 {
|
||||
t.Fatalf("the painted shore is not ruled: rows %d..%d; the test would measure nothing", lo, hi)
|
||||
}
|
||||
|
||||
out := r.RoughenCoast(l, p, Coast{
|
||||
AmplitudePx: 48, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7,
|
||||
})
|
||||
lo, hi := shoreSpread(out, l)
|
||||
if hi-lo < 20 {
|
||||
t.Errorf("the roughened shore spans %d rows (%d..%d); the mask is barely moving it", hi-lo+1, lo, hi)
|
||||
}
|
||||
// And it must stay a coastline rather than dissolving into speckle: the land has to remain one run down
|
||||
// every column, not a scatter of pixels.
|
||||
if runs := columnRuns(out, l, 1024/2); runs > 3 {
|
||||
t.Errorf("a column crosses the waterline %d times; the mask is dissolving the shore, not shaping it",
|
||||
runs)
|
||||
}
|
||||
}
|
||||
|
||||
// An archipelago has to survive. Under a wavelength far wider than an islet the noise is very nearly a
|
||||
// constant across it, so without the guard the whole islet steps to the wrong side of zero at once and a
|
||||
// scatter of islands disappears between two runs.
|
||||
func TestSmallIslandsAreNibbledRatherThanDeleted(t *testing.T) {
|
||||
p := testCylinder(t, 1024, 512)
|
||||
l := mustLegend(t, goodLegend)
|
||||
land := uint8(l.Index("land"))
|
||||
sea := uint8(l.Index("ocean"))
|
||||
|
||||
r := &Raster{W: 1024, H: 512, Class: make([]uint8, 1024*512)}
|
||||
for i := range r.Class {
|
||||
r.Class[i] = sea
|
||||
}
|
||||
// Twelve islets of radius 8, well apart, none of them anywhere near the amplitude in size.
|
||||
centres := [][2]int{}
|
||||
for k := 0; k < 12; k++ {
|
||||
centres = append(centres, [2]int{60 + k*80, 200 + (k%3)*90})
|
||||
}
|
||||
for _, c := range centres {
|
||||
for dy := -8; dy <= 8; dy++ {
|
||||
for dx := -8; dx <= 8; dx++ {
|
||||
if dx*dx+dy*dy <= 64 {
|
||||
r.Class[(c[1]+dy)*r.W+c[0]+dx] = land
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := r.RoughenCoast(l, p, Coast{
|
||||
AmplitudePx: 64, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7,
|
||||
})
|
||||
|
||||
gone := 0
|
||||
for _, c := range centres {
|
||||
alive := false
|
||||
for dy := -20; dy <= 20 && !alive; dy++ {
|
||||
for dx := -20; dx <= 20; dx++ {
|
||||
x, y := c[0]+dx, c[1]+dy
|
||||
if x < 0 || y < 0 || x >= out.W || y >= out.H {
|
||||
continue
|
||||
}
|
||||
if out.Class[y*out.W+x] == land {
|
||||
alive = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !alive {
|
||||
gone++
|
||||
}
|
||||
}
|
||||
if gone > 0 {
|
||||
t.Errorf("%d of %d islets were erased by a mask four times their radius; the island guard is not "+
|
||||
"holding", gone, len(centres))
|
||||
}
|
||||
}
|
||||
|
||||
// The seam is the one place a coastline can break invisibly, because the map's two edges are as far apart on
|
||||
// screen as they can be. The mask is world-indexed and its distance transform wraps, so a shore crossing the
|
||||
// seam has to come out continuous.
|
||||
func TestTheCoastMaskWrapsAtTheSeam(t *testing.T) {
|
||||
p := testCylinder(t, 1024, 512)
|
||||
l := mustLegend(t, goodLegend)
|
||||
r := stripeRaster(1024, 512, l)
|
||||
out := r.RoughenCoast(l, p, Coast{
|
||||
AmplitudePx: 48, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7,
|
||||
})
|
||||
|
||||
// The waterline's row in the first column and in the last must be within a pixel or two of each other,
|
||||
// exactly as two adjacent columns anywhere inside the map are.
|
||||
rowAt := func(x int) int {
|
||||
for y := 0; y < out.H; y++ {
|
||||
if l.Classes[out.Class[y*out.W+x]].Sea {
|
||||
return y
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
seam := rowAt(0) - rowAt(out.W-1)
|
||||
if seam < 0 {
|
||||
seam = -seam
|
||||
}
|
||||
worst := 0
|
||||
for x := 1; x < out.W; x++ {
|
||||
d := rowAt(x) - rowAt(x-1)
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
if seam > worst {
|
||||
t.Errorf("the shore steps %d rows across the seam against %d anywhere inside the map", seam, worst)
|
||||
}
|
||||
}
|
||||
|
||||
func testCylinder(t *testing.T, w, h int) world.Planet {
|
||||
t.Helper()
|
||||
p := world.Planet{CellM: 8, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * 8}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// shoreSpread is the lowest and highest row at which a column first meets water.
|
||||
func shoreSpread(r *Raster, l *Legend) (lo, hi int) {
|
||||
lo, hi = 1<<30, -1
|
||||
for x := 0; x < r.W; x++ {
|
||||
for y := 0; y < r.H; y++ {
|
||||
if l.Classes[r.Class[y*r.W+x]].Sea {
|
||||
if y < lo {
|
||||
lo = y
|
||||
}
|
||||
if y > hi {
|
||||
hi = y
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
// columnRuns counts how many times a column crosses the waterline.
|
||||
func columnRuns(r *Raster, l *Legend, x int) int {
|
||||
n := 0
|
||||
prev := l.Classes[r.Class[x]].Sea
|
||||
for y := 1; y < r.H; y++ {
|
||||
cur := l.Classes[r.Class[y*r.W+x]].Sea
|
||||
if cur != prev {
|
||||
n++
|
||||
prev = cur
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// stripeRaster is a painting with one ruled coastline: land in the top half, ocean in the bottom.
|
||||
func stripeRaster(w, h int, l *Legend) *Raster {
|
||||
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
|
||||
land := uint8(l.Index("land"))
|
||||
sea := uint8(l.Index("ocean"))
|
||||
for y := 0; y < h; y++ {
|
||||
c := land
|
||||
if y >= h/2 {
|
||||
c = sea
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
r.Class[y*w+x] = c
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// The failure this exists for, built in miniature: a one-pixel ribbon of a class nobody painted, lying along
|
||||
// the boundary between the two it is a blend of. On the real template that ribbon was `desert` along every
|
||||
// temperate coast, because the JPEG's blend of surf and lowland is nearer to desert than to either parent.
|
||||
func TestDespeckleRemovesAHairlineBetweenTwoClasses(t *testing.T) {
|
||||
l := mustLegend(t, goodLegend)
|
||||
const w, h = 64, 64
|
||||
land := uint8(l.Index("land"))
|
||||
sea := uint8(l.Index("ocean"))
|
||||
ice := uint8(l.Index("ice")) // standing in for the class nobody painted
|
||||
|
||||
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
c := land
|
||||
if y > h/2 {
|
||||
c = sea
|
||||
}
|
||||
if y == h/2 {
|
||||
c = ice // the hairline, one pixel wide, all the way across
|
||||
}
|
||||
r.Class[y*w+x] = c
|
||||
}
|
||||
}
|
||||
|
||||
n := r.Despeckle()
|
||||
if n == 0 {
|
||||
t.Fatal("nothing was despeckled; the hairline is still there")
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
if got := r.Class[(h/2)*w+x]; got == ice {
|
||||
t.Fatalf("column %d of the hairline survived as %q", x, l.Classes[got].Name)
|
||||
}
|
||||
}
|
||||
// And it must have joined one of its neighbours rather than becoming something else again.
|
||||
for x := 0; x < w; x++ {
|
||||
if got := r.Class[(h/2)*w+x]; got != land && got != sea {
|
||||
t.Fatalf("column %d became %q, which is neither side of the boundary", x, l.Classes[got].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the contract, and the one that keeps the rule honest: a band two pixels wide is
|
||||
// something an author drew, and it has to survive untouched. Without this the threshold could be raised
|
||||
// until it ate the map.
|
||||
func TestDespeckleLeavesARealBandAlone(t *testing.T) {
|
||||
l := mustLegend(t, goodLegend)
|
||||
const w, h = 64, 64
|
||||
land := uint8(l.Index("land"))
|
||||
sea := uint8(l.Index("ocean"))
|
||||
ice := uint8(l.Index("ice"))
|
||||
|
||||
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
c := land
|
||||
if y > h/2+1 {
|
||||
c = sea
|
||||
}
|
||||
if y == h/2 || y == h/2+1 {
|
||||
c = ice // two pixels wide: a painted shoreline band, not a codec artefact
|
||||
}
|
||||
r.Class[y*w+x] = c
|
||||
}
|
||||
}
|
||||
before := append([]uint8(nil), r.Class...)
|
||||
r.Despeckle()
|
||||
for i := range before {
|
||||
if before[i] != r.Class[i] {
|
||||
t.Fatalf("pixel %d changed; a two-pixel band is a feature and must survive", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The mask can be masked, which is the whole of D-57's contribution to the coastline: a shore somebody drew
|
||||
// on purpose stays where they drew it while the rest of the world is still roughened.
|
||||
func TestTheCoastMaskIsMaskedByTheOverlay(t *testing.T) {
|
||||
const w, h = 1024, 512
|
||||
p := testCylinder(t, w, h)
|
||||
l := mustLegend(t, goodLegend)
|
||||
r := stripeRaster(w, h, l) // land above the halfway row, ocean below
|
||||
|
||||
cfg := Coast{AmplitudePx: 48, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7}
|
||||
free := r.RoughenCoast(l, p, cfg)
|
||||
|
||||
// Pin the left half and say nothing about the right. "Say nothing" is -1, not 1: an unmarked cell takes
|
||||
// its instruction from the far side of the waterline, and that is what makes a stroke on one side enough.
|
||||
scale := make([]float32, w*h)
|
||||
for i := range scale {
|
||||
scale[i] = -1
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w/2; x++ {
|
||||
scale[y*w+x] = 0
|
||||
}
|
||||
}
|
||||
cfg.Scale = scale
|
||||
masked := r.RoughenCoast(l, p, cfg)
|
||||
|
||||
// The pinned half is the painting, exactly.
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w/2; x++ {
|
||||
if masked.Class[y*w+x] != r.Class[y*w+x] {
|
||||
t.Fatalf("pixel (%d,%d) moved inside a pinned stretch", x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the half that said nothing is still roughened, or the test above proves nothing.
|
||||
moved := 0
|
||||
for y := 0; y < h; y++ {
|
||||
for x := w / 2; x < w; x++ {
|
||||
if masked.Class[y*w+x] != r.Class[y*w+x] {
|
||||
moved++
|
||||
}
|
||||
}
|
||||
}
|
||||
if moved == 0 {
|
||||
t.Fatal("nothing moved in the unmarked half; the mask is switching the whole pass off")
|
||||
}
|
||||
// The unmarked half must be exactly what it was with no mask at all - the noise is a function of world
|
||||
// position, so pinning one stretch cannot move another.
|
||||
for y := 0; y < h; y++ {
|
||||
for x := w/2 + int(cfg.AmplitudePx) + 2; x < w; x++ {
|
||||
if masked.Class[y*w+x] != free.Class[y*w+x] {
|
||||
t.Fatalf("pixel (%d,%d) differs from the unmasked run; pinning one stretch moved another",
|
||||
x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Painting only the water is enough, and so is painting only the land. A mark is a brush stroke along a
|
||||
// coastline and it lands on whichever side the author's hand was on; if an unmarked cell took the default
|
||||
// amplitude, the other side would march across the line anyway and the coast would move regardless.
|
||||
func TestPinningOneSideOfTheWaterlineIsEnough(t *testing.T) {
|
||||
const w, h = 512, 256
|
||||
p := testCylinder(t, w, h)
|
||||
l := mustLegend(t, goodLegend)
|
||||
r := stripeRaster(w, h, l)
|
||||
cfg := Coast{AmplitudePx: 24, WavelengthPx: 128, Octaves: 4, Gain: 0.55, Seed: 3}
|
||||
|
||||
// Everything that could move is within the amplitude of the halfway row, so the two cases below pin the
|
||||
// same stretch of shore from opposite sides.
|
||||
landOnly := make([]float32, w*h)
|
||||
seaOnly := make([]float32, w*h)
|
||||
for i := range landOnly {
|
||||
landOnly[i], seaOnly[i] = -1, -1
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
if y < h/2 {
|
||||
landOnly[y*w+x] = 0
|
||||
} else {
|
||||
seaOnly[y*w+x] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
scale []float32
|
||||
}{{"the land side", landOnly}, {"the sea side", seaOnly}} {
|
||||
cfg.Scale = c.scale
|
||||
out := r.RoughenCoast(l, p, cfg)
|
||||
for i := range r.Class {
|
||||
if out.Class[i] != r.Class[i] {
|
||||
t.Fatalf("painting %s only did not hold the shore: pixel %d moved", c.name, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,12 @@ func Apply(h []float32, w, hgt int, cellM, talus float64, passes int, fixed []bo
|
||||
if passes <= 0 || talus <= 0 {
|
||||
return
|
||||
}
|
||||
// The scratch is the caller's so a pass inside a loop does not allocate a grid every time; it is optional
|
||||
// because every other caller of this package passes one and the one that did not spent its first run in a
|
||||
// panic (slice bounds out of range) rather than in a weather simulation.
|
||||
if cap(scratch) < len(h) {
|
||||
scratch = make([]float32, len(h))
|
||||
}
|
||||
delta := scratch[:len(h)]
|
||||
card := cellM
|
||||
diag := cellM * math.Sqrt2
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Package tile cuts the detail grid into pieces that can be baked one at a time.
|
||||
//
|
||||
// Docs/Terrain-Next.md 3.3 splits the work in two and this is the easy half. The fluvial solve is global in a
|
||||
// way that cannot be tiled - drainage area is an integral over the whole upstream catchment - and it is
|
||||
// handled by decomposing the planet per landmass instead (internal/region). Every pass after it is *local*:
|
||||
// noise is pointwise, thermal weathering propagates a cell at a time, and a droplet travels at most its
|
||||
// lifetime in cells. So a tile is cut with an overlap margin sized by how far the pass it runs can move
|
||||
// material, the passes run, and the margin is thrown away. Nothing is exchanged between tiles and nothing
|
||||
// needs to be.
|
||||
//
|
||||
// That only works because of rule 1. Every hash and every noise lattice is keyed on absolute world position,
|
||||
// so a cell reached in a tile's interior and the same cell reached inside a neighbour's margin get the same
|
||||
// answer to the bit. Key anything on a tile-local index and every seam shows.
|
||||
package tile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Grid is the tiling of one planet's detail resolution.
|
||||
type Grid struct {
|
||||
P world.Planet // the geology cylinder the tiles are cut from
|
||||
|
||||
Factor int // detail cells per geology cell, the manifest's geology_factor
|
||||
SideGeo int // interior side of a tile, in geology cells
|
||||
MarginGeo int // overlap carried on every side, in geology cells
|
||||
|
||||
NX, NY int // tiles across and down
|
||||
GeoW, GeoH int // the painted geology raster the tiles cover
|
||||
}
|
||||
|
||||
// NewGrid works out the tiling. sidePx is the interior side of a tile in *detail* cells, and must be a whole
|
||||
// number of geology cells; marginPx is the overlap in detail cells.
|
||||
//
|
||||
// X must divide exactly, because it wraps: a tile grid that did not come out whole would leave the last tile
|
||||
// overlapping the first by an arbitrary amount and there would be no honest way to name the seam.
|
||||
func NewGrid(p world.Planet, factor, sidePx, marginPx int) (*Grid, error) {
|
||||
if factor < 1 {
|
||||
return nil, fmt.Errorf("detail factor is %d", factor)
|
||||
}
|
||||
if sidePx < factor || sidePx%factor != 0 {
|
||||
return nil, fmt.Errorf("tile side %d detail cells is not a whole number of %d-cell geology blocks",
|
||||
sidePx, factor)
|
||||
}
|
||||
side := sidePx / factor
|
||||
if p.W%side != 0 {
|
||||
return nil, fmt.Errorf("a %d cell planet does not divide into %d cell tiles; X wraps, so it must. "+
|
||||
"The nearest sides that work are %s", p.W, side, divisorsNear(p.W, side))
|
||||
}
|
||||
margin := (marginPx + factor - 1) / factor
|
||||
if margin < 1 {
|
||||
margin = 1
|
||||
}
|
||||
g := &Grid{
|
||||
P: p, Factor: factor, SideGeo: side, MarginGeo: margin,
|
||||
GeoW: p.W, GeoH: p.PaintH(),
|
||||
}
|
||||
g.NX = g.GeoW / side
|
||||
g.NY = (g.GeoH + side - 1) / side
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Tile is one piece: where it sits and how big it is.
|
||||
type Tile struct {
|
||||
IX, IY int
|
||||
|
||||
// X0, Y0 and W, H are the interior, in geology cells of the painted raster. The last row of tiles is
|
||||
// short wherever the planet's height is not a whole number of tiles, and that is recorded rather than
|
||||
// padded: padding would put invented ground in the output.
|
||||
X0, Y0, W, H int
|
||||
}
|
||||
|
||||
// Tiles lists every tile in row-major order.
|
||||
func (g *Grid) Tiles() []Tile {
|
||||
out := make([]Tile, 0, g.NX*g.NY)
|
||||
for iy := 0; iy < g.NY; iy++ {
|
||||
y0 := iy * g.SideGeo
|
||||
h := g.SideGeo
|
||||
if y0+h > g.GeoH {
|
||||
h = g.GeoH - y0
|
||||
}
|
||||
for ix := 0; ix < g.NX; ix++ {
|
||||
out = append(out, Tile{IX: ix, IY: iy, X0: ix * g.SideGeo, Y0: y0, W: g.SideGeo, H: h})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DetailW and DetailH are the tile's interior at detail resolution.
|
||||
func (g *Grid) DetailW(t Tile) int { return t.W * g.Factor }
|
||||
func (g *Grid) DetailH(t Tile) int { return t.H * g.Factor }
|
||||
|
||||
// OriginXM and OriginYM are the world position of the tile's first interior detail cell.
|
||||
func (g *Grid) OriginXM(t Tile) float64 { return g.P.XM(t.X0) }
|
||||
func (g *Grid) OriginYM(t Tile) float64 { return g.P.YM(t.Y0 + g.P.PadY) }
|
||||
|
||||
// Cut extracts a tile's geology source: the interior plus the margin, wrapping in X and clamping in Y.
|
||||
//
|
||||
// The extra sample is the upsample's: field.UpsampleInt turns N samples into (N-1)*factor+1, so covering
|
||||
// W*factor interior detail cells needs W+1 geology samples, and the margin is on top of that.
|
||||
//
|
||||
// Clamping in Y rather than wrapping is not a shortcut - the top and bottom of the map are the poles, not
|
||||
// each other - and it only ever touches the polar pad, which is water.
|
||||
func (g *Grid) Cut(t Tile, src *field.Field, padY int) (out *field.Field, interiorX, interiorY int) {
|
||||
w := t.W + 2*g.MarginGeo + 1
|
||||
h := t.H + 2*g.MarginGeo + 1
|
||||
out = field.New(w, h, src.CellM)
|
||||
x0 := t.X0 - g.MarginGeo
|
||||
y0 := t.Y0 - g.MarginGeo
|
||||
for y := 0; y < h; y++ {
|
||||
sy := y0 + y
|
||||
if sy < 0 {
|
||||
sy = 0
|
||||
} else if sy >= g.GeoH {
|
||||
sy = g.GeoH - 1
|
||||
}
|
||||
row := (sy + padY) * src.W
|
||||
for x := 0; x < w; x++ {
|
||||
out.Data[y*w+x] = src.Data[row+g.P.WrapX(x0+x)]
|
||||
}
|
||||
}
|
||||
return out, g.MarginGeo * g.Factor, g.MarginGeo * g.Factor
|
||||
}
|
||||
|
||||
// CutMask is Cut for a boolean field, nearest by construction.
|
||||
func (g *Grid) CutMask(t Tile, src []bool, srcW, padY int) []bool {
|
||||
w := t.W + 2*g.MarginGeo + 1
|
||||
h := t.H + 2*g.MarginGeo + 1
|
||||
out := make([]bool, w*h)
|
||||
x0 := t.X0 - g.MarginGeo
|
||||
y0 := t.Y0 - g.MarginGeo
|
||||
for y := 0; y < h; y++ {
|
||||
sy := y0 + y
|
||||
if sy < 0 {
|
||||
sy = 0
|
||||
} else if sy >= g.GeoH {
|
||||
sy = g.GeoH - 1
|
||||
}
|
||||
row := (sy + padY) * srcW
|
||||
for x := 0; x < w; x++ {
|
||||
out[y*w+x] = src[row+g.P.WrapX(x0+x)]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CutClass is Cut for the painted class raster, which is indexed over the whole planet including the polar
|
||||
// pad, so it takes the pad offset rather than assuming the painted rows.
|
||||
func (g *Grid) CutClass(t Tile, src []uint8, srcW, padY int) []uint8 {
|
||||
w := t.W + 2*g.MarginGeo + 1
|
||||
h := t.H + 2*g.MarginGeo + 1
|
||||
out := make([]uint8, w*h)
|
||||
x0 := t.X0 - g.MarginGeo
|
||||
y0 := t.Y0 - g.MarginGeo
|
||||
for y := 0; y < h; y++ {
|
||||
sy := y0 + y
|
||||
if sy < 0 {
|
||||
sy = 0
|
||||
} else if sy >= g.GeoH {
|
||||
sy = g.GeoH - 1
|
||||
}
|
||||
row := (sy + padY) * srcW
|
||||
for x := 0; x < w; x++ {
|
||||
out[y*w+x] = src[row+g.P.WrapX(x0+x)]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Frame is the tile's cut rectangle as a world frame at *detail* resolution, which is what the noise and the
|
||||
// hashes are indexed by.
|
||||
func (g *Grid) Frame(t Tile) world.Frame {
|
||||
detail := g.P
|
||||
detail.CellM = g.P.CellM / float64(g.Factor)
|
||||
detail.W = g.P.W * g.Factor
|
||||
detail.H = g.P.H * g.Factor
|
||||
detail.PadY = g.P.PadY * g.Factor
|
||||
w := (t.W + 2*g.MarginGeo) * g.Factor
|
||||
h := (t.H + 2*g.MarginGeo) * g.Factor
|
||||
return world.Frame{
|
||||
P: detail,
|
||||
X0: detail.WrapX((t.X0 - g.MarginGeo) * g.Factor),
|
||||
Y0: (t.Y0 - g.MarginGeo + g.P.PadY) * g.Factor,
|
||||
W: w + 1, H: h + 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Name is the file stem a tile is written under.
|
||||
func (t Tile) Name(prefix string) string { return fmt.Sprintf("%s_x%02d_y%02d", prefix, t.IX, t.IY) }
|
||||
|
||||
// divisorsNear lists a few tile sides that do divide, for the error message.
|
||||
func divisorsNear(w, want int) string {
|
||||
var below, above int
|
||||
for d := want; d >= 1; d-- {
|
||||
if w%d == 0 {
|
||||
below = d
|
||||
break
|
||||
}
|
||||
}
|
||||
for d := want; d <= w; d++ {
|
||||
if w%d == 0 {
|
||||
above = d
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d and %d geology cells", below, above)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package tile
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
func testPlanet(t *testing.T) world.Planet {
|
||||
t.Helper()
|
||||
// 64 geology columns of 8 m is a 512 m circumference, 40 painted rows, 4 of polar pad.
|
||||
p := world.Planet{CellM: 8, W: 64, H: 48, PadY: 4, NoisePeriodM: 512}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestNewGridDivides(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
// 64 detail cells at factor 4 is 16 geology cells, and 64 divides by 16.
|
||||
g, err := NewGrid(p, 4, 64, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.NX != 4 {
|
||||
t.Errorf("NX = %d, want 4", g.NX)
|
||||
}
|
||||
// 40 painted rows over 16-cell tiles is 3 rows, the last one short.
|
||||
if g.NY != 3 {
|
||||
t.Errorf("NY = %d, want 3", g.NY)
|
||||
}
|
||||
if g.MarginGeo != 2 {
|
||||
t.Errorf("MarginGeo = %d, want 2 (8 detail cells at factor 4)", g.MarginGeo)
|
||||
}
|
||||
}
|
||||
|
||||
// X wraps, so a tile grid that did not come out whole would leave the last tile overlapping the first by an
|
||||
// arbitrary amount and there would be no honest way to name the seam.
|
||||
func TestNewGridRefusesASideThatDoesNotDivide(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
_, err := NewGrid(p, 4, 4*13, 8) // 13 geology cells does not divide 64
|
||||
if err == nil {
|
||||
t.Fatal("accepted a tile side that does not divide the circumference")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "X wraps") {
|
||||
t.Errorf("error %q does not say why", err)
|
||||
}
|
||||
if _, err := NewGrid(p, 4, 66, 8); err == nil {
|
||||
t.Fatal("accepted a tile side that is not a whole number of geology cells")
|
||||
}
|
||||
}
|
||||
|
||||
// The last row is short rather than padded: padding would put invented ground in the output.
|
||||
func TestTilesCoverThePaintedRowsExactly(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
g, err := NewGrid(p, 4, 64, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := make([]int, p.W*p.PaintH())
|
||||
for _, tl := range g.Tiles() {
|
||||
for y := tl.Y0; y < tl.Y0+tl.H; y++ {
|
||||
for x := tl.X0; x < tl.X0+tl.W; x++ {
|
||||
seen[y*p.W+x]++
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, n := range seen {
|
||||
if n != 1 {
|
||||
t.Fatalf("cell %d covered %d times, want exactly 1", i, n)
|
||||
}
|
||||
}
|
||||
last := g.Tiles()[len(g.Tiles())-1]
|
||||
if last.H != p.PaintH()-2*g.SideGeo {
|
||||
t.Errorf("the last tile row is %d cells, want %d", last.H, p.PaintH()-2*g.SideGeo)
|
||||
}
|
||||
}
|
||||
|
||||
// A tile at the seam reads its left margin from the far side of the map, and one at a pole clamps rather
|
||||
// than wrapping: the top and bottom of the map are the poles, not each other.
|
||||
func TestCutWrapsInXAndClampsInY(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
g, err := NewGrid(p, 4, 64, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A source whose value encodes its own position, so a misplaced read is obvious.
|
||||
src := field.New(p.W, p.PaintH(), p.CellM)
|
||||
for y := 0; y < src.H; y++ {
|
||||
for x := 0; x < src.W; x++ {
|
||||
src.Data[y*src.W+x] = float32(y*1000 + x)
|
||||
}
|
||||
}
|
||||
|
||||
first := g.Tiles()[0] // X0 = 0, Y0 = 0: both edges
|
||||
out, ix, iy := g.Cut(first, src, 0)
|
||||
if ix != g.MarginGeo*g.Factor || iy != g.MarginGeo*g.Factor {
|
||||
t.Errorf("interior offset %d,%d, want %d", ix, iy, g.MarginGeo*g.Factor)
|
||||
}
|
||||
// The left margin is the far side of the cylinder.
|
||||
if got, want := out.Data[g.MarginGeo*out.W+0], float32(0*1000+p.W-g.MarginGeo); got != want {
|
||||
t.Errorf("left margin reads %v, want %v (the columns across the seam)", got, want)
|
||||
}
|
||||
// The top margin is row 0 repeated, not the bottom of the map.
|
||||
if got, want := out.Data[0*out.W+g.MarginGeo], float32(0*1000+0); got != want {
|
||||
t.Errorf("top margin reads %v, want %v (row 0 clamped)", got, want)
|
||||
}
|
||||
// And the interior is itself.
|
||||
if got, want := out.Data[g.MarginGeo*out.W+g.MarginGeo], float32(0); got != want {
|
||||
t.Errorf("interior corner reads %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The frame is what every hash and noise lattice in the detail passes is keyed on, so it has to name the
|
||||
// right physical place at detail resolution.
|
||||
func TestFrameIsTheDetailWorldPosition(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
g, err := NewGrid(p, 4, 64, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tiles := g.Tiles()
|
||||
second := tiles[1] // X0 = 16 geology cells
|
||||
|
||||
f := g.Frame(second)
|
||||
if f.P.CellM != 2 {
|
||||
t.Errorf("frame cell = %v m, want 2", f.P.CellM)
|
||||
}
|
||||
if f.P.W != p.W*4 {
|
||||
t.Errorf("frame planet width = %d, want %d detail columns", f.P.W, p.W*4)
|
||||
}
|
||||
// The cut starts a margin before the interior: (16 - 2) geology cells is 56 detail columns.
|
||||
if f.X0 != (16-g.MarginGeo)*4 {
|
||||
t.Errorf("frame X0 = %d, want %d", f.X0, (16-g.MarginGeo)*4)
|
||||
}
|
||||
// The interior's first detail column is the margin in, and it must name geology column 16.
|
||||
wx, _ := f.PlanetXY(g.MarginGeo*g.Factor, 0)
|
||||
if wx != 16*4 {
|
||||
t.Errorf("the interior's first column is detail column %d, want %d", wx, 16*4)
|
||||
}
|
||||
if got, want := g.OriginXM(second), 16*8.0; got != want {
|
||||
t.Errorf("OriginXM = %v, want %v", got, want)
|
||||
}
|
||||
// Row 0 of the painted map is world Y zero; the polar pad is behind it.
|
||||
if got := g.OriginYM(tiles[0]); got != 0 {
|
||||
t.Errorf("OriginYM of the first tile row = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A frame that straddles the seam names the same physical columns as one that does not.
|
||||
func TestASeamTileNamesTheSameColumns(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
g, err := NewGrid(p, 4, 64, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := g.Tiles()[0] // X0 = 0, so its left margin is across the seam
|
||||
f := g.Frame(first)
|
||||
// Detail column 0 of the cut is (0 - margin) geology cells, wrapped.
|
||||
wx, _ := f.PlanetXY(0, 0)
|
||||
if want := (p.W - g.MarginGeo) * 4; wx != want {
|
||||
t.Errorf("the cut's first column is detail column %d, want %d across the seam", wx, want)
|
||||
}
|
||||
// And the interior's first column is detail column 0.
|
||||
wx, _ = f.PlanetXY(g.MarginGeo*g.Factor, 0)
|
||||
if wx != 0 {
|
||||
t.Errorf("the interior's first column is %d, want 0", wx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package uplift
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/plates"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Faults belonging to a plate boundary rather than to a painted class.
|
||||
//
|
||||
// The specification for this file is a map of the Alpide belt - Spain through the Maghreb, Italy, Greece,
|
||||
// Turkey, Iran, Afghanistan, the Pamir, the Himalaya and into Burma - with every mapped fault trace on it.
|
||||
// Six things are true of that picture and not one of them is true of a fault set scattered inside a painted
|
||||
// colour:
|
||||
//
|
||||
// 1. **The traces are in swarms along a belt, and everywhere else is blank.** The Sahara has none. Arabia's
|
||||
// interior has none. Peninsular India, Kazakhstan, Ukraine: none. A craton is not lightly faulted, it is
|
||||
// unfaulted, and the belt next to it is saturated. Density is therefore a function of distance to a
|
||||
// boundary and of nothing else - not of which colour the ground was painted.
|
||||
// 2. **The belt is wide, and how wide varies enormously.** Through Italy and Greece it is a hundred
|
||||
// kilometres; across Iran and Tibet it is well over a thousand, a fan of parallel traces from the Zagros
|
||||
// to the Alborz. So the zone is not a fixed halo: it scales with what the margin is doing.
|
||||
// 3. **Faults are near the line, not on it.** Almost none of those traces *is* the plate boundary. They sit
|
||||
// tens to hundreds of kilometres either side of it, thickest near it and thinning outwards - deformation
|
||||
// is distributed across a zone, and the boundary is only where it is centred.
|
||||
// 4. **Within a swarm they are sub-parallel**, to each other and to the belt, and they follow it round its
|
||||
// bends: the Turkish arc, the Zagros arc, the Himalayan arc, the fan at the Burma syntaxis. The strike
|
||||
// comes from the local tangent of the boundary, which is why the whole set curves where the margin does.
|
||||
// 5. **There is a second, conjugate direction** in the wide interiors - Tibet and Mongolia show two sets
|
||||
// crossing at a high angle. One direction alone reads as corduroy, which is the defect Terrain-Next 4.A3
|
||||
// records against the procedural path at a different scale.
|
||||
// 6. **They splay and anastomose** rather than running as isolated segments, which the en-echelon stepping
|
||||
// in painted_faults.go already produces and which is kept here unchanged.
|
||||
//
|
||||
// Everything about a *trace* - the walked heading, the taper over the last sixth, the en-echelon step past
|
||||
// twelve kilometres, the escarpment profile, the repose ceiling - is shared with the class-based set through
|
||||
// traceSet. What is new here is only where a fault is put and which way it points, which is exactly the part
|
||||
// that was wrong.
|
||||
|
||||
const srcBeltFaults = 29
|
||||
|
||||
// beltWidth is how wide each kind of margin's deformation zone is, as a multiple of the configured width.
|
||||
//
|
||||
// These are ratios between kinds of boundary rather than tuning, which is why they are constants and not
|
||||
// manifest keys. A continental collision has nowhere to put the convergence except into the crust on both
|
||||
// sides, so it deforms a belt a thousand kilometres across; a subduction margin puts most of it down the slab
|
||||
// and deforms an arc and a forearc; a transform is a narrow braid however long it runs, because the motion is
|
||||
// taken up by sliding rather than by shortening; a rift deforms its two shoulders; and a mid-ocean ridge is
|
||||
// the narrowest of all, an axis a few tens of kilometres wide.
|
||||
var beltWidth = map[plates.Kind]float64{
|
||||
plates.Collision: 1.00,
|
||||
plates.Subduction: 0.55,
|
||||
plates.Rift: 0.35,
|
||||
plates.Transform: 0.30,
|
||||
plates.Ridge: 0.15,
|
||||
}
|
||||
|
||||
// beltFalloff shapes how the traces thin out away from the line.
|
||||
//
|
||||
// An offset drawn as zone*u would spread them evenly across the whole zone, which is not what the map shows:
|
||||
// the swarm is dense at the margin and trails off. Raising a uniform draw to this power biases it towards
|
||||
// zero, so the density falls smoothly outwards and the zone edge is a fading-out rather than a line where
|
||||
// faults stop.
|
||||
const beltFalloff = 1.8
|
||||
|
||||
// beltThrowFloor and beltThrowCeil bound how far the closing rate is allowed to scale a throw. A margin that
|
||||
// has almost stopped still has inherited structure in it, and one going twice as fast as the reference does
|
||||
// not build scarps four times the size, because the repose ceiling is waiting either way.
|
||||
const (
|
||||
beltThrowFloor = 0.35
|
||||
beltThrowCeil = 2.0
|
||||
)
|
||||
|
||||
// beltLandProbes is how many positions are sampled across the zone to find out how much of it is land.
|
||||
//
|
||||
// It is measured rather than assumed because the density has to keep meaning what it says. A margin running
|
||||
// down the middle of an ocean and one running along a continent have the same length and the same zone area,
|
||||
// and if the count came from the zone area alone the first would ask for as many traces as the second and
|
||||
// then fail to place them - so the density would quietly mean something different on every boundary.
|
||||
const beltLandProbes = 512
|
||||
|
||||
// beltPlaceTries is how many times a trace is redrawn when it lands in the sea before giving up on it.
|
||||
const beltPlaceTries = 12
|
||||
|
||||
// beltLandShare is how much of a trace has to be on land for it to be kept. Half rather than all, because a
|
||||
// fault that runs out to a coast and stops is right and a fault forbidden from reaching one is not: the
|
||||
// result of demanding every probe be land is a set that avoids the shore, which is the opposite mistake.
|
||||
const beltLandShare = 0.5
|
||||
|
||||
// BuildBeltFaults places a fault set in the deformation zones around a planet's plate boundaries.
|
||||
//
|
||||
// land reports whether a world position is painted land, the same callback plates.Build takes. Offshore
|
||||
// faults are real - the reference map has them all over the Mediterranean and the Arabian Sea - but the solve
|
||||
// fixes every ocean cell at sea level, so a trace out there changes nothing and only clutters the diagnostic.
|
||||
// They are therefore kept on land, and the density is measured against the part of each zone that *is* land
|
||||
// so that the number an author sets keeps meaning what it says.
|
||||
func BuildBeltFaults(p world.Planet, seed int64, cfg plates.Belt, bs []plates.Boundary,
|
||||
land func(xM, yM float64) bool) []FaultTrace {
|
||||
|
||||
if !cfg.Wanted() || len(bs) == 0 {
|
||||
return nil
|
||||
}
|
||||
cfg = cfg.WithDefaults()
|
||||
|
||||
s := noise.NewSource(seed, srcBeltFaults)
|
||||
// The bend lattice a trace's walk turns on. Its wavelength is tied to the zone rather than to the
|
||||
// planet's fault grain: a trace inside a belt should curve on the belt's own scale.
|
||||
bendCells := int(p.NoisePeriodM/(cfg.ZoneKm*1000) + 0.5)
|
||||
if bendCells < 1 {
|
||||
bendCells = 1
|
||||
}
|
||||
pl := &beltPlacer{
|
||||
p: p, s: s, bend: noise.NewLattice(bendCells*4, s), bendCells: bendCells, cfg: cfg,
|
||||
spread: cfg.Spread() * math.Pi / 180,
|
||||
conj: cfg.ConjugateDeg * math.Pi / 180,
|
||||
land: land,
|
||||
}
|
||||
refM := cfg.ReferenceCmYr / 100 // cm/yr to m/yr
|
||||
|
||||
var out []FaultTrace
|
||||
for bi := range bs {
|
||||
b := &bs[bi]
|
||||
if len(b.V) < 2 {
|
||||
continue
|
||||
}
|
||||
seg := beltSegments(b, cfg, refM)
|
||||
if seg.zoneKm2 <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// How much of this belt's zone a fault can actually be placed in, measured by proposing faults
|
||||
// exactly the way the placement loop below does and counting how many survive. The same draw and the
|
||||
// same test, so the acceptance rate the loop will see is the one the count is scaled by and the
|
||||
// density keeps meaning what it says.
|
||||
kept := 0
|
||||
for i := 0; i < beltLandProbes; i++ {
|
||||
if len(pl.propose(seg)) > 0 {
|
||||
kept++
|
||||
}
|
||||
}
|
||||
usable := float64(kept) / beltLandProbes
|
||||
if usable <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
want := cfg.Per1000Km2 * seg.zoneKm2 * usable / 1000
|
||||
n := int(want)
|
||||
// Stochastic rounding, so a short margin too small for one whole fault still gets one sometimes and
|
||||
// the density means what it says averaged over a planet rather than being floored to zero.
|
||||
if s.Float() < want-float64(n) {
|
||||
n++
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
for try := 0; try < beltPlaceTries; try++ {
|
||||
if set := pl.propose(seg); len(set) > 0 {
|
||||
out = append(out, set...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// beltPlacer is everything one fault's placement needs, carried together because proposing a fault and
|
||||
// measuring how often a proposal succeeds have to be the same code. See propose.
|
||||
type beltPlacer struct {
|
||||
p world.Planet
|
||||
s *noise.Source
|
||||
bend *noise.Lattice
|
||||
bendCells int
|
||||
cfg plates.Belt
|
||||
spread float64
|
||||
conj float64
|
||||
land func(xM, yM float64) bool
|
||||
}
|
||||
|
||||
// propose draws one fault in the zone, builds it, and returns the traces it is made of with any that ended
|
||||
// up offshore removed. An empty result is a proposal the caller should redraw.
|
||||
//
|
||||
// The land test is applied to the **walked geometry**, not to the straight line the fault was proposed along,
|
||||
// and that distinction is the whole reason this function exists. Between a proposal and a trace sit two
|
||||
// things that move it: a fault over twelve kilometres is broken into en-echelon segments staggered across
|
||||
// strike, and every segment is then walked with a perturbed heading. Testing the proposal let a trace be
|
||||
// accepted on a headland and then stepped and walked out into open water, which is what the first run of
|
||||
// this pass drew across two straits.
|
||||
//
|
||||
// Segments are filtered one at a time rather than the set being kept or dropped whole, because part of a
|
||||
// fault continuing offshore while the rest of it is on land is the ordinary case at any coast.
|
||||
func (pl *beltPlacer) propose(seg beltSeg) []FaultTrace {
|
||||
xM, yM, tangent, halfM, side := seg.sample(pl.s)
|
||||
|
||||
a := tangent + (pl.s.Float()*2-1)*pl.spread
|
||||
if pl.cfg.Conjugate() > 0 && pl.s.Float() < pl.cfg.Conjugate() {
|
||||
// The second set, crossing the first. Which way it leans is drawn per fault, because a conjugate
|
||||
// pair is two directions and picking one of them globally would be the corduroy this exists to avoid.
|
||||
if pl.s.Float() < 0.5 {
|
||||
a += pl.conj
|
||||
} else {
|
||||
a -= pl.conj
|
||||
}
|
||||
}
|
||||
|
||||
lengthM := (pl.cfg.LengthKm[0] + (pl.cfg.LengthKm[1]-pl.cfg.LengthKm[0])*pl.s.Float()) * 1000
|
||||
// A wide belt carries long faults. Scaled against the configured width so that the length range an
|
||||
// author sets is the one they get on a reference-rate collision.
|
||||
lengthM *= clampF(halfM/(pl.cfg.ZoneKm*1000), 0.4, 2.2)
|
||||
|
||||
throw := pl.cfg.ThrowM[0] + (pl.cfg.ThrowM[1]-pl.cfg.ThrowM[0])*pl.s.Float()
|
||||
|
||||
// Vergence, and this is the point of carrying the side at all. A thrust belt is doubly vergent: the
|
||||
// faults on each flank face outwards, away from the boundary and towards the foreland they are riding
|
||||
// over. So which block goes up is decided by which side of the line the fault sits on, rather than by
|
||||
// the coin flip a class fault set has to use for want of anything better.
|
||||
set := traceSet(pl.p, pl.bend, pl.bendCells, pl.s,
|
||||
xM, yM, a, lengthM, throw*seg.throwScale, side < 0, -1)
|
||||
|
||||
if pl.land == nil {
|
||||
return set
|
||||
}
|
||||
out := set[:0]
|
||||
for _, f := range set {
|
||||
if traceLandShare(f, pl.land) >= beltLandShare {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// traceLandShare is how much of a built trace stands on painted land.
|
||||
//
|
||||
// Offshore faults are real - the reference map has them throughout the Mediterranean and the Arabian Sea -
|
||||
// but the solve fixes every ocean cell at sea level, so a trace out there changes no height and does nothing
|
||||
// but clutter the diagnostic the fault set is read from.
|
||||
func traceLandShare(f FaultTrace, land func(xM, yM float64) bool) float64 {
|
||||
if len(f.PointsM) == 0 {
|
||||
return 0
|
||||
}
|
||||
on := 0
|
||||
for _, pt := range f.PointsM {
|
||||
if land(pt[0], pt[1]) {
|
||||
on++
|
||||
}
|
||||
}
|
||||
return float64(on) / float64(len(f.PointsM))
|
||||
}
|
||||
|
||||
// beltSeg is one boundary prepared for sampling: cumulative length along it, the zone half-width at each
|
||||
// vertex, and the zone's total area.
|
||||
type beltSeg struct {
|
||||
b *plates.Boundary
|
||||
|
||||
// cum[i] is the length along the polyline up to vertex i, so a uniform draw over cum[last] picks a point
|
||||
// uniformly along the *line* rather than uniformly among its vertices - which would over-sample wherever
|
||||
// the chain happened to be dense.
|
||||
cum []float64
|
||||
half []float64 // zone half-width in metres at each vertex
|
||||
|
||||
zoneKm2 float64
|
||||
throwScale float64
|
||||
}
|
||||
|
||||
// beltSegments measures a boundary: how wide its zone is at every point, how much ground that is, and how
|
||||
// much the closing rate should scale the throws in it.
|
||||
func beltSegments(b *plates.Boundary, cfg plates.Belt, refM float64) beltSeg {
|
||||
seg := beltSeg{b: b, cum: make([]float64, len(b.V)), half: make([]float64, len(b.V))}
|
||||
|
||||
base := cfg.ZoneKm * 1000
|
||||
closingTotal := 0.0
|
||||
for i, v := range b.V {
|
||||
w := beltWidth[v.Kind]
|
||||
rate := math.Abs(v.ClosingMYr)
|
||||
if v.Kind == plates.Transform {
|
||||
// A transform closes at nothing by definition, so its zone has to be scaled by how fast it is
|
||||
// *sliding* instead. Without this every transform margin in the world would have a zone of zero
|
||||
// and the San Andreas would be unfaulted.
|
||||
rate = math.Abs(v.SlipMYr)
|
||||
}
|
||||
// The square root, not the rate itself: doubling the convergence does not double the width of the
|
||||
// belt it deforms, and a linear scale makes the fastest margin swallow a continent.
|
||||
scale := math.Sqrt(clampF(rate/refM, 0.04, 6))
|
||||
seg.half[i] = base * w * scale
|
||||
closingTotal += rate
|
||||
}
|
||||
for i := 1; i < len(b.V); i++ {
|
||||
d := math.Hypot(b.V[i].XM-b.V[i-1].XM, b.V[i].YM-b.V[i-1].YM)
|
||||
seg.cum[i] = seg.cum[i-1] + d
|
||||
// The zone either side of this segment, as a trapezium on each flank.
|
||||
seg.zoneKm2 += d * (seg.half[i-1] + seg.half[i]) / 1e6
|
||||
}
|
||||
if n := len(b.V); n > 0 {
|
||||
mean := closingTotal / float64(n)
|
||||
seg.throwScale = clampF(mean/refM, beltThrowFloor, beltThrowCeil)
|
||||
}
|
||||
return seg
|
||||
}
|
||||
|
||||
// sample draws one position in the zone: a point along the line, then an offset across it.
|
||||
//
|
||||
// It returns the world position, the belt's local strike there, the local zone half-width, and which side of
|
||||
// the line the point fell on. The side is what vergence is read from, and the half-width is what a trace's
|
||||
// length is scaled by.
|
||||
func (seg beltSeg) sample(s *noise.Source) (xM, yM, strike, halfM, side float64) {
|
||||
total := seg.cum[len(seg.cum)-1]
|
||||
if total <= 0 {
|
||||
return seg.b.V[0].XM, seg.b.V[0].YM, 0, seg.half[0], 1
|
||||
}
|
||||
at := s.Float() * total
|
||||
// Walk to the segment holding it. Linear rather than a binary search on purpose: a boundary is a few
|
||||
// hundred vertices and this runs a few thousand times, so the search is not where the time goes and a
|
||||
// loop with no off-by-one in it is worth more here than the log.
|
||||
i := 1
|
||||
for i < len(seg.cum)-1 && seg.cum[i] < at {
|
||||
i++
|
||||
}
|
||||
t := 0.0
|
||||
if d := seg.cum[i] - seg.cum[i-1]; d > 0 {
|
||||
t = (at - seg.cum[i-1]) / d
|
||||
}
|
||||
|
||||
a, b := seg.b.V[i-1], seg.b.V[i]
|
||||
lx := a.XM + (b.XM-a.XM)*t
|
||||
ly := a.YM + (b.YM-a.YM)*t
|
||||
nx := a.NX + (b.NX-a.NX)*t
|
||||
ny := a.NY + (b.NY-a.NY)*t
|
||||
if d := math.Hypot(nx, ny); d > 0 {
|
||||
nx, ny = nx/d, ny/d
|
||||
}
|
||||
halfM = seg.half[i-1] + (seg.half[i]-seg.half[i-1])*t
|
||||
|
||||
// Across the line. The offset is biased towards zero so the swarm is dense at the margin and trails off,
|
||||
// and the side is drawn separately so both flanks are populated.
|
||||
side = 1
|
||||
if s.Float() < 0.5 {
|
||||
side = -1
|
||||
}
|
||||
off := halfM * math.Pow(s.Float(), beltFalloff) * side
|
||||
|
||||
// The strike is the boundary's own tangent, which is the perpendicular of the normal. This single line is
|
||||
// the whole difference between a swarm that follows the Zagros round its arc and a set of traces pointing
|
||||
// wherever a noise lattice happened to say.
|
||||
strike = math.Atan2(-nx, ny)
|
||||
|
||||
return lx + nx*off, ly + ny*off, strike, halfM, side
|
||||
}
|
||||
|
||||
func clampF(v, lo, hi float64) float64 {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package uplift
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/plates"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// A straight north-south margin down the middle of a planet, closing head-on. Everything a belt fault is
|
||||
// supposed to do is measurable against a line whose direction is known: the traces should run along it, sit
|
||||
// near it, and face away from it.
|
||||
func straightMargin(t *testing.T, p world.Planet) []plates.Boundary {
|
||||
t.Helper()
|
||||
const n = 200
|
||||
xM := p.CircumferenceM() / 2
|
||||
v := make([]plates.Vertex, n)
|
||||
for i := range v {
|
||||
v[i] = plates.Vertex{
|
||||
XM: xM,
|
||||
YM: p.HeightM() * float64(i) / float64(n-1),
|
||||
NX: 1, // the margin runs north-south, so its normal points east
|
||||
NY: 0,
|
||||
ClosingMYr: 0.04,
|
||||
Kind: plates.Collision,
|
||||
Over: -1,
|
||||
}
|
||||
}
|
||||
return []plates.Boundary{{A: 0, B: 1, V: v}}
|
||||
}
|
||||
|
||||
func beltPlanet(t *testing.T) world.Planet {
|
||||
t.Helper()
|
||||
p, err := world.New(40000, 8, 100, 50, 0, 40000)
|
||||
if err != nil {
|
||||
t.Fatalf("planet: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func testBelt() plates.Belt {
|
||||
b := plates.DefaultBelt()
|
||||
b.ZoneKm = 3
|
||||
b.Per1000Km2 = 400
|
||||
none := 0.0
|
||||
b.ConjugateFraction = &none // measured separately; the main set has to be parallel on its own
|
||||
return b
|
||||
}
|
||||
|
||||
func allLand(xM, yM float64) bool { return true }
|
||||
|
||||
func TestBeltFaultsRunAlongTheMargin(t *testing.T) {
|
||||
p := beltPlanet(t)
|
||||
fs := BuildBeltFaults(p, 7, testBelt(), straightMargin(t, p), allLand)
|
||||
if len(fs) < 20 {
|
||||
t.Fatalf("%d traces; not enough to measure anything", len(fs))
|
||||
}
|
||||
|
||||
// The margin runs north-south, so every trace should too. Measured as the angle between the trace's own
|
||||
// end-to-end direction and the line, folded into 0..90 because a fault has no head or tail.
|
||||
worst, total := 0.0, 0.0
|
||||
for _, f := range fs {
|
||||
a, b := f.PointsM[0], f.PointsM[len(f.PointsM)-1]
|
||||
deg := foldedAngleDeg(math.Atan2(b[1]-a[1], b[0]-a[0]), math.Pi/2)
|
||||
total += deg
|
||||
if deg > worst {
|
||||
worst = deg
|
||||
}
|
||||
}
|
||||
mean := total / float64(len(fs))
|
||||
// The configured spread is 11 degrees, and the walk wanders on top of it. A mean much above that would
|
||||
// mean the strike is not coming from the boundary at all, which is the defect this whole file exists for.
|
||||
if mean > 20 {
|
||||
t.Errorf("traces average %.1f degrees off the margin; they are not following it", mean)
|
||||
}
|
||||
if worst > 55 {
|
||||
t.Errorf("a trace is %.1f degrees off the margin; nothing should be near perpendicular to it", worst)
|
||||
}
|
||||
}
|
||||
|
||||
// foldedAngleDeg is the angle between two directions, in degrees, folded into 0..90: a line at 170 degrees
|
||||
// and one at 10 are twenty degrees apart, not a hundred and sixty.
|
||||
func foldedAngleDeg(a, b float64) float64 {
|
||||
d := math.Abs(a-b) * 180 / math.Pi
|
||||
d = math.Mod(d, 180)
|
||||
if d > 90 {
|
||||
d = 180 - d
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestBeltFaultsStayInTheDeformationZone(t *testing.T) {
|
||||
p := beltPlanet(t)
|
||||
cfg := testBelt()
|
||||
fs := BuildBeltFaults(p, 7, cfg, straightMargin(t, p), allLand)
|
||||
if len(fs) == 0 {
|
||||
t.Fatal("no traces")
|
||||
}
|
||||
|
||||
xM := p.CircumferenceM() / 2
|
||||
// The zone half-width here is the configured width times the collision multiplier times the rate scale.
|
||||
// A *fault's* centre is placed inside it, but a trace's need not be: a fault over twelve kilometres is
|
||||
// broken into en-echelon segments staggered up to 0.06 of its length across strike, which is the whole
|
||||
// point of the stepping. So the bound on a segment centre is the zone plus that stagger, and the bound on
|
||||
// any point of it is a further half-length beyond that.
|
||||
half := cfg.ZoneKm * 1000 * beltWidth[plates.Collision] * math.Sqrt(0.04/(cfg.ReferenceCmYr/100))
|
||||
longest := cfg.LengthKm[1] * 1000 * 2.2
|
||||
centreBound := half + 0.06*longest
|
||||
anyBound := centreBound + longest
|
||||
|
||||
far := 0
|
||||
inZone := 0
|
||||
for _, f := range fs {
|
||||
mid := f.PointsM[len(f.PointsM)/2]
|
||||
d := math.Abs(mid[0] - xM)
|
||||
if d > centreBound {
|
||||
far++
|
||||
}
|
||||
if d <= half {
|
||||
inZone++
|
||||
}
|
||||
for _, pt := range f.PointsM {
|
||||
if math.Abs(pt[0]-xM) > anyBound {
|
||||
t.Fatalf("a trace reaches %.0f m from the margin; the zone, the stagger and a trace is %.0f m",
|
||||
math.Abs(pt[0]-xM), anyBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
if far > 0 {
|
||||
t.Errorf("%d of %d trace centres sit outside the deformation zone and its en-echelon stagger",
|
||||
far, len(fs))
|
||||
}
|
||||
// And they should be *concentrated* near the line rather than spread evenly across the zone: that is what
|
||||
// beltFalloff is for, and what the reference map shows.
|
||||
near := 0
|
||||
for _, f := range fs {
|
||||
if math.Abs(f.PointsM[len(f.PointsM)/2][0]-xM) < half/2 {
|
||||
near++
|
||||
}
|
||||
}
|
||||
if float64(near)/float64(inZone) < 0.55 {
|
||||
t.Errorf("only %d of %d traces are in the inner half of the zone; the falloff is not biting",
|
||||
near, inZone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeltFaultsVergeAwayFromTheMargin(t *testing.T) {
|
||||
p := beltPlanet(t)
|
||||
cfg := testBelt()
|
||||
// Short faults only. Vergence is decided per *fault*, from which side of the line it was placed on, and
|
||||
// then every en-echelon segment of it inherits that - correctly, since the segments are one fault. Keeping
|
||||
// every fault under enEchelonM means one trace per placement, so the side a trace sits on and the side it
|
||||
// was placed on are the same thing and the property can be measured at all.
|
||||
cfg.LengthKm = [2]float64{2, 4}
|
||||
fs := BuildBeltFaults(p, 7, cfg, straightMargin(t, p), allLand)
|
||||
if len(fs) < 20 {
|
||||
t.Fatalf("%d traces; not enough to measure anything", len(fs))
|
||||
}
|
||||
|
||||
xM := p.CircumferenceM() / 2
|
||||
wrong := 0
|
||||
for _, f := range fs {
|
||||
mid := f.PointsM[len(f.PointsM)/2]
|
||||
// A doubly-vergent belt faces outwards on both flanks, so the two sides must disagree about which
|
||||
// block goes up. Which flank got which sign does not matter; that they are consistent within a flank
|
||||
// does, because the alternative is the coin flip a class fault set has to use.
|
||||
if (mid[0] > xM) != f.Reverse {
|
||||
wrong++
|
||||
}
|
||||
}
|
||||
if wrong != 0 && wrong != len(fs) {
|
||||
t.Errorf("%d of %d traces disagree with their own flank about vergence; a belt is doubly vergent, "+
|
||||
"not randomly vergent", min(wrong, len(fs)-wrong), len(fs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeltFaultsNeedLand(t *testing.T) {
|
||||
p := beltPlanet(t)
|
||||
m := straightMargin(t, p)
|
||||
if got := BuildBeltFaults(p, 7, testBelt(), m, func(xM, yM float64) bool { return false }); len(got) != 0 {
|
||||
t.Errorf("%d traces on a planet with no land", len(got))
|
||||
}
|
||||
// A coast down one side of the margin: every trace must be mostly on the land side.
|
||||
xM := p.CircumferenceM() / 2
|
||||
half := func(x, y float64) bool { return x < xM }
|
||||
fs := BuildBeltFaults(p, 7, testBelt(), m, half)
|
||||
if len(fs) == 0 {
|
||||
t.Fatal("no traces on a half-land planet")
|
||||
}
|
||||
for _, f := range fs {
|
||||
on := 0
|
||||
for _, pt := range f.PointsM {
|
||||
if half(pt[0], pt[1]) {
|
||||
on++
|
||||
}
|
||||
}
|
||||
if share := float64(on) / float64(len(f.PointsM)); share < 0.3 {
|
||||
t.Errorf("a trace is only %.0f%% on land; the span test should have refused it", share*100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABeltWithNoNumbersAsksForNothing(t *testing.T) {
|
||||
p := beltPlanet(t)
|
||||
if got := BuildBeltFaults(p, 7, plates.Belt{}, straightMargin(t, p), allLand); got != nil {
|
||||
t.Errorf("%d traces from an empty config; leaving the block out must leave the feature off", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFasterMarginDeformsAWiderBelt(t *testing.T) {
|
||||
p := beltPlanet(t)
|
||||
cfg := testBelt()
|
||||
|
||||
spread := func(closing float64) float64 {
|
||||
bs := straightMargin(t, p)
|
||||
for i := range bs[0].V {
|
||||
bs[0].V[i].ClosingMYr = closing
|
||||
}
|
||||
fs := BuildBeltFaults(p, 7, cfg, bs, allLand)
|
||||
if len(fs) == 0 {
|
||||
t.Fatalf("no traces at %.3g m/yr", closing)
|
||||
}
|
||||
xM := p.CircumferenceM() / 2
|
||||
worst := 0.0
|
||||
for _, f := range fs {
|
||||
mid := f.PointsM[len(f.PointsM)/2]
|
||||
if d := math.Abs(mid[0] - xM); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
slow, fast := spread(0.01), spread(0.08)
|
||||
if fast <= slow*1.4 {
|
||||
t.Errorf("a margin closing eight times faster deforms a belt %.0f m wide against %.0f m; the zone is "+
|
||||
"not scaling with the rate", fast, slow)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package uplift
|
||||
|
||||
import (
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// The planet's upland fabric: where the ground stands above the plain, before any class has said how far.
|
||||
//
|
||||
// It exists because of one piece of arithmetic. For n = 1 the steady-state divide slope is U/(K*A^m) applied
|
||||
// down to a single cell, so a class's uplift rate *is* its hillslope angle - 0.08 mm/yr is 11.3 degrees at an
|
||||
// 8 m cell - and a class is one rate over every cell an author painted with it. A landmass painted one colour
|
||||
// therefore comes out uniformly dissected from the waterline to the summit, with no flat ground anywhere on
|
||||
// it. That is not what a continent looks like. Europe away from the Alps is a plain at a fraction of a degree
|
||||
// with isolated massifs standing out of it, and the difference is not the rate, it is that the rate is not
|
||||
// the same everywhere.
|
||||
//
|
||||
// So a class carries a floor as well as a rate, and this decides, per cell, how far between the two it sits.
|
||||
//
|
||||
// One fabric for the whole planet, cut at a different level by each class that asks. That is deliberate and
|
||||
// it is the reason this is not a per-class noise: a highland belt and the hills in the lowland next to it
|
||||
// then come out as the high and low parts of one structure - an orogen and its outliers - rather than as two
|
||||
// unrelated fields meeting at a painted edge.
|
||||
|
||||
// fabricProbeW is how finely a fabric is sampled to find out what its values mean. 1024 columns is 98 m a
|
||||
// sample on a 100 km planet against a finest octave of a kilometre or so, which is ten samples across it: the
|
||||
// distribution the probe measures is the distribution the 8 m grid will draw from, which is the only thing
|
||||
// asked of it.
|
||||
const fabricProbeW = 1024
|
||||
|
||||
// fabricBins is the resolution of the measured distribution. The fabric lives in a fraction of 0..1, so four
|
||||
// thousand bins over the whole interval is finer than the probe's sampling error by a wide margin.
|
||||
const fabricBins = 4096
|
||||
|
||||
// massifOctaves and massifGain shape the fabric itself. Five octaves at 0.45 keeps the blocks legible at
|
||||
// their own wavelength while giving their edges a fractal outline, which is what stops a massif reading as a
|
||||
// painted blob - the thing an author would have drawn by hand, and the reason they should not have to.
|
||||
const (
|
||||
massifOctaves = 5
|
||||
massifGain = 0.45
|
||||
)
|
||||
|
||||
// massifWarp is how far the fabric is bent by the shared low-frequency warp, as a fraction of its own
|
||||
// wavelength. Ridges take 0.8 and crest lines 3.1; a little under one wavelength keeps a block a block while
|
||||
// stopping the lattice showing through as a grid of round hills.
|
||||
const massifWarp = 0.9
|
||||
|
||||
// paintWarp is the low-frequency warp every painted noise field is built on, so that ridges curve and blocks
|
||||
// are not polygons. Shared rather than copied: the fabric has to be bent by the same field the relief is, or
|
||||
// a massif and the ridges on it would disagree about which way the grain runs.
|
||||
func paintWarp(u, v *field.Field, seed int64) (wx, wy *field.Field) {
|
||||
ws := noise.NewSource(seed, srcPaintWarp)
|
||||
wx = noise.FBMAt(u, v, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
|
||||
wy = noise.FBMAt(u, v, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
|
||||
return wx, wy
|
||||
}
|
||||
|
||||
// massifFabric samples the upland fabric at the given world coordinates.
|
||||
func massifFabric(u, v, wx, wy *field.Field, seed int64, baseCells int) *field.Field {
|
||||
ms := noise.NewSource(seed, srcPaintMassif)
|
||||
mu, mv := noise.Warp(u, v, wx, wy, massifWarp/float64(baseCells))
|
||||
return noise.FBMAt(mu, mv, ms, noise.Params{
|
||||
BaseCells: baseCells, Octaves: massifOctaves, Gain: massifGain,
|
||||
})
|
||||
}
|
||||
|
||||
// fabricCDF is a planet-wide fabric's distribution, measured once over the whole cylinder: it turns a fabric
|
||||
// value into the share of the planet standing below it.
|
||||
//
|
||||
// It is shared by every field that has to be cut at the same level in every region - the upland fabric here
|
||||
// and the lithology in painted_rock.go - because the argument below is not about massifs, it is about what a
|
||||
// threshold on a *decomposed* planet is allowed to be.
|
||||
//
|
||||
// The measurement is the hard part of this feature and it is worth saying why. A threshold cannot be a
|
||||
// percentile of the region. uplift.Build takes percentiles of the grid it is handed and FromTemplate exists
|
||||
// precisely not to do that: two regions taking quantiles of their own extents would put the same physical
|
||||
// hillside on different sides of the cut, and the planet would disagree with itself along every region
|
||||
// boundary. A quantile of the *planet* is a different animal. It is one number for the whole world, every
|
||||
// region computes the same one from the same samples because the samples are defined by the planet and not by
|
||||
// the caller, and it costs half a million noise evaluations - about ten milliseconds, once per region.
|
||||
//
|
||||
// It is a histogram rather than a sort for the same reason it is cheap: a sorted copy of the probe is four
|
||||
// megabytes and a hundred milliseconds, and nothing here needs a resolution a sort would buy.
|
||||
type fabricCDF struct {
|
||||
lo, hi float64
|
||||
cum []float64 // fabricBins+1 entries: cum[i] is the share below lo + i*(hi-lo)/fabricBins
|
||||
}
|
||||
|
||||
// fabricFunc builds a planet-wide fabric at the given world coordinates. The two that exist are massifFabric
|
||||
// and rockFabric; both take the shared low-frequency warp so that every field on a planet bends the same way.
|
||||
type fabricFunc func(u, v, wx, wy *field.Field, seed int64, baseCells int) *field.Field
|
||||
|
||||
// measureFabric probes a fabric over the entire cylinder, the pad included, and measures its distribution.
|
||||
//
|
||||
// The pad is in on purpose. It is a couple of hundred rows of synthetic ocean at each pole, no class ever
|
||||
// reads a rate there, and leaving it out would make the answer depend on how thick the pad happened to be.
|
||||
// What matters is that the probe is a property of the planet and of nothing else.
|
||||
func measureFabric(p world.Planet, seed int64, baseCells int, build fabricFunc) fabricCDF {
|
||||
w := fabricProbeW
|
||||
if w > p.W {
|
||||
w = p.W
|
||||
}
|
||||
h := int(float64(w)*float64(p.H)/float64(p.W) + 0.5)
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
// The probe walks the same world metres the regions do, at a coarser step, through the same WorldUV: what
|
||||
// it measures is the same field, sampled more sparsely.
|
||||
cellM := p.CircumferenceM() / float64(w)
|
||||
u, v := noise.WorldUV(w, h, cellM, 0, p.YM(0), p.NoisePeriodM)
|
||||
wx, wy := paintWarp(u, v, seed)
|
||||
f := build(u, v, wx, wy, seed, baseCells)
|
||||
|
||||
lo, hi := f.MinMax()
|
||||
c := fabricCDF{lo: float64(lo), hi: float64(hi), cum: make([]float64, fabricBins+1)}
|
||||
if c.hi <= c.lo {
|
||||
// A degenerate fabric - one lattice cell, or a probe of a single column. Every value is the same,
|
||||
// so every cell is at the same place in the distribution and the shape below is flat.
|
||||
c.hi = c.lo + 1
|
||||
return c
|
||||
}
|
||||
|
||||
// Counted serially. It is a millisecond and cross-cutting rule 12 says the answer must not depend on how
|
||||
// many goroutines ran.
|
||||
counts := make([]float64, fabricBins)
|
||||
scale := float64(fabricBins) / (c.hi - c.lo)
|
||||
for _, x := range f.Data {
|
||||
b := int((float64(x) - c.lo) * scale)
|
||||
if b < 0 {
|
||||
b = 0
|
||||
}
|
||||
if b >= fabricBins {
|
||||
b = fabricBins - 1
|
||||
}
|
||||
counts[b]++
|
||||
}
|
||||
total := float64(len(f.Data))
|
||||
run := 0.0
|
||||
for i, n := range counts {
|
||||
c.cum[i] = run / total
|
||||
run += n
|
||||
}
|
||||
c.cum[fabricBins] = 1
|
||||
return c
|
||||
}
|
||||
|
||||
// at is the share of the planet standing below this fabric value, in 0..1.
|
||||
func (c fabricCDF) at(x float64) float64 {
|
||||
t := (x - c.lo) / (c.hi - c.lo) * float64(fabricBins)
|
||||
if t <= 0 {
|
||||
return 0
|
||||
}
|
||||
if t >= float64(fabricBins) {
|
||||
return 1
|
||||
}
|
||||
i := int(t)
|
||||
return c.cum[i] + (c.cum[i+1]-c.cum[i])*(t-float64(i))
|
||||
}
|
||||
|
||||
// MassifRate blends a class's floor and its rate at one place in the fabric: the plain where the fabric is
|
||||
// low, the class rate where it is high.
|
||||
//
|
||||
// The ramp is cut in the *rank* - the share of the planet standing below this cell - rather than in the
|
||||
// fabric's own values, which is what makes fraction mean something an author can predict: exactly `fraction`
|
||||
// of the planet stands above the midpoint, half that again reaches the class rate outright, and half again
|
||||
// above that is off the plain at all. Cutting in value space instead would make the realised share depend on
|
||||
// the shape of the noise's distribution, which is not a number anybody should have to know.
|
||||
func MassifRate(floorMYr, rateMYr, rank, fraction float64) float64 {
|
||||
return floorMYr + (rateMYr-floorMYr)*massifShape(rank, fraction)
|
||||
}
|
||||
|
||||
func massifShape(rank, fraction float64) float64 {
|
||||
lo := 1 - 1.5*fraction
|
||||
hi := 1 - 0.5*fraction
|
||||
t := (rank - lo) / (hi - lo)
|
||||
if t <= 0 {
|
||||
return 0
|
||||
}
|
||||
if t >= 1 {
|
||||
return 1
|
||||
}
|
||||
return t * t * (3 - 2*t)
|
||||
}
|
||||
|
||||
// MassifRank is where every cell of a frame sits in the planet's upland fabric: 0 is the lowest ground on the
|
||||
// planet and 1 the highest, as a share of the planet's surface rather than as a height.
|
||||
//
|
||||
// It takes world coordinates rather than a Frame so that the diagnostic maps, which point-sample the planet
|
||||
// down to an image, can ask about exactly the cells they drew rather than about a frame they do not have.
|
||||
func MassifRank(p world.Planet, seed int64, baseCells int, u, v *field.Field) *field.Field {
|
||||
if baseCells < 1 {
|
||||
baseCells = 1
|
||||
}
|
||||
wx, wy := paintWarp(u, v, seed)
|
||||
fabric := massifFabric(u, v, wx, wy, seed, baseCells)
|
||||
cdf := measureFabric(p, seed, baseCells, massifFabric)
|
||||
|
||||
out := field.NewLike(fabric)
|
||||
field.Rows(out.H, func(y0, y1 int) {
|
||||
for i := y0 * out.W; i < y1*out.W; i++ {
|
||||
out.Data[i] = float32(cdf.at(float64(fabric.Data[i])))
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// anyMassif reports whether any class in the legend asked for a fabric.
|
||||
func anyMassif(fraction []float64) bool {
|
||||
for _, f := range fraction {
|
||||
if f > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package uplift
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// testPlanet is a small cylinder with a period that divides its circumference, which world.Planet.Validate
|
||||
// requires and which every noise field here depends on.
|
||||
func testPlanet(t *testing.T, w, h int, cellM float64) world.Planet {
|
||||
t.Helper()
|
||||
p := world.Planet{CellM: cellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * cellM}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// The whole contract of the fraction key: it is a share of the planet's surface, and it is the share standing
|
||||
// above the midpoint between the class floor and the class rate.
|
||||
//
|
||||
// This is the test that makes the number worth writing in a legend. Cutting the fabric at a fixed *value*
|
||||
// instead would make the realised share depend on the shape of the noise's distribution, which nobody can
|
||||
// predict from a JSON file, and it would drift every time an octave count changed.
|
||||
func TestTheMassifFractionIsTheShareOfThePlanetThatStandsUp(t *testing.T) {
|
||||
// Wider than massifProbeW, deliberately. At 512 the probe clamps to the planet's own width and samples
|
||||
// the identical grid, so every number below comes out exact and the test measures nothing - which is
|
||||
// what the first version of it did. A real planet is 12500 columns against a 1024-column probe, so the
|
||||
// distribution being applied is always a coarser measurement of the field than the field it is applied
|
||||
// to, and that gap is the thing worth bounding.
|
||||
const w, h, cellM = 2048, 512, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
f := world.Whole(p)
|
||||
|
||||
for _, fraction := range []float64{0.05, 0.15, 0.30, 0.50} {
|
||||
u, v := noise.WorldUV(w, h, cellM, f.OriginXM(), f.OriginYM(), p.NoisePeriodM)
|
||||
rank := MassifRank(p, 7, 8, u, v)
|
||||
|
||||
above, full, off := 0, 0, 0
|
||||
for _, r := range rank.Data {
|
||||
s := massifShape(float64(r), fraction)
|
||||
if s > 0.5 {
|
||||
above++
|
||||
}
|
||||
if s >= 1 {
|
||||
full++
|
||||
}
|
||||
if s > 0 {
|
||||
off++
|
||||
}
|
||||
}
|
||||
got := float64(above) / float64(len(rank.Data))
|
||||
// Two per cent of the planet. The probe is 1024 x 256 against this 2048 x 512 grid, so the two are
|
||||
// sampling the same field at different steps and cannot agree to the cell.
|
||||
if math.Abs(got-fraction) > 0.02 {
|
||||
t.Errorf("fraction %.2f: %.1f%% of the planet stands above the midpoint, want %.0f%%",
|
||||
fraction, 100*got, 100*fraction)
|
||||
}
|
||||
// Half the fraction again reaches the class rate outright and half again above that is off the plain
|
||||
// at all. Both follow from the ramp and both are what the legend documents.
|
||||
if g, want := float64(full)/float64(len(rank.Data)), fraction*0.5; math.Abs(g-want) > 0.02 {
|
||||
t.Errorf("fraction %.2f: %.1f%% is at the full rate, want %.0f%%", fraction, 100*g, 100*want)
|
||||
}
|
||||
if g, want := float64(off)/float64(len(rank.Data)), fraction*1.5; math.Abs(g-want) > 0.03 {
|
||||
t.Errorf("fraction %.2f: %.1f%% is off the plain, want %.0f%%", fraction, 100*g, 100*want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 1 of the tiling plan, for the fabric: two regions covering the same physical place must agree to the
|
||||
// bit. This is the one that would fail if the threshold were ever taken as a percentile of the region, which
|
||||
// is the obvious implementation and the wrong one - see the note on massifCDF.
|
||||
func TestTwoFramesAgreeAboutTheSameGround(t *testing.T) {
|
||||
const w, h, cellM = 2048, 512, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
|
||||
rankIn := func(f world.Frame) []float32 {
|
||||
u, v := noise.WorldUV(f.W, f.H, cellM, f.OriginXM(), f.OriginYM(), p.NoisePeriodM)
|
||||
return MassifRank(p, 7, 8, u, v).Data
|
||||
}
|
||||
|
||||
whole := rankIn(world.Whole(p))
|
||||
// A window well inside the planet, and a second one overlapping it from a different origin.
|
||||
a := world.Frame{P: p, X0: 400, Y0: 80, W: 240, H: 160}
|
||||
b := world.Frame{P: p, X0: 520, Y0: 120, W: 240, H: 160}
|
||||
ra, rb := rankIn(a), rankIn(b)
|
||||
|
||||
checked := 0
|
||||
for y := 0; y < a.H; y++ {
|
||||
for x := 0; x < a.W; x++ {
|
||||
px, py := a.PlanetXY(x, y)
|
||||
if px < b.X0 || px >= b.X0+b.W || py < b.Y0 || py >= b.Y0+b.H {
|
||||
continue
|
||||
}
|
||||
got := ra[y*a.W+x]
|
||||
want := rb[(py-b.Y0)*b.W+(px-b.X0)]
|
||||
if got != want {
|
||||
t.Fatalf("at planet (%d,%d) frame A says %v and frame B says %v", px, py, got, want)
|
||||
}
|
||||
if wh := whole[py*p.W+px]; wh != got {
|
||||
t.Fatalf("at planet (%d,%d) a frame says %v and the whole planet says %v", px, py, got, wh)
|
||||
}
|
||||
checked++
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("the two frames do not overlap; this test measured nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// A frame that straddles the seam is ordinary, not special: column W-1 and column 0 are neighbours, so the
|
||||
// fabric has to run continuously across them. A wrong noise period is the way this breaks, and it breaks
|
||||
// invisibly on a map whose two edges are as far apart on screen as they can be.
|
||||
func TestTheFabricCrossesTheSeam(t *testing.T) {
|
||||
const w, h, cellM = 2048, 512, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
|
||||
u, v := noise.WorldUV(w, h, cellM, 0, 0, p.NoisePeriodM)
|
||||
whole := MassifRank(p, 7, 8, u, v)
|
||||
|
||||
// The step across the seam must be no bigger than a typical step inside the map.
|
||||
worstSeam, worstInside := 0.0, 0.0
|
||||
for y := 0; y < h; y++ {
|
||||
d := math.Abs(float64(whole.Data[y*w] - whole.Data[y*w+w-1]))
|
||||
if d > worstSeam {
|
||||
worstSeam = d
|
||||
}
|
||||
for x := 1; x < w; x++ {
|
||||
if e := math.Abs(float64(whole.Data[y*w+x] - whole.Data[y*w+x-1])); e > worstInside {
|
||||
worstInside = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if worstSeam > worstInside {
|
||||
t.Errorf("the biggest step across the seam is %.4f against %.4f anywhere inside the map; "+
|
||||
"the fabric does not wrap", worstSeam, worstInside)
|
||||
}
|
||||
}
|
||||
|
||||
// What the feature is for, measured on the thing an author actually gets: a class with a massif has to come
|
||||
// out mostly plain, and the plain has to be the floor rather than some average of the two.
|
||||
func TestAPaintedClassWithAMassifIsMostlyPlain(t *testing.T) {
|
||||
const w, h, cellM = 2048, 512, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
f := world.Whole(p)
|
||||
|
||||
class := make([]uint8, w*h)
|
||||
land := make([]bool, w*h)
|
||||
for i := range class {
|
||||
class[i], land[i] = 1, true
|
||||
}
|
||||
|
||||
const rate = 0.00008 // 0.08 mm/yr, the rate a massif reaches
|
||||
const floor = 0.00001 // 0.01 mm/yr, the plain
|
||||
const fraction = 0.15
|
||||
|
||||
m := manifest.Defaults()
|
||||
m.Source.Seed = 7
|
||||
up := FromTemplate(Paint{
|
||||
Frame: f, Class: class, Land: land,
|
||||
Rates: []float32{0, rate},
|
||||
Ks: []float32{1, 1},
|
||||
PlainM: []float64{0, 0},
|
||||
PlainFloor: []float32{0, 0},
|
||||
MassifFloor: []float32{0, floor},
|
||||
MassifFraction: []float64{0, fraction},
|
||||
MassifCells: 8,
|
||||
Variation: 0, // the swell off, so the fabric is the only thing being measured
|
||||
}, m)
|
||||
|
||||
// Under twice the floor is "plain" for this purpose: the ramp is smooth, so a cell just off the plain is
|
||||
// still plain, and the question being asked is whether most of the class is down there at all.
|
||||
plain, high := 0, 0
|
||||
for _, r := range up.Rate.Data {
|
||||
if float64(r) < 2*floor {
|
||||
plain++
|
||||
}
|
||||
if float64(r) > 0.5*(rate+floor) {
|
||||
high++
|
||||
}
|
||||
}
|
||||
if share := float64(plain) / float64(len(up.Rate.Data)); share < 0.6 {
|
||||
t.Errorf("only %.0f%% of the class is plain; the point of a massif is that most of it is", 100*share)
|
||||
}
|
||||
if share := float64(high) / float64(len(up.Rate.Data)); math.Abs(share-fraction) > 0.02 {
|
||||
t.Errorf("%.1f%% of the class is above the midpoint, want %.0f%%", 100*share, 100*fraction)
|
||||
}
|
||||
|
||||
// And the floor has to be the floor. Before this existed the lowest rate on a uniformly painted class was
|
||||
// the class rate itself, which is exactly the defect: 0.08 mm/yr is an 11 degree hillslope everywhere.
|
||||
lo := math.Inf(1)
|
||||
for _, r := range up.Rate.Data {
|
||||
if float64(r) < lo {
|
||||
lo = float64(r)
|
||||
}
|
||||
}
|
||||
if math.Abs(lo-floor) > 0.02*floor {
|
||||
t.Errorf("the lowest rate on the class is %.5f mm/yr, want the floor %.3f", lo*1000, floor*1000)
|
||||
}
|
||||
}
|
||||
|
||||
// A legend that asks for no massif has to produce exactly what it did before the fabric existed. The fabric
|
||||
// is opt-in and it must not be a silent change to every template already written against the old contract.
|
||||
func TestAClassWithoutAMassifIsUnchanged(t *testing.T) {
|
||||
const w, h, cellM = 256, 128, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
f := world.Whole(p)
|
||||
|
||||
class := make([]uint8, w*h)
|
||||
land := make([]bool, w*h)
|
||||
for i := range class {
|
||||
class[i], land[i] = 1, true
|
||||
}
|
||||
const rate = 0.00008
|
||||
|
||||
m := manifest.Defaults()
|
||||
m.Source.Seed = 7
|
||||
base := Paint{
|
||||
Frame: f, Class: class, Land: land,
|
||||
Rates: []float32{0, rate}, Ks: []float32{1, 1},
|
||||
PlainM: []float64{0, 0}, PlainFloor: []float32{0, 0},
|
||||
Variation: 0.3,
|
||||
}
|
||||
without := FromTemplate(base, m)
|
||||
|
||||
withTables := base
|
||||
withTables.MassifFloor = []float32{0, 0}
|
||||
withTables.MassifFraction = []float64{0, 0} // the tables present, the feature not asked for
|
||||
withTables.MassifCells = 8
|
||||
same := FromTemplate(withTables, m)
|
||||
|
||||
for i := range without.Rate.Data {
|
||||
if without.Rate.Data[i] != same.Rate.Data[i] {
|
||||
t.Fatalf("cell %d: %v without the massif tables, %v with them at fraction 0",
|
||||
i, without.Rate.Data[i], same.Rate.Data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package uplift
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"salty/terrain/internal/dt"
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// The painted source, and the one decision behind all of it: paint the uplift, never the height.
|
||||
//
|
||||
// Docs/Terrain-Next.md 6 lists importing a painted heightmap under "do not redo these", and the reason is
|
||||
// not taste. A stream-power solve handed a painted surface erodes it into something else within a few
|
||||
// hundred steps, and what it produces instead has no relationship to what was drawn - while the drainage
|
||||
// network, which is the entire reason this generator replaced the droplet pipeline, is thrown away and
|
||||
// rebuilt from whatever the painting happened to leave behind.
|
||||
//
|
||||
// Painting the uplift rate instead means an author draws intent - a range here, lowlands there, a coast like
|
||||
// this - and the simulation produces terrain that honours it and has real rivers, real divides and a real
|
||||
// valley hierarchy, because those came out of the physics rather than out of the brush.
|
||||
//
|
||||
// What is left for noise to do is therefore narrow, and it is not decoration:
|
||||
//
|
||||
// - The regional swell. D-49 is arithmetic: with critical_area_m2 at 0 the steady-state slope is
|
||||
// U/(K*A^m) down to a single cell, so a uniform uplift rate over a wide area gives a surface with no
|
||||
// divides at all. A painted lowland holds one rate over tens of kilometres. Without a long-wavelength
|
||||
// modulation the plains come out table-flat, the only gradient across them is the priority-flood's
|
||||
// epsilon, and the router draws the flood's traversal order as rivers. That was measured once already.
|
||||
// - The initial relief, which only breaks the symmetry. Small on purpose: the solve is what produces
|
||||
// relief, and starting it from big ridges means it spends its run tearing them down.
|
||||
//
|
||||
// Every noise field here is built on world coordinates through noise.WorldUV, so two regions covering the
|
||||
// same physical place agree to the bit. That is rule 1 of the tiling plan.
|
||||
|
||||
// Pass indices for the painted path's seeded sources. They sit above the procedural path's 1..9 so that the
|
||||
// two never share a stream and adding one here cannot reshuffle the other.
|
||||
// Feature counts, in lattice cells per noise period. They are named because the warp amounts are derived
|
||||
// from them - a warp is only meaningful as a fraction of the wavelength it is bending.
|
||||
const (
|
||||
swellCells = 4 // 25 km at a 100 km period: the regional swell
|
||||
ridgeCells = 24 // 4.2 km: the initial relief
|
||||
crestCells = 64 // 1.6 km: the crest lines
|
||||
plainCells = 48 // 2.1 km: the lowland break-up
|
||||
)
|
||||
|
||||
const (
|
||||
srcPaintSwell = 20
|
||||
srcPaintRidges = 21
|
||||
srcPaintCrests = 22
|
||||
srcPaintPlains = 23
|
||||
srcPaintWarp = 24
|
||||
srcPaintMassif = 25
|
||||
)
|
||||
|
||||
// Paint is a region's painted world: which class every cell is, which cells are land, and what the legend
|
||||
// says those classes mean.
|
||||
type Paint struct {
|
||||
Frame world.Frame
|
||||
|
||||
Class []uint8 // one legend index per frame cell
|
||||
Land []bool // land the region owns; everything else is water, including other regions' islands
|
||||
|
||||
Rates []float32 // per class, metres a year
|
||||
Ks []float32 // per class, the multiplier on stream-power K
|
||||
|
||||
// PlainM and PlainFloor put a class's range inland: within PlainM metres of the waterline the rate ramps
|
||||
// from PlainFloor up to the class rate. Per class; zero PlainM means the class reaches the sea at its
|
||||
// full rate, which is what every class did before and still does unless an author asks otherwise.
|
||||
PlainM []float64
|
||||
PlainFloor []float32
|
||||
|
||||
// MassifFloor and MassifFraction break a class into plain and upland instead of holding it at one rate.
|
||||
// Where the fraction is zero the class is uniform, which is what every class did before this existed and
|
||||
// what a legend that asks for nothing still gets. See massif.go: the class rate is then the rate a massif
|
||||
// reaches, the floor is the plain between them, and the fraction is how much of the class stands above
|
||||
// the midpoint of the two.
|
||||
MassifFloor []float32
|
||||
MassifFraction []float64
|
||||
|
||||
// MassifCells is the fabric's wavelength in lattice cells of the noise period, from
|
||||
// manifest.Planet.MassifCells. Read only when some class asks for a massif.
|
||||
MassifCells int
|
||||
|
||||
// RockCells and RockMult are the planet's lithology: the wavelength of the rock field in lattice cells,
|
||||
// and the erodibility multiplier of each rock type. LithMix is per class, how much of it shows through.
|
||||
// Zero cells, fewer than two multipliers, or every mix at zero means no rock field is built at all.
|
||||
RockCells int
|
||||
RockMult []float64
|
||||
LithMix []float64
|
||||
|
||||
// Faults is the planet's whole fault set, in world metres. A region filters it to the traces that reach
|
||||
// into its own frame, which is why it is the planet's and not the region's: a fault crossing a region
|
||||
// boundary has to be one fault, and two decompositions of the same planet have to produce the same
|
||||
// escarpment. RunYears is how long the solve runs, which turns a total throw into a rate.
|
||||
Faults []FaultTrace
|
||||
RunYears float64
|
||||
|
||||
// ClampCeilM is the uplift rate at which a divide reaches the angle of repose, at K x1, in metres a year.
|
||||
// It bounds what a *fault* may add and nothing else: an author who paints a class past the ceiling gets
|
||||
// what they asked for and a warning from `terrain plan`, but a fault stacking on top of one is an
|
||||
// accident nobody chose. Zero switches the bound off.
|
||||
ClampCeilM float64
|
||||
|
||||
// Variation is how far the swell modulates the painted rate, as a fraction. See the note above: this
|
||||
// is what gives a painted plain its divides, and it is the first thing that will be cut for time.
|
||||
Variation float64
|
||||
}
|
||||
|
||||
// FromTemplate builds the geology inputs for one region of a painted planet.
|
||||
//
|
||||
// It is a sibling of Build rather than a branch inside it. Build's continent mask, percentile range band,
|
||||
// normalised swell and percentile lithology split are all global operations over the grid they are given,
|
||||
// and a region is not a world - two regions taking percentiles of their own extents would disagree about
|
||||
// the same rock. None of them survives here; the paint replaces all four.
|
||||
func FromTemplate(p Paint, m *manifest.Manifest) *Result {
|
||||
f := p.Frame
|
||||
cfg := m.Pipeline
|
||||
seed := m.Source.Seed
|
||||
w, h := f.W, f.H
|
||||
cellM := f.P.CellM
|
||||
|
||||
u, v := noise.WorldUV(w, h, cellM, f.OriginXM(), f.OriginYM(), f.P.NoisePeriodM)
|
||||
|
||||
// A low-frequency warp, which bends everything built on it so that ridges curve and cells are not
|
||||
// polygons. Build has one and this did not, which was a porting mistake with a very visible signature:
|
||||
// Terrain.md records that cellular crest lines without a strong enough warp "turn ranges into a honeycomb
|
||||
// of polygon walls", and that is exactly what the first painted mountains looked like - flat plates with
|
||||
// hard edges, at every uplift rate, which is how it was eventually told apart from the repose clamp.
|
||||
//
|
||||
// The warp amounts need converting rather than copying. Build works in map coordinates where 0..1 spans
|
||||
// the map once, so its 0.16 and 0.224 are fractions of a whole map; here 0..1 spans one noise period, and
|
||||
// what has to be preserved is the warp measured in the *feature's own wavelength*. Build warps the ridges
|
||||
// by 0.8 of their wavelength (0.16 against BaseCells 5) and the crests by 3.1 of theirs (0.224 against
|
||||
// BaseCells 14), so those ratios are what carry across.
|
||||
wx, wy := paintWarp(u, v, seed)
|
||||
|
||||
// The regional swell: long-wavelength, so a painted lowland has hills and basins of its own rather than
|
||||
// one uniform rate across a whole continent. One turn of the planet at BaseCells 4 is a 25 km feature,
|
||||
// and four octaves take it down to about 3 km.
|
||||
ss := noise.NewSource(seed, srcPaintSwell)
|
||||
swu, swv := noise.Warp(u, v, wx, wy, 0.2/swellCells)
|
||||
swell := noise.FBMAt(swu, swv, ss, noise.Params{BaseCells: swellCells, Octaves: 4, Gain: 0.5})
|
||||
|
||||
rate := field.New(w, h, cellM)
|
||||
k := field.New(w, h, cellM)
|
||||
land := field.New(w, h, cellM)
|
||||
base := make([]bool, w*h)
|
||||
|
||||
// Distance from every land cell to the nearest water, for the coastal plain. One exact transform over the
|
||||
// region, computed only when some class asks for it. The region's frame is flat - it is a rectangle cut
|
||||
// out of the cylinder with water all round it - so this does not wrap, and the water it measures to is
|
||||
// this region's own coastline: anything else inside the frame is a different landmass, and a different
|
||||
// landmass is more than a margin away by construction.
|
||||
var shoreM []float32
|
||||
if wantsPlain(p.PlainM) {
|
||||
d2 := dt.Distance2(invert(p.Land), w, h, false)
|
||||
shoreM = make([]float32, len(d2))
|
||||
for i, d := range d2 {
|
||||
shoreM[i] = float32(math.Sqrt(float64(d)) * cellM)
|
||||
}
|
||||
}
|
||||
|
||||
// The upland fabric, built only when a class asks for one. It is the one field here that is a cut of a
|
||||
// planet-wide measurement rather than a value read straight off a noise, which is why it lives in
|
||||
// massif.go with the note on why that measurement cannot be a percentile of the region.
|
||||
var rank *field.Field
|
||||
if anyMassif(p.MassifFraction) {
|
||||
rank = MassifRank(f.P, seed, p.MassifCells, u, v)
|
||||
}
|
||||
|
||||
// The rock field, the same way and for the same reason: a quantile of the planet, never of the region.
|
||||
var rock *field.Field
|
||||
if anyMix(p.LithMix) {
|
||||
rock = RockK(f.P, seed, p.RockCells, p.RockMult, u, v)
|
||||
}
|
||||
|
||||
// And the faults, which are a rate *difference* across a line rather than a field of their own. Built
|
||||
// once for the planet and filtered to this frame; nil when none of them reaches it.
|
||||
fault := FaultDelta(f, p.Faults, p.RunYears)
|
||||
|
||||
maxRate := 0.0
|
||||
for _, r := range p.Rates {
|
||||
if float64(r) > maxRate {
|
||||
maxRate = float64(r)
|
||||
}
|
||||
}
|
||||
if maxRate <= 0 {
|
||||
maxRate = 1
|
||||
}
|
||||
|
||||
// The painted class boundary is deliberately not smoothed. The blend rule in Docs/Terrain-Next.md 3.2
|
||||
// exists because a painted map coarser than the grid reads as blocks; here a paint pixel is 12.9 m
|
||||
// against an 8 m cell, so there is barely an upsample to soften. And a step in the uplift *rate* is a
|
||||
// step in steady-state slope, not in height: the solve grades the transition over a hillslope of its own
|
||||
// accord, which is a better answer than a blur, and blurring would have pulled the sea's zero into the
|
||||
// coastal cells - the mistake D-52 undid, where the land ending decided how fast it was rising.
|
||||
// preFault is the rate before any fault touches it: the class rate after the massif cut and the
|
||||
// coastal-plain ramp. The initial relief is scaled by it rather than by the finished rate, which is
|
||||
// D-63 and is not a detail - see the amplitude below.
|
||||
preFault := make([]float32, len(rate.Data))
|
||||
|
||||
clamped := 0
|
||||
for i := range rate.Data {
|
||||
c0 := p.Class[i]
|
||||
kk := float64(p.Ks[c0])
|
||||
if rock != nil && p.LithMix[c0] > 0 {
|
||||
// The rock field multiplies the class's own erodibility rather than replacing it: `k_mult` is
|
||||
// what the author said this ground is made of, and the province is the variation within it.
|
||||
kk *= 1 + p.LithMix[c0]*(float64(rock.Data[i])-1)
|
||||
}
|
||||
k.Data[i] = float32(kk)
|
||||
if !p.Land[i] {
|
||||
base[i] = true
|
||||
continue
|
||||
}
|
||||
land.Data[i] = 1
|
||||
c := p.Class[i]
|
||||
r := float64(p.Rates[c])
|
||||
if rank != nil && p.MassifFraction[c] > 0 {
|
||||
// The class rate is the rate a massif reaches; the floor is the plain between them.
|
||||
r = MassifRate(float64(p.MassifFloor[c]), r, float64(rank.Data[i]), p.MassifFraction[c])
|
||||
}
|
||||
if shoreM != nil && p.PlainM[c] > 0 {
|
||||
// Smoothstep rather than linear, so the plain meets the range without a crease in the slope
|
||||
// field - a crease there would be a line of channel heads all starting at the same distance
|
||||
// from the sea, which is the sort of thing that reads as a contour rather than as terrain.
|
||||
t := float64(shoreM[i]) / p.PlainM[c]
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
t = t * t * (3 - 2*t)
|
||||
floor := float64(p.PlainFloor[c])
|
||||
// Only ever downwards. Before massifs the class rate was uniform and the legend guarantees the
|
||||
// coastal floor is below it, so this could not fire; a cell of plain between two massifs now
|
||||
// sits below the coastal floor perfectly legitimately, and ramping it *up* towards the shore
|
||||
// would put a rim of hills round the edge of every continent.
|
||||
if r > floor {
|
||||
r = floor + (r-floor)*t
|
||||
}
|
||||
}
|
||||
asked := r
|
||||
preFault[i] = float32(asked)
|
||||
if fault != nil {
|
||||
// A fault is a difference in rate across a line. It adds on one side and subtracts on the other,
|
||||
// and the subtraction is what tilts the block rather than merely raising a ridge - so it is
|
||||
// allowed to take the rate down, but not below zero: subsidence is not modelled.
|
||||
if r += float64(fault[i]); r < 0 {
|
||||
r = 0
|
||||
}
|
||||
// The only ceiling in the whole painted path, and it binds on faults alone. Past
|
||||
// U = tan(talus)*K*cell the repose clamp shapes the ground instead of erosion and the surface
|
||||
// comes out as polygonal facets; an author may choose that for a class, but a fault stacking on
|
||||
// top of ground that was already near it is nobody's choice. So the bound is the ceiling *or*
|
||||
// whatever the author's own numbers asked for here, whichever is higher. The count is reported.
|
||||
if p.ClampCeilM > 0 {
|
||||
lim := p.ClampCeilM * kk
|
||||
if lim < asked {
|
||||
lim = asked
|
||||
}
|
||||
if r > lim {
|
||||
r = lim
|
||||
clamped++
|
||||
}
|
||||
}
|
||||
}
|
||||
rate.Data[i] = float32(r * (1 + p.Variation*(2*float64(swell.Data[i])-1)))
|
||||
}
|
||||
|
||||
// Initial relief. The spec says 50-150 m times normalised uplift and means it; this only breaks the
|
||||
// symmetry so the solve has something to bite on.
|
||||
rs := noise.NewSource(seed, srcPaintRidges)
|
||||
ru, rv := noise.Warp(u, v, wx, wy, 0.8/ridgeCells)
|
||||
ridges := noise.FBMAt(ru, rv, rs, noise.Params{BaseCells: ridgeCells, Octaves: 6, Gain: 0.42, Ridged: true})
|
||||
cs := noise.NewSource(seed, srcPaintCrests)
|
||||
cu, cv := noise.Warp(u, v, wx, wy, 3.1/crestCells) // the stronger warp the crest lines need
|
||||
crests := noise.CellularEdges(cu, cv, cs, int(crestCells), 0.95)
|
||||
ps := noise.NewSource(seed, srcPaintPlains)
|
||||
pu, pv := noise.Warp(u, v, wx, wy, 0.5/plainCells)
|
||||
plains := noise.FBMAt(pu, pv, ps, noise.Params{BaseCells: int(plainCells), Octaves: 4, Gain: 0.45})
|
||||
|
||||
ampLo := cfg.Relief.AmplitudeM.Lo()
|
||||
ampHi := cfg.Relief.AmplitudeM.Hi()
|
||||
crestW := cfg.Relief.CrestWeight
|
||||
|
||||
height := field.New(w, h, cellM)
|
||||
for i := range height.Data {
|
||||
if base[i] {
|
||||
// Ocean sits at sea level for the whole solve and the coastal pass lays the floor afterwards.
|
||||
// Left at a real depth, a coastal cell drains into it and the solver cuts the river down to meet
|
||||
// it; the first run with a coast eroded the land to 174 m below sea level for exactly that.
|
||||
height.Data[i] = float32(m.SeaLevelM)
|
||||
continue
|
||||
}
|
||||
// The amplitude comes from the rate *before* the faults, and is bounded at one (D-63).
|
||||
//
|
||||
// It used to come from rate.Data, which is the finished rate with the fault delta in it and no
|
||||
// upper bound, and that coupling is half of why Bake_018's flanks came out ribbed. The initial
|
||||
// relief exists only to break the symmetry of the background so the solve has something to bite
|
||||
// on; how much noise is stamped on a hillside is not a fault's decision. With D-62's six
|
||||
// kilometre footwalls the ratio went from about 0.18 on unfaulted foreland - 39 m of relief - to
|
||||
// 1.12 on a footwall, which is 166 m, on a landmass whose whole relief is 221 m. A thousand steps
|
||||
// cannot erase initial relief the size of the landscape, so the ridged noise stopped being a
|
||||
// symmetry-breaker and became the terrain: the ribs measure 250-300 m, which is octave five of a
|
||||
// 4.2 km ridged fBm. The bound at one is a guard rather than the fix - with the fault gone the
|
||||
// rate cannot exceed the largest class rate - but it is the property worth stating.
|
||||
norm := float64(preFault[i]) / maxRate
|
||||
if norm > 1 {
|
||||
norm = 1
|
||||
} else if norm < 0 {
|
||||
norm = 0
|
||||
}
|
||||
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, FaultClamped: clamped}
|
||||
}
|
||||
|
||||
// anyMix reports whether any class lets the rock field through.
|
||||
func anyMix(mix []float64) bool {
|
||||
for _, v := range mix {
|
||||
if v > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wantsPlain(plain []float64) bool {
|
||||
for _, v := range plain {
|
||||
if v > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func invert(b []bool) []bool {
|
||||
out := make([]bool, len(b))
|
||||
for i, v := range b {
|
||||
out[i] = !v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package uplift
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// oneClassCandidates is a planet where every painted cell belongs to class 0, sampled at a stride.
|
||||
func oneClassCandidates(p world.Planet, stride int) ([][]int32, []int) {
|
||||
var cells []int32
|
||||
for y := 0; y < p.H; y += stride {
|
||||
for x := 0; x < p.W; x += stride {
|
||||
cells = append(cells, int32(y*p.W+x))
|
||||
}
|
||||
}
|
||||
return [][]int32{cells}, []int{p.W * p.H}
|
||||
}
|
||||
|
||||
func testSpec() []FaultSpec {
|
||||
return []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{4, 8}}}
|
||||
}
|
||||
|
||||
// A seed names a fault set, and the same seed names the same one. Everything else here rests on that.
|
||||
func TestTheSameSeedDrawsTheSameFaults(t *testing.T) {
|
||||
const w, h, cellM = 1024, 512, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
cand, area := oneClassCandidates(p, 8)
|
||||
|
||||
a := BuildFaults(p, 7, 32, testSpec(), cand, area)
|
||||
b := BuildFaults(p, 7, 32, testSpec(), cand, area)
|
||||
if len(a) == 0 {
|
||||
t.Fatal("no faults were drawn; this test measured nothing")
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
t.Fatalf("two runs of one seed drew %d and %d traces", len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].ThrowM != b[i].ThrowM || a[i].Reverse != b[i].Reverse ||
|
||||
len(a[i].PointsM) != len(b[i].PointsM) || a[i].PointsM[0] != b[i].PointsM[0] {
|
||||
t.Fatalf("trace %d differs between two runs of one seed", i)
|
||||
}
|
||||
}
|
||||
// And a different seed is a different world.
|
||||
if c := BuildFaults(p, 9342, 32, testSpec(), cand, area); len(c) > 0 && c[0].PointsM[0] == a[0].PointsM[0] {
|
||||
t.Error("a different seed put the first trace in the same place")
|
||||
}
|
||||
}
|
||||
|
||||
// The density means what it says: traces per thousand square kilometres of the class, not per map.
|
||||
func TestFaultDensityIsPerAreaOfTheClass(t *testing.T) {
|
||||
const cellM = 64.0
|
||||
small := testPlanet(t, 512, 256, cellM)
|
||||
big := testPlanet(t, 1024, 512, cellM)
|
||||
|
||||
count := func(p world.Planet) int {
|
||||
cand, area := oneClassCandidates(p, 8)
|
||||
return len(BuildFaults(p, 7, 32, testSpec(), cand, area))
|
||||
}
|
||||
ns, nb := count(small), count(big)
|
||||
if ns == 0 {
|
||||
t.Fatal("the small planet drew nothing; this test measured nothing")
|
||||
}
|
||||
// Four times the area, so about four times the traces. Loose, because a long fault becomes two or three
|
||||
// en-echelon segments and the draw is stochastic - the assertion is that it scales, not that it is exact.
|
||||
if ratio := float64(nb) / float64(ns); ratio < 2.5 || ratio > 6 {
|
||||
t.Errorf("four times the area gave %d traces against %d, a ratio of %.1f", nb, ns, ratio)
|
||||
}
|
||||
}
|
||||
|
||||
// The property the whole port exists for: a fault is the planet's, not a region's. Two frames overlapping the
|
||||
// same ground have to agree about the rate it contributes, and both have to agree with the whole planet.
|
||||
//
|
||||
// `uplift.Build` places a trace at two calls to Float() read as fractions of the grid it is filling, so the
|
||||
// obvious port of it fails this - the same fault would land somewhere different in every region.
|
||||
func TestTwoFramesAgreeAboutTheSameFaults(t *testing.T) {
|
||||
const w, h, cellM = 1024, 512, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
cand, area := oneClassCandidates(p, 8)
|
||||
faults := BuildFaults(p, 7, 32, testSpec(), cand, area)
|
||||
if len(faults) == 0 {
|
||||
t.Fatal("no faults; this test measured nothing")
|
||||
}
|
||||
const runYears = 1.5e6
|
||||
|
||||
whole := FaultDelta(world.Whole(p), faults, runYears)
|
||||
a := world.Frame{P: p, X0: 200, Y0: 100, W: 300, H: 200}
|
||||
b := world.Frame{P: p, X0: 380, Y0: 160, W: 300, H: 200}
|
||||
da, db := FaultDelta(a, faults, runYears), FaultDelta(b, faults, runYears)
|
||||
if da == nil || db == nil {
|
||||
t.Fatal("neither frame was reached by any fault; move the windows")
|
||||
}
|
||||
|
||||
checked, nonZero := 0, 0
|
||||
for y := 0; y < a.H; y++ {
|
||||
for x := 0; x < a.W; x++ {
|
||||
px, py := a.PlanetXY(x, y)
|
||||
if px < b.X0 || px >= b.X0+b.W || py < b.Y0 || py >= b.Y0+b.H {
|
||||
continue
|
||||
}
|
||||
got := da[y*a.W+x]
|
||||
if want := db[(py-b.Y0)*b.W+(px-b.X0)]; got != want {
|
||||
t.Fatalf("at planet (%d,%d) frame A says %v and frame B says %v", px, py, got, want)
|
||||
}
|
||||
if wh := whole[py*p.W+px]; wh != got {
|
||||
t.Fatalf("at planet (%d,%d) a frame says %v and the whole planet says %v", px, py, got, wh)
|
||||
}
|
||||
checked++
|
||||
if got != 0 {
|
||||
nonZero++
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("the two frames do not overlap")
|
||||
}
|
||||
if nonZero == 0 {
|
||||
t.Fatal("every cell of the overlap is zero; the agreement is vacuous")
|
||||
}
|
||||
}
|
||||
|
||||
// X wraps, so a fault whose trace runs past the meridian has to reach the ground on the other side of it.
|
||||
// The trace is stored unwrapped and shifted once per fault; this is what says that shift works.
|
||||
func TestAFaultReachesAcrossTheSeam(t *testing.T) {
|
||||
const w, h, cellM = 512, 256, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
circ := p.CircumferenceM()
|
||||
|
||||
// A trace lying just east of the seam, running north-south, well inside the reach of the map's west edge.
|
||||
x := 300.0
|
||||
trace := FaultTrace{
|
||||
PointsM: [][2]float64{{x, 2000}, {x, 5000}, {x, 8000}},
|
||||
ThrowM: 400, LengthM: 6000, Reverse: false,
|
||||
}
|
||||
west := world.Frame{P: p, X0: 0, Y0: 0, W: 40, H: h}
|
||||
if d := FaultDelta(west, []FaultTrace{trace}, 1.5e6); d == nil {
|
||||
t.Fatal("a trace 300 m east of the seam did not reach a frame at the seam")
|
||||
}
|
||||
|
||||
// The same trace written with its X a whole world further east is the same fault, so a frame at the far
|
||||
// end of the map must see the identical field.
|
||||
shifted := FaultTrace{
|
||||
PointsM: [][2]float64{{x + circ, 2000}, {x + circ, 5000}, {x + circ, 8000}},
|
||||
ThrowM: 400, LengthM: 6000,
|
||||
}
|
||||
near := FaultDelta(west, []FaultTrace{trace}, 1.5e6)
|
||||
far := FaultDelta(west, []FaultTrace{shifted}, 1.5e6)
|
||||
if far == nil {
|
||||
t.Fatal("the shifted trace reached nothing; the wrap is not being applied")
|
||||
}
|
||||
for i := range near {
|
||||
if near[i] != far[i] {
|
||||
t.Fatalf("a trace and the same trace one circumference east differ at cell %d: %v vs %v",
|
||||
i, near[i], far[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The scarp is asymmetric, which is what makes a fault a tilted block rather than a ridge: it rises fast on
|
||||
// one side over a couple of hundred metres and falls away slowly on the other over a couple of kilometres.
|
||||
func TestAFaultIsATiltedBlockAndNotARidge(t *testing.T) {
|
||||
const w, h, cellM = 1024, 1024, 32.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
mid := float64(h) * cellM / 2
|
||||
// A straight east-west trace across the middle.
|
||||
pts := make([][2]float64, 9)
|
||||
for i := range pts {
|
||||
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 8, mid}
|
||||
}
|
||||
d := FaultDelta(world.Whole(p), []FaultTrace{{PointsM: pts, ThrowM: 400, LengthM: float64(w) * cellM}}, 1.5e6)
|
||||
if d == nil {
|
||||
t.Fatal("the trace reached nothing")
|
||||
}
|
||||
|
||||
col := w / 2
|
||||
at := func(yM float64) float64 { return float64(d[int(yM/cellM)*w+col]) }
|
||||
// One side is positive and the other negative: the block tilts.
|
||||
up, down := at(mid-1400), at(mid+1200)
|
||||
if up*down >= 0 {
|
||||
t.Fatalf("both sides of the trace have the same sign (%v, %v); that is a ridge, not a fault", up, down)
|
||||
}
|
||||
// And the footwall reaches further than the hanging wall.
|
||||
if math.Abs(at(mid-4800)) <= math.Abs(at(mid+4800)) {
|
||||
t.Errorf("at 4800 m the footwall is %v and the hanging wall %v; the asymmetry is the wrong way "+
|
||||
"round or absent", math.Abs(at(mid-4800)), math.Abs(at(mid+4800)))
|
||||
}
|
||||
}
|
||||
|
||||
// The defect this file's shape block is about (D-62), as two numbers rather than a hillshade.
|
||||
//
|
||||
// The profile used to put the whole throw on one side of the trace and the whole throw negated on the
|
||||
// other, one cell apart, inside a welt six hundred metres wide. That is unsolvable twice over: a step in
|
||||
// the rate field is a cliff the erosion can only clamp at the angle of repose, and a block narrower than
|
||||
// one hillslope has no drainage area on it for stream power to cut with, so the profile is printed onto
|
||||
// the surface instead of being eroded into a landform.
|
||||
//
|
||||
// So: continuous through the trace, and wide enough on the upthrown side for a drainage network to live
|
||||
// on. 1100 m is the hillslope length measured on Bake_013 (a drainage density of 0.45 channels per km),
|
||||
// and three of them is the least that can carry a valley and its two divides.
|
||||
func TestAFaultIsSolvableRatherThanPrinted(t *testing.T) {
|
||||
const w, h, cellM = 2048, 2048, 8.0
|
||||
const throw, runYears = 400.0, 1.5e6
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
mid := float64(h) * cellM / 2
|
||||
pts := make([][2]float64, 17)
|
||||
for i := range pts {
|
||||
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, mid}
|
||||
}
|
||||
d := FaultDelta(world.Whole(p), []FaultTrace{{PointsM: pts, ThrowM: throw, LengthM: float64(w) * cellM}}, runYears)
|
||||
if d == nil {
|
||||
t.Fatal("the trace reached nothing")
|
||||
}
|
||||
|
||||
col := w / 2
|
||||
// Metres of displacement the rate builds over the whole run, which is what the surface has to carry.
|
||||
at := func(yM float64) float64 { return float64(d[int(yM/cellM)*w+col]) * runYears }
|
||||
|
||||
var crest, trough, crestAt, troughAt, steepest, steepestAt float64
|
||||
prev := at(mid - faultReachM)
|
||||
for dy := -faultReachM + cellM; dy <= faultReachM; dy += cellM {
|
||||
v := at(mid + dy)
|
||||
if v > crest {
|
||||
crest, crestAt = v, dy
|
||||
}
|
||||
if v < trough {
|
||||
trough, troughAt = v, dy
|
||||
}
|
||||
if g := math.Abs(v-prev) / cellM; g > steepest {
|
||||
steepest, steepestAt = g, dy
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
|
||||
// Continuous: no cell-to-cell step steeper than ground the solve can actually shape. The repose clamp
|
||||
// is at 35 degrees and the old profile measured 89.
|
||||
if deg := math.Atan(steepest) * 180 / math.Pi; deg > 25 {
|
||||
t.Errorf("the steepest cell-to-cell step in the rate field is %.1f degrees at %+.0f m; that is a "+
|
||||
"cliff in the uplift, and the solve can only clamp it at the angle of repose", deg, steepestAt)
|
||||
}
|
||||
// Zero on the trace itself: a rate difference across a line averages to the regional rate at the line.
|
||||
if v := math.Abs(at(mid)); v > throw/50 {
|
||||
t.Errorf("the anomaly on the trace is %.1f m; it should be nothing", v)
|
||||
}
|
||||
// Wide enough to be dissected: the upthrown flank has to carry a drainage network.
|
||||
const hillslopeM = 1100
|
||||
var above float64
|
||||
for dy := 0.0; dy <= faultReachM; dy += cellM {
|
||||
if at(mid-dy) >= crest/2 {
|
||||
above = dy
|
||||
}
|
||||
}
|
||||
if above < 3*hillslopeM {
|
||||
t.Errorf("the footwall stands above half its crest for only %.0f m, under three hillslope lengths "+
|
||||
"(%d m); nothing can cut a valley into it and the profile will print", above, 3*hillslopeM)
|
||||
}
|
||||
// And the step across the fault is the throw the author asked for, not two of them.
|
||||
if step := crest - trough; math.Abs(step-throw) > throw/20 {
|
||||
t.Errorf("the step across the fault is %.0f m against a throw of %.0f m", step, throw)
|
||||
}
|
||||
t.Logf("crest %+.0f m at %+.0f m, trough %+.0f m at %+.0f m, step %.0f m over %.0f m (%.1f deg mean), "+
|
||||
"steepest cell %.1f deg, footwall above half-crest for %.0f m",
|
||||
crest, crestAt, trough, troughAt, crest-trough, crestAt-troughAt,
|
||||
math.Atan((crest-trough)/math.Abs(crestAt-troughAt))*180/math.Pi,
|
||||
math.Atan(steepest)*180/math.Pi, above)
|
||||
}
|
||||
|
||||
// A fault dies out along strike instead of stopping dead, which is what left an abrupt cut across a summit on
|
||||
// the procedural path - and it is never flat along strike either, which is what left it extruded.
|
||||
func TestTheThrowTapersToNothingAtTheTips(t *testing.T) {
|
||||
if tipTaper(0) != 0 || tipTaper(1) != 0 {
|
||||
t.Errorf("the tips carry no throw: got %v and %v", tipTaper(0), tipTaper(1))
|
||||
}
|
||||
if tipTaper(0.5) != 1 {
|
||||
t.Errorf("the middle carries all of it: got %v", tipTaper(0.5))
|
||||
}
|
||||
// Monotone to the middle, so the ramp has no step in it.
|
||||
prev := 0.0
|
||||
for a := 0.0; a <= 0.5; a += 0.01 {
|
||||
v := tipTaper(a)
|
||||
if v < prev-1e-12 {
|
||||
t.Fatalf("the taper goes backwards at %v: %v after %v", a, v, prev)
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
// Symmetric about the middle.
|
||||
for _, a := range []float64{0.05, 0.2, 0.37} {
|
||||
if math.Abs(tipTaper(a)-tipTaper(1-a)) > 1e-12 {
|
||||
t.Errorf("the two ends differ at %v: %v against %v", a, tipTaper(a), tipTaper(1-a))
|
||||
}
|
||||
}
|
||||
// Nowhere flat: the old taper held exactly 1 across the middle two thirds, which extrudes the
|
||||
// cross-section along most of every trace. Nothing between the tips and the centre may repeat.
|
||||
if tipTaper(0.2) >= tipTaper(0.35) || tipTaper(0.35) >= tipTaper(0.5) {
|
||||
t.Errorf("the throw is flat along strike: %v, %v, %v at a fifth, a third and the middle",
|
||||
tipTaper(0.2), tipTaper(0.35), tipTaper(0.5))
|
||||
}
|
||||
// But it still has a body: most of a trace carries at least half its throw.
|
||||
above := 0
|
||||
for i := 0; i <= 1000; i++ {
|
||||
if tipTaper(float64(i)/1000) >= 0.5 {
|
||||
above++
|
||||
}
|
||||
}
|
||||
if above < 750 {
|
||||
t.Errorf("only %d parts in a thousand of the trace carry half the throw; the fault is a spike", above)
|
||||
}
|
||||
}
|
||||
|
||||
// A long fault steps rather than running as one ruled line.
|
||||
func TestALongFaultBreaksIntoEnEchelonSegments(t *testing.T) {
|
||||
const w, h, cellM = 2048, 1024, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
cand, area := oneClassCandidates(p, 8)
|
||||
|
||||
short := []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{4, 6}}}
|
||||
long := []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{20, 26}}}
|
||||
ns := len(BuildFaults(p, 7, 32, short, cand, area))
|
||||
nl := len(BuildFaults(p, 7, 32, long, cand, area))
|
||||
if ns == 0 {
|
||||
t.Fatal("nothing was drawn; this test measured nothing")
|
||||
}
|
||||
if nl <= ns {
|
||||
t.Errorf("faults over the en-echelon length gave %d traces against %d for short ones; they are not "+
|
||||
"stepping", nl, ns)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is drawn when nothing asks, and nothing is rasterised when no trace reaches a frame - which is what
|
||||
// keeps a planet with no faults paying nothing for the pass.
|
||||
func TestNoFaultsCostsNothing(t *testing.T) {
|
||||
const w, h, cellM = 256, 128, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
cand, area := oneClassCandidates(p, 8)
|
||||
if got := BuildFaults(p, 7, 32, []FaultSpec{{}}, cand, area); got != nil {
|
||||
t.Errorf("an empty spec drew %d traces", len(got))
|
||||
}
|
||||
if got := BuildFaults(p, 7, 0, testSpec(), cand, area); got != nil {
|
||||
t.Error("a zero grain wavelength should draw nothing")
|
||||
}
|
||||
far := FaultTrace{PointsM: [][2]float64{{0, 100000}, {1000, 100000}}, ThrowM: 400}
|
||||
if d := FaultDelta(world.Whole(p), []FaultTrace{far}, 1.5e6); d != nil {
|
||||
t.Error("a trace far off the frame should allocate no field at all")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package uplift
|
||||
|
||||
import (
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/noise"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Lithology on a painted planet: what the rock is, underneath what the author painted it as.
|
||||
//
|
||||
// A class is a rate and an erodibility, and on a painted world the erodibility was one flat number over every
|
||||
// cell of a colour. That is one range made of one rock, everywhere, and it shows: `map_erodibility.png` on a
|
||||
// painted planet was a recolour of `map_class.png`, and two bakes of the same painting under different seeds
|
||||
// differed on it only where the *coastline* had moved. Texture inside a range - the reason one flank is
|
||||
// gullied and the next is a set of benches - has nowhere to come from.
|
||||
//
|
||||
// So there is a rock field: low-frequency noise cut into a few types, each with its own multiplier on K,
|
||||
// exactly the spec's 4.3 and exactly what the procedural path has always had. What is different here is the
|
||||
// two things a decomposed planet forces, and both are the same two the massif fabric ran into first.
|
||||
//
|
||||
// **The cut is a quantile of the planet, never of the region.** `uplift.Build` takes `f.Percentile()` of the
|
||||
// grid it is handed, which on a planet means two regions measuring their own extents and putting the same
|
||||
// physical hillside in different rock. The threshold is measured once over the whole cylinder by
|
||||
// measureFabric, from a probe that is a property of the planet and of nothing else, so every region computes
|
||||
// the identical number from the identical samples.
|
||||
//
|
||||
// **And nothing here may look at a neighbour.** The procedural version ends in `out.Blur(2)`, so that a rock
|
||||
// boundary is a transition rather than a wall the solver carves into a cliff. A blur is a neighbourhood
|
||||
// operation, and a neighbourhood operation near a region's edge reads cells that a different decomposition
|
||||
// would not have given it. The softening is therefore done **pointwise, in rank space**: a cell near the edge
|
||||
// of its band is blended towards the next band by how near it is, which needs only the cell's own value. The
|
||||
// width of the transition on the ground then follows the fabric's own gradient - sharp where the rock changes
|
||||
// fast, gradual where it does not - which is a better answer than a fixed blur radius anyway.
|
||||
|
||||
const (
|
||||
srcPaintRock = 26
|
||||
)
|
||||
|
||||
// rockOctaves and rockGain shape the rock field. Fewer octaves than the upland fabric on purpose: a lithology
|
||||
// map is broad provinces with ragged edges, not a fractal at every scale, and the detail that does belong at
|
||||
// metre scale is the strata model in the detail passes rather than this.
|
||||
const (
|
||||
rockOctaves = 4
|
||||
rockGain = 0.5
|
||||
)
|
||||
|
||||
// rockWarp bends the rock field by the shared low-frequency warp, as a fraction of its own wavelength. The
|
||||
// same field that bends the massifs and the ridges, because a province boundary that ignored the grain
|
||||
// everything else follows would read as a stencil laid over the world.
|
||||
const rockWarp = 0.7
|
||||
|
||||
// rockEdge is how much of a band's width is spent blending into its neighbour, at each end. At 0.15 a
|
||||
// province is flat over the middle seven tenths of its range and graded across the rest.
|
||||
const rockEdge = 0.15
|
||||
|
||||
// rockFabric samples the rock field at the given world coordinates.
|
||||
func rockFabric(u, v, wx, wy *field.Field, seed int64, baseCells int) *field.Field {
|
||||
rs := noise.NewSource(seed, srcPaintRock)
|
||||
ru, rv := noise.Warp(u, v, wx, wy, rockWarp/float64(baseCells))
|
||||
return noise.FBMAt(ru, rv, rs, noise.Params{
|
||||
BaseCells: baseCells, Octaves: rockOctaves, Gain: rockGain,
|
||||
})
|
||||
}
|
||||
|
||||
// RockK is the erodibility multiplier the lithology contributes at every cell of a frame, around 1.
|
||||
//
|
||||
// mult is the manifest's k_multipliers, in order, and the bands are **equal area over the planet**: the rank
|
||||
// is uniform on 0..1 by construction, so cutting it into n equal pieces gives each rock type the same share
|
||||
// of the world whatever the seed did to the noise. That is the property the procedural path got from
|
||||
// `f.Percentile` and the reason it is worth keeping - a seed that produced no hard rock anywhere would be a
|
||||
// seed that quietly removed a process.
|
||||
//
|
||||
// Returns nil when there is nothing to build, which is what a planet with no lithology_wavelength_km gets and
|
||||
// what every painted planet got before this existed.
|
||||
func RockK(p world.Planet, seed int64, baseCells int, mult []float64, u, v *field.Field) *field.Field {
|
||||
if baseCells < 1 || len(mult) < 2 {
|
||||
return nil
|
||||
}
|
||||
wx, wy := paintWarp(u, v, seed)
|
||||
fabric := rockFabric(u, v, wx, wy, seed, baseCells)
|
||||
cdf := measureFabric(p, seed, baseCells, rockFabric)
|
||||
|
||||
n := len(mult)
|
||||
out := field.NewLike(fabric)
|
||||
field.Rows(out.H, func(y0, y1 int) {
|
||||
for i := y0 * out.W; i < y1*out.W; i++ {
|
||||
out.Data[i] = float32(bandValue(cdf.at(float64(fabric.Data[i])), mult, n))
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// bandValue picks the rock type a rank falls in and softens the boundary, pointwise.
|
||||
//
|
||||
// The blend is half-and-half exactly at a boundary from either side, which is what makes it continuous: a
|
||||
// cell at the top of band b is (b + b+1)/2 and a cell at the bottom of band b+1 is (b+1 + b)/2, the same
|
||||
// number approached from opposite directions.
|
||||
func bandValue(rank float64, mult []float64, n int) float64 {
|
||||
x := rank * float64(n)
|
||||
b := int(x)
|
||||
if b >= n {
|
||||
b = n - 1
|
||||
}
|
||||
if b < 0 {
|
||||
b = 0
|
||||
}
|
||||
f := x - float64(b)
|
||||
switch {
|
||||
case f > 1-rockEdge && b+1 < n:
|
||||
t := noise.Smoothstep((f-(1-rockEdge))/rockEdge) * 0.5
|
||||
return mult[b] + (mult[b+1]-mult[b])*t
|
||||
case f < rockEdge && b > 0:
|
||||
t := noise.Smoothstep((rockEdge-f)/rockEdge) * 0.5
|
||||
return mult[b] + (mult[b-1]-mult[b])*t
|
||||
}
|
||||
return mult[b]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user