1396 lines
51 KiB
Go
1396 lines
51 KiB
Go
// 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"
|
|
"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"
|
|
)
|
|
|
|
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 "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:
|
|
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] 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
|
|
`)
|
|
}
|
|
|
|
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")
|
|
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")
|
|
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")
|
|
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 {
|
|
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 *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
|
|
}
|
|
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 *breakM > 0 {
|
|
m.Pipeline.Coast.BreakM = *breakM
|
|
}
|
|
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,
|
|
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 {
|
|
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)
|
|
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,
|
|
// 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 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.
|
|
// 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.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 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))
|
|
}
|
|
|
|
// 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)
|
|
// 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 {
|
|
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()
|
|
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) { 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
|
|
}
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for i := 0; i < 8; i++ {
|
|
candidate := filepath.Join(dir, "RawContent", "World", name)
|
|
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/%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 {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
})
|
|
}
|