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:
+53
-34
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user