1
0

feat: SFO, LAX, both bridges, a road that reads as a road, and aeroplanes that move

**The aeroplanes were stuck because the wire could not describe motion.**
`WireAircraft` carried position, altitude and heading and nothing else, so the
client could only interpolate between the last two observations: every aircraft
replayed a segment it had already flown, arrived at the newest known point, and
sat still until the next poll landed five to fifteen seconds later. The feed had
the missing numbers the whole time and the server threw them away. Sampled live
from `api.adsb.lol/v2/point` while writing this — `gs` ground speed, `track`,
`baro_rate`, plus `r` registration and `t` type designator. They are on the wire
now in SI, aircraft dead-reckon along their own track and correct toward the
truth when a fix lands, and the click card an anonymous visitor gets says
"B739 · N68834". That last part is the enrichment FR24 was wanted for, obtained
from an ODbL feed we may actually republish.

**SFO and LAX exist.** A new `engine/airports.ts` composes an airport from
runways, taxiways, aprons and terminal masses, with markings drawn on a canvas
rather than modelled; the pattern of the runways is what the eye recognises from
altitude, long before any building does. SFO is the two crossing pairs on the bay
fill; LAX is the four parallels either side of the terminal horseshoe, plus the
Southland fields under the traffic that actually flies there.

**The Golden Gate and the Bay Bridge are those bridges.** One kit in
`engine/bridges.ts`, because a suspension bridge is a repeated tower, a catenary
main cable, a series of hangers and a deck — so both are configurations rather
than two private implementations. The Bay Bridge carries the real 2013 topology:
two suspension towers west of Yerba Buena, one east, then the piered causeway.
The freeway stopped being a wireframe overlay and became a road, with shoulders,
a median, and lane markings as texture.

**And the board got faster while all of that landed.** California went from
728,744 triangles and 562 draw calls to 391,169 and 371 — headroom from 2.8% to
47.8%. The Bay Area board is 506,550 triangles lighter than before this work.
Two things paid for it:

- `transmission: 0.08` on the aircraft cockpit glass. three.js runs a full
  transmission backdrop pass whenever any rendered material has transmission
  above zero, re-drawing the entire opaque scene into a second target every
  frame — so the city was rendering terrain, every block and every freeway piece
  TWICE. Measured by patching only that number in a copy of the built bundle:
  703,267 tris / 562 draws with it, 398,608 / 371 without. The material was
  already `transparent: true, opacity: 0.86`, so it was buying nothing.
- Flatness-adaptive terrain LOD, which collapses runs of lattice cells wherever
  the height and colour agree with the quad replacing them. The coastline is
  provably untouched — a patch collapses only when every point is on land and
  agrees about `park` — and a test asserts the drawn footprint matches the
  cell-by-cell area to 1e-6. `createTerrain` got *faster*: the vertices it stops
  emitting cost more than the flatness scan costs to run.

**The budget now watches the boards this was built on.** There was no `bay-area`
or `socal` cell — so SFO, LAX and both bridges all landed in frames nothing
measured, which is how a cap you do not have looks from the inside. Both are in
the matrix now with caps set from measurement, and the rationale lives in the
harness because JSON cannot hold a comment.

Two known defects ship with this, both recorded in TODO.md rather than hidden:

- `bay-area.desktop` drops about one frame in twenty (p50 16.7, p95 33.3). It is
  desktop-only and not fill rate — mobile runs the same 2.26 M triangles at a
  comparable pixel count and holds 16.7 flat — which points at the 2048 shadow
  map desktop uses against handheld's 1024. Measured at the commit before this
  work with the same harness: identical p95 33.3. Pre-existing, and invisible
  until the cell existed.
- The aeroplane glyph is still about 1.5x the Golden Gate's main span at chapter
  zoom, down from 2.5x. `GLYPH_MAX_SCALE` is 52 because the raw scale at the far
  end of the California orbit is 51.0 at a 60-degree field of view, and 26 —
  tried first — put the glyph at 0.0123 of the frame against the 0.012 where the
  wings stop resolving. The real fix is to clamp against the camera's focus
  distance rather than the aircraft's, which is a signature change.

