1
0

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_COMMIT b7f5c41, FILMS_COMMIT 2aa4049, 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:
2026-08-23 03:29:58 -07:00
parent e7a8aa028b
commit bcac6aa41a
15 changed files with 2429 additions and 304 deletions
+23 -3
View File
@@ -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) {
+166
View File
@@ -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",
);
}
});
});
+427
View File
@@ -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 });
});
});
+52 -7
View File
@@ -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",
);
});
});