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.
|
* 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,
|
* 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
|
* which is 0.23 scene units on Southern California — under half a pixel at the
|
||||||
* this object has to do its most important work. So it carries a floor in scene
|
* whole-board pose, where this object has to do its most important work. So it
|
||||||
* units the way `bridges.ts`'s members do, and for the same stated reason: an
|
* carries a floor in **metres** the way `bridges.ts`'s members do, and for the
|
||||||
* invisible breakwater is a worse answer than a chunky one. `socal.ts`'s LA
|
* same stated reason: an invisible breakwater is a worse answer than a chunky
|
||||||
* River sets the same precedent from the other direction, drawn at four times
|
* one. The floor used to be scene units (0.11 / 0.3), tuned at 391 m to the
|
||||||
* its true width because anything narrower than a terrain cell renders as a
|
* unit. On the merged California board those always won, and the crest half
|
||||||
* dotted line rather than a thin one.
|
* 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_CREST_M = 44;
|
||||||
const BREAKWATER_BASE_M = 140;
|
const BREAKWATER_BASE_M = 140;
|
||||||
const BREAKWATER_CREST_FLOOR = 0.11;
|
const BREAKWATER_CREST_FLOOR_M = 43;
|
||||||
const BREAKWATER_BASE_FLOOR = 0.3;
|
const BREAKWATER_BASE_FLOOR_M = 117;
|
||||||
/** Crest height above the waterline, metres. Exaggerated by the board like any height. */
|
/** Crest height above the waterline, metres. Exaggerated by the board like any height. */
|
||||||
const BREAKWATER_CREST_HEIGHT_M = 7;
|
const BREAKWATER_CREST_HEIGHT_M = 7;
|
||||||
|
|
||||||
@@ -832,21 +835,40 @@ const BREAKWATER_CREST_HEIGHT_M = 7;
|
|||||||
* Dredged channel width, metres, and its floor.
|
* Dredged channel width, metres, and its floor.
|
||||||
*
|
*
|
||||||
* The Main Channel at San Pedro really is about 340 m between the shoal edges,
|
* 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
|
* which is 0.87 units on SoCal — legible at the Harbour pose and marginal at
|
||||||
* board, hence the floor. It is one number for every channel on the board
|
* the whole board, hence the floor. The floor used to be 0.55 scene units:
|
||||||
* rather than a field on `Port`, because a channel drawn wider than the water it
|
* 215 m at Los Angeles, 1.05 km on merged California. 340 m already clears
|
||||||
* lies in laps onto the quay beside it, and 340 m is what the narrowest reach on
|
* 215 m, so both boards now draw the real channel. It is one number for every
|
||||||
* this board can take.
|
* 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_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. */
|
/** A gantry's dimensions, metres, and the floors that keep the members visible. */
|
||||||
const CRANE_RAIL_GAUGE_M = 30;
|
const CRANE_RAIL_GAUGE_M = 30;
|
||||||
const CRANE_MEMBER_M = 5;
|
const CRANE_MEMBER_M = 5;
|
||||||
const CRANE_GAUGE_FLOOR = 0.115;
|
const CRANE_GAUGE_FLOOR_M = 45;
|
||||||
const CRANE_MEMBER_FLOOR = 0.032;
|
const CRANE_MEMBER_FLOOR_M = 12.5;
|
||||||
const CRANE_OUTREACH_FLOOR = 0.26;
|
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. */
|
/** Degrees the boom stands at when it is raised. A stowed gantry is near vertical. */
|
||||||
const CRANE_BOOM_IDLE_DEG = 74;
|
const CRANE_BOOM_IDLE_DEG = 74;
|
||||||
|
|
||||||
@@ -970,7 +992,6 @@ function buildPort(
|
|||||||
bandTotal: number,
|
bandTotal: number,
|
||||||
): void {
|
): void {
|
||||||
const unit = 1 / world.metresPerUnit;
|
const unit = 1 / world.metresPerUnit;
|
||||||
const size = (metres: number, floor: number) => Math.max(metres * unit, floor);
|
|
||||||
const plate = plateHeight(world, port);
|
const plate = plateHeight(world, port);
|
||||||
|
|
||||||
// ---- The breakwater -----------------------------------------------------
|
// ---- The breakwater -----------------------------------------------------
|
||||||
@@ -989,8 +1010,8 @@ function buildPort(
|
|||||||
"ports:stone",
|
"ports:stone",
|
||||||
bermGeometry(
|
bermGeometry(
|
||||||
points,
|
points,
|
||||||
size(BREAKWATER_CREST_M / 2, BREAKWATER_CREST_FLOOR),
|
size(world, BREAKWATER_CREST_M / 2, BREAKWATER_CREST_FLOOR_M),
|
||||||
size(BREAKWATER_BASE_M / 2, BREAKWATER_BASE_FLOOR),
|
size(world, BREAKWATER_BASE_M / 2, BREAKWATER_BASE_FLOOR_M),
|
||||||
world.metres(BREAKWATER_CREST_HEIGHT_M),
|
world.metres(BREAKWATER_CREST_HEIGHT_M),
|
||||||
SEA_Y - 0.02,
|
SEA_Y - 0.02,
|
||||||
),
|
),
|
||||||
@@ -1008,7 +1029,7 @@ function buildPort(
|
|||||||
});
|
});
|
||||||
batch.add(
|
batch.add(
|
||||||
"ports:channel",
|
"ports:channel",
|
||||||
stripGeometry(points, size(CHANNEL_WIDTH_M, CHANNEL_FLOOR)),
|
stripGeometry(points, size(world, CHANNEL_WIDTH_M, CHANNEL_FLOOR_M)),
|
||||||
paints.channel,
|
paints.channel,
|
||||||
{ receive: false },
|
{ receive: false },
|
||||||
);
|
);
|
||||||
@@ -1037,7 +1058,7 @@ function buildPort(
|
|||||||
* stops the wall reading as a razor-thin line where the concrete meets the
|
* 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.
|
* 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 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];
|
const toeB: [number, number] = [b[0] + outward[0] * toe, b[1] + outward[1] * toe];
|
||||||
batch.add(
|
batch.add(
|
||||||
@@ -1184,12 +1205,10 @@ function pushCraneMatrices(
|
|||||||
plate: number,
|
plate: number,
|
||||||
out: THREE.Matrix4[],
|
out: THREE.Matrix4[],
|
||||||
): void {
|
): void {
|
||||||
const unit = 1 / world.metresPerUnit;
|
const gauge = size(world, CRANE_RAIL_GAUGE_M, CRANE_GAUGE_FLOOR_M);
|
||||||
const size = (metres: number, floor: number) => Math.max(metres * unit, floor);
|
const member = size(world, CRANE_MEMBER_M, CRANE_MEMBER_FLOOR_M);
|
||||||
const gauge = size(CRANE_RAIL_GAUGE_M, CRANE_GAUGE_FLOOR);
|
|
||||||
const member = size(CRANE_MEMBER_M, CRANE_MEMBER_FLOOR);
|
|
||||||
const portal = world.metres(crane.height);
|
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.
|
// The boom's horizontal direction in scene space: x east, z south.
|
||||||
const [east, north] = bearingVector(crane.bearing);
|
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
|
// 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
|
// 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
|
// 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
|
// all is 12.5 m — 0.032 units on this board. The pair would overlap by
|
||||||
// extra boxes to draw one slightly fatter leg. The board's own scale is the
|
// ninety per cent and cost 56 extra boxes to draw one slightly fatter
|
||||||
// argument, not the triangle count.
|
// leg. The board's own scale is the argument, not the triangle count.
|
||||||
push(rx, base + portal / 2, rz, member, portal, member);
|
push(rx, base + portal / 2, rz, member, portal, member);
|
||||||
push(rx - boomX * gauge, base + portal / 2, rz - boomZ * gauge, 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[] } {
|
export function craneLights(world: World, port: Port): { heads: number[] } {
|
||||||
const heads: number[] = [];
|
const heads: number[] = [];
|
||||||
const plate = plateHeight(world, port);
|
const plate = plateHeight(world, port);
|
||||||
const gauge = Math.max(CRANE_RAIL_GAUGE_M / world.metresPerUnit, CRANE_GAUGE_FLOOR);
|
const gauge = size(world, CRANE_RAIL_GAUGE_M, CRANE_GAUGE_FLOOR_M);
|
||||||
const member = Math.max(CRANE_MEMBER_M / world.metresPerUnit, CRANE_MEMBER_FLOOR);
|
const member = size(world, CRANE_MEMBER_M, CRANE_MEMBER_FLOOR_M);
|
||||||
const setback = gauge * CRANE_APEX_SETBACK;
|
const setback = gauge * CRANE_APEX_SETBACK;
|
||||||
for (const crane of port.cranes ?? []) {
|
for (const crane of port.cranes ?? []) {
|
||||||
// Clear of the apex cap, for the reason `bridges.ts` lifts its head lights
|
// 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.
|
* 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 {
|
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);
|
const portal = world.metres(crane.height);
|
||||||
return plate + portal + member * 1.4 + portal * CRANE_MAST_RISE;
|
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
|
// 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
|
// 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.
|
// 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) {
|
for (let i = 1; i < heads.length; i += 3) {
|
||||||
const y = heads[i]!;
|
const y = heads[i]!;
|
||||||
assert.ok(
|
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