1
0

fix: a bridge member is a true-metre object, not a scene-unit floor

size(metres, floor) took floor in scene units, tuned at 94 m to the
unit. On one California (1,919 m/unit) those floors always won: a
21 m deck half became 173 m and the Golden Gate read as a highway.
The same bug the metre conversion fixed for the Vincent Thomas at
391 m/unit, reintroduced by the floor.

Floors are metres now — the Bay-board values, so that board does
not move. Two boards twenty times apart draw the same 21 m half.
Lamp pitch follows.
This commit is contained in:
2026-08-24 22:48:17 -07:00
parent ce85867de9
commit 2c59b2cfce
3 changed files with 112 additions and 26 deletions
+38 -23
View File
@@ -61,10 +61,10 @@
* `socal.ts` asked for this in a comment: bridge members used to be constants in
* scene units tuned at San Francisco's 94 m per unit, so at Los Angeles' 391 the
* Vincent Thomas came out four times too heavy. Every cross-section here is
* `metres / metresPerUnit` instead — with a minimum, because true scale on the
* SoCal board puts the deck of the Long Beach Gateway below one pixel and an
* invisible bridge is a worse answer than a chunky one. Heights are the
* exception and stay on `world.metres()`, which carries the board's vertical
* `metres / metresPerUnit` instead — with a **metre** floor, because a floor in
* scene units is a floor that assumes one board. 0.09 units is 8.5 m on the Bay
* and 173 m on merged California: the Golden Gate's deck became a highway.
* Heights stay on `world.metres()`, which carries the board's vertical
* exaggeration: a deck has to sit at the same exaggerated height as the hills it
* lands on or it lands inside them.
*/
@@ -272,8 +272,9 @@ function stations(world: BridgeWorld, path: LatLng[], spacing: number): Station[
* hangers are quantised to, and a coarser one leaves the Golden Gate with nine
* hangers a span and a cable made of straight lines.
*/
function stationSpacing(deckHalf: number): number {
return Math.max(deckHalf * 4, 0.28);
function stationSpacing(deckHalf: number, perUnit: number): number {
// 26 m was 0.28 scene units on the Bay board, the floor this was tuned on.
return Math.max(deckHalf * 4, 26 / perUnit);
}
/** A point offset laterally from a station, at a given height. */
@@ -524,7 +525,15 @@ function layout(world: BridgeWorld, bridge: Bridge): BridgeLayout {
// `world.metres`, which carries the board's vertical exaggeration. See the
// module comment for why those two are not the same conversion.
const perUnit = world.metresPerUnit;
const size = (metres: number, floor: number) => Math.max(metres / perUnit, floor);
/**
* Cross-section in metres, never thinner than `floorM` metres.
*
* The second argument used to be scene units, tuned at 94 m/unit. On a board
* 1,919 m to the unit those floors always won: a 21 m deck half became 173 m.
* Floors are the metres those old constants measured on the Bay, so that
* board does not move and every other board draws the same steel.
*/
const size = (metres: number, floorM: number) => Math.max(metres, floorM) / perUnit;
// Three of these are wider than the real member, and the reason is the
// board's vertical exaggeration. San Francisco draws height at 3.6× and distance at
// 1×, so a tower at true scale is 227 m tall and 12 m thick — a 62:1 needle
@@ -536,13 +545,13 @@ function layout(world: BridgeWorld, bridge: Bridge): BridgeLayout {
// saddle) and where nothing else needs the room. The deck follows at 42 m
// against a true 27, which is the same allowance `socal.ts` records for its
// landmarks: at this scale a true-width deck is under two pixels.
const deckHalf = size(21, 0.09);
const deckDepth = size(14, 0.04);
const legWidth = size(20, 0.05);
const legDepth = size(46, 0.1);
const cableRadius = size(8, 0.015);
const hangerHalf = size(1.9, 0.005);
const pierHalf = size(8, 0.025);
const deckHalf = size(21, 8.5);
const deckDepth = size(14, 3.8);
const legWidth = size(20, 4.7);
const legDepth = size(46, 9.4);
const cableRadius = size(8, 1.4);
const hangerHalf = size(1.9, 0.5);
const pierHalf = size(8, 2.4);
const deckY = world.metres(bridge.deckHeight);
const towerY = world.metres(bridge.towerHeight);
@@ -564,7 +573,7 @@ function layout(world: BridgeWorld, bridge: Bridge): BridgeLayout {
const footing = (ground: number, margin: number) =>
Math.max(ground, seabed) - world.metres(margin);
const list = stations(world, bridge.path, stationSpacing(deckHalf));
const list = stations(world, bridge.path, stationSpacing(deckHalf, perUnit));
const towerAt = bridge.towers.map((tower) => nearestStation(world, list, tower));
const reaches = classify(list, towerAt, bridge.towerHeight, perUnit);
const runs = [
@@ -580,11 +589,11 @@ function layout(world: BridgeWorld, bridge: Bridge): BridgeLayout {
// has something to land on, and a deck held rigidly at 67 m simply disappeared
// into the Presidio bluff with no visible touchdown.
const total = list[list.length - 1]?.along ?? 0;
const rampLength = Math.min(total * 0.18, Math.max(deckHalf * 12, 1.2));
const rampLength = Math.min(total * 0.18, Math.max(deckHalf * 12, 113 / perUnit));
const deckTopAt = (station: Station): number => {
const fromEnd = Math.min(station.along, total - station.along);
if (fromEnd >= rampLength) return deckY;
const landing = Math.max(station.ground + deckDepth + size(4, 0.01), 0);
const landing = Math.max(station.ground + deckDepth + size(4, 0.9), 0);
if (landing >= deckY) return deckY;
const t = rampLength <= 0 ? 1 : fromEnd / rampLength;
// Smoothstep, so the deck leaves the shore tangentially rather than kinking.
@@ -758,7 +767,7 @@ export function buildBridge(
}
// ---- Cables and hangers ----
const hangerSpacing = Math.max(deckHalf * 3.4, size(60, 0.22));
const hangerSpacing = Math.max(deckHalf * 3.4, size(60, 21));
for (const chain of runs) {
for (const side of [-1, 1] as const) {
const lateral = side * deckHalf;
@@ -852,7 +861,7 @@ export function buildBridge(
// One every so often under an approach, and none at all where the ground has
// already risen to the deck: that is what walks the Bay Bridge onto Yerba
// Buena instead of standing it on stilts over the top of the island.
const pierSpacing = Math.max(deckHalf * 9, size(240, 0.7));
const pierSpacing = Math.max(deckHalf * 9, size(240, 66));
for (const reach of reaches) {
if (reach.kind !== "approach") continue;
let last = -Infinity;
@@ -895,12 +904,16 @@ export interface BridgePlan {
towerStations: number[];
stationCount: number;
deckLength: number;
/** Deck half-width in true metres. Same on every board; see `bridgeScale.test.ts`. */
deckHalfMetres: number;
}
export function planBridge(world: BridgeWorld, bridge: Bridge): BridgePlan {
const perUnit = world.metresPerUnit;
const deckHalf = Math.max(15 / perUnit, 0.075);
const list = stations(world, bridge.path, stationSpacing(deckHalf));
// Same 21 m half as `layout`. A second, smaller number here meant the
// classifier sampled a different deck from the one that was drawn.
const deckHalf = Math.max(21, 8.5) / perUnit;
const list = stations(world, bridge.path, stationSpacing(deckHalf, perUnit));
const towerAt = bridge.towers.map((tower) => nearestStation(world, list, tower));
const reaches = classify(list, towerAt, bridge.towerHeight, perUnit);
return {
@@ -911,6 +924,7 @@ export function planBridge(world: BridgeWorld, bridge: Bridge): BridgePlan {
towerStations: towerAt,
stationCount: list.length,
deckLength: list[list.length - 1]?.along ?? 0,
deckHalfMetres: deckHalf * perUnit,
};
}
@@ -981,7 +995,8 @@ export interface BridgeLights {
* whole Vincent Thomas.
*/
const DECK_LAMP_SPACING_M = 50;
const DECK_LAMP_MIN_SPACING = 0.16;
/** 0.16 scene units was 15 m on the Bay; a scene-unit min is 307 m on California. */
const DECK_LAMP_MIN_SPACING_M = 15;
/** Lamp height above the deck slab, and the head light's clearance over the saddle. */
const DECK_LAMP_HEIGHT_M = 12;
@@ -999,7 +1014,7 @@ export function bridgeLights(world: BridgeWorld, bridge: Bridge): BridgeLights {
// is set by what a *cable* needs and is three times coarser under an approach,
// so one lamp per station would thin out over exactly the causeways that have
// nothing else to look at.
const spacing = Math.max(DECK_LAMP_SPACING_M / world.metresPerUnit, DECK_LAMP_MIN_SPACING);
const spacing = Math.max(DECK_LAMP_SPACING_M, DECK_LAMP_MIN_SPACING_M) / world.metresPerUnit;
const lift = world.metres(DECK_LAMP_HEIGHT_M);
const total = list[list.length - 1]?.along ?? 0;
const kerb = deckHalf * 0.92;
+66
View File
@@ -0,0 +1,66 @@
/**
* A bridge member is a true-metre object, and every board has to agree.
*
* `size(metres, floor)` used to take `floor` in scene units, tuned at 94 m to
* the unit. On the merged California board (1,919 m/unit) those floors always
* won: a 21 m deck half became 173 m, and the Golden Gate read as a highway.
* The floor is metres now. This is the property that would have caught it:
* two boards twenty times apart draw the same deck width on the ground.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { planBridge } from "../../engine/bridges.ts";
import type { Bridge } from "../../engine/types.ts";
import type { World } from "../../engine/world.ts";
const GOLDEN_GATE: Bridge = {
name: "Golden Gate Bridge",
path: [
[37.8025, -122.4752],
[37.8155, -122.4783],
[37.825, -122.479],
[37.8375, -122.4806],
],
towers: [
[37.8155, -122.4783],
[37.825, -122.479],
],
towerHeight: 227,
deckHeight: 67,
sag: 0.55,
color: 0xc0442c,
};
function board(latScale: number): World {
const centre = { lat: 37.7749, lng: -122.4194 };
const lngScale = latScale * Math.cos((centre.lat * Math.PI) / 180);
const metresPerUnit = 111_320 / latScale;
return {
project(lat: number, lng: number): [number, number] {
return [(lng - centre.lng) * lngScale, -(lat - centre.lat) * latScale];
},
groundAt(): number {
return 0;
},
metres(value: number): number {
return value / metresPerUnit;
},
metresPerUnit,
} as unknown as World;
}
test("the Golden Gate's deck is the same width on a 94 m board and a 1,919 m one", () => {
const metro = planBridge(board(1180), GOLDEN_GATE);
const state = planBridge(board(58), GOLDEN_GATE);
assert.ok(
Math.abs(metro.deckHalfMetres - state.deckHalfMetres) < 0.5,
`deck half is ${metro.deckHalfMetres.toFixed(1)} m on the Bay and ` +
`${state.deckHalfMetres.toFixed(1)} m on California`,
);
assert.ok(
metro.deckHalfMetres > 15 && metro.deckHalfMetres < 30,
`deck half measures ${metro.deckHalfMetres.toFixed(1)} m; that is not a highway`,
);
});
+8 -3
View File
@@ -179,12 +179,17 @@ test("a coarser board gets a smaller lamp, because it draws a smaller bridge", (
// San Francisco at 94 m to the unit against Los Angeles at 391. A sprite size
// in scene units tuned on the first would be several times the width of the
// deck on the second, which is the bug `socal.ts` asked the kit to stop having.
// The floor is metres now, so both boards draw the same 21 m half and the
// coarser one is smaller in scene units, not clamped to 0.09 (173 m on CA).
const fine = bridgeLights(board(1180, 3.6), GOLDEN_GATE);
const coarse = bridgeLights(board(285, 2.2), GOLDEN_GATE);
assert.ok(coarse.scale < fine.scale, "a coarser board draws a narrower deck");
assert.ok(coarse.scale >= 0.09, "…but never below the legibility floor");
// The run still reaches both ends of the crossing; it is the pitch floor that
// keeps a 391 m/unit board from putting four lamps on a whole bridge.
const fineM = fine.scale * (111_320 / 1180);
const coarseM = coarse.scale * (111_320 / 285);
assert.ok(
Math.abs(fineM - coarseM) < 0.5,
`deck half is ${fineM.toFixed(1)} m on the Bay and ${coarseM.toFixed(1)} m on SoCal`,
);
assert.ok(triples(coarse.deck).length >= 8, "a coarse board still gets a run, not a handful");
});