Tests 1020 -> 1137. Typecheck, build, eight budget cells, no-binaries,
provenance, zero-config boot, dependency licences, arena source hashes and the
UI smoke across two viewports and two access tiers all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 05:06:12 -07:00
parent d9f8baf171
commit f81d5218d4
29 changed files with 7056 additions and 288 deletions
+293
View File
@@ -0,0 +1,293 @@
/**
* The bridge kit's judgement, which is the part a screenshot cannot check.
*
* `bridges.ts` decides what *kind* of bridge a `Bridge` record describes: which
* reaches of deck hang from a cable, where the anchorages go, and which stretches
* stand on piers instead. Those decisions are invisible in a picture except as
* their consequences — the Bay Bridge's Yerba Buena crossing is right when there
* is no cable over the island, and "no cable" is also exactly what a broken
* classifier looks like. So they are asserted here.
*
* The world below is San Francisco's real projection, not a unit square: one
* scene unit is 94.34 m and heights carry the pack's 3.6× exaggeration. That
* matters because the first version of the span limit compared an exaggerated
* tower height against an unexaggerated deck length, decided the Bay Bridge
* could suspend two and a half kilometres, and drew it. A fake world with a
* tidy 1:1 scale would have passed.
*/
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { buildBridge, planBridge } from "../../engine/bridges.ts";
import type { GeometrySink, SurfaceKind } from "../../engine/bridges.ts";
import type { Bridge } from "../../engine/types.ts";
import type { World } from "../../engine/world.ts";
const LAT_SCALE = 1180;
const CENTRE = { lat: 37.7749, lng: -122.4194 };
const METRES_PER_UNIT = 111_320 / LAT_SCALE;
const EXAGGERATION = 3.6;
/** San Francisco's projection, with the ground wherever the caller says. */
function bayWorld(groundAt: (lat: number, lng: number) => number = () => 0): World {
const lngScale = LAT_SCALE * Math.cos((CENTRE.lat * Math.PI) / 180);
return {
project(lat: number, lng: number): [number, number] {
return [(lng - CENTRE.lng) * lngScale, -(lat - CENTRE.lat) * LAT_SCALE];
},
groundAt,
metres(value: number): number {
return (value / METRES_PER_UNIT) * EXAGGERATION;
},
metresPerUnit: METRES_PER_UNIT,
} as unknown as World;
}
/** Collects what a build emitted, without a `Batch` or a renderer in sight. */
function sink(): GeometrySink & { parts: { name: string; geometry: THREE.BufferGeometry }[] } {
const materials = new Map<string, THREE.Material>();
const parts: { name: string; geometry: THREE.BufferGeometry }[] = [];
return {
parts,
material(kind: SurfaceKind, color: number): THREE.Material {
const key = `${kind}:${color}`;
const hit = materials.get(key);
if (hit) return hit;
const made = new THREE.MeshBasicMaterial({ color });
made.name = key;
materials.set(key, made);
return made;
},
add(name, geometry) {
parts.push({ name, geometry });
},
};
}
const ROADWAY = new THREE.MeshBasicMaterial({ color: 0x3a3f42 });
/** The pack's Golden Gate, coordinate for coordinate. */
const GOLDEN_GATE: Bridge = {
name: "Golden Gate Bridge",
path: [
[37.8025, -122.4752],
[37.8106, -122.4775],
[37.8155, -122.4783],
[37.825, -122.479],
[37.8325, -122.4798],
[37.8375, -122.4806],
],
towers: [
[37.8155, -122.4783],
[37.825, -122.479],
],
towerHeight: 227,
deckHeight: 67,
sag: 0.55,
color: 0xc0442c,
};
/** The pack's Bay Bridge: two west-span towers, a tunnel, and one more tower. */
const BAY_BRIDGE: Bridge = {
name: "Bay Bridge",
path: [
[37.7905, -122.3885],
[37.7965, -122.3805],
[37.8035, -122.3725],
[37.8095, -122.3648],
[37.8155, -122.3535],
[37.8205, -122.3405],
[37.8225, -122.3275],
],
towers: [
[37.7955, -122.3815],
[37.8035, -122.3725],
[37.8165, -122.3515],
],
towerHeight: 160,
deckHeight: 58,
sag: 0.4,
color: 0x9aa6b2,
};
/** The pack's San MateoHayward: eleven kilometres of trestle, one high span. */
const SAN_MATEO: Bridge = {
name: "San MateoHayward Bridge",
path: [
[37.5745, -122.2585],
[37.578, -122.255],
[37.5865, -122.2405],
[37.6, -122.212],
[37.615, -122.175],
[37.628, -122.128],
[37.6305, -122.1235],
],
towers: [[37.5865, -122.2405]],
towerHeight: 58,
deckHeight: 14,
sag: 0.3,
color: 0x9aa6b2,
};
// ---- The classifier --------------------------------------------------------
test("the Golden Gate is a main span between its towers", () => {
const plan = planBridge(bayWorld(), GOLDEN_GATE);
const main = plan.reaches.filter((reach) => reach.kind === "main");
assert.equal(main.length, 1, "the strait is one main span, not several");
const [span] = main;
assert.ok(span);
assert.deepEqual(
[span.from, span.to],
plan.towerStations,
"the main span does not run tower to tower",
);
// One cable run: anchorage, tower, tower, anchorage.
assert.equal(plan.chains, 1);
});
test("the Golden Gate anchors its cable short of the shore", () => {
const plan = planBridge(bayWorld(), GOLDEN_GATE);
// The pack runs the path 1.4 km past each tower so the span has something to
// land on. A real anchorage sits about half a main span out and the rest is
// approach viaduct — anchoring at the end of the path instead is what used to
// run the side cables up the Presidio bluff.
const kinds = plan.reaches.map((reach) => reach.kind);
assert.equal(kinds[0], "approach", "the first reach should be viaduct, not cable");
assert.equal(kinds[kinds.length - 1], "approach");
assert.equal(kinds.filter((kind) => kind === "side").length, 2);
});
test("the Bay Bridge does not suspend a cable over Yerba Buena", () => {
const plan = planBridge(bayWorld(), BAY_BRIDGE);
const towers = plan.towerStations;
const [west, centre, east] = towers;
assert.ok(west !== undefined && centre !== undefined && east !== undefined);
const between = plan.reaches.find((reach) => reach.from === centre && reach.to === east);
assert.ok(between, "no reach runs from the west span's far tower to the east span's");
// 2.35 km at 160 m of tower is fifteen tower-heights. Nothing ever built
// reaches nine, and this one crosses an island.
assert.equal(between.kind, "approach");
const main = plan.reaches.filter((reach) => reach.kind === "main");
assert.equal(main.length, 1, "only the west span is suspended tower-to-tower");
assert.deepEqual([main[0]?.from, main[0]?.to], [west, centre]);
// Two separate cable runs: the west span's, and the single-tower east span's.
assert.equal(plan.chains, 2);
});
test("a trestle with one channel tower gets a hump, not a cable across the bay", () => {
const plan = planBridge(bayWorld(), SAN_MATEO);
assert.equal(plan.chains, 1, "the ship-channel tower should carry exactly one cable run");
const suspended = plan.reaches.filter((reach) => reach.kind !== "approach");
const suspendedLength = suspended.length;
assert.ok(suspendedLength > 0, "the tower is holding nothing up");
// Eleven kilometres of crossing, and the cable covers a few hundred metres of
// it. `sideLimit` for a 58 m tower is 58 × 9 × 0.55 = 287 m.
const cableSpan = suspended.reduce((sum, reach) => sum + (reach.to - reach.from), 0);
assert.ok(
cableSpan < plan.stationCount * 0.2,
`the cable covers ${cableSpan} of ${plan.stationCount} stations`,
);
});
// ---- What comes out --------------------------------------------------------
test("a bridge is two buckets: painted structure, and roadway", () => {
const into = sink();
const cost = buildBridge(bayWorld(), GOLDEN_GATE, into, ROADWAY);
const names = new Set(into.parts.map((part) => part.name));
assert.deepEqual([...names].sort(), ["Golden Gate Bridge", "bridge:roadway"]);
assert.ok(cost.roadway > 0, "the deck has no running surface");
assert.ok(cost.structure > cost.roadway, "the structure should outweigh one flat plate");
// The parts the whole kit exists for. `byPart` is the census, not the buckets.
for (const part of ["deck", "tower", "cable", "hanger", "pier", "anchorage"]) {
assert.ok((cost.byPart[part] ?? 0) > 0, `the bridge has no ${part}`);
}
});
test("every part carries the attributes a merge needs", () => {
const into = sink();
buildBridge(bayWorld(), BAY_BRIDGE, into, ROADWAY);
// `mergeGeometries` returns null when attribute sets disagree, and `Batch`
// drops the whole bucket. A part missing a UV takes the bridge with it.
for (const part of into.parts) {
for (const attribute of ["position", "normal", "uv"]) {
assert.ok(
part.geometry.getAttribute(attribute),
`${part.name} lost its ${attribute}`,
);
}
assert.ok(part.geometry.getIndex(), `${part.name} is not indexed`);
}
});
test("the towers stand at their authored height and the deck at its own", () => {
const into = sink();
buildBridge(bayWorld(), GOLDEN_GATE, into, ROADWAY);
const world = bayWorld();
const box = new THREE.Box3();
for (const part of into.parts) {
part.geometry.computeBoundingBox();
if (part.geometry.boundingBox) box.union(part.geometry.boundingBox);
}
assert.ok(
Math.abs(box.max.y - world.metres(227)) < 0.2,
`the towers top out at ${box.max.y.toFixed(2)}, not ${world.metres(227).toFixed(2)}`,
);
const roadway = into.parts.filter((part) => part.name === "bridge:roadway");
const deck = new THREE.Box3();
for (const part of roadway) {
part.geometry.computeBoundingBox();
if (part.geometry.boundingBox) deck.union(part.geometry.boundingBox);
}
assert.ok(
Math.abs(deck.max.y - world.metres(67)) < 0.05,
`the deck sits at ${deck.max.y.toFixed(2)}, not ${world.metres(67).toFixed(2)}`,
);
// The ramp at each end drops the deck onto the shore, so the lowest roadway
// is below the authored deck height rather than at it.
assert.ok(deck.min.y < deck.max.y - 0.2, "the deck never lands on anything");
});
test("a pier is not driven through an island", () => {
// Yerba Buena, as a hill under the middle of the crossing: ground above the
// deck for a stretch of it. The approach must walk onto that rather than
// standing on stilts over the top of it.
const island = bayWorld((lat, lng) =>
lat > 37.806 && lat < 37.813 && lng > -122.368 && lng < -122.36 ? 4 : 0,
);
const into = sink();
buildBridge(island, BAY_BRIDGE, into, ROADWAY);
const flat = sink();
buildBridge(bayWorld(), BAY_BRIDGE, flat, ROADWAY);
const piers = (parts: typeof into.parts) =>
parts.filter((part) => {
part.geometry.computeBoundingBox();
const box = part.geometry.boundingBox;
return box !== null && box.min.y < -0.05;
}).length;
assert.ok(
piers(into.parts) < piers(flat.parts),
"the island did not remove a single pier",
);
});
test("the whole crossing stays inside its triangle allowance", () => {
// The Bay Area board had 21,256 spare triangles when this kit was written and
// five crossings to spend them on. This is the per-bridge share, and it is
// here because the cheapest way to lose it is a spacing constant: halving
// `stationSpacing` quadruples nothing visible and doubles the deck.
const world = bayWorld();
for (const bridge of [GOLDEN_GATE, BAY_BRIDGE, SAN_MATEO]) {
const into = sink();
const cost = buildBridge(world, bridge, into, ROADWAY);
const total = cost.structure + cost.roadway;
assert.ok(total < 4_000, `${bridge.name} costs ${total} triangles`);
}
});
+101
View File
@@ -0,0 +1,101 @@
/**
* The aeroplane glyph has a floor AND a ceiling, and the ceiling is the newer half.
*
* The floor is a screen-space rule: never smaller than legible, because position
* and heading are what a reader wants off a map and neither survives half a
* pixel. It scales by the distance to *that aircraft*, which is the same thing
* as "how far the camera has zoomed" only when everything in frame is equally
* far away. On a whole-board pose it is. Beside a landmark it is not — at the
* Golden Gate the bridge is a couple of units from the camera and the traffic
* over the Pacific is a couple of thousand, so the floor fired hard on the
* aeroplane and not at all on the bridge, and an airliner was drawn about two
* and a half times the length of the main span.
*
* These tests pin both ends: that the board still gets a symbol it can read, and
* that no camera anywhere can produce a state-sized aeroplane again.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { glyphScale } from "../../engine/flights.ts";
/** The field of view the city scenes actually use, near enough for a ratio. */
const FOV = 50;
describe("glyph scale", () => {
it("never shrinks an aeroplane below the size it was drawn at", () => {
for (const d of [0.5, 5, 20, 34]) {
assert.ok(glyphScale(d, FOV) >= 1, `close camera at ${d} must not shrink the glyph`);
}
});
it("still grows the glyph across the whole-board range, where the floor is the point", () => {
const near = glyphScale(200, FOV);
const far = glyphScale(400, FOV);
assert.ok(near > 1, "a board-distance aeroplane must be enlarged to stay readable");
assert.ok(far > near, "the floor must keep tracking distance until the ceiling binds");
});
it("stops growing at all, however far the aircraft is", () => {
const capped = glyphScale(2280, FOV);
assert.equal(
glyphScale(4000, FOV),
capped,
"past the ceiling the scale must be flat, not merely slower",
);
assert.equal(glyphScale(1e9, FOV), capped, "and flat all the way out");
});
/*
* Deliberately asserts a REDUCTION and not an absolute size, because the
* ceiling is a mitigation and calling it a cure in a test name would be the
* test lying about the product.
*
* 2,280 units is the measured Golden Gate case, where the uncapped glyph
* reached about 81x — roughly two and a half times the bridge's 1,280 m main
* span at ~94 m to the unit. The ceiling takes that to about one and a half.
* It is still bigger than the bridge. The complete fix is to clamp against the
* camera's focus distance rather than the aircraft's, which is a signature
* change and is written up in `flights.ts`.
*/
it("cuts the worst case by a third without touching any board distance", () => {
/** The floor alone, with no ceiling — what the scale used to be. */
const uncapped = (d: number, fov: number): number => {
const frustum = 2 * d * Math.tan((fov * Math.PI) / 360);
return Math.max(1, (0.016 * frustum) / 0.42);
};
// 1,160 units is the far end of the orbit over the California corridor — a
// pose people actually use. It must be untouched at every field of view the
// scenes run at, because below 0.012 of the frame the wings stop resolving
// and a ceiling of 26 put it at 0.0123.
for (const fov of [42, 50, 60]) {
assert.equal(
glyphScale(1160, fov),
uncapped(1160, fov),
`the ceiling must not bind at 1160 units and ${fov} degrees`,
);
}
// And the chapter-zoom case must actually come down.
assert.ok(
glyphScale(2280, 50) < uncapped(2280, 50) * 0.7,
"the ceiling must remove at least 30% of the worst case",
);
});
it("is monotonic, so an aeroplane never grows as it approaches", () => {
let previous = 0;
for (const d of [1, 10, 50, 100, 300, 700, 900, 2000, 5000]) {
const s = glyphScale(d, FOV);
assert.ok(s >= previous, `scale fell between distances at ${d}`);
previous = s;
}
});
it("returns 1 for degenerate inputs rather than emptying the sky", () => {
for (const [d, fov] of [[0, FOV], [-1, FOV], [10, 0], [10, 180], [NaN, FOV], [10, NaN]]) {
assert.equal(glyphScale(d as number, fov as number), 1);
}
});
});
+92 -38
View File
@@ -35,24 +35,34 @@ import type { Bridge, City, Road } from "../../engine/types.ts";
import type { World } from "../../engine/world.ts";
/**
* The smallest thing `structures.ts` will accept: a flat projection, ground at
* zero, and metres straight through.
* The smallest thing `structures.ts` will accept: San Francisco's projection,
* ground at sea level, and no heightfield.
*
* A real `World` builds a heightfield, which is 0.53M lattice points and a
* couple of seconds — none of which any assertion here depends on.
* A real `World` builds one, which is 0.53M lattice points and a couple of
* seconds — none of which any assertion here depends on. The *scale* does
* matter and used to be a tidy 20 units per degree with metres straight
* through: `bridges.ts` sizes its members against `metresPerUnit` and compares
* span lengths against tower heights, so a world whose projection and whose
* `metres()` disagree gives a bridge nothing real to be checked against.
*/
const LAT_SCALE = 1180;
const CENTRE = { lat: 37.7749, lng: -122.4194 };
const METRES_PER_UNIT = 111_320 / LAT_SCALE;
function flatWorld(city: Partial<City>): World {
const lngScale = LAT_SCALE * Math.cos((CENTRE.lat * Math.PI) / 180);
return {
city: { roads: [], bridges: [], inlandWater: [], ...city } as unknown as City,
project(lat: number, lng: number): [number, number] {
return [(lng + 122) * 20, -(lat - 37) * 20];
return [(lng - CENTRE.lng) * lngScale, -(lat - CENTRE.lat) * LAT_SCALE];
},
groundAt(): number {
return 0;
},
metres(value: number): number {
return value / 100;
return (value / METRES_PER_UNIT) * 3.6;
},
metresPerUnit: METRES_PER_UNIT,
} as unknown as World;
}
@@ -73,6 +83,11 @@ const GOLDEN_GATE: Bridge = {
color: 0xc0553b,
};
/** Everything painted the bridge's own colour, which is everything but the road. */
function structureOf(root: THREE.Object3D): THREE.Mesh | undefined {
return meshes(root).find((mesh) => mesh.name !== "bridge:roadway");
}
function meshes(root: THREE.Object3D): THREE.Mesh[] {
const found: THREE.Mesh[] = [];
root.traverse((object) => {
@@ -92,29 +107,32 @@ function materialsIn(root: THREE.Object3D): Set<THREE.Material> {
// ---- Bridges ---------------------------------------------------------------
test("a suspension bridge is one material and one draw call", () => {
test("a suspension bridge is two draw calls: structure and roadway", () => {
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
// The spec's number is six; a bridge is painted one colour throughout, so
// anything above one is a part that was left out of the bucket.
// The spec's number was six for a bridge painted one colour throughout, and
// it is two now for a reason worth stating: the deck of a bridge is a road,
// and painting it International Orange with the towers is most of why the
// Golden Gate used to read as a red line. Everything structural is still one
// material — anything above two is a part that fell out of a bucket.
const distinct = materialsIn(bridge);
assert.ok(distinct.size <= 6, `the bridge holds ${distinct.size} materials`);
assert.equal(distinct.size, 1, `the bridge holds ${distinct.size} materials, not one`);
assert.equal(meshes(bridge).length, 1, "the bridge did not merge into one mesh");
assert.equal(distinct.size, 2, `the bridge holds ${distinct.size} materials, not two`);
assert.equal(meshes(bridge).length, 2, "the bridge did not merge into two meshes");
assert.ok(structureOf(bridge), "nothing in the bridge is painted the bridge's colour");
});
test("merging kept every part of the bridge", () => {
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
const merged = meshes(bridge)[0];
const merged = structureOf(bridge);
assert.ok(merged);
// The arithmetic, because a bucket that failed to merge comes out as one
// *span* of geometry and otherwise looks entirely healthy: a 3-point deck tube
// is 7 × 5 = 35 vertices, two towers and four braces are 24 each = 144, three
// cable spans at 25 × 6 = 450, and the hangers are 24 boxes of 24 less
// whichever ones the deck-clearance test culls — call it 1,000 at the floor.
// *part* of a bridge and otherwise looks entirely healthy. Two towers are six
// frusta, two fenders and five struts each 13 boxes, 24 vertices apiece —
// the deck box is four strips over about fifty stations, and the cables and
// their hangers are the rest. Two thousand is well under the floor.
const vertices = merged.geometry.getAttribute("position").count;
assert.ok(vertices > 1_000, `the bridge merged down to ${vertices} vertices`);
assert.ok(vertices > 2_000, `the bridge merged down to ${vertices} vertices`);
// The merge only happens because every part carries the same attributes.
for (const name of ["position", "normal", "uv"]) {
@@ -127,30 +145,38 @@ test("merging kept every part of the bridge", () => {
});
test("the bridge is still shaped like a bridge after the merge", () => {
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
const merged = meshes(bridge)[0];
const world = flatWorld({});
const bridge = createBridge(world, GOLDEN_GATE);
const merged = structureOf(bridge);
assert.ok(merged);
merged.geometry.computeBoundingBox();
const box = merged.geometry.boundingBox;
assert.ok(box);
// Towers to 2.27 units, deck at 0.67, cables sagging between. Baking the
// transforms into the geometry is where a merge goes wrong — a part that lost
// its translation collapses onto the origin and the box stops matching.
assert.ok(Math.abs(box.max.y - 2.27) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
assert.ok(box.min.y > 0, "something sank below the water line");
assert.ok(box.max.x - box.min.x > 0.4, "the bridge has no span");
// Towers to 8.66 units — 227 m at 94 m per unit and 3.6× exaggeration — with
// the deck and its cables below. Baking the transforms into the geometry is
// where a merge goes wrong: a part that lost its translation collapses onto
// the origin and the box stops matching.
const top = world.metres(227);
assert.ok(Math.abs(box.max.y - top) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
// Tower feet and pier footings go under the surface on purpose; nothing
// should be a whole tower's worth of them.
assert.ok(box.min.y > -1, `something sank to ${box.min.y.toFixed(2)}`);
assert.ok(box.max.z - box.min.z > 20, "the bridge has no span");
});
test("two bridges are two draw calls, not sixty-eight", () => {
test("two bridges are three draw calls, not sixty-eight", () => {
const second: Bridge = { ...GOLDEN_GATE, name: "bay-bridge", color: 0x9aa6ad };
const group = createBridges(flatWorld({ bridges: [GOLDEN_GATE, second] }));
assert.equal(meshes(group).length, 2);
// Different colours, so genuinely two materials. Each bridge builds its own
// batch, which is deliberate: the cache cannot outlive the build, because
// `createScene().dispose()` walks the scene disposing every material it finds
// and a shared cache would hand the next board a disposed one.
assert.equal(materialsIn(group).size, 2);
// Two structures — different colours, so genuinely two materials — and one
// roadway, because both decks are the same asphalt and one batch covers the
// whole board. That batch still cannot outlive the build: `createScene()
// .dispose()` walks the scene disposing every material it finds, and a cache
// that survived would hand the next board a disposed one.
assert.equal(meshes(group).length, 3);
assert.equal(materialsIn(group).size, 3);
const names = meshes(group).map((mesh) => mesh.name).sort();
assert.deepEqual(names, ["bay-bridge", "bridge:roadway", "golden-gate"]);
});
// ---- Roads -----------------------------------------------------------------
@@ -180,7 +206,7 @@ test("identical roads share one material and one mesh", () => {
assert.ok(merged.geometry.getAttribute("uv"), "the road deck lost the UVs merging depends on");
});
test("a freeway keeps its median stroke as a second material", () => {
test("a freeway carries its markings as texture, not as a second ribbon", () => {
const freeway: Road = {
kind: "freeway",
width: 0.14,
@@ -190,8 +216,36 @@ test("a freeway keeps its median stroke as a second material", () => {
],
};
const group = createRoads(flatWorld({ roads: [freeway] }));
// Two colours is two calls, and that is the floor rather than a regression:
// the stroke is a different colour from the deck it sits on.
assert.equal(meshes(group).length, 2);
assert.equal(materialsIn(group).size, 2);
// One ribbon, one call. The median stroke used to be a second draped ribbon
// in a second colour; verge, shoulders, edge lines and median now live in the
// surface texture, which costs no triangles and reads as a road rather than
// as a line on a map.
assert.equal(meshes(group).length, 1);
assert.equal(materialsIn(group).size, 1);
});
test("a road is drawn wider than its carriageway, and streets less so", () => {
const shape = (kind: Road["kind"]): number => {
const road: Road = {
kind,
width: 0.2,
path: [
[37.7, -122.4],
[37.9, -122.4],
],
};
const mesh = meshes(createRoads(flatWorld({ roads: [road] })))[0];
assert.ok(mesh);
mesh.geometry.computeBoundingBox();
const box = mesh.geometry.boundingBox;
assert.ok(box);
return box.max.x - box.min.x;
};
// The widening is the graded right-of-way the texture paints, and a freeway
// gets more of it than a boulevard does. Both are wider than the authored
// 0.2, which is the carriageway alone.
const street = shape("street");
const freeway = shape("freeway");
assert.ok(street > 0.2 && street < 0.35, `a street came out ${street.toFixed(3)} wide`);
assert.ok(freeway > street, "a freeway is no wider than a street");
});
+298
View File
@@ -0,0 +1,298 @@
/**
* 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 { 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. */
const BASE: Omit<City, "hills"> = {
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<World> {
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<ScenePalette> = { sand: 0xffffff, flats: 0x000000 };
const plain: Partial<ScenePalette> = { 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}`,
);
});