feat: the crane grows a mast, the harbour works a shift, and the site is re-shot
**The Asset Factory verdict, and it mostly went against the vote.** Nine candidates were thumbed up. One was taken. TOOK the STS crane. Rebuilt in `ports.ts` from 5 unit boxes to 11 — an A-frame mast and apex cap, a forestay to the boom, a backstay to the tail, a sill, the truck-lane portal beam, a machinery house — still exactly ONE InstancedMesh. What was missing is the thing that makes a gantry a gantry: on a real STS the tallest part of a WORKING crane is the A-frame apex, not the boom, and a parked raised boom clears its own apex by only 15-25%. Before, 56 gantries read from altitude as 56 crosses — two coincident verticals with one bar through them and nothing above it — so a berth flattened into a picket fence. Proportions came from both upvoted candidates agreeing independently (hinge ~58 m under an apex at 99-104 m), taken conservatively because Tera's packs already author an 82 m hinge against a real 55-60. The apex beacon came across as EMISSION: `craneLights()` returns bare positions, `nightlights.ts` turns them into one additive Points cloud, 56 points, one draw call, night only, no THREE.Light anywhere. 0.09 units was invisible against the port's own cream emissive; 0.17 — half a bridge head light — is right, and the screenshot at 0.09 is what condemned it. REJECTED all three bridges, city-lights and both aircraft: the incumbents won on the picture, decisively for the bridge. TWO PARTS WERE BUILT FROM THE APPROVED CANDIDATES, PHOTOGRAPHED, AND CUT. Four legs: 14 m of quay spacing is 0.036 units at 391 m/unit against a 0.032 member floor, so 90% overlap. A portal X-brace: the bay is 0.115 wide by 0.38 tall, so both diagonals come out near-vertical and add a lump at mid-leg. Both are among the best things about the factory cranes AT THE FACTORY'S FRAMING. Neither survives at board scale. That gap is the whole reason a factory asset is reference geometry and not a drop-in. Fixed a defect the rebuild exposed: the backreach started a full rail-gauge behind the hinge, leaving a gap over the portal with the beam floating below it. One unbroken girder now. And every inclined member goes through a `strut()` that takes two points in the (distance-along-boom, height) plane, so the vertical-exaggeration bug the module header warns about is no longer reachable — it needs a length and an angle, and there is now no way to start from those. **The harbour works a shift.** It was a frozen tableau: 19 hulls placed from the pack's berths that never changed. Vessels now arrive through the channel, are met by a tug, berth, work and depart — seeded, so two people see the same harbour and a capture script shoots the same frame twice. A ship loses its wake when it ties up, because the wake is the information. **Every still and film re-shot.** The site was showing a Tera that no longer existed — SHOTS_COMMITb7f5c41, FILMS_COMMIT2aa4049, against an engine that has since gained fires, the whole state, ports, ships and night infrastructure. Two frames were bad and are fixed by moving the hour, not by retouching: `bay-relief-day` and `peninsula-day` were white lids of marine layer. Four captions described a Tera that no longer existed and are rewritten to the delivered frame. `california-relief-night` is measurably brighter than the frame it replaces (canvas mean 7.91 -> 10.57) despite the state being 30% larger. Ten budget cells pass, run twice. socal 1,422,025 -> 1,429,993 triangles against 1,700,000, 218 draws against 320. The measured delta is double the geometry because the crane mesh casts shadow, so renderer.info counts it in both passes — worth knowing before anyone reads that number as geometry. Tests 1,540 -> 1,570, server 295. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
isMakingWay,
|
||||
metresBetween,
|
||||
modelHarbour,
|
||||
MODELLED_INTERVAL_SECONDS,
|
||||
promoteVessels,
|
||||
reckonVessel,
|
||||
resolveBearing,
|
||||
@@ -415,7 +416,17 @@ describe("the modelled harbour, which is what runs this round", () => {
|
||||
it("says it is modelled, and the panel says so too", () => {
|
||||
const modelled = modelHarbour([PORT], { seed: 115 });
|
||||
assert.equal(modelled.source, "modelled");
|
||||
assert.equal(modelled.intervalSeconds, 900);
|
||||
/**
|
||||
* A minute, not the fifteen a real AIS listener declares.
|
||||
*
|
||||
* `intervalSeconds` is a property of the *source*, and this source is a
|
||||
* closed form of the clock that can be asked for any instant — see
|
||||
* `MODELLED_INTERVAL_SECONDS`. Fifteen minutes was mimicry, and it drew
|
||||
* arriving ships over Terminal Island, because a consumer dead-reckons along
|
||||
* a straight course and the Main Channel bends. A real feed still arrives
|
||||
* declaring its own 900 and is still dead-reckoned for 900.
|
||||
*/
|
||||
assert.equal(modelled.intervalSeconds, MODELLED_INTERVAL_SECONDS);
|
||||
const promotion = promoteVessels(modelled, SAN_PEDRO, berthAnchors([PORT]));
|
||||
assert.match(vesselSummary(promotion), /Modelled/);
|
||||
assert.match(vesselSummary(promotion), /no names and no MMSIs/);
|
||||
@@ -441,8 +452,17 @@ describe("the modelled harbour, which is what runs this round", () => {
|
||||
SAN_PEDRO,
|
||||
berthAnchors([PORT]),
|
||||
);
|
||||
assert.equal(promotion.makingWay, 3);
|
||||
assert.ok(promotion.alongside >= 2, "the quays came out empty");
|
||||
/**
|
||||
* A band, not a number, and the band is the honest assertion.
|
||||
*
|
||||
* `underWayPerPort` sizes the berth cycle so that the transits add up to the
|
||||
* target, but the legs differ in length and each arriving or departing ship
|
||||
* may have a tug attending her, so the count breathes. What must hold is
|
||||
* that the channel is neither empty nor a traffic jam.
|
||||
*/
|
||||
assert.ok(promotion.makingWay >= 2, `only ${promotion.makingWay} under way`);
|
||||
assert.ok(promotion.makingWay <= 8, `${promotion.makingWay} under way is a jam`);
|
||||
assert.ok(promotion.alongside >= 1, "the quays came out empty");
|
||||
// Every moving hull has a course, or the layer could not reckon it and would
|
||||
// not draw a wake — which is the one thing that reads at board scale.
|
||||
for (const drawn of promotion.drawn) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* The gantries' obstruction lights, and the one thing about them a picture
|
||||
* cannot check.
|
||||
*
|
||||
* A screenshot tells you there are red dots over San Pedro Bay. It does not
|
||||
* tell you they are on the masts *this build drew* rather than on a mast height
|
||||
* computed a second time in a second file — and that is the whole failure mode,
|
||||
* because it does not look like a failure. A light hung off an independently
|
||||
* recomputed apex is a row of red dots hovering a few metres over a row of
|
||||
* cranes, which at any framing this board is looked at from is indistinguishable
|
||||
* from a row of red dots on a row of cranes. It would ship.
|
||||
*
|
||||
* So the assertion is the *identity*: for every gantry the pack declares, there
|
||||
* is exactly one light, and its height is the height of the tallest mast box in
|
||||
* that gantry's own instance list plus the clearance. Nothing here re-derives
|
||||
* the apex; both sides come out of `ports.ts`, which is the point. This is the
|
||||
* twin of `nightInfrastructure.test.ts`'s argument about `bridgeLights` and the
|
||||
* deck it hangs lamps off.
|
||||
*
|
||||
* `craneLights` is also the reason `nightlights.ts` may import `ports.ts` at
|
||||
* all, so the second thing asserted here is that this seam stays what
|
||||
* CONTRACT §4 allows it to be: **positions, not lights.** The function returns
|
||||
* numbers. If it ever returns something that can illuminate a surface, this
|
||||
* fails at compile time and then again here.
|
||||
*
|
||||
* The world is Southern California's real projection — `latScale: 285` puts one
|
||||
* scene unit at 390.6 m, and heights carry the pack's 3.4x exaggeration. A tidy
|
||||
* 1:1 fake would pass while every apex sat 3.4 times too high, which is exactly
|
||||
* the class of bug `ports.ts` already has a comment about.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { craneLights, craneStations, createPorts } from "../../engine/ports.ts";
|
||||
import { LOS_ANGELES, LONG_BEACH, PORTS } from "../../cities/socal.ts";
|
||||
import type { World } from "../../engine/world.ts";
|
||||
|
||||
/** `socal.ts`: centre 33.82 / -118.05, `latScale: 285`, exaggeration 3.4. */
|
||||
function socalWorld(): World {
|
||||
const centre = { lat: 33.82, lng: -118.05 };
|
||||
const latScale = 285;
|
||||
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: () => 0,
|
||||
metresPerUnit,
|
||||
metres(value: number): number {
|
||||
return (value / metresPerUnit) * 3.4;
|
||||
},
|
||||
} as unknown as World;
|
||||
}
|
||||
|
||||
/** Every gantry the two packs declare, across both ports. */
|
||||
function gantryCount(): number {
|
||||
return PORTS.flatMap((port) => port.cranes ?? []).reduce((total, row) => total + row.count, 0);
|
||||
}
|
||||
|
||||
describe("the container gantries mark themselves after dark", () => {
|
||||
it("puts one light over every gantry on the board, and no more", () => {
|
||||
const world = socalWorld();
|
||||
let lights = 0;
|
||||
for (const port of PORTS) lights += craneLights(world, port).heads.length / 3;
|
||||
assert.equal(gantryCount(), 56, "San Pedro Bay is authored with fifty-six gantries");
|
||||
assert.equal(lights, 56, "one obstruction light per gantry, never per crane *row*");
|
||||
});
|
||||
|
||||
it("hands back numbers, not anything that could light a surface", () => {
|
||||
const world = socalWorld();
|
||||
const { heads } = craneLights(world, LOS_ANGELES);
|
||||
assert.ok(heads.length > 0);
|
||||
assert.equal(heads.length % 3, 0, "flat xyz triples, as `bridgeLights` returns");
|
||||
for (const value of heads) {
|
||||
assert.equal(typeof value, "number");
|
||||
assert.ok(Number.isFinite(value), "a light off the end of a board is a light nobody finds");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The identity this file exists for.
|
||||
*
|
||||
* `createPorts` builds the mast; `craneLights` builds the lamp. They must
|
||||
* agree, and the only honest way to check that is to measure the drawn mesh
|
||||
* rather than to recompute the arithmetic a third time here — a test that
|
||||
* recomputes it is a test that passes when all three copies drift together.
|
||||
*
|
||||
* The apex is found as the highest point of any instance box, using the box's
|
||||
* true rotated half-extent: the mast is a strut standing at near eighty
|
||||
* degrees, so most of its length is in world Y and almost none in the local X
|
||||
* it is long on.
|
||||
*/
|
||||
it("hangs each light on the mast the same build drew, not on a second guess at it", () => {
|
||||
const world = socalWorld();
|
||||
// One port, one row, all booms down, so the tallest thing on the board is
|
||||
// unambiguously a mast and not somebody's raised boom.
|
||||
const row = (LONG_BEACH.cranes ?? [])[0];
|
||||
assert.ok(row);
|
||||
const port = { ...LONG_BEACH, cranes: [{ ...row, idleFraction: 0 }] };
|
||||
const group = createPorts(world, [port]);
|
||||
|
||||
let mesh: THREE.InstancedMesh | null = null;
|
||||
group.traverse((object) => {
|
||||
if (object instanceof THREE.InstancedMesh && object.name === "ports:cranes") mesh = object;
|
||||
});
|
||||
assert.ok(mesh, "no crane mesh to measure against");
|
||||
const gantries = mesh as THREE.InstancedMesh;
|
||||
|
||||
const matrix = new THREE.Matrix4();
|
||||
const position = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scale = new THREE.Vector3();
|
||||
const basis = new THREE.Matrix4();
|
||||
let apex = -Infinity;
|
||||
for (let i = 0; i < gantries.count; i += 1) {
|
||||
gantries.getMatrixAt(i, matrix);
|
||||
matrix.decompose(position, quaternion, scale);
|
||||
basis.makeRotationFromQuaternion(quaternion);
|
||||
const e = basis.elements;
|
||||
const halfY =
|
||||
0.5 * (scale.x * Math.abs(e[1]!) + scale.y * Math.abs(e[5]!) + scale.z * Math.abs(e[9]!));
|
||||
apex = Math.max(apex, position.y + halfY);
|
||||
}
|
||||
assert.ok(Number.isFinite(apex));
|
||||
|
||||
const { heads } = craneLights(world, port);
|
||||
assert.equal(heads.length / 3, craneStations(row).length);
|
||||
// Every light at the same height — one row of gantries is one height — and
|
||||
// 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);
|
||||
for (let i = 1; i < heads.length; i += 3) {
|
||||
const y = heads[i]!;
|
||||
assert.ok(
|
||||
y > apex - member * 1.2 && y < apex + member * 1.6,
|
||||
`light at ${y.toFixed(4)} against a drawn apex of ${apex.toFixed(4)} — ` +
|
||||
"the lamp and the mast have stopped agreeing, which looks like nothing at all",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A light is a *marker*, so it has to be over the mast rather than over the
|
||||
* boom — the boom moves and the mast does not, and a lamp that followed the
|
||||
* boom would swing out over the water on a working crane and stand over the
|
||||
* yard on a parked one. Working and parked rows must agree.
|
||||
*/
|
||||
it("does not move when the boom does", () => {
|
||||
const world = socalWorld();
|
||||
const row = (LOS_ANGELES.cranes ?? [])[0];
|
||||
assert.ok(row);
|
||||
const working = craneLights(world, { ...LOS_ANGELES, cranes: [{ ...row, idleFraction: 0 }] });
|
||||
const parked = craneLights(world, { ...LOS_ANGELES, cranes: [{ ...row, idleFraction: 1 }] });
|
||||
assert.equal(working.heads.length, parked.heads.length);
|
||||
for (let i = 0; i < working.heads.length; i += 1) {
|
||||
assert.ok(
|
||||
Math.abs(working.heads[i]! - parked.heads[i]!) < 1e-9,
|
||||
"an obstruction light followed the boom; it belongs on the mast",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* The harbour's working day: ships arriving, berthing, working and leaving.
|
||||
*
|
||||
* The owner's ask was "simulate the boats coming in", and the thing that makes
|
||||
* that hard is not the animation — it is that this board is supposed to be an
|
||||
* instrument. A harbour that is *alive* and a harbour that is *honest* pull in
|
||||
* opposite directions, and every assertion below is on the seam between them.
|
||||
*
|
||||
* Four properties carry the file:
|
||||
*
|
||||
* 1. **It is a plan, not a state.** `harbourCalls` never sees a clock;
|
||||
* `modelHarbour` is a closed form of `atMs`. So two people on two machines
|
||||
* see the same ships, `look.mjs --at` shoots the same frame twice, and — the
|
||||
* part that is easy to lose — asking for a later instant and then an earlier
|
||||
* one gives the earlier one back unchanged, because there is no accumulator
|
||||
* anywhere to have moved. `server/wire.ts` makes exactly this argument for
|
||||
* the simulated sky, and the sea is the same shape.
|
||||
*
|
||||
* 2. **`sog` is the derivative of the position it arrives with.** This is the
|
||||
* seam that lets a real AIS feed replace the simulator without the renderer
|
||||
* changing a line. A consumer is licensed to dead-reckon along a reported
|
||||
* course at a reported speed, so a fix whose speed is not the derivative of
|
||||
* its own track is a lie the renderer will draw faithfully. It is asserted
|
||||
* numerically, against the simulator's own next position.
|
||||
*
|
||||
* 3. **A moored ship has no wake, and loses it the moment she is tied up.**
|
||||
* A wake is a function of speed through water; at 391 m to the scene unit it
|
||||
* is also most of what a ship *is* on this board. An arriving ship that kept
|
||||
* her V alongside would be the layer claiming motion it has no evidence for.
|
||||
*
|
||||
* 4. **Nothing is drawn where a ship cannot float.** A hull lies half a beam off
|
||||
* the wall rather than centred on it, and a berth the channel cannot reach
|
||||
* gets no route at all rather than a straight line across a container yard.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
BERTH_APPROACH_REACH_METRES,
|
||||
MODELLED_INTERVAL_SECONDS,
|
||||
approachRun,
|
||||
berthAnchors,
|
||||
harbourCalls,
|
||||
harbourMoment,
|
||||
metresBetween,
|
||||
modelHarbour,
|
||||
promoteVessels,
|
||||
reckonVessel,
|
||||
vesselSummary,
|
||||
} from "../../server/vessels.ts";
|
||||
import {
|
||||
HULL_SHAPES,
|
||||
hullGeometry,
|
||||
hullShape,
|
||||
wakeLengthMetres,
|
||||
} from "../../engine/vessels.ts";
|
||||
import SOCAL from "../../cities/socal.ts";
|
||||
import type { Port } from "../../engine/types.ts";
|
||||
|
||||
const PORTS = SOCAL.ports ?? [];
|
||||
const ANCHORS = berthAnchors(PORTS);
|
||||
const SEED = 115;
|
||||
/** An arbitrary but fixed instant. Every sweep below is relative to it. */
|
||||
const EPOCH = Date.parse("2026-08-23T20:00:00Z");
|
||||
|
||||
function harbourAt(atMs: number) {
|
||||
const body = modelHarbour(PORTS, { seed: SEED, atMs });
|
||||
return { body, promotion: promoteVessels(body, SOCAL.bounds, ANCHORS, atMs) };
|
||||
}
|
||||
|
||||
/** Every call the two SoCal ports schedule, flattened. */
|
||||
function allCalls() {
|
||||
return PORTS.flatMap((port: Port) => harbourCalls(port, { seed: SEED }));
|
||||
}
|
||||
|
||||
describe("the harbour is a plan evaluated at an instant, not a thing that runs", () => {
|
||||
it("gives the same harbour twice for the same instant", () => {
|
||||
assert.deepEqual(harbourAt(EPOCH).body, harbourAt(EPOCH).body);
|
||||
});
|
||||
|
||||
it("has no memory: going forward and coming back lands on the same harbour", () => {
|
||||
// The property a `setInterval` and an accumulator cannot have, and the one
|
||||
// that makes a scrubbed clock and a capture script agree. Ask for an hour
|
||||
// later, then two hours earlier, then the original instant again.
|
||||
const first = harbourAt(EPOCH).body;
|
||||
harbourAt(EPOCH + 3_600_000);
|
||||
harbourAt(EPOCH - 7_200_000);
|
||||
assert.deepEqual(harbourAt(EPOCH).body, first);
|
||||
});
|
||||
|
||||
it("is seeded, so a different seed is a different harbour", () => {
|
||||
const a = modelHarbour(PORTS, { seed: 115, atMs: EPOCH });
|
||||
const b = modelHarbour(PORTS, { seed: 116, atMs: EPOCH });
|
||||
assert.notDeepEqual(a.vessels, b.vessels);
|
||||
});
|
||||
|
||||
it("says out loud that the rate of arrivals is modelled and the speeds are not", () => {
|
||||
// The SEA panel's standard: name what is drawn from the board's own geometry
|
||||
// and name the one thing that is a modelling choice, in the same breath.
|
||||
const { body, promotion } = harbourAt(EPOCH);
|
||||
const attribution = (body.attribution ?? []).join(" ");
|
||||
assert.match(attribution, /Not an observation of any vessel/);
|
||||
assert.match(attribution, /compressed/);
|
||||
assert.match(vesselSummary(promotion), /Modelled from this board's own berths and channels/);
|
||||
assert.match(vesselSummary(promotion), /no names and no MMSIs/);
|
||||
// The panel, not just the licence sheet: a reader watching a berth change
|
||||
// hands twice in an afternoon must be told that rate is the modelled part.
|
||||
assert.match(vesselSummary(promotion), /arrivals run at about seven times a real day's rate/);
|
||||
assert.match(vesselSummary(promotion), /Ship speeds, the channel and the berths are true/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a working day: in through the channel, alongside, and out again", () => {
|
||||
it("walks every berth through inbound, alongside, outbound and empty, in that order", () => {
|
||||
const call = allCalls()[0];
|
||||
assert.ok(call, "no berth was given a call at all");
|
||||
const seen: string[] = [];
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const phase = harbourMoment(call, call.offsetSeconds + (i * call.cycleSeconds) / 600).phase;
|
||||
if (seen[seen.length - 1] !== phase) seen.push(phase);
|
||||
}
|
||||
assert.deepEqual(seen, ["inbound", "alongside", "outbound", "empty"]);
|
||||
});
|
||||
|
||||
it("brings an arriving ship steadily closer to her berth and never past it", () => {
|
||||
const call = allCalls()[0];
|
||||
assert.ok(call);
|
||||
const berth = call.approach[call.approach.length - 1];
|
||||
assert.ok(berth);
|
||||
let previous = Infinity;
|
||||
for (let i = 0; i <= 50; i++) {
|
||||
const at = call.offsetSeconds + (i / 50) * call.inboundSeconds * 0.999;
|
||||
const moment = harbourMoment(call, at);
|
||||
assert.equal(moment.phase, "inbound");
|
||||
const ship = shipOf(call, at);
|
||||
assert.ok(ship, "an inbound leg with no ship on it");
|
||||
const metres = metresBetween(ship.lat, ship.lon, berth[0], berth[1]);
|
||||
assert.ok(metres <= previous + 1, `the ship went backwards at ${i}`);
|
||||
previous = metres;
|
||||
}
|
||||
// And arrives: within a ship's length of the quay by the end of the leg.
|
||||
assert.ok(previous < 200, `she stopped ${previous.toFixed(0)} m short`);
|
||||
});
|
||||
|
||||
it("gives a berth a fresh ship each cycle rather than the same one for ever", () => {
|
||||
const call = allCalls()[0];
|
||||
assert.ok(call);
|
||||
const first = harbourMoment(call, call.offsetSeconds + 60);
|
||||
const next = harbourMoment(call, call.offsetSeconds + call.cycleSeconds + 60);
|
||||
assert.equal(next.index, first.index + 1);
|
||||
assert.notEqual(
|
||||
shipOf(call, call.offsetSeconds + 60)?.id,
|
||||
shipOf(call, call.offsetSeconds + call.cycleSeconds + 60)?.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the channel busy without turning it into a parade", () => {
|
||||
// Swept rather than sampled once: the interesting failure is a schedule that
|
||||
// is right at one instant and has nine ships in one channel at another.
|
||||
let most = 0;
|
||||
let fewest = Infinity;
|
||||
for (let minute = 0; minute < 240; minute += 5) {
|
||||
const { promotion } = harbourAt(EPOCH + minute * 60_000);
|
||||
most = Math.max(most, promotion.makingWay);
|
||||
fewest = Math.min(fewest, promotion.makingWay);
|
||||
assert.ok(promotion.drawn.length <= 32, `${promotion.drawn.length} hulls on the board`);
|
||||
assert.equal(promotion.offBoard, 0, "a modelled hull fell off its own board");
|
||||
assert.equal(promotion.withoutOrientation, 0, "a modelled hull would not say which way it faced");
|
||||
}
|
||||
assert.ok(fewest >= 1, "the harbour went completely still");
|
||||
assert.ok(most <= 12, `${most} hulls under way at once is a parade`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the seam a real AIS feed has to slot into", () => {
|
||||
it("reports a speed that is the derivative of the position it reports", () => {
|
||||
/**
|
||||
* The load-bearing assertion in this file.
|
||||
*
|
||||
* `reckonVessel` advances a fix along its own reported course at its own
|
||||
* reported speed, and that is the only motion the renderer is licensed to
|
||||
* draw. So for every moving hull the simulator emits, dead-reckoning it one
|
||||
* declared interval forward has to land near where the simulator itself puts
|
||||
* it an interval later. The tolerance is a scene unit — 391 m on SoCal —
|
||||
* because the ship is also *turning* over that interval and a straight
|
||||
* course cannot follow a bend. That residual is a true property of a
|
||||
* course-and-speed feed and is exactly why the declared interval is a minute
|
||||
* and not the fifteen a real listener will send.
|
||||
*/
|
||||
const step = MODELLED_INTERVAL_SECONDS;
|
||||
const before = harbourAt(EPOCH).body.vessels;
|
||||
const after = new Map(harbourAt(EPOCH + step * 1000).body.vessels.map((v) => [v.id, v]));
|
||||
let checked = 0;
|
||||
for (const fix of before) {
|
||||
if (fix.speed < 0.257 || fix.course === null) continue;
|
||||
const later = after.get(fix.id);
|
||||
if (!later) continue;
|
||||
const reckoned = reckonVessel(
|
||||
{ lat: fix.lat, lng: fix.lon, speed: fix.speed, course: fix.course },
|
||||
step,
|
||||
);
|
||||
const error = metresBetween(reckoned.lat, reckoned.lng, later.lat, later.lon);
|
||||
assert.ok(error < 391, `${fix.id} dead-reckoned ${error.toFixed(0)} m off its own next fix`);
|
||||
checked += 1;
|
||||
}
|
||||
assert.ok(checked >= 2, `only ${checked} moving hulls were checkable`);
|
||||
});
|
||||
|
||||
it("declares a minute, because a straight course cannot follow a bent channel", () => {
|
||||
assert.equal(harbourAt(EPOCH).body.intervalSeconds, MODELLED_INTERVAL_SECONDS);
|
||||
assert.ok(MODELLED_INTERVAL_SECONDS <= 60);
|
||||
});
|
||||
|
||||
it("never volunteers a heading, so the berths keep doing the orienting", () => {
|
||||
for (const fix of harbourAt(EPOCH).body.vessels) assert.equal(fix.heading, null);
|
||||
const { promotion } = harbourAt(EPOCH);
|
||||
assert.ok(promotion.alongside > 0, "no modelled hull found its berth");
|
||||
});
|
||||
|
||||
it("carries no name, no MMSI, no callsign and no destination", () => {
|
||||
for (const fix of harbourAt(EPOCH).body.vessels) {
|
||||
for (const forbidden of ["name", "mmsi", "callsign", "destination", "laden"]) {
|
||||
assert.equal(forbidden in fix, false, `a modelled vessel carried ${forbidden}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wake is the ship, so a berthed ship must not have one", () => {
|
||||
it("drops the wake to nothing in the same fix that puts her alongside", () => {
|
||||
const call = allCalls()[0];
|
||||
assert.ok(call);
|
||||
// The last minute of the run in, then the first of the lie alongside.
|
||||
const arriving = shipOf(call, call.offsetSeconds + call.inboundSeconds * 0.98);
|
||||
const berthed = shipOf(call, call.offsetSeconds + call.inboundSeconds + 60);
|
||||
assert.ok(arriving && berthed);
|
||||
assert.ok(wakeLengthMetres(arriving.speed, arriving.length ?? 300) >= 0);
|
||||
assert.equal(berthed.speed, 0);
|
||||
assert.equal(wakeLengthMetres(berthed.speed, berthed.length ?? 300), 0);
|
||||
});
|
||||
|
||||
it("leaves no hull at rest with a wake anywhere on the board, at any hour", () => {
|
||||
for (let minute = 0; minute < 240; minute += 7) {
|
||||
for (const drawn of harbourAt(EPOCH + minute * 60_000).promotion.drawn) {
|
||||
if (drawn.berthId === undefined) continue;
|
||||
assert.equal(drawn.speed, 0, `${drawn.id} is alongside and moving`);
|
||||
assert.equal(wakeLengthMetres(drawn.speed, drawn.length), 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives an arriving ship a wake long enough to read at board scale", () => {
|
||||
// 391 m to the unit: a 300 m hull is 0.77 units and a fleck, and its wake at
|
||||
// full speed is over three units and is the thing that says "moving".
|
||||
const call = allCalls().find((c) => c.approachMetres > 6000);
|
||||
assert.ok(call);
|
||||
const early = shipOf(call, call.offsetSeconds + call.inboundSeconds * 0.1);
|
||||
assert.ok(early);
|
||||
const wake = wakeLengthMetres(early.speed, early.length ?? 300);
|
||||
assert.ok(wake > 3 * 391, `an entering ship's wake is only ${wake.toFixed(0)} m`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nothing is drawn where a ship cannot float", () => {
|
||||
it("lies a berthed hull off the wall rather than through it", () => {
|
||||
let checked = 0;
|
||||
for (let minute = 0; minute < 180; minute += 11) {
|
||||
for (const drawn of harbourAt(EPOCH + minute * 60_000).promotion.drawn) {
|
||||
if (drawn.berthId === undefined) continue;
|
||||
const anchor = ANCHORS.find((a) => a.id === drawn.berthId);
|
||||
if (!anchor) continue;
|
||||
const call = allCalls().find((c) => c.berthId === drawn.berthId);
|
||||
if (!call) continue; // an unscheduled berth still lies on its own point
|
||||
const off = metresBetween(drawn.lat, drawn.lng, anchor.lat, anchor.lng);
|
||||
assert.ok(off >= drawn.beam / 2, `${drawn.id} is ${off.toFixed(0)} m off a ${drawn.beam} m beam`);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert.ok(checked > 0, "no scheduled berth was ever occupied");
|
||||
});
|
||||
|
||||
it("refuses to route to a berth the channel does not reach", () => {
|
||||
// Los Angeles' East Basin is three kilometres off the Main Channel, and the
|
||||
// straight line between them crosses Terminal Island. A berth like that
|
||||
// keeps a hull alongside and takes no calls — see the constant's own note.
|
||||
const lax = PORTS.find((p: Port) => p.id === "USLAX");
|
||||
assert.ok(lax);
|
||||
const scheduled = new Set(harbourCalls(lax, { seed: SEED }).map((c) => c.berthId));
|
||||
assert.ok(scheduled.has("lax-401"), "Pier 400 is on the channel and should be worked");
|
||||
assert.equal(scheduled.has("lax-232"), false, "the East Basin was routed to across the island");
|
||||
// And the quay is not left bare: the unreachable berths still carry hulls.
|
||||
const alongside = harbourAt(EPOCH).promotion.drawn.filter((v) => v.berthId?.startsWith("lax-2"));
|
||||
assert.ok(alongside.length > 0, "the East Basin came out empty");
|
||||
});
|
||||
|
||||
it("keeps every routed berth inside the reach the routing promises", () => {
|
||||
for (const call of allCalls()) {
|
||||
const berth = call.approach[call.approach.length - 1];
|
||||
const from = call.approach[call.approach.length - 2];
|
||||
assert.ok(berth && from);
|
||||
const cross = metresBetween(berth[0], berth[1], from[0], from[1]);
|
||||
assert.ok(
|
||||
cross <= BERTH_APPROACH_REACH_METRES + 1,
|
||||
`${call.berthId} runs ${cross.toFixed(0)} m off the channel`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tug, which is the hull that is usually moving", () => {
|
||||
it("comes out to meet an arriving ship and is gone once she is tied up", () => {
|
||||
const call = allCalls()[0];
|
||||
assert.ok(call);
|
||||
const tugAt = (x: number) =>
|
||||
modelHarbour(PORTS, { seed: SEED, atMs: (call.offsetSeconds + x * call.inboundSeconds) * 1000 })
|
||||
.vessels.find((v) => v.id === `m-${call.portId}:${call.berthId}:0:tug`);
|
||||
assert.equal(tugAt(0.2), undefined, "a tug was out before there was anything to meet");
|
||||
assert.ok(tugAt(0.7), "no tug met the arriving ship");
|
||||
assert.equal(tugAt(0.99), undefined, "the tug was still under way after she berthed");
|
||||
});
|
||||
|
||||
it("runs seaward to make the meeting, against the ship it is meeting", () => {
|
||||
// The sub-leg worth having: two wakes crossing in opposite directions is a
|
||||
// harbour doing something, where a tug that materialises alongside is a decal.
|
||||
const call = allCalls()[0];
|
||||
assert.ok(call);
|
||||
const at = (call.offsetSeconds + 0.47 * call.inboundSeconds) * 1000;
|
||||
const fleet = modelHarbour(PORTS, { seed: SEED, atMs: at }).vessels;
|
||||
const ship = fleet.find((v) => v.id === `m-${call.portId}:${call.berthId}:0`);
|
||||
const tug = fleet.find((v) => v.id === `m-${call.portId}:${call.berthId}:0:tug`);
|
||||
assert.ok(ship && tug, "no ship-and-tug pair at the meeting");
|
||||
assert.ok(ship.course !== null && tug.course !== null);
|
||||
const between = Math.abs(((((tug.course - ship.course) % 360) + 540) % 360) - 180);
|
||||
assert.ok(between > 120, `the tug is running with her, ${between.toFixed(0)} apart`);
|
||||
assert.ok(tug.speed > ship.speed * 0.5, "the tug is loitering rather than running");
|
||||
});
|
||||
|
||||
it("is drawn from its own solid, which is one more draw call and only when it floats", () => {
|
||||
assert.equal(hullShape("tug"), "tug");
|
||||
assert.equal(hullShape("container"), "generic");
|
||||
assert.equal(HULL_SHAPES.length, 2, "a third hull shape is a third draw call");
|
||||
const tug = hullGeometry("tug");
|
||||
const triangles = tug.getAttribute("position").count / 3;
|
||||
assert.equal(triangles, 64, `the tug is ${triangles} triangles`);
|
||||
for (const attribute of ["position", "normal", "uv"]) {
|
||||
assert.ok(tug.getAttribute(attribute), `the tug hull has no ${attribute}`);
|
||||
}
|
||||
tug.dispose();
|
||||
});
|
||||
|
||||
it("is wound outward, by the same flux test the merchant hull is held to", () => {
|
||||
const geometry = hullGeometry("tug");
|
||||
const position = geometry.getAttribute("position");
|
||||
let flux = 0;
|
||||
for (let t = 0; t < position.count; t += 3) {
|
||||
const a = new THREE.Vector3().fromBufferAttribute(position, t);
|
||||
const b = new THREE.Vector3().fromBufferAttribute(position, t + 1);
|
||||
const c = new THREE.Vector3().fromBufferAttribute(position, t + 2);
|
||||
const face = new THREE.Vector3()
|
||||
.subVectors(b, a)
|
||||
.cross(new THREE.Vector3().subVectors(c, a))
|
||||
.multiplyScalar(0.5);
|
||||
flux += a.clone().add(b).add(c).divideScalar(3).dot(face);
|
||||
}
|
||||
assert.ok(flux > 0, `the tug hull encloses ${flux.toFixed(3)} — a face is inside out`);
|
||||
geometry.dispose();
|
||||
});
|
||||
|
||||
it("carries the mass forward, which is the whole of the silhouette", () => {
|
||||
/**
|
||||
* The one measurement that separates a tug from a small freighter, asserted
|
||||
* because "tidy up the hull" is exactly the change that would undo it. The
|
||||
* bow is at -z, so the superstructure's centre of area must sit *forward* of
|
||||
* amidships — the opposite of the merchant hull, whose house and funnel are
|
||||
* aft over the screw.
|
||||
*/
|
||||
const centroid = (shape: "generic" | "tug") => {
|
||||
const position = hullGeometry(shape).getAttribute("position");
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
if (position.getY(i) <= 1.05) continue; // hull only below the main deck
|
||||
sum += position.getZ(i);
|
||||
n += 1;
|
||||
}
|
||||
return n > 0 ? sum / n : 0;
|
||||
};
|
||||
assert.ok(centroid("tug") < 0, "the tug's house is aft, which makes it a small ship");
|
||||
assert.ok(centroid("generic") > 0, "the merchant house moved forward");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
/** The ship this call has on the board at `atSeconds`, from the whole body. */
|
||||
function shipOf(call: ReturnType<typeof allCalls>[number], atSeconds: number) {
|
||||
const moment = harbourMoment(call, atSeconds);
|
||||
return modelHarbour(PORTS, { seed: SEED, atMs: atSeconds * 1000 }).vessels.find(
|
||||
(v) => v.id === `m-${call.portId}:${call.berthId}:${moment.index}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("the arithmetic under the run in", () => {
|
||||
it("enters at speed and arrives at nothing", () => {
|
||||
const fast = approachRun(9000, 2150, 0.02, "inbound");
|
||||
const slow = approachRun(9000, 2150, 0.98, "inbound");
|
||||
assert.ok(fast.speedMps > 6, `entering at ${fast.speedMps.toFixed(1)} m/s`);
|
||||
assert.ok(slow.speedMps < 1, `berthing at ${slow.speedMps.toFixed(1)} m/s`);
|
||||
assert.ok(fast.arcMetres < slow.arcMetres);
|
||||
});
|
||||
|
||||
it("leaves at nothing and departs at speed, which is the run in backwards", () => {
|
||||
const slipping = approachRun(9000, 2000, 0.02, "outbound");
|
||||
const away = approachRun(9000, 2000, 0.98, "outbound");
|
||||
assert.ok(slipping.speedMps < 1);
|
||||
assert.ok(away.speedMps > 6);
|
||||
// Arc is measured from the seaward end, so a departure counts down.
|
||||
assert.ok(slipping.arcMetres > away.arcMetres);
|
||||
});
|
||||
|
||||
it("is total: a zero-length or zero-time leg is still a number", () => {
|
||||
assert.deepEqual(approachRun(0, 100, 0.5, "inbound"), { arcMetres: 0, speedMps: 0 });
|
||||
assert.deepEqual(approachRun(100, 0, 0.5, "outbound"), { arcMetres: 0, speedMps: 0 });
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,8 @@
|
||||
* merge-across-ports property is asserted here rather than assumed: **four ports
|
||||
* must produce exactly the same mesh count as one.**
|
||||
*
|
||||
* **No crane is a `Group`.** Fifty-six gantries at five boxes each is 280
|
||||
* matrices in one `InstancedMesh` or 280 draw calls, and this repo has already
|
||||
* **No crane is a `Group`.** Fifty-six gantries at eleven boxes each is 616
|
||||
* matrices in one `InstancedMesh` or 616 draw calls, and this repo has already
|
||||
* made the second mistake twice — a suspension bridge at ~34 draw calls, and
|
||||
* twelve identical asphalt freeways that could never merge because a fresh
|
||||
* material was allocated per ribbon. A test is the only thing that keeps the
|
||||
@@ -175,7 +175,7 @@ describe("every gantry on the board is one InstancedMesh", () => {
|
||||
assert.deepEqual(groups, [], `a crane became a Group: ${groups.join(", ")}`);
|
||||
});
|
||||
|
||||
it("puts five boxes per gantry in a single instanced mesh", () => {
|
||||
it("puts eleven boxes per gantry in a single instanced mesh", () => {
|
||||
const group = createPorts(socalWorld(), PORTS);
|
||||
const cranes = meshes(group).filter((mesh) => mesh.name === "ports:cranes");
|
||||
assert.equal(cranes.length, 1, "there must be exactly one crane mesh for the whole board");
|
||||
@@ -186,7 +186,20 @@ describe("every gantry on the board is one InstancedMesh", () => {
|
||||
0,
|
||||
);
|
||||
assert.equal(gantries, 56, "San Pedro Bay is authored with fifty-six gantries");
|
||||
assert.equal((mesh as THREE.InstancedMesh).count, gantries * 5);
|
||||
// The census, and the reason it is a number and not a range. A gantry was
|
||||
// five boxes — two legs, a portal beam, a boom, a backreach — and fifty-six
|
||||
// of those read from altitude as fifty-six crosses. It is eleven now, the
|
||||
// six new ones being the A-frame mast and its apex cap, the two stays that
|
||||
// sling the boom off it, the sill under the legs and the machinery house
|
||||
// over the tail. 132 triangles a gantry against 60: **7,392 for the whole
|
||||
// of San Pedro Bay against 3,360**, on a board whose desktop budget is 1.7
|
||||
// million and which measures 1.43 million with this in it. The count is
|
||||
// pinned because the thing being defended is not the triangles, it is that
|
||||
// adding a part to a crane must stay an entry in a matrix list — the moment
|
||||
// it becomes a child mesh this is 616 draw calls.
|
||||
assert.equal((mesh as THREE.InstancedMesh).count, gantries * 11);
|
||||
const triangles = (mesh as THREE.InstancedMesh).count * 12;
|
||||
assert.equal(triangles, 7392);
|
||||
});
|
||||
|
||||
it("raises exactly the booms the pack asked for, from the far end of the rail", () => {
|
||||
@@ -210,23 +223,55 @@ describe("every gantry on the board is one InstancedMesh", () => {
|
||||
const idle = createPorts(world, [
|
||||
{ ...LOS_ANGELES, cranes: [{ ...(LOS_ANGELES.cranes ?? [])[0]!, idleFraction: 1 }] },
|
||||
]);
|
||||
/**
|
||||
* The true world-space top of a gantry: every box's own axis-aligned
|
||||
* extent, not `position.y + scale.x / 2`.
|
||||
*
|
||||
* The cheap version was right when the only tilted box was the boom, whose
|
||||
* long axis is X. It is wrong for the mast and the two stays — a strut
|
||||
* standing at eighty degrees has almost all of its length in Y and none of
|
||||
* it in the X the shortcut reads. Measuring the rotated half-extent is four
|
||||
* more lines and cannot be fooled by which axis a part happens to be long
|
||||
* on.
|
||||
*/
|
||||
const topOf = (group: THREE.Object3D) => {
|
||||
const mesh = meshes(group).find((m) => m.name === "ports:cranes") as THREE.InstancedMesh;
|
||||
const matrix = new THREE.Matrix4();
|
||||
const position = new THREE.Vector3();
|
||||
const scale = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const basis = new THREE.Matrix4();
|
||||
let highest = -Infinity;
|
||||
for (let i = 0; i < mesh.count; i += 1) {
|
||||
mesh.getMatrixAt(i, matrix);
|
||||
matrix.decompose(position, quaternion, scale);
|
||||
highest = Math.max(highest, position.y + scale.x / 2);
|
||||
basis.makeRotationFromQuaternion(quaternion);
|
||||
const e = basis.elements;
|
||||
// Row 1 of the rotation, dotted with the half-extents' magnitudes.
|
||||
const halfY =
|
||||
0.5 *
|
||||
(scale.x * Math.abs(e[1]!) + scale.y * Math.abs(e[5]!) + scale.z * Math.abs(e[9]!));
|
||||
highest = Math.max(highest, position.y + halfY);
|
||||
}
|
||||
return highest;
|
||||
};
|
||||
/**
|
||||
* 1.1, where it used to be 1.4, and the mast is the whole reason.
|
||||
*
|
||||
* A working gantry's tallest part is no longer its own deck line — it is
|
||||
* the apex of the A-frame, `CRANE_MAST_RISE` of the portal above the
|
||||
* girder. So the ratio this asserts is now the ratio between a raised boom
|
||||
* tip and an apex, and on real hardware that is 1.16 (a 104 m apex under a
|
||||
* 121 m boom tip) to 1.25 (99 m under 133), not 1.4. Asking for 1.4 would
|
||||
* be asking for a mast shorter than any gantry has.
|
||||
*
|
||||
* It still catches the bug it was written for. Rotating the boom instead of
|
||||
* composing it takes the tip through the *unexaggerated* horizontal, which
|
||||
* on this board lands it below the apex — ratio under 1, and a failure.
|
||||
*/
|
||||
assert.ok(
|
||||
topOf(idle) > topOf(working) * 1.4,
|
||||
"a raised boom must reach well above a lowered one; the vertical axis is exaggerated and the horizontal is not, so the boom has to be composed rather than rotated",
|
||||
topOf(idle) > topOf(working) * 1.1,
|
||||
"a raised boom must reach well above the mast apex; the vertical axis is exaggerated and the horizontal is not, so the boom has to be composed rather than rotated",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user