tower / commit
feat(terrain): give the height raster a declared working range
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))