From 23e4bb8ab7aa39627c048f9343603cac28a0572b Mon Sep 17 00:00:00 2001 From: Kartios Date: Mon, 24 Aug 2026 10:05:03 -0700 Subject: [PATCH] fix: withdraw the corridor glyph close in, and give the aeroplane a true-metre size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two scale lies on the merged board, both of the same kind and one of them exactly the bug `LOT` had: a constant denominated in **scene units**, which means a different real size on every board. **The corridor is 4.7 km wide and it is deliberate.** `createFreewayWorld` draws US-101 and I-5 as an atlas glyph because DRIVE mode has to be able to drive down it on a board where a real freeway is a fifth of a pixel — `main.ts` says so where it sets the corridor altitude. On three separate boards that was invisible, because the only board with a corridor was the one you never came close on. `cities/unify.ts` put cities on that board, and the first photograph of San Francisco from 9 km is two black ribbons four and a half kilometres wide lying across the city, wider than the peninsula at the Golden Gate. `overviewLayers` is the mirror of `detailLayers`: withdrawn when the camera comes close, and empty on every board that is not the merged one. Drive mode is exempt, for the obvious reason. **It withdraws the glyph rather than replacing it, and that is a half-measure with the honest half named.** A true-width corridor is the real answer and it is not one commit: the roadside props are laid out against the slab, so a 44 m ribbon with today's props puts 600 m oaks beside a two-lane road; and drive mode's camera cannot see a true-scale car at all — the near plane is 0.1 units, 192 m on this board, against a car 0.0026 units long. Until both are done, a missing road reads as "not modelled yet" and a 4.7 km slab reads as broken. **And with the slabs gone, the aeroplanes were the size of neighbourhoods.** `AIRLINER_LENGTH` is 0.42 *scene units*. `aircraftGeometry.ts` states what that means without flinching — 40 m over San Francisco, 164 m over the Southland, the same 0.42 on both — and on the statewide board at 1,919.3 m to the unit it is **806 metres**. The focus-distance clamp was working correctly and pinning the glyph to scale 1; scale 1 was the problem. `flightBaseScale` gives the glyph a true-metre size, 60 m, and **floors it at 1 so no board's aeroplanes ever grow**: | board | authored | after | | --- | --- | --- | | sf | 40 m | 40 m — untouched | | socal | 164 m | 60 m | | california / one | 806 m | 60 m | San Francisco ships exactly as it is; the two boards where the authored size was 2.7x and 13.5x life are corrected downward. A fix that only ever removes an exaggeration cannot surprise a board that did not have one. The screen-space floor is unchanged and still lifts the glyph to a legible size wherever it is far away — what changes is only the size it collapses *to* when a real aeroplane would be legible on its own. `glyphScale` takes the floor as a fourth argument defaulting to 1, so the twenty assertions pinning its two-argument behaviour are untouched. **A latent throw fixed on the way.** `applyDetailLod` reads `controlMode` to exempt drive mode, and the LOD is applied once up front — before the line `controlMode` used to be declared on. A `let` is in its temporal dead zone until its declaration *executes*, so that ordering made the merged board throw a `ReferenceError` on the first board it drew. TypeScript cannot catch it: the binding is in scope for the whole function and only the runtime knows the order. All twelve budget cells pass and every board's draw calls fell: bay-area 204 -> 199, socal 206 -> 203, california 373 -> 371, california-one 420 -> 415. 1,705 tests pass. Next, and measured rather than guessed: landmarks go through `world.metres`, which multiplies by `verticalExaggeration`. Los Angeles City Hall at 760 m draws 11,399 m tall on the merged board (15x) and Salesforce Tower draws 1,883 m on San Francisco's own (5.8x). That is the grey cube standing over downtown in the photograph, and it is one board, one exaggeration — an owner's call before it is a code change. Co-Authored-By: Claude Opus 5 (1M context) --- src/engine/flights.ts | 58 +++++++++++++++++++++++++++++++++++++++---- src/engine/scene.ts | 56 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/engine/flights.ts b/src/engine/flights.ts index 971311c..036dc1b 100644 --- a/src/engine/flights.ts +++ b/src/engine/flights.ts @@ -1053,6 +1053,43 @@ const GLYPH_MAX_SCALE = 52; */ const GLYPH_FOCUS_HEADROOM = 3; +/** + * How long an airliner actually is, in metres. + * + * **`AIRLINER_LENGTH` is 0.42 *scene units*, and that is the same bug `LOT` had + * in `blocks.ts`.** A constant denominated in scene units means a different real + * size on every board: `aircraftGeometry.ts` states it plainly — 40 m over San + * Francisco, 164 m over the Southland, the same 0.42 on both — and on the + * statewide board at 1,919.3 m to the unit it is **806 metres**. That was + * invisible while the only board you ever came close on was a metro board. + * `cities/unify.ts` put cities on the state board, and the first photograph of + * San Francisco from 9 km has aeroplanes the size of neighbourhoods lying over + * it. + * + * 60 m is a mid-size airliner — an A320 is 38, a 777 is 74 — and the exact + * number matters much less than the units, because the screen-space floor above + * still lifts the glyph to a legible size wherever it is far away. What this + * changes is only the size it collapses *to* when the camera is close enough + * for a real one to be legible on its own. + * + * **Floored at 1 in `flightBaseScale`, so no board's aeroplanes ever grow.** On + * San Francisco the authored 0.42 units is already 39.6 m and the floor keeps it + * exactly as it ships; on the Southland and the statewide board, where the + * authored size is 2.7x and 13.5x life, it is corrected downward. A fix that + * only ever removes an exaggeration cannot surprise a board that did not have + * one. + */ +export const AIRLINER_TRUE_METRES = 60; + +/** + * The multiplier that makes the authored glyph true size on this board, never + * larger than the glyph as drawn. + */ +export function flightBaseScale(metresPerUnit: number): number { + if (!Number.isFinite(metresPerUnit) || metresPerUnit <= 0) return 1; + return Math.min(1, AIRLINER_TRUE_METRES / metresPerUnit / AIRLINER_LENGTH); +} + /** * The radius of the sphere a pointer actually has to hit, in glyph lengths. * @@ -1459,6 +1496,11 @@ export function createFlightLayer(world: World): FlightLayer { * what an office sky, a chase camera and every test with no controls get. */ let focusDistance: number | null = null; + /* + * Computed once: the board's scale does not change under it, and this is read + * per aircraft per frame. + */ + const baseScale = flightBaseScale(world.metresPerUnit); /** * One material per altitude band, built on demand. @@ -1953,7 +1995,12 @@ export function createFlightLayer(world: World): FlightLayer { */ if (viewer !== null) { track.mesh.scale.setScalar( - glyphScale(viewer.position.distanceTo(track.head), viewer.fov, focusDistance ?? undefined), + glyphScale( + viewer.position.distanceTo(track.head), + viewer.fov, + focusDistance ?? undefined, + baseScale, + ), ); } // A heading of 0 is north, and north is -z, so an aircraft whose nose is @@ -2165,9 +2212,10 @@ export function glyphScale( distance: number, fovDegrees: number, focusDistance?: number, + minScale = 1, ): number { - if (!Number.isFinite(distance) || !Number.isFinite(fovDegrees)) return 1; - if (distance <= 0 || fovDegrees <= 0 || fovDegrees >= 180) return 1; + if (!Number.isFinite(distance) || !Number.isFinite(fovDegrees)) return minScale; + if (distance <= 0 || fovDegrees <= 0 || fovDegrees >= 180) return minScale; const legible = legibleScale(distance, fovDegrees); /* * The third argument is optional and omitting it must reproduce the previous @@ -2182,10 +2230,10 @@ export function glyphScale( focusDistance !== undefined && Number.isFinite(focusDistance) && focusDistance > 0 ? Math.min( GLYPH_MAX_SCALE, - Math.max(1, legibleScale(focusDistance, fovDegrees) * GLYPH_FOCUS_HEADROOM), + Math.max(minScale, legibleScale(focusDistance, fovDegrees) * GLYPH_FOCUS_HEADROOM), ) : GLYPH_MAX_SCALE; - return Math.min(ceiling, Math.max(1, legible)); + return Math.min(ceiling, Math.max(minScale, legible)); } /** diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 30566a9..d37724d 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -851,9 +851,10 @@ export async function createScene( scene.add(createWater(world)); scene.add(createShorePlates(world)); scene.add(createTerrain(world)); - scene.add(options.roadTraffic + const corridorGroup = options.roadTraffic ? createFreewayWorld(world, options.roadTraffic.pack) - : createRoads(world)); + : createRoads(world); + scene.add(corridorGroup); const buildingReservations: BuildingReservation[] = []; for (const marker of options.markers ?? []) { const glyph = marker.glyph; @@ -948,7 +949,52 @@ export async function createScene( * Empty on every other board, because `hasDetail` is false there and * `applyDetailLod` returns before reading this. */ + /** + * Declared **here**, above the detail LOD, and not beside its listeners. + * + * `applyDetailLod` reads it to exempt drive mode from the corridor withdrawal, + * and the LOD is applied once up front — before the line this used to sit on. + * A `let` is in its temporal dead zone until its declaration *executes*, so + * the earlier position made the merged board throw a `ReferenceError` on the + * first board it ever drew. TypeScript does not catch that: the binding is in + * scope for the whole function and only the runtime knows the order. + */ + let controlMode: CityControlMode = "overview"; + const detailLayers: THREE.Object3D[] = []; + + /** + * Layers that belong to the wide view and are **withdrawn** when the camera + * comes in close — the mirror of `detailLayers`, and empty on every board that + * is not the merged one. + * + * The corridor is the whole of it. `createFreewayWorld` draws US-101 and I-5 + * as a deliberate atlas glyph about 4.7 km across, because DRIVE mode has to + * be able to drive down it on a board where a real freeway is a fifth of a + * pixel — `main.ts` says so where it sets the corridor's altitude. On the + * three separate boards that was invisible, because the only board with a + * corridor was the one you never came close on. + * + * `cities/unify.ts` put cities on that board, and the first photograph of San + * Francisco from 9 km showed what that means: two black ribbons four and a + * half kilometres wide lying across the city, wider than the peninsula is at + * the Golden Gate. It is the single most dominant thing in every close frame + * of the merged board. + * + * **This withdraws the glyph rather than replacing it**, and that is a + * deliberate half-measure with the honest half named. A true-width corridor is + * the real answer and it is not one commit: the roadside props are laid out + * against the slab (`structures.ts` sets their setback from it), so a 44 m + * ribbon with today's props puts 600 m oaks beside a two-lane road; and drive + * mode's camera cannot see a true-scale car at all — the near plane is 0.1 + * units, which is 192 m on this board, against a car 0.0026 units long. Until + * both are done, a missing road reads as "not modelled yet" and a 4.7 km slab + * reads as broken, and the first is the better of the two. + * + * Drive mode is exempt, for the obvious reason: it is the mode that needs a + * road to be on. + */ + const overviewLayers: THREE.Object3D[] = []; let detailShown = true; function applyDetailLod(): void { if (!hasDetail) return; @@ -975,6 +1021,10 @@ export async function createScene( detailShown = want; for (const child of detailLandmarks) child.visible = want; for (const layer of detailLayers) layer.visible = want; + // Withdrawn close in, except when the mode being used is the one that needs + // a road under it. + const driving = kit.controls.enabled === false || controlMode === "drive"; + for (const layer of overviewLayers) layer.visible = !want || driving; } // 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. @@ -1045,6 +1095,7 @@ export async function createScene( */ if (hasDetail) { detailLayers.push(bridgeGroup, airportGroup); + overviewLayers.push(corridorGroup); if (portLayer) detailLayers.push(portLayer.group); if (vesselLayer) detailLayers.push(vesselLayer.group); detailShown = true; @@ -1174,7 +1225,6 @@ export async function createScene( let currentChapter = first.id; const chapterListeners: ((id: string) => void)[] = []; - let controlMode: CityControlMode = "overview"; const controlModeListeners: ((mode: CityControlMode) => void)[] = []; function applyControlMode(requested: CityControlMode): CityControlMode {