// Package manifest reads RawContent/World/World.json, the one place that says how big L_World is, what a // heightmap value means in metres, and where the height comes from. It is the Go half of a contract whose // other half is Scripts/Authoring/world_manifest.py: create_world.py still reads the same file to place the // landscape, so the two must derive the same Z scale and the same Z offset from the same keys. Any change to // the height contract here is a change there. package manifest import ( "encoding/json" "fmt" "math" "os" "path/filepath" ) // The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m. const EngineSpanMAtScale100 = 512.0 // Range is the [low, high] pair the pipeline block uses for anything a seed picks between. type Range [2]float64 func (r Range) Lo() float64 { return r[0] } func (r Range) Hi() float64 { return r[1] } // Pick returns a value in the range from a unit random. func (r Range) Pick(u float64) float64 { return r[0] + (r[1]-r[0])*u } type Elevation struct { Min float64 `json:"min"` Max float64 `json:"max"` } type Source struct { Kind string `json:"kind"` Seed int64 `json:"seed"` Path string `json:"path"` Elevation *Elevation `json:"elevation_m"` Width int `json:"width"` FlipY bool `json:"flip_y"` SmoothPasses int `json:"smooth_passes"` } // Layers is the paint-layer rule block, unchanged in meaning from the numpy pipeline. type Layers struct { RockSlopeStart float64 `json:"rock_slope_start"` RockSlopeFull float64 `json:"rock_slope_full"` HighAltitudeStartM float64 `json:"high_altitude_start_m"` HighAltitudeFullM float64 `json:"high_altitude_full_m"` BreakupM float64 `json:"breakup_m"` WearRockStart float64 `json:"wear_rock_start"` RidgeRock float64 `json:"ridge_rock"` DepositSoftens float64 `json:"deposit_softens"` } type Plates struct { Count int `json:"count"` VelocityCmYr Range `json:"velocity_cm_yr"` ConvergentMmYr Range `json:"convergent_mm_yr"` BandKm Range `json:"band_km"` DivergentMmYr Range `json:"divergent_mm_yr"` RiftKm Range `json:"rift_km"` // IntraplateMmYr and IntraplateSwellMmYr are the two ends of the regional swell: the interior warps // between them over tens of kilometres. A single uniform intraplate rate is what produced a table-flat // plain with no divides on it, and therefore no drainage for the router to find. See package uplift. IntraplateMmYr float64 `json:"intraplate_mm_yr"` IntraplateSwellMmYr float64 `json:"intraplate_swell_mm_yr"` LowUpliftFraction Range `json:"low_uplift_fraction"` } type Faults struct { Major Range `json:"major"` Minor Range `json:"minor"` LengthKm Range `json:"length_km"` SpacingKm Range `json:"spacing_km"` ThrowMajorM Range `json:"throw_major_m"` ThrowMinorM Range `json:"throw_minor_m"` StrikeSlipM Range `json:"strike_slip_m"` } type Lithology struct { Types int `json:"types"` KMultipliers []float64 `json:"k_multipliers"` } type Relief struct { Octaves int `json:"octaves"` Gain float64 `json:"gain"` BaseFrequencyM float64 `json:"base_frequency_m"` AmplitudeM Range `json:"amplitude_m"` CrestWeight float64 `json:"crest_weight"` } // Fluvial is the stream-power block: dh/dt = U - K * A^m * S^n, solved implicitly up the drainage stack. type Fluvial struct { K float64 `json:"k"` M float64 `json:"m"` N float64 `json:"n"` DtYr float64 `json:"dt_yr"` Steps int `json:"steps"` DiffusionM2Yr float64 `json:"diffusion_m2_yr"` // FillEvery is the one number that decides whether a full run is five minutes or half an hour: the // priority-flood is the only part of a step that is not O(n). See Docs/Terrain.md, the time budget. FillEvery int `json:"fill_every"` // CriticalAreaM2 is where channels begin; below it a cell is a hillslope. See package fluvial. CriticalAreaM2 float64 `json:"critical_area_m2"` ChannelTaper float64 `json:"channel_taper"` // The nonlinear hillslope law, q = D*S/(1-(S/Sc)^2). CriticalSlopeDeg is Sc as an angle; 0 falls back to // linear diffusion with the repose clamp inside the step loop. See internal/fluvial/hillslope.go. CriticalSlopeDeg float64 `json:"critical_slope_deg"` SlopeCap float64 `json:"slope_cap"` MaxHillslopeSub int `json:"max_hillslope_substeps"` } type Thermal struct { CoarsePasses int `json:"coarse_passes"` Every int `json:"every"` FinePasses int `json:"fine_passes"` TalusDeg float64 `json:"talus_deg"` } type Strata struct { PeriodM float64 `json:"period_m"` Contrast float64 `json:"contrast"` } type Detail struct { Octaves int `json:"octaves"` AmplitudeM Range `json:"amplitude_m"` } // 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"` Lifetime int `json:"lifetime"` Scale float64 `json:"scale"` MinErodeSlope float64 `json:"min_erode_slope"` MaxChange float64 `json:"max_change"` MaxSpeed float64 `json:"max_speed"` MaxLoad float64 `json:"max_load"` Inertia float64 `json:"inertia"` Capacity float64 `json:"capacity"` MinSlope float64 `json:"min_slope"` ErodeRate float64 `json:"erode_rate"` DepositRate float64 `json:"deposit_rate"` Evaporation float64 `json:"evaporation"` Gravity float64 `json:"gravity"` Batch int `json:"batch"` } // Continent is the coast and the sea floor: not in the incoming spec at all, kept by D-48 because sea level // is a better-posed base level for the fluvial solve than one outlet edge. type Continent struct { Enabled bool `json:"enabled"` Radius float64 `json:"radius"` CoastWarp float64 `json:"coast_warp"` SeaFloorM Range `json:"sea_floor_m"` // LandFraction is met exactly, by thresholding the continent field at the percentile that yields it, so // the land area does not wander with the seed. LandFraction float64 `json:"land_fraction"` // RadialBias pulls the land towards the middle. It only biases: at 0 the continent is wherever the noise // puts it, and high values return the disc with a wobbly edge that the first version produced. RadialBias float64 `json:"radial_bias"` // ShoreWidthPct is how many percentiles the shore transition spans. Small is a cliff coast, large is a // wide tidal shelf. ShoreWidthPct float64 `json:"shore_width_pct"` // OutlineOctaves and OutlineGain are how much detail the coastline itself has. A real coastline is // fractal — that is the whole point of the Richardson coastline paradox — and five octaves over a 14 km // map puts the finest feature at about 450 m, which is a smooth blob with no inlets, no headlands and no // islands. Measured on seed 7 with five octaves: the fetch called the median stretch of coast fully open, // because there was nothing at the fetch scale to shelter anything from anything. OutlineOctaves int `json:"outline_octaves"` OutlineGain float64 `json:"outline_gain"` } // Coast is what happens where the land meets the sea: the shape of the sea floor, and the two processes // that work on the shoreline itself. // // It is a separate block from Continent because the two answer different questions. Continent decides *where* // the coastline runs — it is part of the tectonics, it is what the fluvial solve takes as its base level, and // it is fixed before a single step of erosion. Coast decides what the shoreline *is*, and it runs after the // solve, on the terrain the solve produced: the sea floor cannot be laid until the land behind it has its // relief, and the surf cannot cut a cliff into a mountain that has not been built yet. type Coast struct { Enabled bool `json:"enabled"` // The sea floor. A real margin is a shelf at a very gentle grade out to a shelf break, and then a much // steeper continental slope down to the abyssal floor; the flat plane at the bottom of the elevation // 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"` SteepCoastM float64 `json:"steep_coast_m"` SlopeKm float64 `json:"slope_km"` ShelfExponent float64 `json:"shelf_exponent"` RoughnessM float64 `json:"roughness_m"` RoughWaveM float64 `json:"rough_wavelength_m"` // Shelter. Fetch is cast from every waterline cell in FetchDirections directions out to FetchRangeM, and // how far the rays get before they hit land is what separates an exposed headland from the back of a bay. // It is the one field both coastal processes are driven by: the surf reaches furthest inland where the // water is open, and sediment settles where it is not. FetchDirections int `json:"fetch_directions"` FetchRangeM float64 `json:"fetch_range_m"` // The surf. Within a reach of the waterline the land is planed towards a shore platform at // PlatformGrade; the step at the back of the planed strip is the cliff, and it is a consequence of the // reach ending rather than something drawn. CutFraction below 1 leaves the platform rough. SurfReachM float64 `json:"surf_reach_m"` PlatformGrade float64 `json:"platform_grade"` CutFraction float64 `json:"cut_fraction"` // Deposition. What the surf cuts does not vanish: it is carried DriftM along the shore and laid in // sheltered water shallower than DepositDepthM and within DepositReachM of the shore, up to BermM above // sea level. Rivers deliver their own load at their mouths, which is what makes a delta. DepositReachM float64 `json:"deposit_reach_m"` DepositDepthM float64 `json:"deposit_depth_m"` ShelterBias float64 `json:"shelter_bias"` BermM float64 `json:"berm_m"` DriftM float64 `json:"drift_m"` RiverM3PerKm2 float64 `json:"river_m3_per_km2"` RiverExponent float64 `json:"river_exponent"` RiverChannelKm2 float64 `json:"river_channel_km2"` } 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"` } type Manifest struct { Path string `json:"-"` Level string `json:"level"` VerticesPerSide int `json:"vertices_per_side"` QuadCm float64 `json:"quad_cm"` ElevationM Elevation `json:"elevation_m"` SeaLevelM float64 `json:"sea_level_m"` SpawnPadM float64 `json:"spawn_pad_m"` StreamingGridComponents int `json:"streaming_grid_components"` Source Source `json:"source"` Layers Layers `json:"layers"` Pipeline Pipeline `json:"pipeline"` // 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"` } // Defaults are the generator's own numbers, so a manifest carries only what differs from them. This is the // Go equivalent of heightmap_erosion.DEFAULTS and it plays the same role. func Defaults() *Manifest { return &Manifest{ Level: "/Game/Maps/L_World", VerticesPerSide: 7141, // 255*28+1 (D-48): the importer's own rule then gives 28x28 components QuadCm: 200, ElevationM: Elevation{Min: -512, Max: 1536}, // span 2048 m is exactly Z scale 400 SeaLevelM: 0, SpawnPadM: 150, StreamingGridComponents: 2, Source: Source{Kind: "noise", Seed: 7}, Layers: Layers{ RockSlopeStart: 0.55, RockSlopeFull: 1.05, HighAltitudeStartM: 1100, HighAltitudeFullM: 1650, BreakupM: 18, WearRockStart: 0.35, RidgeRock: 0.6, DepositSoftens: 0.7, }, Pipeline: Pipeline{ GeologyFactor: 4, Continent: Continent{ Enabled: true, Radius: 0.62, CoastWarp: 0.28, SeaFloorM: Range{-180, -30}, LandFraction: 0.62, RadialBias: 0.85, ShoreWidthPct: 3, // Measured on seed 7 at 1400, sweeping the gain with everything else held: shoreline length // 64 km at 0.50, 81 at 0.58, 96 at 0.62, 114 at 0.66, and the fetch's view of the coast went // from "the median stretch is fully open" (1.00) to 0.98, 0.84 and 0.51. 0.62 is where the // coast has islands, inlets and headlands that shelter each other without the outline // breaking up into speckle. Octaves past 9 buy nothing: at gain 0.50 the sweep 5, 7, 8, 9, 10 // gave 59, 63, 64, 65, 66 km and it had flattened. OutlineOctaves: 8, OutlineGain: 0.62, }, Coast: Coast{ Enabled: true, // The canvas is 14.28 km a side and the sea is a third of it, so a real shelf — 75 km out // to a break at 130 m — does not fit and is not what these numbers are. They are the same // *shape* scaled to the map: a gentle shelf a kilometre or two wide, a break at the // SeaFloorM high end, and a slope to the SeaFloorM low end over another kilometre and a // half. The two SeaFloorM numbers keep their meaning; what changes is that the depth // between them is now a function of distance offshore rather than of the mask's ramp. ShelfKm: Range{0.6, 3.0}, SteepCoastM: 300, SlopeKm: 1.6, ShelfExponent: 0.7, RoughnessM: 10, RoughWaveM: 1200, FetchDirections: 16, FetchRangeM: 1500, // 110 m of reach is 14 cells at the 8 m geology cell, which is about the least that can // carry a platform and a cliff at this resolution. The shore is the one landform whose // scale is set by physics rather than by the map, so it does not grow with the canvas; // when the detail passes exist this pass is where the beach itself gets built, at 2 m. SurfReachM: 110, PlatformGrade: 0.02, CutFraction: 0.85, DepositReachM: 350, DepositDepthM: 25, ShelterBias: 1.5, BermM: 2, DriftM: 300, // Untuned, and deliberately reported rather than assumed: the summary prints the volume // cut, the volume laid and the volume the rivers delivered, so the next round of tuning // has a number to work from instead of an impression of a picture. RiverM3PerKm2: 1.2e5, RiverExponent: 0.6, RiverChannelKm2: 0.5, }, Plates: Plates{ Count: 6, VelocityCmYr: Range{1, 5}, BandKm: Range{2, 4}, DivergentMmYr: Range{-2, -1}, RiftKm: Range{3, 6}, // The swell is the fix for the dead plains and it stays: what a lowland needs in order to // have drainage is not a higher uplift rate but a *varying* one, because divides come from // variation. What was wrong was the absolute rate, not the idea. // // Steady-state slope is S = U/(K*A^m), and at CriticalAreaM2 0 that law is applied down to // a single cell, so every divide on the map sits at A = cell². At K 5e-5, m 0.5 and an 8 m // cell that is S = U/4e-4: 0.25 mm/yr puts every divide at 32 degrees and 0.9 mm/yr puts it // past the 35 degree repose clamp. Measured on the old numbers, 81 % of the land came out // in the >0.5 mm/yr class and the plain class held 1 %, all of it sea cliff. The plains were // not over-dissected; they were being uplifted at mountain rates, and U sets how *high* the // summits get, not how steep the ground is — for n = 1 the hillslope angle is the same // everywhere the same U is applied. // // So the rate drops an order of magnitude and the variation stays: 0.03 to 0.08 is still // the ~2.5-fold warp that puts divides on a plain, and it gives 4 to 11 degree hillslopes // and lowland channel gradients near 1 m/km. Against a convergent 1-2 mm/yr that is a // 30-to-60-fold mountain-to-plain ratio, which is what real ones are; the three-fold ratio // this replaces was not mountains and plains, it was mountains and slightly lower mountains. // The percentile ramp in rangeMask keeps the foreland continuous, so nothing becomes bimodal. ConvergentMmYr: Range{1.0, 2.0}, IntraplateMmYr: 0.03, IntraplateSwellMmYr: 0.08, LowUpliftFraction: Range{0.2, 0.4}, }, Faults: Faults{ Major: Range{3, 6}, Minor: Range{10, 30}, LengthKm: Range{2, 15}, SpacingKm: Range{1, 4}, ThrowMajorM: Range{100, 400}, ThrowMinorM: Range{20, 80}, StrikeSlipM: Range{200, 800}, }, Lithology: Lithology{Types: 3, KMultipliers: []float64{0.5, 1.0, 3.0}}, Relief: Relief{ // The low end is 15 m, not 50: amplitude scales with normalised uplift, so the lo end is // what the plains start as, and steady-state plain relief at the rates above is about 10 m. // Starting them as 50 m hills means the run spends itself eroding away relief it was handed // rather than carving what the uplift field asks for. Octaves: 7, Gain: 0.45, BaseFrequencyM: 4000, AmplitudeM: Range{15, 150}, CrestWeight: 0.12, }, Fluvial: Fluvial{ // 1000 steps, not the incoming spec's 5000: at this K the trunk response time is about // 45 000 yr, and the exponent stops moving after 500 steps at 512². FillEvery is 1 and is // not a budget knob: at 50 the solve is simply wrong (see Docs/Terrain.md). K: 5e-5, M: 0.5, N: 1.0, DtYr: 1500, Steps: 1000, DiffusionM2Yr: 0.02, FillEvery: 1, // 0 disables it, and it is disabled on purpose. A channelization threshold is the textbook // answer to stream power over-steepening hillslopes, but it only works paired with a // hillslope transport law strong enough to carry the uplift into the channels, and at this // timescale there isn't one: the diffusivity it would need (~0.3 m²/yr over a 220 m // hillslope) has a diffusion length of sqrt(D*t) ≈ 470 m over 1.5 Myr, which smooths away // every landform the generator exists to make. Measured: the map went to melted wax. With // the threshold on and diffusion left low, hillslopes instead accumulate uplift unchecked // and the map clipped 22% of the elevation range. Landsliding carries the hillslopes here. // Measured again after the uplift field was fixed, and it still fails: at 1e4 the plains // went from 0.8 to 7.0 degrees median, the rolling class from 7.4 to 32.8 with half of it // pinned against the repose clamp, and the mountains to 79 % pinned. The reason is the same // one as before — the hillslope the threshold creates has to shed its uplift by diffusion, // and at D 0.02 it cannot, so the clamp takes the job instead. It stays at 0 until there is // a transport law strong enough to pair it with. CriticalAreaM2: 0, ChannelTaper: 2, // Sc is the repose angle, so the nonlinear law limits at the same place the clamp did; what // changes is that it approaches it smoothly and isotropically instead of cutting to it along // eight grid directions. See internal/fluvial/hillslope.go for what the cap and the sub-step // budget buy and what they cost. CriticalSlopeDeg: 35, SlopeCap: 0.9, MaxHillslopeSub: 24, }, 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}}, Particle: Particle{ Droplets: 9000000, 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, }, }, } } // Load reads the manifest over the defaults, so a key absent from the file keeps the generator's number. // encoding/json only assigns fields that are present, which gives exactly the merge the numpy pipeline did // with {**DEFAULTS, **settings}. func Load(path string) (*Manifest, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } m := Defaults() if err := json.Unmarshal(raw, m); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } m.Path = path return m, m.Validate() } func (m *Manifest) Validate() error { if m.VerticesPerSide < 2 { return fmt.Errorf("%s: vertices_per_side must be at least 2", m.Path) } if m.QuadCm <= 0 { return fmt.Errorf("%s: quad_cm must be positive", m.Path) } if m.ElevationM.Max <= m.ElevationM.Min { return fmt.Errorf("%s: elevation_m.max must be above .min", m.Path) } // D-45: the importer picks the largest section size that divides the quad count, preferring one section // per component, so a resolution off the ladder of 255*N+1 or 127*N+1 silently multiplies the component // count. 4033 gave 4096 components and a forty-minute import. Refuse rather than let it happen again. q := m.QuadsPerSide() section := 0 for _, s := range []int{255, 127, 63, 31, 15, 7} { if q%s == 0 { section = s break } } if section == 0 { return fmt.Errorf("%s: vertices_per_side %d gives %d quads, which no section size divides; use 255*N+1 or 127*N+1", m.Path, m.VerticesPerSide, q) } if components := (q / section) * (q / section); components > 1024 { return fmt.Errorf("%s: vertices_per_side %d gives %d components of %d quads; that import takes tens of minutes (D-45)", m.Path, m.VerticesPerSide, components, section) } 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) } return nil } // Derived geometry, all of it mirroring world_manifest.py. func (m *Manifest) QuadsPerSide() int { return m.VerticesPerSide - 1 } func (m *Manifest) QuadM() float64 { return m.QuadCm / 100 } func (m *Manifest) SideM() float64 { return float64(m.QuadsPerSide()) * m.QuadM() } func (m *Manifest) AreaKm2() float64 { s := m.SideM() / 1000; return s * s } func (m *Manifest) ElevationSpanM() float64 { return m.ElevationM.Max - m.ElevationM.Min } func (m *Manifest) ElevationMidM() float64 { return (m.ElevationM.Max + m.ElevationM.Min) / 2 } // ZScale is the landscape actor's Z scale so the 16-bit range spans exactly the manifest's elevation range. func (m *Manifest) ZScale() float64 { return m.ElevationSpanM() / EngineSpanMAtScale100 * 100 } // LandscapeZCm places value 32768 at the middle of the range, so elevation 0 m lands on world Z 0. func (m *Manifest) LandscapeZCm() float64 { return m.ElevationMidM() * 100 } func (m *Manifest) MetresToValue(metres float64) float64 { return (metres - m.ElevationM.Min) / m.ElevationSpanM() * 65535 } func (m *Manifest) ValueToMetres(v float64) float64 { return m.ElevationM.Min + v/65535*m.ElevationSpanM() } // GeologySize is the coarse grid the tectonics and the fluvial solve run on: an exact integer factor of the // quad count, so the upsample back to full resolution lands every sample on a sample. func (m *Manifest) GeologySize() int { return m.QuadsPerSide()/m.Pipeline.GeologyFactor + 1 } func (m *Manifest) GeologyCellM() float64 { return m.QuadM() * float64(m.Pipeline.GeologyFactor) } // SectionLayout reports what the engine's importer will choose, so a run can print it and a person can see // the component count before the editor spends minutes on it. func (m *Manifest) SectionLayout() (section, componentsPerSide int) { q := m.QuadsPerSide() for _, s := range []int{255, 127, 63, 31, 15, 7} { if q%s == 0 { return s, q / s } } return 0, 0 } // Resolve reads a manifest path as relative to the project root. func (m *Manifest) Resolve(rel string) string { if filepath.IsAbs(rel) { return rel } return filepath.Join(ProjectRoot(m.Path), rel) } // ProjectRoot walks up from the manifest (RawContent/World/World.json) to the repository root. func ProjectRoot(manifestPath string) string { abs, err := filepath.Abs(manifestPath) if err != nil { return "." } return filepath.Dir(filepath.Dir(filepath.Dir(abs))) } func (m *Manifest) Describe() string { section, perSide := m.SectionLayout() return fmt.Sprintf( "%d vertices a side at %g cm: %.2f km, %.0f km2; elevation %g..%g m (Z scale %g, actor Z %g cm, %.2f cm a step); "+ "%dx%d components of %d quads; geology %d at %.1f m; source %s seed %d", m.VerticesPerSide, m.QuadCm, m.SideM()/1000, m.AreaKm2(), m.ElevationM.Min, m.ElevationM.Max, m.ZScale(), m.LandscapeZCm(), m.ElevationSpanM()/65535*100, perSide, perSide, section, m.GeologySize(), m.GeologyCellM(), m.Source.Kind, m.Source.Seed) } // ClipFraction is the check D-48 made a pass/fail: U/K is the one relief knob and the elevation ceiling is a // hard clip in the 16-bit encoding, so a run that clips is a failed run, not a rounded one. func (m *Manifest) ClipFraction(metres []float32) float64 { if len(metres) == 0 { return 0 } var n int for _, v := range metres { if float64(v) < m.ElevationM.Min || float64(v) > m.ElevationM.Max { n++ } } return float64(n) / float64(len(metres)) } // Encode turns metres into the 16-bit values the PNG carries, clamping to the range. func (m *Manifest) Encode(metres []float32) []uint16 { out := make([]uint16, len(metres)) for i, v := range metres { x := m.MetresToValue(float64(v)) if x < 0 { x = 0 } else if x > 65535 { x = 65535 } out[i] = uint16(math.Round(x)) } return out }