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:
+38
-23
@@ -61,10 +61,10 @@
|
|||||||
* `socal.ts` asked for this in a comment: bridge members used to be constants in
|
* `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
|
* 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
|
* Vincent Thomas came out four times too heavy. Every cross-section here is
|
||||||
* `metres / metresPerUnit` instead — with a minimum, because true scale on the
|
* `metres / metresPerUnit` instead — with a **metre** floor, because a floor in
|
||||||
* SoCal board puts the deck of the Long Beach Gateway below one pixel and an
|
* scene units is a floor that assumes one board. 0.09 units is 8.5 m on the Bay
|
||||||
* invisible bridge is a worse answer than a chunky one. Heights are the
|
* and 173 m on merged California: the Golden Gate's deck became a highway.
|
||||||
* exception and stay on `world.metres()`, which carries the board's vertical
|
* 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
|
* exaggeration: a deck has to sit at the same exaggerated height as the hills it
|
||||||
* lands on or it lands inside them.
|
* 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 are quantised to, and a coarser one leaves the Golden Gate with nine
|
||||||
* hangers a span and a cable made of straight lines.
|
* hangers a span and a cable made of straight lines.
|
||||||
*/
|
*/
|
||||||
function stationSpacing(deckHalf: number): number {
|
function stationSpacing(deckHalf: number, perUnit: number): number {
|
||||||
return Math.max(deckHalf * 4, 0.28);
|
// 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. */
|
/** 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
|
// `world.metres`, which carries the board's vertical exaggeration. See the
|
||||||
// module comment for why those two are not the same conversion.
|
// module comment for why those two are not the same conversion.
|
||||||
const perUnit = world.metresPerUnit;
|
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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// landmarks: at this scale a true-width deck is under two pixels.
|
||||||
const deckHalf = size(21, 0.09);
|
const deckHalf = size(21, 8.5);
|
||||||
const deckDepth = size(14, 0.04);
|
const deckDepth = size(14, 3.8);
|
||||||
const legWidth = size(20, 0.05);
|
const legWidth = size(20, 4.7);
|
||||||
const legDepth = size(46, 0.1);
|
const legDepth = size(46, 9.4);
|
||||||
const cableRadius = size(8, 0.015);
|
const cableRadius = size(8, 1.4);
|
||||||
const hangerHalf = size(1.9, 0.005);
|
const hangerHalf = size(1.9, 0.5);
|
||||||
const pierHalf = size(8, 0.025);
|
const pierHalf = size(8, 2.4);
|
||||||
|
|
||||||
const deckY = world.metres(bridge.deckHeight);
|
const deckY = world.metres(bridge.deckHeight);
|
||||||
const towerY = world.metres(bridge.towerHeight);
|
const towerY = world.metres(bridge.towerHeight);
|
||||||
@@ -564,7 +573,7 @@ function layout(world: BridgeWorld, bridge: Bridge): BridgeLayout {
|
|||||||
const footing = (ground: number, margin: number) =>
|
const footing = (ground: number, margin: number) =>
|
||||||
Math.max(ground, seabed) - world.metres(margin);
|
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 towerAt = bridge.towers.map((tower) => nearestStation(world, list, tower));
|
||||||
const reaches = classify(list, towerAt, bridge.towerHeight, perUnit);
|
const reaches = classify(list, towerAt, bridge.towerHeight, perUnit);
|
||||||
const runs = [
|
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
|
// has something to land on, and a deck held rigidly at 67 m simply disappeared
|
||||||
// into the Presidio bluff with no visible touchdown.
|
// into the Presidio bluff with no visible touchdown.
|
||||||
const total = list[list.length - 1]?.along ?? 0;
|
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 deckTopAt = (station: Station): number => {
|
||||||
const fromEnd = Math.min(station.along, total - station.along);
|
const fromEnd = Math.min(station.along, total - station.along);
|
||||||
if (fromEnd >= rampLength) return deckY;
|
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;
|
if (landing >= deckY) return deckY;
|
||||||
const t = rampLength <= 0 ? 1 : fromEnd / rampLength;
|
const t = rampLength <= 0 ? 1 : fromEnd / rampLength;
|
||||||
// Smoothstep, so the deck leaves the shore tangentially rather than kinking.
|
// Smoothstep, so the deck leaves the shore tangentially rather than kinking.
|
||||||
@@ -758,7 +767,7 @@ export function buildBridge(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Cables and hangers ----
|
// ---- 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 chain of runs) {
|
||||||
for (const side of [-1, 1] as const) {
|
for (const side of [-1, 1] as const) {
|
||||||
const lateral = side * deckHalf;
|
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
|
// 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
|
// 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.
|
// 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) {
|
for (const reach of reaches) {
|
||||||
if (reach.kind !== "approach") continue;
|
if (reach.kind !== "approach") continue;
|
||||||
let last = -Infinity;
|
let last = -Infinity;
|
||||||
@@ -895,12 +904,16 @@ export interface BridgePlan {
|
|||||||
towerStations: number[];
|
towerStations: number[];
|
||||||
stationCount: number;
|
stationCount: number;
|
||||||
deckLength: 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 {
|
export function planBridge(world: BridgeWorld, bridge: Bridge): BridgePlan {
|
||||||
const perUnit = world.metresPerUnit;
|
const perUnit = world.metresPerUnit;
|
||||||
const deckHalf = Math.max(15 / perUnit, 0.075);
|
// Same 21 m half as `layout`. A second, smaller number here meant the
|
||||||
const list = stations(world, bridge.path, stationSpacing(deckHalf));
|
// 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 towerAt = bridge.towers.map((tower) => nearestStation(world, list, tower));
|
||||||
const reaches = classify(list, towerAt, bridge.towerHeight, perUnit);
|
const reaches = classify(list, towerAt, bridge.towerHeight, perUnit);
|
||||||
return {
|
return {
|
||||||
@@ -911,6 +924,7 @@ export function planBridge(world: BridgeWorld, bridge: Bridge): BridgePlan {
|
|||||||
towerStations: towerAt,
|
towerStations: towerAt,
|
||||||
stationCount: list.length,
|
stationCount: list.length,
|
||||||
deckLength: list[list.length - 1]?.along ?? 0,
|
deckLength: list[list.length - 1]?.along ?? 0,
|
||||||
|
deckHalfMetres: deckHalf * perUnit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -981,7 +995,8 @@ export interface BridgeLights {
|
|||||||
* whole Vincent Thomas.
|
* whole Vincent Thomas.
|
||||||
*/
|
*/
|
||||||
const DECK_LAMP_SPACING_M = 50;
|
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. */
|
/** Lamp height above the deck slab, and the head light's clearance over the saddle. */
|
||||||
const DECK_LAMP_HEIGHT_M = 12;
|
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,
|
// 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
|
// so one lamp per station would thin out over exactly the causeways that have
|
||||||
// nothing else to look at.
|
// 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 lift = world.metres(DECK_LAMP_HEIGHT_M);
|
||||||
const total = list[list.length - 1]?.along ?? 0;
|
const total = list[list.length - 1]?.along ?? 0;
|
||||||
const kerb = deckHalf * 0.92;
|
const kerb = deckHalf * 0.92;
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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
|
// 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
|
// 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.
|
// 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 fine = bridgeLights(board(1180, 3.6), GOLDEN_GATE);
|
||||||
const coarse = bridgeLights(board(285, 2.2), 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 < fine.scale, "a coarser board draws a narrower deck");
|
||||||
assert.ok(coarse.scale >= 0.09, "…but never below the legibility floor");
|
const fineM = fine.scale * (111_320 / 1180);
|
||||||
// The run still reaches both ends of the crossing; it is the pitch floor that
|
const coarseM = coarse.scale * (111_320 / 285);
|
||||||
// keeps a 391 m/unit board from putting four lamps on a whole bridge.
|
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");
|
assert.ok(triples(coarse.deck).length >= 8, "a coarse board still gets a run, not a handful");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user