Added: Initial world generation tool
This commit is contained in:
@@ -0,0 +1,617 @@
|
||||
// Command terrain generates L_World's heightmap. See Docs/Terrain.md.
|
||||
//
|
||||
// terrain generate the manifest as it stands
|
||||
// terrain generate --seed 12 another continent
|
||||
// terrain generate --stage fluvial --size 1024 one pass at a small size, the iteration loop
|
||||
//
|
||||
// The flags are deliberately the ones Scripts/Authoring/generate_heightmap.py had, so the two documented
|
||||
// commands in RawContent/World/README.md and everybody's muscle memory survive the port.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"salty/terrain/internal/coast"
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/stats"
|
||||
"salty/terrain/internal/thermal"
|
||||
"salty/terrain/internal/uplift"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "generate":
|
||||
if err := generate(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "terrain:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "terrain: unknown command %q\n", os.Args[1])
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `terrain - the world's heightmap generator (Docs/Terrain.md)
|
||||
|
||||
terrain generate [flags]
|
||||
|
||||
--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
|
||||
--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
|
||||
`)
|
||||
}
|
||||
|
||||
func generate(args []string) error {
|
||||
fs := flag.NewFlagSet("generate", flag.ExitOnError)
|
||||
manifestPath := fs.String("manifest", "", "path to World.json")
|
||||
seed := fs.Int64("seed", -1, "override the noise seed")
|
||||
size := fs.Int("size", 0, "run the geology grid at this size instead of the manifest's")
|
||||
stage := fs.String("stage", "fluvial", "stop after this stage: uplift, fluvial")
|
||||
steps := fs.Int("steps", 0, "override the fluvial step count")
|
||||
out := fs.String("out", "", "output directory")
|
||||
quiet := fs.Bool("quiet", false, "only print the summary")
|
||||
fillEvery := fs.Int("fill-every", 0, "override the priority-flood interval")
|
||||
channelKm2 := fs.Float64("channel-km2", 1.0, "drainage area that counts as a channel, km2")
|
||||
criticalM2 := fs.Float64("critical-m2", 0, "override where channels begin, m2")
|
||||
diffusion := fs.Float64("diffusion", 0, "override hillslope diffusivity, m2/yr")
|
||||
talusDeg := fs.Float64("talus", 0, "override the angle of repose, degrees")
|
||||
thermalEvery := fs.Int("thermal-every", 0, "override the landslide interval, steps")
|
||||
thermalPasses := fs.Int("thermal-passes", 0, "override landslide passes per application")
|
||||
convergent := fs.Float64("convergent", 0, "override the convergent uplift rate, mm/yr")
|
||||
intraplate := fs.Float64("intraplate", 0, "override the intraplate sag rate, mm/yr")
|
||||
intraSwell := fs.Float64("intraplate-swell", 0, "override the intraplate swell rate, mm/yr")
|
||||
lithTypes := fs.Int("lithology-types", -1, "override the rock type count; 1 disables lithology")
|
||||
faultScale := fs.Float64("fault-scale", -1, "scale the fault counts; 0 disables faults")
|
||||
kOverride := fs.Float64("k", 0, "override the stream-power erodibility K")
|
||||
cropX := fs.Float64("crop-x", 0.20, "detail crop, left edge in map coordinates")
|
||||
cropY := fs.Float64("crop-y", 0.62, "detail crop, top edge in map coordinates")
|
||||
cropSize := fs.Float64("crop-size", 0.22, "detail crop, side length in map coordinates")
|
||||
reliefWindowM := fs.Float64("relief-window", 500, "window the per-bucket local relief is measured over, m")
|
||||
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")
|
||||
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")
|
||||
noCoast := fs.Bool("no-coast", false, "skip the coastal pass: a flat sea floor and an unworked shoreline")
|
||||
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")
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := findManifest(*manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := manifest.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *seed >= 0 {
|
||||
m.Source.Seed = *seed
|
||||
}
|
||||
if *steps > 0 {
|
||||
m.Pipeline.Fluvial.Steps = *steps
|
||||
}
|
||||
if *fillEvery > 0 {
|
||||
m.Pipeline.Fluvial.FillEvery = *fillEvery
|
||||
}
|
||||
if *criticalM2 > 0 {
|
||||
m.Pipeline.Fluvial.CriticalAreaM2 = *criticalM2
|
||||
}
|
||||
if *diffusion > 0 {
|
||||
m.Pipeline.Fluvial.DiffusionM2Yr = *diffusion
|
||||
}
|
||||
if *talusDeg > 0 {
|
||||
m.Pipeline.Thermal.TalusDeg = *talusDeg
|
||||
}
|
||||
if *thermalEvery > 0 {
|
||||
m.Pipeline.Thermal.Every = *thermalEvery
|
||||
}
|
||||
if *thermalPasses > 0 {
|
||||
m.Pipeline.Thermal.CoarsePasses = *thermalPasses
|
||||
}
|
||||
if *convergent > 0 {
|
||||
m.Pipeline.Plates.ConvergentMmYr[1] = *convergent
|
||||
}
|
||||
if *intraplate > 0 {
|
||||
m.Pipeline.Plates.IntraplateMmYr = *intraplate
|
||||
}
|
||||
if *intraSwell > 0 {
|
||||
m.Pipeline.Plates.IntraplateSwellMmYr = *intraSwell
|
||||
}
|
||||
if *criticalSlope >= 0 {
|
||||
m.Pipeline.Fluvial.CriticalSlopeDeg = *criticalSlope
|
||||
}
|
||||
if *slopeCap > 0 {
|
||||
m.Pipeline.Fluvial.SlopeCap = *slopeCap
|
||||
}
|
||||
if *hillslopeSub > 0 {
|
||||
m.Pipeline.Fluvial.MaxHillslopeSub = *hillslopeSub
|
||||
}
|
||||
if *kOverride > 0 {
|
||||
m.Pipeline.Fluvial.K = *kOverride
|
||||
}
|
||||
if *lithTypes >= 1 {
|
||||
m.Pipeline.Lithology.Types = *lithTypes
|
||||
}
|
||||
if *faultScale >= 0 {
|
||||
m.Pipeline.Faults.Major[0] *= *faultScale
|
||||
m.Pipeline.Faults.Major[1] *= *faultScale
|
||||
m.Pipeline.Faults.Minor[0] *= *faultScale
|
||||
m.Pipeline.Faults.Minor[1] *= *faultScale
|
||||
}
|
||||
if *outlineOctaves > 0 {
|
||||
m.Pipeline.Continent.OutlineOctaves = *outlineOctaves
|
||||
}
|
||||
if *outlineGain > 0 {
|
||||
m.Pipeline.Continent.OutlineGain = *outlineGain
|
||||
}
|
||||
if *noCoast {
|
||||
m.Pipeline.Coast.Enabled = false
|
||||
}
|
||||
if *surfReach > 0 {
|
||||
m.Pipeline.Coast.SurfReachM = *surfReach
|
||||
}
|
||||
if *cutFraction > 0 {
|
||||
m.Pipeline.Coast.CutFraction = *cutFraction
|
||||
}
|
||||
if *shelfKm > 0 {
|
||||
m.Pipeline.Coast.ShelfKm[1] = *shelfKm
|
||||
}
|
||||
if *driftM > 0 {
|
||||
m.Pipeline.Coast.DriftM = *driftM
|
||||
}
|
||||
if *riverSediment >= 0 {
|
||||
m.Pipeline.Coast.RiverM3PerKm2 = *riverSediment
|
||||
}
|
||||
if m.Erosion != nil {
|
||||
fmt.Println("note: this manifest still has an 'erosion' block; D-47 replaced it with 'pipeline' and it is ignored")
|
||||
}
|
||||
|
||||
geoSize := m.GeologySize()
|
||||
geoCell := m.GeologyCellM()
|
||||
preview := *size > 0
|
||||
if preview {
|
||||
// A --size run keeps the manifest's physical extent and just samples it more coarsely, so the
|
||||
// metres, the uplift rates and the stream-power constants all still mean what they mean.
|
||||
geoSize = *size
|
||||
geoCell = m.SideM() / float64(*size-1)
|
||||
}
|
||||
|
||||
outDir := *out
|
||||
if outDir == "" {
|
||||
if preview {
|
||||
outDir = filepath.Join(manifest.ProjectRoot(path), "RawContent", "World", "Preview")
|
||||
} else {
|
||||
outDir = filepath.Join(filepath.Dir(path), "Heightmaps")
|
||||
}
|
||||
}
|
||||
|
||||
log := func(format string, a ...any) {
|
||||
if !*quiet {
|
||||
fmt.Printf(format+"\n", a...)
|
||||
}
|
||||
}
|
||||
log("%s", m.Describe())
|
||||
log("geology grid %d at %.2f m a cell, %d cores, GOMAXPROCS %d", geoSize, geoCell, runtime.NumCPU(), runtime.GOMAXPROCS(0))
|
||||
if preview {
|
||||
log("preview run: the geology grid only, written to %s", outDir)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
up := uplift.Build(geoSize, geoCell, m)
|
||||
rateLo, rateHi := up.Rate.MinMax()
|
||||
hLo, hHi := up.Height.MinMax()
|
||||
landFrac := fractionTrue(invert(up.Base))
|
||||
kLo, kHi := float32(1), float32(1)
|
||||
if up.K != nil {
|
||||
kLo, kHi = up.K.MinMax()
|
||||
}
|
||||
log("uplift %.3f..%.3f mm/yr, K x%.2f..%.2f, %d faults, initial relief %.0f..%.0f m, %.0f%% land [%s]",
|
||||
float64(rateLo)*1000, float64(rateHi)*1000, kLo, kHi, len(up.Faults), hLo, hHi, landFrac*100, since(started))
|
||||
|
||||
h := up.Height.Clone()
|
||||
var grid *fluvial.Grid
|
||||
var kField []float32
|
||||
if up.K != nil {
|
||||
kField = up.K.Data
|
||||
}
|
||||
|
||||
if *stage != "uplift" {
|
||||
p := fluvial.Params{
|
||||
K: m.Pipeline.Fluvial.K, M: m.Pipeline.Fluvial.M, N: m.Pipeline.Fluvial.N,
|
||||
DtYr: m.Pipeline.Fluvial.DtYr, Steps: m.Pipeline.Fluvial.Steps,
|
||||
Diffusion: m.Pipeline.Fluvial.DiffusionM2Yr, FillEvery: m.Pipeline.Fluvial.FillEvery,
|
||||
TalusSlope: thermal.TalusFromDegrees(m.Pipeline.Thermal.TalusDeg),
|
||||
ThermalEvery: m.Pipeline.Thermal.Every,
|
||||
ThermalPasses: m.Pipeline.Thermal.CoarsePasses,
|
||||
CriticalAreaM2: m.Pipeline.Fluvial.CriticalAreaM2,
|
||||
ChannelTaper: m.Pipeline.Fluvial.ChannelTaper,
|
||||
CriticalSlope: thermal.TalusFromDegrees(m.Pipeline.Fluvial.CriticalSlopeDeg),
|
||||
SlopeCap: m.Pipeline.Fluvial.SlopeCap,
|
||||
MaxHillslopeSub: m.Pipeline.Fluvial.MaxHillslopeSub,
|
||||
}
|
||||
hillslope := fmt.Sprintf("linear D %.3f m2/yr, repose clamp every %d steps", p.Diffusion, m.Pipeline.Thermal.Every)
|
||||
if p.CriticalSlope > 0 {
|
||||
hillslope = fmt.Sprintf("nonlinear D %.3f m2/yr, Sc %.0f deg, cap %.2f, up to %d sub-steps",
|
||||
p.Diffusion, m.Pipeline.Fluvial.CriticalSlopeDeg, p.SlopeCap, p.MaxHillslopeSub)
|
||||
}
|
||||
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)
|
||||
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,
|
||||
// with headroom for uplift that outruns erosion before the warning catches it.
|
||||
grid.SetElevationRange(m.ElevationM.Min-200, m.ElevationM.Max+500)
|
||||
solveStart := time.Now()
|
||||
grid.Run(h.Data, up.Rate.Data, kField, p, func(step, total int, pct float64) {
|
||||
if step == 0 {
|
||||
return
|
||||
}
|
||||
lo, hi := h.MinMax()
|
||||
elapsed := time.Since(solveStart)
|
||||
eta := time.Duration(float64(elapsed) / (pct / 100) * (1 - pct/100))
|
||||
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 coast, last, on the terrain the solve produced: the sea floor, the surf and the sediment it moves.
|
||||
// It owns the sea floor outright — the ocean cells were held at sea level for the whole solve so that
|
||||
// rivers cut down to sea level and no further, and this is where they stop being held.
|
||||
coastStart := time.Now()
|
||||
var flow []float32
|
||||
if grid != nil {
|
||||
flow = grid.Area
|
||||
}
|
||||
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(),
|
||||
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,
|
||||
m.Pipeline.Coast.DriftM, m.Pipeline.Coast.FetchDirections, m.Pipeline.Coast.FetchRangeM,
|
||||
since(coastStart))
|
||||
}
|
||||
|
||||
// Everything downstream asks about the terrain rather than about the mask that seeded it: a beach the
|
||||
// coastal pass built is land and a headland it planed under the waterline is not, so the mask that the
|
||||
// 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),
|
||||
}
|
||||
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)
|
||||
}
|
||||
fmt.Println(rep.Summary())
|
||||
if m.Pipeline.Coast.Enabled {
|
||||
fmt.Println()
|
||||
fmt.Println(cs.Stats.Summary())
|
||||
}
|
||||
if rep.ClipFraction > 0.001 {
|
||||
fmt.Printf("\nWARNING: %.2f%% of the map is outside elevation_m %g..%g. U/K is the relief knob and the\n"+
|
||||
" ceiling is a hard clip in the 16-bit encoding, so this is a failed run, not a rounded one.\n",
|
||||
rep.ClipFraction*100, m.ElevationM.Min, m.ElevationM.Max)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := field.WriteThumbnail(filepath.Join(outDir, "thumb.png"), h, 512); err != nil {
|
||||
return err
|
||||
}
|
||||
// The one that is actually worth looking at: hypsometric tint, hillshade and the drainage network.
|
||||
pv := field.PreviewOptions{Sea: sea, SeaLevelM: m.SeaLevelM, RiverKm2: 0.5, Size: 1600}
|
||||
if grid != nil {
|
||||
flow := field.New(h.W, h.H, h.CellM)
|
||||
copy(flow.Data, grid.Area)
|
||||
pv.Flow = flow
|
||||
}
|
||||
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
|
||||
// hill country or as small mountains, and that distinction is the current question.
|
||||
detail := pv
|
||||
detail.Crop = [4]float64{*cropX, *cropY, *cropX + *cropSize, *cropY + *cropSize}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
// The geology-grid height, so a preview run has something to look at. The full-resolution height belongs
|
||||
// to the detail passes, which are not built yet (build-order steps 5 to 8).
|
||||
if err := field.WriteGray16(filepath.Join(outDir, "geology_height.png"), h.W, h.H, m.Encode(h.Data), png.DefaultCompression); err != nil {
|
||||
return err
|
||||
}
|
||||
if grid != nil {
|
||||
flow := field.New(h.W, h.H, h.CellM)
|
||||
copy(flow.Data, grid.Area)
|
||||
unit := flow.ToUnit(99.5, true)
|
||||
if err := field.WriteGray8(filepath.Join(outDir, "geology_flow.png"), unit.W, unit.H, toBytes(unit.Data), png.BestSpeed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// The false-colour maps: the inputs the run worked from and the structure it produced, beside the result.
|
||||
// preview.png says whether the landscape looks right; these say why it looks the way it does, and when it
|
||||
// does not they are where the answer is. See internal/field/datamap.go.
|
||||
if err := writeDataMaps(outDir, h, up, cs, grid, *mapSize); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := map[string]any{
|
||||
"seed": m.Source.Seed,
|
||||
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"geology_size": geoSize,
|
||||
"geology_cell_m": geoCell,
|
||||
"preview": preview,
|
||||
"manifest": m,
|
||||
"stats": rep,
|
||||
"coast": cs.Stats,
|
||||
"elapsed_s": time.Since(started).Seconds(),
|
||||
}
|
||||
blob, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outDir, "meta.json"), blob, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
log("\nwritten to %s in %s", outDir, since(started))
|
||||
return nil
|
||||
}
|
||||
|
||||
func toBytes(v []float32) []uint8 {
|
||||
out := make([]uint8, len(v))
|
||||
for i, x := range v {
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x > 1 {
|
||||
x = 1
|
||||
}
|
||||
out[i] = uint8(x*255 + 0.5)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func invert(b []bool) []bool {
|
||||
out := make([]bool, len(b))
|
||||
for i, v := range b {
|
||||
out[i] = !v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func minMaxWhere(v []float32, mask []bool) (float64, float64) {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for i, x := range v {
|
||||
if !mask[i] {
|
||||
continue
|
||||
}
|
||||
f := float64(x)
|
||||
if f < lo {
|
||||
lo = f
|
||||
}
|
||||
if f > hi {
|
||||
hi = f
|
||||
}
|
||||
}
|
||||
if math.IsInf(lo, 1) {
|
||||
return 0, 0
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
func fractionTrue(b []bool) float64 {
|
||||
if len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, v := range b {
|
||||
if v {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return float64(n) / float64(len(b))
|
||||
}
|
||||
|
||||
func since(t time.Time) string { return time.Since(t).Round(time.Millisecond).String() }
|
||||
|
||||
// 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) {
|
||||
if explicit != "" {
|
||||
return explicit, nil
|
||||
}
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
candidate := filepath.Join(dir, "RawContent", "World", "World.json")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return "", fmt.Errorf("no RawContent/World/World.json above %q; pass --manifest", mustWd())
|
||||
}
|
||||
|
||||
func mustWd() string {
|
||||
d, _ := os.Getwd()
|
||||
return d
|
||||
}
|
||||
|
||||
// writeDataMaps renders the run's inputs and structure as false-colour PNGs beside the result.
|
||||
//
|
||||
// Which maps, and why each one earns its place:
|
||||
//
|
||||
// - map_uplift: rock uplift in mm/yr, the field everything else is a consequence of. Steady-state slope is
|
||||
// U/(K*A^m), so this map and the slope map should be recognisably the same picture; when they are not,
|
||||
// something downstream is overriding the tectonics, which is exactly how the repose clamp turned out to
|
||||
// be the surface of the whole continent.
|
||||
// - map_erodibility: the lithology multiplier on K. Hard bands stand up as ridges and soft ones are cut
|
||||
// away, so this is where the texture inside a range comes from.
|
||||
// - map_slope: degrees. The one to read next to map_uplift.
|
||||
// - map_relief: local relief over 500 m, which separates a 5 m hummock from a 500 m mountainside — both
|
||||
// can stand at 30 degrees and the slope map cannot tell them apart.
|
||||
// - map_basins: one colour per drainage basin. The direct picture of whether the solve made a network.
|
||||
// - map_flow: log drainage area, the rivers themselves.
|
||||
func writeDataMaps(dir string, h *field.Field, up *uplift.Result, cs *coast.Result, grid *fluvial.Grid, size int) error {
|
||||
sea := cs.Sea
|
||||
|
||||
rateMmYr := field.NewLike(up.Rate)
|
||||
for i, v := range up.Rate.Data {
|
||||
rateMmYr.Data[i] = v * 1000
|
||||
}
|
||||
if err := field.WriteDataMap(filepath.Join(dir, "map_uplift.png"), rateMmYr,
|
||||
field.DataMapOptions{Sea: sea, Size: size, Palette: field.Inferno}); err != nil {
|
||||
return err
|
||||
}
|
||||
if up.K != nil {
|
||||
if err := field.WriteDataMap(filepath.Join(dir, "map_erodibility.png"), up.K,
|
||||
field.DataMapOptions{Sea: sea, Size: size}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
slope := h.Slope()
|
||||
deg := field.NewLike(slope)
|
||||
for i, s := range slope.Data {
|
||||
deg.Data[i] = float32(math.Atan(float64(s)) * 180 / math.Pi)
|
||||
}
|
||||
if err := field.WriteDataMap(filepath.Join(dir, "map_slope.png"), deg,
|
||||
field.DataMapOptions{Sea: sea, Size: size, Lo: 0, Hi: 45, Palette: field.Inferno}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := field.WriteDataMap(filepath.Join(dir, "map_relief.png"), localRelief(h, 500),
|
||||
field.DataMapOptions{Sea: sea, Size: size, Palette: field.Inferno}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The coast's own two. Exposure is the input both shore processes are driven by, and it is the one to
|
||||
// read when a beach turns up on a headland or a cliff at the back of a bay. The change map is the pass's
|
||||
// whole effect in one picture: cool where the surf cut, warm where the sediment landed.
|
||||
//
|
||||
// Exposure is drawn only within a kilometre of the water, and the rest is rendered as the flat "no data"
|
||||
// colour. That is not tidiness: exposure is measured on the waterline and carried to every other cell by
|
||||
// "the stretch of shore nearest to you", which past a few hundred metres is a map of the continent's
|
||||
// medial axis rather than of anything coastal. The first render of it was a sunburst of polygonal wedges
|
||||
// meeting in the middle of the continent, which says nothing about a coast and hides what does.
|
||||
band := make([]bool, len(cs.Exposure.Data))
|
||||
for i, d := range cs.Geometry.Dist.Data {
|
||||
band[i] = math.Abs(float64(d)) > 1000
|
||||
}
|
||||
if err := field.WriteDataMap(filepath.Join(dir, "map_exposure.png"), cs.Exposure,
|
||||
field.DataMapOptions{Sea: band, Size: size, Lo: 0, Hi: 1, Palette: field.Inferno}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := field.WriteDataMap(filepath.Join(dir, "map_coast.png"), cs.Change,
|
||||
field.DataMapOptions{Size: size, Lo: -30, Hi: 30, Palette: field.Divergent}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if grid == nil {
|
||||
return nil
|
||||
}
|
||||
flow := field.New(h.W, h.H, h.CellM)
|
||||
copy(flow.Data, grid.Area)
|
||||
if err := field.WriteDataMap(filepath.Join(dir, "map_flow.png"), flow,
|
||||
field.DataMapOptions{Sea: sea, Size: size, Log: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
return field.WriteBasinMap(filepath.Join(dir, "map_basins.png"), h.W, h.H, grid.Receiver, sea, size)
|
||||
}
|
||||
|
||||
// localRelief is max minus min over a square window, as a field. Separable: the row pass then the column
|
||||
// pass, each a sliding min and max, so the cost does not grow with the window.
|
||||
func localRelief(h *field.Field, windowM float64) *field.Field {
|
||||
r := int(math.Round(windowM / h.CellM / 2))
|
||||
if r < 1 {
|
||||
r = 1
|
||||
}
|
||||
rowLo, rowHi := field.NewLike(h), field.NewLike(h)
|
||||
for y := 0; y < h.H; y++ {
|
||||
for x := 0; x < h.W; x++ {
|
||||
lo, hi := float32(math.Inf(1)), float32(math.Inf(-1))
|
||||
for d := -r; d <= r; d++ {
|
||||
v := h.AtClamped(x+d, y)
|
||||
if v < lo {
|
||||
lo = v
|
||||
}
|
||||
if v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
rowLo.Data[y*h.W+x], rowHi.Data[y*h.W+x] = lo, hi
|
||||
}
|
||||
}
|
||||
out := field.NewLike(h)
|
||||
for y := 0; y < h.H; y++ {
|
||||
for x := 0; x < h.W; x++ {
|
||||
lo, hi := float32(math.Inf(1)), float32(math.Inf(-1))
|
||||
for d := -r; d <= r; d++ {
|
||||
if v := rowLo.AtClamped(x, y+d); v < lo {
|
||||
lo = v
|
||||
}
|
||||
if v := rowHi.AtClamped(x, y+d); v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
out.Data[y*h.W+x] = hi - lo
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user