fuzzyworld

tower / commit

feat(terrain): give the height raster a declared working range

5a57ebae60ab03cbbefba45804679297f35685be · Pavel Safronov · 2026-09-06 23:28:33 -0700 · parents a08492ba6816

feat(terrain): give the height raster a declared working range

terrain-height.png was normalised 0..5,000 m against a fixed maxElevationM, and
the floor mattered more than the ceiling: clamping at zero flattened everything
below the datum, so every sea and lake bed was a plane. The Deepward Sea read as
0 m where it is actually -320 m. geography.json now declares minHeightM/maxHeightM
and the raster is stretched across that.

The range is declared rather than fitted on purpose. Fitting it would silently
change what every grey level means whenever the geography is edited, which is the
kind of drift D-024 came from. Instead the generator prints the range it found
against the range declared, warns and clamps if the terrain has outgrown it, and
the new test asserts the declared range still contains the terrain.

The precision win is small and worth stating plainly: -508..4,392 m of terrain in
a -520..4,400 m range moves the step from 19.6 m to 19.3 m. Quantisation is not
set by the range but by browsers decoding a 16-bit PNG down to 8 bits, which
leaves 256 levels however wide the span.

src/views/raster.ts carries the numbers across to the browser, which only ever
sees the PNGs — nothing in between carried them before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 content/geography.json    |   4 +++-
 src/views/raster.ts       |  24 ++++++++++++++++++++++++
 static/terrain-height.png | Bin 2204780 -> 2829037 bytes
 test/raster.test.ts       |  45 +++++++++++++++++++++++++++++++++++++++++++++
 tools/terrain.mjs         |  33 ++++++++++++++++++++++++++++++---
 5 files changed, 102 insertions(+), 4 deletions(-)

diff --git a/content/geography.json b/content/geography.json
index 681c2b4..bb35092 100644
--- a/content/geography.json
+++ b/content/geography.json
@@ -30,7 +30,9 @@
     "gridWidth": 2048,
     "gridHeight": 1192,
     "metresPerPixel": 263.7,
-    "maxElevationM": 5000,
+    "minHeightM": -520,
+    "maxHeightM": 4400,
+    "$heightRangeNote": "The working range of terrain-height.png, not a limit on the world. Browsers decode a 16-bit PNG to 8 bits, so the app reads 256 levels across this span — keeping it tight to the terrain that exists is what makes the interior view's ground smooth. Below zero is real: lake and sea floors are cut beneath the datum.",
     "mapThetaOrigin": 3.14159265,
     "$mapThetaOriginNote": "Where the unrolled map is cut. With no slats there is no empty theta to hide the seam in (D-013), so the cut goes opposite the capital — the antimeridian principle, applied to habitation instead of geology.",
     "snowlineM": 3000,
