1
0

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.
This commit is contained in:
2026-08-24 22:58:28 -07:00
parent 2c59b2cfce
commit bda7a61acd
3 changed files with 185 additions and 35 deletions
+53 -34
View File
@@ -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;
}
+1 -1
View File
@@ -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(
+131
View File
@@ -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`,
);
});