Tooling
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user