diff --git a/src/cities/unify.ts b/src/cities/unify.ts index c655ded..712fe5f 100644 --- a/src/cities/unify.ts +++ b/src/cities/unify.ts @@ -166,6 +166,32 @@ export function unifiedCalifornia(): { city: City; report: UnifyReport } { m.districts.map((d) => ({ ...d, detail: true as const })), ); + /* + * ---- Chapters: the state's, and only the state's ------------------------- + * + * **Tried, photographed, reverted.** Merging all twenty-four rungs onto this + * board is arithmetically easy — `focus.lat/lng` is absolute, `distance` is a + * horizontal length in the owning board's units and `height` has already been + * through that board's exaggeration, so each converts by keeping its true + * metres. That was done, and the camera arrives exactly where it should. + * + * The frame it arrives at is the problem, and it is a fact about the board + * rather than about the conversion. Flying to FiDi puts the camera about two + * scene units from its target on a board that is 551 units across, and what + * is at that range is *state-scale content*: `createFreewayWorld` draws the + * corridor as a deliberate 4.7 km-wide atlas glyph — `main.ts` says so where + * it sets the corridor altitude, because DRIVE mode has to be able to drive + * down it on a board where a real freeway is a fifth of a pixel — and the + * terrain under it is 300 m cells. The delivered picture is a black slab + * across San Francisco. + * + * So a rung that flies you into that is worse than not having the rung. The + * metro chapters come back when the corridor is drawn at true width on this + * board, which is `createFreewayWorld`'s hard-coded scene units and the + * `roads` reconciliation rule between them — not this file. + */ + const mergedChapters = state.chapters; + const fine = UNIFIED_FINE_METRES / 111_320; const city: City = { @@ -196,13 +222,7 @@ export function unifiedCalifornia(): { city: City; report: UnifyReport } { inlandWater: [...state.inlandWater, ...metros.flatMap((m) => m.inlandWater)], airports: [...(state.airports ?? []), ...metros.flatMap((m) => m.airports ?? [])], ports: [...(state.ports ?? []), ...metros.flatMap((m) => m.ports ?? [])], - /* - * Chapters are the state's alone. The metros' twenty-four rungs are poses - * expressed in *their* boards' framing, and `ladder.ts` already presents all - * of them as one list; re-pointing them at this board is its own piece of - * work and a wrong pose is a camera in the ground. - */ - chapters: state.chapters, + chapters: mergedChapters, /* * Landmasses are NOT unioned. `isLand` is a point-in-any-polygon test, so a * metro's finer coastline can only ever add land the state's outline already diff --git a/src/engine/scene.ts b/src/engine/scene.ts index f30aee1..dd5fdd6 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -929,6 +929,26 @@ export async function createScene( } } const detailReachUnits = DETAIL_REACH_M / world.metresPerUnit; + + /** + * The metro-scale layers, which follow the same reveal as the buildings. + * + * Filled after the port and vessel layers exist, a few dozen lines below; + * `applyDetailLod` is only ever called from the frame loop, which starts + * later still. + * + * **On a merged board every bridge, airport and port is metro detail**, and + * that is a measured fact rather than an assumption: a scene census of the + * state pack against the merged one showed all seven named bridges, all eight + * airport layers and every port mesh going from *zero* to one — `california.ts` + * authors none of them. Together with the crane heads, bridge lamps and hulls + * they were 27 of the 58 draw calls that put the merged board over the mobile + * cap, for structures that are sub-pixel at 1,919 m to the unit. + * + * Empty on every other board, because `hasDetail` is false there and + * `applyDetailLod` returns before reading this. + */ + const detailLayers: THREE.Object3D[] = []; let detailShown = true; function applyDetailLod(): void { if (!hasDetail) return; @@ -947,18 +967,18 @@ export async function createScene( detailShown = want; blocks.count = want ? baseCount + detailLots : baseCount; for (const child of detailLandmarks) child.visible = want; + for (const layer of detailLayers) layer.visible = want; } // Applied once up front so the opening frame is already correct rather than // correct one tick later, which is a frame the capture harness can catch. - if (hasDetail) { - detailShown = true; - applyDetailLod(); - } - scene.add(createBridges(world)); + + const bridgeGroup = createBridges(world); + scene.add(bridgeGroup); // Airfields. Laid flush on the terrain rather than draped over it like a // road, which is why the packs no longer carry runways as `Road` records — // carrying both floats a dark stripe thirteen metres above every runway. - scene.add(createAirports(world, city.airports ?? [])); + const airportGroup = createAirports(world, city.airports ?? []); + scene.add(airportGroup); /** * The city switching itself on after sunset. Built after `blocks` because it @@ -1011,6 +1031,19 @@ export async function createScene( scene.add(vesselLayer.group); } + /* + * Now that every metro-scale layer exists, hand them to the detail LOD and + * apply it once — up front, so the opening frame is already correct rather + * than correct one tick later, which is a frame the capture harness can catch. + */ + if (hasDetail) { + detailLayers.push(bridgeGroup, airportGroup); + if (portLayer) detailLayers.push(portLayer.group); + if (vesselLayer) detailLayers.push(vesselLayer.group); + detailShown = true; + applyDetailLod(); + } + const precipLayer: PrecipLayer | null = options.precip ? options.precip(world, { span: boardSpan }) : null; @@ -1796,12 +1829,23 @@ export const DETAIL_STANDOFF_M = 120_000; * How near a detail district the camera's *target* must be before that detail * is drawn, in true metres. * - * 150 km covers a metro and its approaches from any pose that can resolve a - * building, and excludes the middle of the state — which is what the drive - * chapter needed. Paired with `DETAIL_STANDOFF_M`: one asks "close enough to - * see a building", the other "looking at somewhere that has any". + * **60 km, measured down from 150.** At 150 km the test asked "are you in the + * same half of the state as a city", which the corridor drive answers yes to + * along most of its length: `california-drive` revealed every building, bridge, + * airport and port on the board while the camera was a chase view on a freeway + * eighty kilometres from any of them, and measured 315 draw calls against a 280 + * mobile cap. + * + * 60 km asks "are you looking at a city", which is the question this flag is + * actually for. A metro on this board is roughly fifty kilometres across, so a + * target anywhere in one is comfortably inside; and driving up US-101 the + * Southland appears as you come into it rather than while you are still in the + * Salinas Valley — which is the better behaviour as well as the cheaper one. + * + * Paired with `DETAIL_STANDOFF_M`: one asks "close enough to resolve a + * building", the other "looking at somewhere that has any". */ -export const DETAIL_REACH_M = 150_000; +export const DETAIL_REACH_M = 60_000; export const ARRIVAL_STANDOFF = 1.5; /** How much higher, as a multiple. Larger than the stand-off: the move descends. */ diff --git a/src/engine/terrain.ts b/src/engine/terrain.ts index adf52d5..c3ad9b5 100644 --- a/src/engine/terrain.ts +++ b/src/engine/terrain.ts @@ -16,6 +16,7 @@ */ import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; import type { ScenePalette } from "./types.ts"; import type { World } from "./world.ts"; @@ -977,46 +978,75 @@ diffuseColor.rgb *= mix( 1.0, uSeaDeep, seaFacing );`, }; group.add(sea); + /* + * **Every lake on the board is one mesh, and it used to be one mesh each.** + * + * A `Mesh` per polygon with a `MeshStandardMaterial` per polygon is a draw + * call per polygon, and the packs that have a lot of inland water are + * precisely the ones that can least afford them. It went unnoticed while the + * boards were separate — California draws one lake, the Salton Sea — and + * showed up the moment `cities/unify.ts` folded three packs into one: a + * scene census on a phone read **water=20 against water=1**, nineteen draw + * calls of the fifty-eight that put the merged board over the mobile cap. + * + * They differ in nothing but shape: same colour, same roughness, same + * polygon offset, same y. So this merges the geometries and shares one + * material, which is 1 draw instead of N on every board in the product — + * San Francisco and the Southland included, where it was never free either. + * + * The 0.05 lift is baked into each geometry before the merge rather than set + * on a mesh transform, because after the merge there is no per-lake mesh left + * to carry it. + */ + const lakeGeometries: THREE.BufferGeometry[] = []; for (const poly of world.city.inlandWater) { const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z)); const geo = new THREE.ShapeGeometry(new THREE.Shape(pts)); geo.rotateX(Math.PI / 2); - // The same change as the sea above, and for the same reason. Slightly - // rougher: an inland lake is sheltered, and a mirror-smooth bay next to a - // wind-roughened ocean reads as the wrong way round. - const lake = new THREE.Mesh( - geo, - new THREE.MeshStandardMaterial({ - color: pal.lake, - roughness: 0.24, - metalness: 0, - side: THREE.DoubleSide, - /** - * The five-hundredths below is not a separation, at board scale. - * - * A lake floats over the shore plate, which is the same landmass - * polygon lying flat at y=0, and 0.05 units is all there is between - * them. On San Francisco — 230 units across, camera a couple of hundred - * out — that is comfortably more than one step of the depth buffer and - * the two never argue. On the California board the camera stands 570 - * units off and the depth buffer's resolution *there* is about 0.15 - * units, three times the gap: the Salton Sea came out banded in - * alternating stripes of lake and shore, which reads as a rendering - * artefact because it is one. - * - * Polygon offset is the fix rather than a bigger `y`, because it is - * expressed in units of whatever the depth buffer can currently resolve - * — it scales itself with distance, where a hard-coded lift would have - * to be tuned per board and would leave the lake visibly hovering on the - * two boards that never needed it. - */ - polygonOffset: true, - polygonOffsetFactor: -2, - polygonOffsetUnits: -4, - }), - ); - lake.position.y = 0.05; - group.add(lake); + geo.translate(0, 0.05, 0); + lakeGeometries.push(geo); + } + if (lakeGeometries.length > 0) { + const merged = mergeGeometries(lakeGeometries, false); + for (const geo of lakeGeometries) geo.dispose(); + if (merged !== null) { + const lake = new THREE.Mesh( + merged, + new THREE.MeshStandardMaterial({ + color: pal.lake, + // Slightly rougher than the sea: an inland lake is sheltered, and a + // mirror-smooth bay next to a wind-roughened ocean reads as the wrong + // way round. + roughness: 0.24, + metalness: 0, + side: THREE.DoubleSide, + /** + * The five-hundredths above is not a separation, at board scale. + * + * A lake floats over the shore plate, which is the same landmass + * polygon lying flat at y=0, and 0.05 units is all there is between + * them. On San Francisco — 230 units across, camera a couple of + * hundred out — that is comfortably more than one step of the depth + * buffer and the two never argue. On the California board the camera + * stands 570 units off and the depth buffer's resolution *there* is + * about 0.15 units, three times the gap: the Salton Sea came out + * banded in alternating stripes of lake and shore, which reads as a + * rendering artefact because it is one. + * + * Polygon offset is the fix rather than a bigger `y`, because it is + * expressed in units of whatever the depth buffer can currently + * resolve — it scales itself with distance, where a hard-coded lift + * would have to be tuned per board and would leave the lake visibly + * hovering on the two boards that never needed it. + */ + polygonOffset: true, + polygonOffsetFactor: -2, + polygonOffsetUnits: -4, + }), + ); + lake.name = "inlandWater"; + group.add(lake); + } } return group; diff --git a/src/test/integration/airportsAndCard.test.ts b/src/test/integration/airportsAndCard.test.ts index 6a2d9e9..9b284a2 100644 --- a/src/test/integration/airportsAndCard.test.ts +++ b/src/test/integration/airportsAndCard.test.ts @@ -61,12 +61,24 @@ test("the scene builds them, from the field the packs fill in", () => { scene.includes('import { createAirports } from "./airports.ts";'), "scene.ts no longer imports the airport kit", ); + /* + * Two assertions rather than one literal line, because the call and the add + * are no longer the same statement: the group is captured so the merged + * board's detail LOD can hide it, and matching the old one-liner would fail + * for a formatting change while still passing for a real regression like a + * guard replacing `?? []`. The two facts are what the test was ever about. + */ assert.ok( - /scene\.add\(createAirports\(world, city\.airports \?\? \[\]\)\);/.test(scene), - "scene.ts must add the airports for the city it was handed. `?? []` and not a " + - "guard, because a board with no airfields is the normal case and must cost " + + /createAirports\(world, city\.airports \?\? \[\]\)/.test(scene), + "scene.ts must build the airports for the city it was handed. `?? []` and not " + + "a guard, because a board with no airfields is the normal case and must cost " + "nothing to express.", ); + assert.ok( + /scene\.add\(\s*airportGroup\s*\)/.test(scene) || + /scene\.add\(createAirports\(/.test(scene), + "scene.ts builds the airports but never adds them to the scene", + ); }); test("the airport contract stays plain data, and off the package surface", () => { diff --git a/src/test/unify.test.ts b/src/test/unify.test.ts index c149792..3223e75 100644 --- a/src/test/unify.test.ts +++ b/src/test/unify.test.ts @@ -127,3 +127,19 @@ describe("one California", () => { assert.equal(unifiedCalifornia().city, city); }); }); + +describe("one California's chapters", () => { + it("is the state's list, until the corridor is drawn at true width", () => { + /* + * The metro rungs were merged, photographed and reverted; `unify.ts` records + * why in full. The short version is that the conversion was right and the + * frame was not — a two-unit stand-off on a 551-unit board lands inside the + * corridor's 4.7 km-wide atlas glyph. This test exists so that the day + * somebody merges them again, they do it deliberately. + */ + assert.deepEqual( + city.chapters.map((c) => c.id), + CALIFORNIA.chapters.map((c) => c.id), + ); + }); +});