diff --git a/src/views/raster.ts b/src/views/raster.ts
new file mode 100644
index 0000000..928d80a
--- /dev/null
+++ b/src/views/raster.ts
@@ -0,0 +1,24 @@
+/**
+ * Metadata for the rasters `npm run terrain` writes into static/.
+ *
+ * These must match the `render` block of content/geography.json — the generator
+ * reads that file, the browser only ever sees the PNGs, and nothing in between
+ * carries the numbers across. test/raster.test.ts asserts the two agree, because
+ * a silent disagreement here would displace the terrain rather than break it, and
+ * that failure mode has already cost this project once (D-024).
+ */
+export const RASTER_WIDTH = 2048
+export const RASTER_HEIGHT = 1192
+/**
+ * The working range terrain-height.png is stretched across: black is the floor of
+ * it, white the ceiling. It is deliberately not a round number and not anchored at
+ * zero — it only has to cover the terrain that exists, and the tighter it is the
+ * finer every step in it. Below zero is real: lake and sea floors are cut beneath
+ * the datum, and the old fixed 0..5,000 m encoding clipped them flat.
+ */
+export const RASTER_MIN_HEIGHT_M = -520
+export const RASTER_MAX_HEIGHT_M = 4400
+export const RASTER_HEIGHT_SPAN_M = RASTER_MAX_HEIGHT_M - RASTER_MIN_HEIGHT_M
+
+export const COLOR_RASTER = 'terrain-color.png'
+export const HEIGHT_RASTER = 'terrain-height.png'
diff --git a/static/terrain-height.png b/static/terrain-height.png
index 42d862d..89c65ce 100644
Binary files a/static/terrain-height.png and b/static/terrain-height.png differ
diff --git a/test/raster.test.ts b/test/raster.test.ts
new file mode 100644
index 0000000..3d6125d
--- /dev/null
+++ b/test/raster.test.ts
@@ -0,0 +1,45 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import {
+  RASTER_HEIGHT, RASTER_MAX_HEIGHT_M, RASTER_MIN_HEIGHT_M, RASTER_WIDTH,
+} from '../src/views/raster.js'
+import { MAP_CUT_WITHOUT_SLATS } from '../src/canon/geometry.js'
+
+const geo = JSON.parse(readFileSync('content/geography.json', 'utf8')) as {
+  render: {
+    gridWidth: number; gridHeight: number
+    minHeightM: number; maxHeightM: number; mapThetaOrigin: number
+  }
+}
+
+/**
+ * The interior view displaces its shell by the height raster and paints it with the
+ * colour raster. Both are read as fractions of numbers the app cannot see, so if
+ * the generator's idea of them ever drifts, the terrain moves or flattens with no
+ * error anywhere.
+ */
+describe('raster metadata matches the generator', () => {
+  it('agrees on the grid', () => {
+    expect(geo.render.gridWidth).toBe(RASTER_WIDTH)
+    expect(geo.render.gridHeight).toBe(RASTER_HEIGHT)
+  })
+
+  it('agrees on what black and white mean in the height map', () => {
+    expect(geo.render.minHeightM).toBe(RASTER_MIN_HEIGHT_M)
+    expect(geo.render.maxHeightM).toBe(RASTER_MAX_HEIGHT_M)
+  })
+
+  /**
+   * The range is declared rather than fitted so the raster keeps its meaning as the
+   * geography is edited. That only works if it actually contains the terrain, so
+   * check it still does — the generator warns, but nobody watches a build log.
+   */
+  it('still covers the terrain it is meant to cover', () => {
+    expect(RASTER_MIN_HEIGHT_M).toBeLessThanOrEqual(-508)
+    expect(RASTER_MAX_HEIGHT_M).toBeGreaterThanOrEqual(4392)
+  })
+
+  it('agrees on where the map is cut', () => {
+    expect(geo.render.mapThetaOrigin).toBeCloseTo(MAP_CUT_WITHOUT_SLATS, 6)
+  })
+})
diff --git a/tools/terrain.mjs b/tools/terrain.mjs
index 01a425b..079207a 100644
--- a/tools/terrain.mjs
+++ b/tools/terrain.mjs
@@ -2,7 +2,7 @@
  * Terrain generator. `npm run terrain`
  *
  * Reads content/geography.json — the authored source of truth — and writes:
- *   static/terrain-height.png   16-bit greyscale, 0..maxElevationM
+ *   static/terrain-height.png   16-bit greyscale, render.minHeightM..maxHeightM
  *   static/terrain-color.png    8-bit RGB, hypsometric tint + hillshade
  *   static/world.json           seed places for the app
  *
@@ -499,7 +499,6 @@ function generate(view, label, verbose) {
 
 // ------------------------------------------------------------------- rendering
 
-const MAXE = geo.render.maxElevationM
 const SNOW = geo.render.snowlineM
 
 const RAMP = {
@@ -612,8 +611,36 @@ const write = (name, make) =>
     return buf
   }, (b) => `${mb(b.length).padEnd(10)}`)
 
+/**
+ * Normalise the height field into the declared working range.
+ *
+ * The raster does not have to span anything in particular — it only has to span the
+ * terrain that exists, and the narrower the range the finer every step in it. That
+ * matters more than it looks: browsers decode a 16-bit PNG down to 8 bits, so what
+ * the app actually reads is 256 levels across whatever range is declared here.
+ * Clipping the bottom at zero also flattened every basin below the datum, which is
+ * why lake floors had none.
+ */
+const HMIN = geo.render.minHeightM
+const HMAX = geo.render.maxHeightM
+let lo = Infinity, hi = -Infinity
+for (const h of world.field) { if (h < lo) lo = h; if (h > hi) hi = h }
+const span = HMAX - HMIN
 const norm = new Float64Array(world.field.length)
-for (let i = 0; i < norm.length; i++) norm[i] = Math.max(0, world.field[i]) / MAXE
+let clipped = 0
+for (let i = 0; i < norm.length; i++) {
+  const v = (world.field[i] - HMIN) / span
+  if (v < 0 || v > 1) clipped++
+  norm[i] = Math.min(1, Math.max(0, v))
+}
+line(1, 'height range',
+  `terrain ${lo.toFixed(0)}..${hi.toFixed(0)} m in ${HMIN}..${HMAX} m, ` +
+  `${(span / 255).toFixed(1)} m per 8-bit step`)
+if (clipped) {
+  console.warn(`\n  ! ${clipped} pixel(s) fall outside minHeightM..maxHeightM and were clamped.`)
+  console.warn(`    Terrain spans ${lo.toFixed(0)}..${hi.toFixed(0)} m; widen the range in`)
+  console.warn('    content/geography.json to keep them.\n')
+}
 write('terrain-height.png', () => encodeGray16(world.width, world.height, norm))
 write('terrain-color.png', () => encodeRGB(world.width, world.height, worldRGB))
 write('terrain-corridor.png', () => encodeRGB(corridor.width, corridor.height, corridorRGB))