From bda7a61acddd0c35967998154b3a524d2bc706d6 Mon Sep 17 00:00:00 2001 From: Kartios Date: Mon, 24 Aug 2026 22:58:28 -0700 Subject: [PATCH] fix: a harbour member is a true-metre object, not a scene-unit floor `size(metres, floor)` took `floor` in scene units, tuned at 391 m to the unit. On the merged California board those floors always won: a 340 m channel became 1.05 km. Floors are the metres those constants measured on SoCal, so that board does not move and every other board draws the same stone. `portScale.test.ts` is the property that would have caught it: two boards five times apart draw the same channel on the water. --- src/engine/ports.ts | 87 ++++++++++-------- src/test/render/craneLights.test.ts | 2 +- src/test/render/portScale.test.ts | 131 ++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 35 deletions(-) create mode 100644 src/test/render/portScale.test.ts diff --git a/src/engine/ports.ts b/src/engine/ports.ts index a85f566..04b7205 100644 --- a/src/engine/ports.ts +++ b/src/engine/ports.ts @@ -813,18 +813,21 @@ const CHANNEL_LIFT = 0.01; * How wide a breakwater is drawn, in metres, before the legibility floor. * * The federal breakwater at San Pedro is about ninety metres across its base, - * which is 0.23 scene units — under half a pixel at the whole-board pose, where - * this object has to do its most important work. So it carries a floor in scene - * units the way `bridges.ts`'s members do, and for the same stated reason: an - * invisible breakwater is a worse answer than a chunky one. `socal.ts`'s LA - * River sets the same precedent from the other direction, drawn at four times - * its true width because anything narrower than a terrain cell renders as a - * dotted line rather than a thin one. + * which is 0.23 scene units on Southern California — under half a pixel at the + * whole-board pose, where this object has to do its most important work. So it + * carries a floor in **metres** the way `bridges.ts`'s members do, and for the + * same stated reason: an invisible breakwater is a worse answer than a chunky + * one. The floor used to be scene units (0.11 / 0.3), tuned at 391 m to the + * unit. On the merged California board those always won, and the crest half + * became 211 m. Floors are the metres those constants measured on SoCal, so + * that board does not move. `socal.ts`'s LA River sets the same precedent from + * the other direction, drawn at four times its true width because anything + * narrower than a terrain cell renders as a dotted line rather than a thin one. */ const BREAKWATER_CREST_M = 44; const BREAKWATER_BASE_M = 140; -const BREAKWATER_CREST_FLOOR = 0.11; -const BREAKWATER_BASE_FLOOR = 0.3; +const BREAKWATER_CREST_FLOOR_M = 43; +const BREAKWATER_BASE_FLOOR_M = 117; /** Crest height above the waterline, metres. Exaggerated by the board like any height. */ const BREAKWATER_CREST_HEIGHT_M = 7; @@ -832,21 +835,40 @@ const BREAKWATER_CREST_HEIGHT_M = 7; * Dredged channel width, metres, and its floor. * * The Main Channel at San Pedro really is about 340 m between the shoal edges, - * which is 0.87 units — legible at the Harbour pose and marginal at the whole - * board, hence the floor. It is one number for every channel on the board - * rather than a field on `Port`, because a channel drawn wider than the water it - * lies in laps onto the quay beside it, and 340 m is what the narrowest reach on - * this board can take. + * which is 0.87 units on SoCal — legible at the Harbour pose and marginal at + * the whole board, hence the floor. The floor used to be 0.55 scene units: + * 215 m at Los Angeles, 1.05 km on merged California. 340 m already clears + * 215 m, so both boards now draw the real channel. It is one number for every + * channel on the board rather than a field on `Port`, because a channel drawn + * wider than the water it lies in laps onto the quay beside it, and 340 m is + * what the narrowest reach on this board can take. */ const CHANNEL_WIDTH_M = 340; -const CHANNEL_FLOOR = 0.55; +const CHANNEL_FLOOR_M = 215; + +/** Quay revetment toe, metres, and the floor that keeps it a strip of rock. */ +const QUAY_TOE_M = 26; +const QUAY_TOE_FLOOR_M = 27; /** A gantry's dimensions, metres, and the floors that keep the members visible. */ const CRANE_RAIL_GAUGE_M = 30; const CRANE_MEMBER_M = 5; -const CRANE_GAUGE_FLOOR = 0.115; -const CRANE_MEMBER_FLOOR = 0.032; -const CRANE_OUTREACH_FLOOR = 0.26; +const CRANE_GAUGE_FLOOR_M = 45; +const CRANE_MEMBER_FLOOR_M = 12.5; +const CRANE_OUTREACH_FLOOR_M = 102; + +/** + * Cross-section in metres, never thinner than `floorM` metres. + * + * The second argument used to be scene units, tuned at 391 m/unit. On a board + * 1,919 m to the unit those floors always won: a 340 m channel became 1.05 km. + * Floors are the metres those old constants measured on SoCal, so that board + * does not move and every other board draws the same stone. + */ +function size(world: World, metres: number, floorM: number): number { + return Math.max(metres, floorM) / world.metresPerUnit; +} + /** Degrees the boom stands at when it is raised. A stowed gantry is near vertical. */ const CRANE_BOOM_IDLE_DEG = 74; @@ -970,7 +992,6 @@ function buildPort( bandTotal: number, ): void { const unit = 1 / world.metresPerUnit; - const size = (metres: number, floor: number) => Math.max(metres * unit, floor); const plate = plateHeight(world, port); // ---- The breakwater ----------------------------------------------------- @@ -989,8 +1010,8 @@ function buildPort( "ports:stone", bermGeometry( points, - size(BREAKWATER_CREST_M / 2, BREAKWATER_CREST_FLOOR), - size(BREAKWATER_BASE_M / 2, BREAKWATER_BASE_FLOOR), + size(world, BREAKWATER_CREST_M / 2, BREAKWATER_CREST_FLOOR_M), + size(world, BREAKWATER_BASE_M / 2, BREAKWATER_BASE_FLOOR_M), world.metres(BREAKWATER_CREST_HEIGHT_M), SEA_Y - 0.02, ), @@ -1008,7 +1029,7 @@ function buildPort( }); batch.add( "ports:channel", - stripGeometry(points, size(CHANNEL_WIDTH_M, CHANNEL_FLOOR)), + stripGeometry(points, size(world, CHANNEL_WIDTH_M, CHANNEL_FLOOR_M)), paints.channel, { receive: false }, ); @@ -1037,7 +1058,7 @@ function buildPort( * stops the wall reading as a razor-thin line where the concrete meets the * water. Two quads, and it is the third thing the STONE bucket is for. */ - const toe = size(26, 0.07); + const toe = size(world, QUAY_TOE_M, QUAY_TOE_FLOOR_M); const toeA: [number, number] = [a[0] + outward[0] * toe, a[1] + outward[1] * toe]; const toeB: [number, number] = [b[0] + outward[0] * toe, b[1] + outward[1] * toe]; batch.add( @@ -1184,12 +1205,10 @@ function pushCraneMatrices( plate: number, out: THREE.Matrix4[], ): void { - const unit = 1 / world.metresPerUnit; - const size = (metres: number, floor: number) => Math.max(metres * unit, floor); - const gauge = size(CRANE_RAIL_GAUGE_M, CRANE_GAUGE_FLOOR); - const member = size(CRANE_MEMBER_M, CRANE_MEMBER_FLOOR); + const gauge = size(world, CRANE_RAIL_GAUGE_M, CRANE_GAUGE_FLOOR_M); + const member = size(world, CRANE_MEMBER_M, CRANE_MEMBER_FLOOR_M); const portal = world.metres(crane.height); - const outreach = size(crane.outreach, CRANE_OUTREACH_FLOOR); + const outreach = size(world, crane.outreach, CRANE_OUTREACH_FLOOR_M); // The boom's horizontal direction in scene space: x east, z south. const [east, north] = bearingVector(crane.bearing); @@ -1268,9 +1287,9 @@ function pushCraneMatrices( // Still two legs and not four. A real gantry has a leg at each corner of a // 27 × 30 m footprint, and at SoCal's 391 m to the unit that 14 m along the // quay is 0.036 units while the member floor that keeps a leg visible at - // all is 0.032 — the pair would overlap by ninety per cent and cost 56 - // extra boxes to draw one slightly fatter leg. The board's own scale is the - // argument, not the triangle count. + // all is 12.5 m — 0.032 units on this board. The pair would overlap by + // ninety per cent and cost 56 extra boxes to draw one slightly fatter + // leg. The board's own scale is the argument, not the triangle count. push(rx, base + portal / 2, rz, member, portal, member); push(rx - boomX * gauge, base + portal / 2, rz - boomZ * gauge, member, portal, member); @@ -1367,8 +1386,8 @@ function pushCraneMatrices( export function craneLights(world: World, port: Port): { heads: number[] } { const heads: number[] = []; const plate = plateHeight(world, port); - const gauge = Math.max(CRANE_RAIL_GAUGE_M / world.metresPerUnit, CRANE_GAUGE_FLOOR); - const member = Math.max(CRANE_MEMBER_M / world.metresPerUnit, CRANE_MEMBER_FLOOR); + const gauge = size(world, CRANE_RAIL_GAUGE_M, CRANE_GAUGE_FLOOR_M); + const member = size(world, CRANE_MEMBER_M, CRANE_MEMBER_FLOOR_M); const setback = gauge * CRANE_APEX_SETBACK; for (const crane of port.cranes ?? []) { // Clear of the apex cap, for the reason `bridges.ts` lifts its head lights @@ -1396,7 +1415,7 @@ export function craneLights(world: World, port: Port): { heads: number[] } { * the crane it is supposed to be on, and nothing but a screenshot would catch it. */ function craneApexY(world: World, crane: Crane, plate: number): number { - const member = Math.max(CRANE_MEMBER_M / world.metresPerUnit, CRANE_MEMBER_FLOOR); + const member = size(world, CRANE_MEMBER_M, CRANE_MEMBER_FLOOR_M); const portal = world.metres(crane.height); return plate + portal + member * 1.4 + portal * CRANE_MAST_RISE; } diff --git a/src/test/render/craneLights.test.ts b/src/test/render/craneLights.test.ts index 7ed6052..8a3a0fb 100644 --- a/src/test/render/craneLights.test.ts +++ b/src/test/render/craneLights.test.ts @@ -132,7 +132,7 @@ describe("the container gantries mark themselves after dark", () => { // that height within a member of the drawn apex. The clearance is what stops // the sprite being half-eaten by the apex cap's own depth; more than a // member above it and the light has come off the crane. - const member = Math.max(5 / world.metresPerUnit, 0.032); + const member = Math.max(5, 12.5) / world.metresPerUnit; for (let i = 1; i < heads.length; i += 3) { const y = heads[i]!; assert.ok( diff --git a/src/test/render/portScale.test.ts b/src/test/render/portScale.test.ts new file mode 100644 index 0000000..5ecbfae --- /dev/null +++ b/src/test/render/portScale.test.ts @@ -0,0 +1,131 @@ +/** + * A harbour member is a true-metre object, and every board has to agree. + * + * `size(metres, floor)` used to take `floor` in scene units, tuned at 391 m to + * the unit on Southern California. On the merged California board (1,919 m/unit) + * those floors always won: a 340 m channel became 1.05 km, and San Pedro Bay + * read as a river. The floor is metres now. This is the property that would + * have caught it: two boards five times apart draw the same channel on the + * water. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import * as THREE from "three"; + +import { createPorts } from "../../engine/ports.ts"; +import type { Port } from "../../engine/types.ts"; +import type { World } from "../../engine/world.ts"; + +/** + * One straight channel and one gantry. Widths come from the kit, not from the + * pack, so the fixture only has to exist — two points of water, two points of + * rail. + */ +const SAMPLE: Port = { + id: "USXXX", + name: "Sample", + lat: 33.74, + lng: -118.27, + harborType: "CB", + channel: [ + [33.74, -118.27], + [33.75, -118.27], + ], + cranes: [ + { + from: [33.74, -118.26], + to: [33.741, -118.26], + count: 1, + bearing: 90, + height: 82, + outreach: 70, + idleFraction: 0, + }, + ], +}; + +function board(latScale: number): World { + const centre = { lat: 33.82, lng: -118.05 }; + 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) * 3.4; + }, + metresPerUnit, + } as unknown as World; +} + +function namedMesh(root: THREE.Object3D, name: string): THREE.Mesh { + let found: THREE.Mesh | undefined; + root.traverse((object) => { + if (object instanceof THREE.Mesh && object.name === name) found = object; + }); + assert.ok(found, `no mesh named ${name}`); + return found; +} + +/** The dredged strip's width, in metres of real water. */ +function channelMetres(world: World): number { + const mesh = namedMesh(createPorts(world, [SAMPLE]), "ports:channel"); + const pos = mesh.geometry.getAttribute("position"); + assert.ok(pos && pos.count >= 2, "the channel strip has no rails to measure"); + const width = + Math.hypot(pos.getX(0) - pos.getX(1), pos.getZ(0) - pos.getZ(1)) * world.metresPerUnit; + return width; +} + +/** + * Distance between the two portal legs of the one gantry, in metres. + * + * `pushCraneMatrices` emits the waterside leg first and the landside leg next, + * `gauge` apart along the boom. Measuring the drawn matrices is the same move + * `craneLights.test.ts` makes for the apex: a helper that recomputes the + * number would pass while the mesh drifted. + */ +function craneGaugeMetres(world: World): number { + const mesh = namedMesh(createPorts(world, [SAMPLE]), "ports:cranes") as THREE.InstancedMesh; + assert.ok(mesh.count >= 2, "a gantry is at least two portal legs"); + const a = new THREE.Matrix4(); + const b = new THREE.Matrix4(); + const pa = new THREE.Vector3(); + const pb = new THREE.Vector3(); + mesh.getMatrixAt(0, a); + mesh.getMatrixAt(1, b); + pa.setFromMatrixPosition(a); + pb.setFromMatrixPosition(b); + return Math.hypot(pa.x - pb.x, pa.z - pb.z) * world.metresPerUnit; +} + +test("San Pedro's channel is the same width on a 391 m board and a 1,919 m one", () => { + const metro = channelMetres(board(285)); + const state = channelMetres(board(58)); + assert.ok( + Math.abs(metro - state) < 2, + `channel is ${metro.toFixed(0)} m at Los Angeles and ${state.toFixed(0)} m on California`, + ); + assert.ok( + metro > 300 && metro < 400, + `channel measures ${metro.toFixed(0)} m; that is not a river`, + ); +}); + +test("a gantry's rail gauge is the same metres on both boards", () => { + const metro = craneGaugeMetres(board(285)); + const state = craneGaugeMetres(board(58)); + assert.ok( + Math.abs(metro - state) < 1, + `gauge is ${metro.toFixed(1)} m at Los Angeles and ${state.toFixed(1)} m on California`, + ); + assert.ok( + metro > 30 && metro < 60, + `gauge measures ${metro.toFixed(1)} m; that is not a city block`, + ); +});