1
0

fix: withdraw the corridor glyph close in, and give the aeroplane a true-metre size

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 10:05:03 -07:00
parent 285b5b19e7
commit 23e4bb8ab7
2 changed files with 106 additions and 8 deletions
+53 -5
View File
@@ -1053,6 +1053,43 @@ const GLYPH_MAX_SCALE = 52;
*/ */
const GLYPH_FOCUS_HEADROOM = 3; 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. * 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. * what an office sky, a chase camera and every test with no controls get.
*/ */
let focusDistance: number | null = null; 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. * One material per altitude band, built on demand.
@@ -1953,7 +1995,12 @@ export function createFlightLayer(world: World): FlightLayer {
*/ */
if (viewer !== null) { if (viewer !== null) {
track.mesh.scale.setScalar( 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 // 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, distance: number,
fovDegrees: number, fovDegrees: number,
focusDistance?: number, focusDistance?: number,
minScale = 1,
): number { ): number {
if (!Number.isFinite(distance) || !Number.isFinite(fovDegrees)) return 1; if (!Number.isFinite(distance) || !Number.isFinite(fovDegrees)) return minScale;
if (distance <= 0 || fovDegrees <= 0 || fovDegrees >= 180) return 1; if (distance <= 0 || fovDegrees <= 0 || fovDegrees >= 180) return minScale;
const legible = legibleScale(distance, fovDegrees); const legible = legibleScale(distance, fovDegrees);
/* /*
* The third argument is optional and omitting it must reproduce the previous * 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 focusDistance !== undefined && Number.isFinite(focusDistance) && focusDistance > 0
? Math.min( ? Math.min(
GLYPH_MAX_SCALE, GLYPH_MAX_SCALE,
Math.max(1, legibleScale(focusDistance, fovDegrees) * GLYPH_FOCUS_HEADROOM), Math.max(minScale, legibleScale(focusDistance, fovDegrees) * GLYPH_FOCUS_HEADROOM),
) )
: GLYPH_MAX_SCALE; : GLYPH_MAX_SCALE;
return Math.min(ceiling, Math.max(1, legible)); return Math.min(ceiling, Math.max(minScale, legible));
} }
/** /**
+53 -3
View File
@@ -851,9 +851,10 @@ export async function createScene(
scene.add(createWater(world)); scene.add(createWater(world));
scene.add(createShorePlates(world)); scene.add(createShorePlates(world));
scene.add(createTerrain(world)); scene.add(createTerrain(world));
scene.add(options.roadTraffic const corridorGroup = options.roadTraffic
? createFreewayWorld(world, options.roadTraffic.pack) ? createFreewayWorld(world, options.roadTraffic.pack)
: createRoads(world)); : createRoads(world);
scene.add(corridorGroup);
const buildingReservations: BuildingReservation[] = []; const buildingReservations: BuildingReservation[] = [];
for (const marker of options.markers ?? []) { for (const marker of options.markers ?? []) {
const glyph = marker.glyph; const glyph = marker.glyph;
@@ -948,7 +949,52 @@ export async function createScene(
* Empty on every other board, because `hasDetail` is false there and * Empty on every other board, because `hasDetail` is false there and
* `applyDetailLod` returns before reading this. * `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[] = []; 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; let detailShown = true;
function applyDetailLod(): void { function applyDetailLod(): void {
if (!hasDetail) return; if (!hasDetail) return;
@@ -975,6 +1021,10 @@ export async function createScene(
detailShown = want; detailShown = want;
for (const child of detailLandmarks) child.visible = want; for (const child of detailLandmarks) child.visible = want;
for (const layer of detailLayers) layer.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 // 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. // correct one tick later, which is a frame the capture harness can catch.
@@ -1045,6 +1095,7 @@ export async function createScene(
*/ */
if (hasDetail) { if (hasDetail) {
detailLayers.push(bridgeGroup, airportGroup); detailLayers.push(bridgeGroup, airportGroup);
overviewLayers.push(corridorGroup);
if (portLayer) detailLayers.push(portLayer.group); if (portLayer) detailLayers.push(portLayer.group);
if (vesselLayer) detailLayers.push(vesselLayer.group); if (vesselLayer) detailLayers.push(vesselLayer.group);
detailShown = true; detailShown = true;
@@ -1174,7 +1225,6 @@ export async function createScene(
let currentChapter = first.id; let currentChapter = first.id;
const chapterListeners: ((id: string) => void)[] = []; const chapterListeners: ((id: string) => void)[] = [];
let controlMode: CityControlMode = "overview";
const controlModeListeners: ((mode: CityControlMode) => void)[] = []; const controlModeListeners: ((mode: CityControlMode) => void)[] = [];
function applyControlMode(requested: CityControlMode): CityControlMode { function applyControlMode(requested: CityControlMode): CityControlMode {