/** * The visible terrain is decimated where the ground is flat, and this is what * holds the decimation honest. * * Everything `lodPatches` does is invisible by construction and therefore * invisible to review: a wrong tolerance, a wrong diagonal or a dropped land * test all produce a mesh that builds, renders and passes every other test in * this directory, and shows up only as a board that has quietly lost its * coastline or grown a crack. The four facts below are the ones the pictures * were checked against, and each of them is a number. * * The boards are synthetic and small — twenty cells a side — for the reason * `seaAndTerrain.test.ts` gives: none of this is about California, and a real * pack would couple a render test to a city's coastline. */ import assert from "node:assert/strict"; import test from "node:test"; import * as THREE from "three"; import { setReconcile } from "../../cities/reconcile.ts"; import { createTerrain } from "../../engine/terrain.ts"; import type { City, ScenePalette } from "../../engine/types.ts"; import { World } from "../../engine/world.ts"; /** A square island in the middle of a one-degree board, at 0.05° per cell. */ /* * Every fixture in this file is a synthetic one-hill board built to isolate one * LOD guard, and `exaggeration` is derived from a pack's own blended peak — so * left on it would rescale a deliberately gentle 40 m swell into something the * height guard fires on, and the colour guard this file is measuring would be * swamped by it. The reconciliation is a statement about the three real packs; * these are not packs. */ setReconcile(false); const BASE: Omit = { id: "lod-board", name: "LOD Board", center: { lat: 37, lng: -122 }, bounds: { minLat: 36.5, maxLat: 37.5, minLng: -122.5, maxLng: -121.5 }, latScale: 100, verticalExaggeration: 2, cellLat: 0.05, cellLng: 0.05, coastFalloff: 0.02, /* * The island's rim sits half a cell outside the lattice corners it wants, so * its land cells run 4..15 on both axes. That is deliberate: the patch levels * are aligned to their own multiple, and an island whose interior straddled * the alignment would make this file a test of where the coast happens to * fall rather than of whether flat ground collapses. */ landmasses: [ [ [36.65, -122.35], [37.35, -122.35], [37.35, -121.65], [36.65, -121.65], ], ], parks: [], inlandWater: [], districts: [], landmarks: [], bridges: [], roads: [], chapters: [], }; /** Flat: no hills at all, so the whole island is one plane at sea level. */ const FLAT: City = { ...BASE, hills: [] }; /** * Rough: a hill every other cell, which is the frequency the lattice itself is * sized for. Nothing here may collapse, because a bilinear patch across two * cells of this is wrong by most of a hill. */ const ROUGH: City = { ...BASE, hills: (() => { const hills: City["hills"] = []; for (let i = 0; i < 6; i++) { for (let j = 0; j < 6; j++) { hills.push({ name: `h${i}-${j}`, lat: 36.75 + i * 0.1, lng: -122.25 + j * 0.1, elevation: 600, radius: 0.05, }); } } return hills; })(), }; async function board(city: City): Promise { const world = new World(city); assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield"); return world; } /** Triangles the surface would have had if every land cell were drawn alone. */ function cellByCellTriangles(world: World): number { const { latSteps, lngSteps, land } = world.lattice(); const w = lngSteps + 1; let cells = 0; for (let i = 0; i < latSteps; i++) { for (let j = 0; j < lngSteps; j++) { const a = i * w + j; if (land[a] && land[a + 1] && land[a + w] && land[a + w + 1]) cells++; } } return cells * 2; } /** The ground the cell-by-cell surface covered, in square scene units. */ function cellByCellArea(world: World): number { const { latSteps, lngSteps, lats, lngs, land } = world.lattice(); const w = lngSteps + 1; let area = 0; for (let i = 0; i < latSteps; i++) { for (let j = 0; j < lngSteps; j++) { const a = i * w + j; if (!land[a] || !land[a + 1] || !land[a + w] || !land[a + w + 1]) continue; const [x0, z0] = world.project(lats[i] as number, lngs[j] as number); const [x1, z1] = world.project(lats[i + 1] as number, lngs[j + 1] as number); area += Math.abs((x1 - x0) * (z1 - z0)); } } return area; } /** * The footprint of a range of the index, in square scene units. * * Area rather than a cell list because that is the property the decimation has * to preserve exactly: the patches cover the same ground, they just cover it * with fewer triangles. A merge that swallowed a coastal cell, or a T-junction * that left a gap, changes this number and nothing else. */ function footprint(geo: THREE.BufferGeometry, start: number, count: number): number { const index = geo.getIndex() as THREE.BufferAttribute; const pos = geo.getAttribute("position") as THREE.BufferAttribute; let area = 0; for (let at = start; at < start + count; at += 3) { const a = index.getX(at); const b = index.getX(at + 1); const c = index.getX(at + 2); // Twice the signed area of the triangle projected onto the ground plane. area += Math.abs( (pos.getX(b) - pos.getX(a)) * (pos.getZ(c) - pos.getZ(a)) - (pos.getX(c) - pos.getX(a)) * (pos.getZ(b) - pos.getZ(a)), ) / 2; } return area; } function visibleTriangles(mesh: THREE.Mesh): number { return mesh.geometry.drawRange.count / 3; } function casterTriangles(mesh: THREE.Mesh): number { const geo = mesh.geometry; return ((geo.getIndex() as THREE.BufferAttribute).count - geo.drawRange.count) / 3; } test("flat ground collapses and cell-scale relief does not", async () => { const flat = await board(FLAT); const rough = await board(ROUGH); const flatMesh = createTerrain(flat); const roughMesh = createTerrain(rough); const flatBase = cellByCellTriangles(flat); const roughBase = cellByCellTriangles(rough); assert.ok(flatBase > 200, `the flat board is too small to be a test: ${flatBase} triangles`); /* * A plane is a plane at any resolution, so the flat island must come out at * the coarsest level the patch list allows — a sixteenth of the cell-by-cell * count in the interior, plus whatever the coast leaves unaligned. */ assert.ok( visibleTriangles(flatMesh) < flatBase / 4, `flat ground kept ${visibleTriangles(flatMesh)} of ${flatBase} triangles`, ); /* * And the opposite, which is the half that a too-loose tolerance would break * silently: ground that moves every cell has to keep every cell. This is the * failure that turns a mountain range into a bump map, and it is the reason * the tolerance is a measured number rather than a large one. */ assert.ok( visibleTriangles(roughMesh) > roughBase * 0.9, `relief at lattice frequency was decimated to ${visibleTriangles(roughMesh)} of ${roughBase}`, ); }); test("the collapsed surface covers exactly the ground the cells covered", async () => { for (const city of [FLAT, ROUGH]) { const world = await board(city); const mesh = createTerrain(world); const drawn = footprint(mesh.geometry, mesh.geometry.drawRange.start, mesh.geometry.drawRange.count); const expected = cellByCellArea(world); /* * The coastline is the whole point of this assertion. A patch is only * collapsed when every one of its lattice points is on land, so the set of * ground covered is unchanged down to the last stair-step — and if a merge * ever reached across the shore, or a T-junction left a hole, the area is * where it shows. */ assert.ok( Math.abs(drawn - expected) < expected * 1e-6, `${city.id} covers ${drawn} square units against ${expected}`, ); } }); test("no point of the collapsed surface strays from the heightfield", async () => { const world = await board(ROUGH); const mesh = createTerrain(world); mesh.updateMatrixWorld(true); const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice(); const w = lngSteps + 1; const raycaster = new THREE.Raycaster(); const down = new THREE.Vector3(0, -1, 0); const from = new THREE.Vector3(); let worst = 0; let sampled = 0; for (let i = 0; i <= latSteps; i++) { for (let j = 0; j <= lngSteps; j++) { const k = i * w + j; if (!land[k]) continue; const [x, z] = world.project(lats[i] as number, lngs[j] as number); // Nudged inward, because a ray down the exact rim of the mesh is a // coin toss between hitting the edge triangle and missing the board. from.set(x + 1e-4, 10_000, z + 1e-4); raycaster.set(from, down); const hit = raycaster.intersectObject(mesh, false)[0]; if (!hit) continue; sampled++; worst = Math.max(worst, Math.abs(hit.point.y - world.metres(height[k] as number))); } } assert.ok(sampled > 100, `only ${sampled} lattice points landed on the surface`); /* * `LOD_HEIGHT_TOLERANCE` is 0.1 scene units and the surface sits 0.012 above * the heightfield to clear the shore plate, so 0.12 is the tolerance plus * that lift plus a rounding allowance. This is the assertion that a raised * tolerance has to walk past: the decimation may not move the ground. */ assert.ok(worst < 0.12, `the surface strays ${worst} scene units from the heightfield`); }); test("a colour boundary the height test cannot see stops the merge", async () => { /* * The coast is flat and its colour is not. `groundColor` ramps `sand` into * `flats` over the first three metres of elevation, which is a band the * coastal falloff makes tens of cells wide and which no height tolerance * loose enough to be useful can protect. So the same board is built twice: * once with a palette whose beach and flats are the same colour, and once * with them far apart. The second must keep more triangles, and the only * mechanism that can produce that difference is the colour guard. */ const beach: Partial = { sand: 0xffffff, flats: 0x000000 }; const plain: Partial = { sand: 0x9d9c93, flats: 0x9d9c93 }; // A single broad, low hill: the island climbs through the sand ramp gently // enough that the height test is happy everywhere. const gentle: City["hills"] = [ { name: "swell", lat: 37, lng: -122, elevation: 40, radius: 0.4 }, ]; const flatColoured = await board({ ...BASE, hills: gentle, palette: plain }); const rampColoured = await board({ ...BASE, hills: gentle, palette: beach }); const a = visibleTriangles(createTerrain(flatColoured)); const b = visibleTriangles(createTerrain(rampColoured)); assert.ok(b > a, `the colour guard changed nothing: ${b} triangles against ${a}`); }); test("the shadow caster is coarser than the surface and stands on the same ground", async () => { const world = await board(ROUGH); const mesh = createTerrain(world); const geo = mesh.geometry; const seen = visibleTriangles(mesh); const cast = casterTriangles(mesh); assert.ok(cast > 0, "the relief stopped casting a shadow"); /* * The caster's floor is `SHADOW_CASTER_STRIDE`, so on ground rough enough to * defeat every merge it is a quarter of the surface and never more. A caster * that came out the same size as the surface would mean the stride had been * lost and the depth pass was paying full price for the board. */ assert.ok(cast <= seen / 3, `the caster kept ${cast} triangles against ${seen} visible`); // Same board, so the same island: the caster may be blockier at the rim, but // it may not be somewhere else. const seenArea = footprint(geo, geo.drawRange.start, geo.drawRange.count); const castArea = footprint(geo, geo.drawRange.count, (geo.getIndex() as THREE.BufferAttribute).count - geo.drawRange.count); assert.ok( castArea <= seenArea * 1.0001 && castArea > seenArea * 0.5, `the caster covers ${castArea} square units against the surface's ${seenArea}`, ); });