diff --git a/src/engine/bridges.ts b/src/engine/bridges.ts index 5088047..53b5fdf 100644 --- a/src/engine/bridges.ts +++ b/src/engine/bridges.ts @@ -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; diff --git a/src/test/render/bridgeScale.test.ts b/src/test/render/bridgeScale.test.ts new file mode 100644 index 0000000..bfedccb --- /dev/null +++ b/src/test/render/bridgeScale.test.ts @@ -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`, + ); +}); diff --git a/src/test/render/nightInfrastructure.test.ts b/src/test/render/nightInfrastructure.test.ts index b59faee..bd78191 100644 --- a/src/test/render/nightInfrastructure.test.ts +++ b/src/test/render/nightInfrastructure.test.ts @@ -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"); });