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
+59 -1
View File
@@ -40,6 +40,7 @@ import * as THREE from "three";
import { nightFactor } from "./atmosphere.ts";
import { FACADE_ATTRIBUTE } from "./blocks.ts";
import { bridgeLights } from "./bridges.ts";
import { craneLights } from "./ports.ts";
import { seededRandom, type World } from "./world.ts";
export interface NightLightsOptions {
@@ -50,6 +51,8 @@ export interface NightLightsOptions {
streetLamps?: boolean;
/** Lamps down the deck of every crossing, and lights at the tower heads. On by default. */
bridgeLamps?: boolean;
/** Obstruction lights on the container gantries. On by default. */
craneLamps?: boolean;
/** Metres between street lamps. */
lampSpacingM?: number;
/**
@@ -194,6 +197,25 @@ const HEAD_LIGHT_COLOR = 0xff3b30;
const BRIDGE_LAMP_SPREAD = 2.4;
const HEAD_LIGHT_SPREAD = 3.4;
/**
* Glow radius of a gantry's obstruction light, in scene units.
*
* Not a multiple of anything, unlike every other size in this file, and the
* reason is that there is nothing to take a multiple of: a bridge lamp is scaled
* off its own deck's half-width and a crane has no such length. So it is picked
* against the *rail spacing* instead. Pier 400's eighteen gantries stand over
* 1.4 km, which is 0.20 scene units apart on this board, and 0.17 is the largest
* sprite that still leaves eighteen separate lights rather than one bar.
*
* It was 0.09 first, chosen off the 0.115 rail gauge, and the frame said no: at
* 0.09 the light is smaller than the crane it is on and disappears against
* `ports.ts`'s own cream emissive, which is already lighting the gantry it was
* meant to mark. Half a bridge head light (0.306) is about right — a gantry is a
* smaller object than a 227 m tower and its light should read smaller, but not
* invisible.
*/
const CRANE_LIGHT_SIZE = 0.17;
/**
* When the lamps come on, in degrees of solar elevation.
*
@@ -248,7 +270,14 @@ export function createNightLights(options: NightLightsOptions): NightLights {
const crossings = (options.bridgeLamps ?? true) ? buildBridgeLamps(world) : [];
for (const cloud of crossings) group.add(cloud.points);
const clouds = [...(lamps ? [lamps] : []), ...crossings];
const gantries = (options.craneLamps ?? true) ? buildCraneLamps(world) : null;
if (gantries) group.add(gantries.points);
const clouds = [
...(lamps ? [lamps] : []),
...crossings,
...(gantries ? [gantries] : []),
];
let strength = 0;
@@ -554,6 +583,35 @@ function buildBridgeLamps(world: World): Lamps[] {
return out;
}
/**
* The container terminals' obstruction lights: one red point per gantry apex.
*
* `ports.ts` decides where they are; this decides what they look like, which is
* the same seam `buildBridgeLamps` sits on. They deliberately share
* `HEAD_LIGHT_COLOR` with the bridge tower heads — the fixture on a 143 m crane
* and the fixture on a 227 m tower are the same fixture doing the same job, and
* a board with two different reds on it for that reason would be a board that
* had stopped meaning anything by its reds.
*
* They do not share the *cloud*, because a `PointsMaterial` carries one size for
* the whole of one and the two want different ones: a bridge head is sized off
* the deck it stands over, and a gantry has no deck. `CRANE_LIGHT_SIZE` is in
* scene units and is the one number here that is not derived, because a crane
* row's spacing is authored in the pack rather than implied by anything this
* module can see. 0.09 is a shade under the 0.115 rail gauge, so a row of
* eighteen at Pier 400 reads as eighteen lights rather than as one bar.
*
* The whole board is fifty-six points in one draw call. If a pack ever declares
* a terminal with a thousand gantries in it, it is still one draw call.
*/
function buildCraneLamps(world: World): Lamps | null {
const heads: number[] = [];
for (const port of world.city.ports ?? []) {
for (const value of craneLights(world, port).heads) heads.push(value);
}
return glowCloud("craneheads", heads, HEAD_LIGHT_COLOR, CRANE_LIGHT_SIZE);
}
// ---- The night level, for anything that is not part of the city -----------
/**
+234 -49
View File
@@ -850,6 +850,32 @@ const CRANE_OUTREACH_FLOOR = 0.26;
/** Degrees the boom stands at when it is raised. A stowed gantry is near vertical. */
const CRANE_BOOM_IDLE_DEG = 74;
/**
* The A-frame mast, as a fraction of the portal height, and how far back over
* the portal its apex sits.
*
* Both are read off real gantries and off the two crane candidates the owner
* approved out of the asset factory, which independently agreed to within a few
* per cent: a hinge at 58 m under an apex at 99104 m is a mast 0.710.78 of the
* portal again, with the apex 714 m — roughly a third of a rail gauge — behind
* the waterside rail. 0.62 is the conservative end of that band, chosen because
* this board's packs already author a gantry taller than a real one (82 m at
* Pier 400 against a real 5560), and taking the full 0.75 on top of an
* already-tall portal would put an apex at 143 m where nothing has ever stood.
*
* The setback is not decoration: the forestay runs from the apex to the boom,
* and an apex level with or ahead of the hinge would draw a stay that pulls the
* boom *down*.
*/
const CRANE_MAST_RISE = 0.62;
const CRANE_APEX_SETBACK = 0.4;
/** Where the truck lane's head beam crosses the portal, as a fraction of it. */
const CRANE_PORTAL_BEAM = 0.44;
/** Backreach length, as a fraction of outreach, measured from the landside rail. */
const CRANE_BACKREACH = 0.55;
/** Every coordinate a port's graded plate has to be at least as high as. */
function plateSamples(port: Port): LatLng[] {
const samples: LatLng[] = [];
@@ -1101,15 +1127,45 @@ function buildPort(
/**
* Every box of every gantry on one rail, as instance matrices.
*
* **The instanced geometry is a unit box and each crane contributes five of
* **The instanced geometry is a unit box and each crane contributes eleven of
* them.** That is what makes a per-instance boom angle possible inside a single
* `InstancedMesh`: an instance matrix can rotate a boom about its pivot, but it
* cannot rotate one limb of a rigid crane geometry relative to the rest. Five
* boxes, sixty triangles, one draw call for every gantry on the board, and the
* boom angle is free. A `Group` per crane — the obvious shape — would be five
* draws times fifty-six gantries against a mobile cap with thirty spare, which
* is the ~34-draw suspension bridge and the twelve unmergeable freeways all over
* again. `ports.test.ts` asserts no crane is a `Group`.
* cannot rotate one limb of a rigid crane geometry relative to the rest. Boxes,
* one draw call for every gantry on the board, and the boom angle is free. A
* `Group` per crane — the obvious shape — would be eleven draws times
* fifty-six gantries against a mobile cap with thirty spare, which is the
* ~34-draw suspension bridge and the twelve unmergeable freeways all over again.
* `ports.test.ts` asserts no crane is a `Group`.
*
* ### Why eleven and not five
*
* It was five — two legs, a portal beam, a boom and a backreach — and from
* altitude fifty-six of those read as **fifty-six crosses**. Two coincident
* verticals, one horizontal bar through them, nothing above the bar. The
* silhouette a container terminal is recognised by has one more move in it than
* that, and it is the one thing the five-box crane had none of: **the A-frame
* mast standing above the girder, with the boom slung from it on stays.** A
* ship-to-shore gantry's hinge is around 58 m and its apex around 100 — the
* mast is two thirds of the portal again on top of it — so the tallest part of
* a *working* crane is not the boom at all, it is the mast, and the raised boom
* of a parked one clears its own apex by only fifteen to twenty-five per cent.
* Without the mast a working crane has no height above its own deck line and a
* whole berth flattens into a picket fence.
*
* The other six boxes are what stop the mast reading as a flagpole: a sill
* beam so the legs stand on something, a truck-lane beam so the two legs read
* as one portal frame, an apex cap, the machinery house over the tail, and the
* two stays that make the boom look *held*. Where the parts came from:
* geometry approved out of the asset factory (`sts-crane-…-eaff8b` and
* `…-df0f72`, both upvoted) was measured for its proportions and rebuilt here,
* rather than pasted — the factory's cranes are three hundred lattice members
* and five materials apiece, which is the right answer for a hero shot of one
* crane and the wrong one for fifty-six of them 1.1 units tall.
*
* Cost, which is the whole reason this is a matrix list and not a model: 132
* triangles a gantry against 60, so 7,392 for San Pedro Bay against 3,360 —
* **+4,032 on a board that draws 1.42 million**, and still exactly one draw
* call. The census in `ports.test.ts` is the ledger.
*
* ### The board stretches height and not width, so the boom is composed
*
@@ -1117,7 +1173,10 @@ function buildPort(
* not, so a 70 m boom is 0.18 units lying flat and 0.61 units standing up. Its
* apparent length and apparent angle are therefore computed from the two
* components separately — never from one length and a rotation — or a raised
* boom would be three times too short.
* boom would be three times too short. Everything inclined below goes through
* `strut`, which takes two points in the (distance along the boom axis, height)
* plane and is therefore composed by construction: there is no way to hand it a
* length and an angle and get the exaggeration wrong.
*/
function pushCraneMatrices(
world: World,
@@ -1162,60 +1221,186 @@ function pushCraneMatrices(
const [rx, rz] = world.project(station.at[0], station.at[1]);
const base = plate;
const top = base + portal;
// The girder line: boom, backreach and mast foot all meet here, one member
// above the leg tops. Everything above the portal is measured from it.
const girderY = top + member * 1.4;
// 1 & 2: the legs. The waterside pair stands on the rail; the landside pair
// is a gauge back from it, away from the water.
/**
* A part placed by where its two ends are, in the vertical plane the boom
* lies in.
*
* `s` is signed distance along the boom's bearing from the waterside rail —
* positive over the water, negative over the yard — and `y` is a scene
* height that has already been through `world.metres`. Both ends given, the
* length and the tilt fall out, which is the point: the exaggeration bug
* this module's header warns about is only reachable by *starting* from a
* length and an angle, and there is no way to do that here.
*/
const strut = (
s0: number,
y0: number,
s1: number,
y1: number,
thick: number,
wide = thick,
) => {
const ds = s1 - s0;
const dy = y1 - y0;
const length = Math.hypot(ds, dy);
if (length <= 0) return;
const s = s0 + ds / 2;
push(
rx + boomX * s,
y0 + dy / 2,
rz + boomZ * s,
length,
thick,
wide,
Math.atan2(dy, ds),
);
};
/** A level member, from `s0` to `s1` at one height. */
const beam = (s0: number, s1: number, y: number, thick: number, wide: number) =>
strut(s0, y, s1, y, thick, wide);
// ---- The portal: two legs, a sill under them, a frame between them ----
//
// Still two legs and not four. A real gantry has a leg at each corner of a
// 27 × 30 m footprint, and at SoCal's 391 m to the unit that 14 m along the
// quay is 0.036 units while the member floor that keeps a leg visible at
// all is 0.032 — the pair would overlap by ninety per cent and cost 56
// extra boxes to draw one slightly fatter leg. The board's own scale is the
// argument, not the triangle count.
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);
// 3: the portal beam and machinery house, spanning the gauge at the top.
push(
rx - boomX * gauge * 0.5,
top + member * 0.7,
rz - boomZ * gauge * 0.5,
gauge + member,
member * 1.4,
member * 1.15,
);
// The sill. Without it the legs end at the quay with nothing under them and
// a crane appears to be standing on two pins.
beam(member * 0.4, -gauge - member * 0.4, base + member * 0.45, member * 0.5, member * 1.15);
// 4: the boom, pivoting at the top of the waterside leg.
// The truck lane's head beam. A gantry straddles a road, and this is the
// thing trucks pass under; it is also the only rung the two legs get, which
// is what makes them read as one frame rather than as two posts that happen
// to stand near each other.
//
// An X of diagonals above it was built, photographed and cut. The bay it
// would brace is 0.115 units wide and 0.38 tall on this board, so the two
// diagonals come out within a few degrees of vertical, land inside the legs'
// own silhouette, and add a lump at mid-leg rather than an X. Two boxes a
// gantry for a smudge is the wrong trade, and *at this scale* is the whole
// reason — the same X at the factory's framing is one of the best things
// about the crane it came from.
beam(0, -gauge, base + portal * CRANE_PORTAL_BEAM, member * 0.62, member * 0.9);
// ---- The girder: boom forward, backreach aft, in one line ----
//
// The backreach starts at the hinge rather than a full gauge behind it, so
// boom and backreach are one unbroken girder over the portal. They used to
// leave a gap the width of the gauge with the portal beam sitting below it,
// which from the air read as a bar floating clear of a second bar.
const angle = station.idle ? CRANE_BOOM_IDLE_DEG * DEG : 0;
const reachX = outreach * Math.cos(angle);
// Vertical is the exaggerated axis; horizontal is not. Composed, not rotated.
const reachY = world.metres(crane.outreach * Math.sin(angle));
const boomLength = Math.hypot(reachX, reachY);
const boomTilt = Math.atan2(reachY, reachX);
push(
rx + boomX * (reachX / 2),
top + member * 1.4 + reachY / 2,
rz + boomZ * (reachX / 2),
boomLength,
member * 0.85,
member * 0.85,
boomTilt,
strut(0, girderY, reachX, girderY + reachY, member * 0.85);
const backLength = outreach * CRANE_BACKREACH;
const tailS = -(gauge + backLength);
beam(0, tailS, girderY, member * 0.75, member * 0.75);
// The machinery house, over the tail. It is the one heavy mass on a gantry
// and it is what tells you which end is the land.
beam(
-gauge - backLength * 0.2,
tailS + backLength * 0.1,
girderY + member * 1.05,
member * 1.6,
member * 1.45,
);
// 5: the backreach, always down over the yard. It is what stops a raised
// boom reading as a flagpole and it is where the boxes land.
const backLength = outreach * 0.55;
push(
rx - boomX * (gauge + backLength / 2),
top + member * 1.4,
rz - boomZ * (gauge + backLength / 2),
backLength,
member * 0.75,
member * 0.75,
);
// ---- The mast, and the two stays that make the boom look held ----
//
// See the header. The apex is `CRANE_MAST_RISE` of the portal height above
// the girder and set back over the portal, which is where a real one sits:
// it has to be behind the hinge for the forestay to pull the boom *up*.
const apexS = -gauge * CRANE_APEX_SETBACK;
// Through `craneApexY`, not `girderY + portal * CRANE_MAST_RISE` written out
// again here: `craneLights` hangs the obstruction light off the same number
// and a second copy of it is a row of red dots hovering over the cranes.
const apexY = craneApexY(world, crane, base);
strut(-gauge * 0.12, girderY, apexS, apexY, member * 0.72);
beam(apexS - member * 0.7, apexS + member * 0.7, apexY + member * 0.25, member * 0.45, member * 0.8);
// Forestay to the boom and backstay to the tail. Drawn at a third of a
// member, which is several times a real pendant's diameter and the smallest
// thing that survives a 391 m scene unit.
strut(apexS, apexY, reachX * 0.6, girderY + reachY * 0.6, member * 0.32);
strut(apexS, apexY, tailS * 0.94, girderY - member * 0.15, member * 0.32);
}
}
/**
* Where a terminal's obstruction lights go, as bare positions.
*
* The twin of `bridges.ts`'s `bridgeLights`, and for the same reason. San Pedro
* Bay at 21:35 is a graded plate the colour of the water it sticks out of, with
* fifty-six pale gantries on it that the moon barely finds; the yards come up on
* their own emissive and the cranes do not come up at all. What actually marks a
* container terminal from the air after dark is the row of **red obstruction
* lights on the crane apexes** — one per gantry, at the top of the A-frame,
* which is what CFR 14 §77 asks for on anything this tall near a channel.
*
* **These are positions, not lights.** CONTRACT.md §4 gives `Atmosphere` the
* only rig there is, and fifty-six point lights is not a slow version of this,
* it is an impossible one. `nightlights.ts` turns what comes back into one
* additive point cloud in one draw call, exactly as it already does for the
* bridge tower heads — and it reuses that cloud's own red, because a light on a
* 143 m crane and a light on a 227 m tower are the same fixture doing the same
* job and there is no reason for the board to have two reds in it.
*
* A pure function of the pack, so it is identical across frames and reloads, and
* it recomputes the apex from the same constants `pushCraneMatrices` uses rather
* than reading them back off the matrices — a lamp derived from a second,
* separately-maintained idea of where the top of a crane is is exactly how a row
* of lights ends up floating beside the cranes instead of on them. Both call the
* same `craneApexY`.
*/
export function craneLights(world: World, port: Port): { heads: number[] } {
const heads: number[] = [];
const plate = plateHeight(world, port);
const gauge = Math.max(CRANE_RAIL_GAUGE_M / world.metresPerUnit, CRANE_GAUGE_FLOOR);
const member = Math.max(CRANE_MEMBER_M / world.metresPerUnit, CRANE_MEMBER_FLOOR);
const setback = gauge * CRANE_APEX_SETBACK;
for (const crane of port.cranes ?? []) {
// Clear of the apex cap, for the reason `bridges.ts` lifts its head lights
// clear of the saddle: a sprite drawn at exactly the top of the thing it
// marks is half-eaten by that thing's own depth, and the half it loses is
// the bottom half — so it reads as dimmer *and* as sitting high.
const y = craneApexY(world, crane, plate) + member;
// Same decomposition `pushCraneMatrices` uses: the apex is a setback back
// along the boom's bearing from the waterside rail, and `boomZ` is `-north`.
const [east, north] = bearingVector(crane.bearing);
for (const station of craneStations(crane)) {
const [rx, rz] = world.project(station.at[0], station.at[1]);
heads.push(rx - east * setback, y, rz + north * setback);
}
}
return { heads };
}
/**
* The height of a gantry's mast apex, in scene units.
*
* The one place that arithmetic lives. `pushCraneMatrices` draws the mast to it
* and `craneLights` hangs the obstruction light off it; a second copy of
* `girder + portal * rise` in either would be a light hovering above or inside
* the crane it is supposed to be on, and nothing but a screenshot would catch it.
*/
function craneApexY(world: World, crane: Crane, plate: number): number {
const member = Math.max(CRANE_MEMBER_M / world.metresPerUnit, CRANE_MEMBER_FLOOR);
const portal = world.metres(crane.height);
return plate + portal + member * 1.4 + portal * CRANE_MAST_RISE;
}
/**
* Every port on a board, as one small set of merged meshes.
*
+247 -97
View File
@@ -38,21 +38,36 @@
* you can see them" is a reasonable-sounding change that would quietly break the
* relationship this layer exists inside.
*
* ### One geometry, one mesh, and the seam for better hulls
* ### Two solids, and the seam that lets there be a third
*
* Every ship on a board is the same ~40-triangle solid — slab, raked bow, aft
* house, funnel — with **length, beam and depth as per-instance scale**. A 30 m
* tug and a 400 m box ship are the same forty triangles at thirteen times the
* size. Deck detail is a band of a canvas atlas rather than geometry, so a hatch
* run and a pipe rack are the same six triangles with different pixels.
* Every merchant ship on a board is the same 40-triangle solid — slab, raked
* bow, aft house, funnel — with **length, beam and depth as per-instance
* scale**, so a 90 m coaster and a 400 m box ship are the same forty triangles
* at four times the size. Deck detail is a band of a canvas atlas rather than
* geometry, so a hatch run and a pipe rack are the same six triangles with
* different pixels.
*
* `hullShape(kind)` is the seam for the better geometry the owner is modelling
* separately: it maps a `VesselKind` to a shape id, every kind currently maps to
* `"generic"`, and a real container-ship mesh arrives as a new shape id plus a
* case in `hullGeometry`. The layer builds one `InstancedMesh` per shape that
* has hulls in it, so the draw count is a function of **how many kinds of hull
* geometry exist**, never of how many ships are on the board — the same property
* `airports.ts` gets by merging buckets across airports rather than per airport.
* The **tug** is the exception and now has its own 64-triangle solid, because
* the merchant hull is not a small ship, it is a *differently shaped* ship: a
* cargo hull carries its block aft over the screw and a tug carries it forward
* and nearly full beam, with the low towing deck astern. Drawn at a tug's size
* the merchant solid produced a thirty-metre container ship, and that is the one
* hull on the board whose proportions were wrong rather than merely simple. See
* `tugHull`, whose numbers come off the component the owner voted up rather than
* out of a catalogue.
*
* `hullShape(kind)` is the seam and the arms are how better geometry lands one
* kind at a time: it maps a `VesselKind` to a shape id, and a real container-ship
* mesh arrives as a new id in `HULL_SHAPES` plus a case in `hullGeometry`. The
* layer builds one `InstancedMesh` per shape and adds it to the group only when
* it has hulls in it, so the draw count is a function of **how many kinds of hull
* geometry are afloat**, never of how many ships are on the board — the same
* property `airports.ts` gets by merging buckets across airports rather than per
* airport. A board with no tug on it still costs one draw.
*
* A shape is a *geometry* and never a material. Two geometries are two draws
* only on a board carrying both; two materials would be two draws on every board
* for ever, so hue stays an `instanceColor` tint over the one shared atlas.
*
* ### What this layer will not do
*
@@ -241,18 +256,19 @@ const WAKE_COLOR_DAY = 0xe8f0f4;
* between an enum the factory switches on and a hardcoded builder, and it is why
* this function exists despite currently having one answer.
*/
export type HullShape = "generic";
export type HullShape = "generic" | "tug";
export function hullShape(kind: VesselKind): HullShape {
// Every arm answers the same thing today and the switch is still written out,
// because the arms are where the better geometry lands one kind at a time. An
// `if` here would have to be replaced; these are added to.
// The arms are where better geometry lands one kind at a time, which is why
// this is a switch and not an `if`: an `if` would have to be replaced, and
// these are added to. `tug` is the first arm to leave home.
switch (kind) {
case "tug":
return "tug";
case "container":
case "tanker":
case "bulk":
case "vehicle-carrier":
case "tug":
case "ferry":
case "fishing":
case "other":
@@ -260,8 +276,17 @@ export function hullShape(kind: VesselKind): HullShape {
}
}
/** Every shape a board may need a mesh for. One, today. */
export const HULL_SHAPES: readonly HullShape[] = ["generic"];
/**
* Every shape a board may need a mesh for. Two.
*
* Two shapes is **two draw calls at most and one on a board with no tug**: the
* layer builds an `InstancedMesh` per shape up front and adds it to the group
* only when it has hulls in it, so the cost is a function of how many kinds of
* hull *geometry* exist and never of how many ships are floating. A third shape
* — the container ship the owner is modelling — is one more entry here, one more
* case in `hullGeometry`, and nothing else.
*/
export const HULL_SHAPES: readonly HullShape[] = ["generic", "tug"];
/**
* Draught as a fraction of length, per kind.
@@ -301,7 +326,17 @@ const FREEBOARD_RATIO: Readonly<Record<VesselKind, number>> = {
tanker: 0.05,
bulk: 0.055,
"vehicle-carrier": 0.14,
tug: 0.17,
/**
* A tug is the one hull whose freeboard is *small*, and 0.17 was wrong.
*
* At 0.17 a 32 m tug stood 5.4 m out of the water, which is a coaster's
* topside on a boat you step down onto from the quay; the real number is under
* three. It mattered once `tugHull` gave the kind its own solid, because the
* whole superstructure is scaled by keel-to-deck depth: an over-deep hull made
* the wheelhouse tower, and the silhouette the owner voted for is a low boat
* under a big house rather than a small ship.
*/
tug: 0.09,
ferry: 0.11,
fishing: 0.16,
other: 0.07,
@@ -341,40 +376,101 @@ export function hullGeometry(shape: HullShape = "generic"): THREE.BufferGeometry
switch (shape) {
case "generic":
return genericHull();
case "tug":
return tugHull();
}
}
/** The one solid this build has. See `hullGeometry` for what replaces it. */
function genericHull(): THREE.BufferGeometry {
/**
* Atlas bands, shared by every hull solid.
*
* `v` runs 0 at the top of the canvas to 1 at the bottom, which is only true
* because `deckAtlas` turns `flipY` off. Module-level rather than per-solid so
* that a second hull shape cannot quietly wear a third set of bands and drift
* out of step with the one canvas they all sample.
*/
const SIDE: Band = [0.02, 0.31];
const DECK: Band = [0.35, 0.64];
const HOUSE: Band = [0.69, 0.98];
type P = readonly [number, number, number];
type Band = readonly [number, number];
type UV = readonly [number, number];
/**
* A face writer, shared by every hull solid in this file.
*
* Non-indexed on purpose, and the reason is `airports.ts:43`'s scar:
* `mergeGeometries` silently drops a bucket whose attribute sets disagree, and
* the only way to be certain a hull carries position, normal *and* uv is to
* write all three. Non-indexed also gives flat facets for free, which is what a
* slab-sided ship is.
*
* Every face handed to `quad` and `tri` must be wound counter-clockwise **seen
* from outside**. `computeVertexNormals` reads the winding, so a face wound the
* other way is both back-face culled and lit from inside. The first photograph
* of the generic hull had a deck wound downward — you saw straight through it to
* the inside of the bottom plating, and it read merely as "the ships are a bit
* dark" rather than as a hole. It took a picture to find and one sign to fix,
* and `vessels.test.ts` now measures the outward flux so the next one is caught
* by the suite instead.
*/
function hullFaces(): {
quad(a: P, b: P, c: P, d: P, band: Band): void;
tri(a: P, b: P, c: P, uvA: UV, uvB: UV, uvC: UV): void;
box(x0: number, x1: number, y0: number, y1: number, z0: number, z1: number, band: Band): void;
finish(name: string): THREE.BufferGeometry;
} {
const positions: number[] = [];
const uvs: number[] = [];
/**
* Atlas bands. `v` runs 0 at the top of the canvas to 1 at the bottom, which
* is only true because `deckAtlas` turns `flipY` off.
*/
const SIDE: [number, number] = [0.02, 0.31];
const DECK: [number, number] = [0.35, 0.64];
const HOUSE: [number, number] = [0.69, 0.98];
type P = readonly [number, number, number];
const quad = (a: P, b: P, c: P, d: P, band: [number, number]) => {
const tri = (a: P, b: P, c: P, uvA: UV, uvB: UV, uvC: UV): void => {
positions.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]);
uvs.push(uvA[0], uvA[1], uvB[0], uvB[1], uvC[0], uvC[1]);
};
const quad = (a: P, b: P, c: P, d: P, band: Band): void => {
const [v0, v1] = band;
tri(a, b, c, [0, v0], [1, v0], [1, v1]);
tri(a, c, d, [0, v0], [1, v1], [0, v1]);
};
const tri = (
a: P,
b: P,
c: P,
uvA: [number, number],
uvB: [number, number],
uvC: [number, number],
) => {
positions.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]);
uvs.push(uvA[0], uvA[1], uvB[0], uvB[1], uvC[0], uvC[1]);
const box = (
x0: number,
x1: number,
y0: number,
y1: number,
z0: number,
z1: number,
band: Band,
): void => {
// Reversed against the obvious ordering, for the winding reason above:
// written the natural way, every one of these six faces points into the box.
quad([x0, y1, z0], [x1, y1, z0], [x1, y0, z0], [x0, y0, z0], band);
quad([x1, y1, z1], [x0, y1, z1], [x0, y0, z1], [x1, y0, z1], band);
quad([x0, y1, z1], [x0, y1, z0], [x0, y0, z0], [x0, y0, z1], band);
quad([x1, y1, z0], [x1, y1, z1], [x1, y0, z1], [x1, y0, z0], band);
quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], band);
quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], band);
};
return {
quad,
tri,
box,
finish(name: string): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
geometry.name = name;
return geometry;
},
};
}
/** The merchant hull: forty triangles, house and funnel aft. */
function genericHull(): THREE.BufferGeometry {
const f = hullFaces();
// Bow at -z, stern at +z, so a bearing of zero points the ship north — the
// board's north is -z, and `bearingRotation` below is the only place that
// convention is turned into a rotation.
@@ -384,67 +480,112 @@ function genericHull(): THREE.BufferGeometry {
const zStern = 0.5;
const stem = 0.34; // how high up the stem the forefoot starts
/**
* Every face below is wound counter-clockwise **seen from outside**, and that
* is not pedantry: `computeVertexNormals` reads the winding, so a face wound
* the other way is both back-face culled and lit from inside. The first
* photograph of this hull had a deck wound downward — you saw straight through
* it to the inside of the bottom plating, and it read merely as "the ships are
* a bit dark" rather than as a hole. It took a picture to find and one sign to
* fix.
*/
// ---- Body: five quads, ten triangles.
quad([-bx, 0, zShoulder], [-bx, 0, zStern], [-bx, 1, zStern], [-bx, 1, zShoulder], SIDE);
quad([bx, 0, zStern], [bx, 0, zShoulder], [bx, 1, zShoulder], [bx, 1, zStern], SIDE);
quad([-bx, 0, zStern], [bx, 0, zStern], [bx, 1, zStern], [-bx, 1, zStern], SIDE);
quad([-bx, 0, zShoulder], [bx, 0, zShoulder], [bx, 0, zStern], [-bx, 0, zStern], SIDE);
quad([-bx, 1, zStern], [bx, 1, zStern], [bx, 1, zShoulder], [-bx, 1, zShoulder], DECK);
f.quad([-bx, 0, zShoulder], [-bx, 0, zStern], [-bx, 1, zStern], [-bx, 1, zShoulder], SIDE);
f.quad([bx, 0, zStern], [bx, 0, zShoulder], [bx, 1, zShoulder], [bx, 1, zStern], SIDE);
f.quad([-bx, 0, zStern], [bx, 0, zStern], [bx, 1, zStern], [-bx, 1, zStern], SIDE);
f.quad([-bx, 0, zShoulder], [bx, 0, zShoulder], [bx, 0, zStern], [-bx, 0, zStern], SIDE);
f.quad([-bx, 1, zStern], [bx, 1, zStern], [bx, 1, zShoulder], [-bx, 1, zShoulder], DECK);
// ---- Bow: two side quads and two triangles, six triangles.
quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
quad([bx, 1, zShoulder], [bx, 0, zShoulder], [0, stem, zBow], [0, 1, zBow], SIDE);
tri([-bx, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, 0.35], [1, 0.35], [0.5, 0.64]);
tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, 0.02], [0.5, 0.31], [1, 0.02]);
f.quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
f.quad([bx, 1, zShoulder], [bx, 0, zShoulder], [0, stem, zBow], [0, 1, zBow], SIDE);
f.tri([-bx, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, 0.35], [1, 0.35], [0.5, 0.64]);
f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, 0.02], [0.5, 0.31], [1, 0.02]);
// ---- Aft house: a box on the quarterdeck, twelve triangles.
box(quad, -0.34, 0.34, 1, 1.55, 0.26, 0.46, HOUSE);
f.box(-0.34, 0.34, 1, 1.55, 0.26, 0.46, HOUSE);
// ---- Funnel: twelve triangles, and the reason a ship reads as a ship.
box(quad, -0.12, 0.12, 1.55, 1.86, 0.32, 0.42, HOUSE);
f.box(-0.12, 0.12, 1.55, 1.86, 0.32, 0.42, HOUSE);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
geometry.name = "vessel-hull";
return geometry;
return f.finish("vessel-hull");
}
function box(
quad: (
a: readonly [number, number, number],
b: readonly [number, number, number],
c: readonly [number, number, number],
d: readonly [number, number, number],
band: [number, number],
) => void,
x0: number,
x1: number,
y0: number,
y1: number,
z0: number,
z1: number,
band: [number, number],
): void {
// Reversed against the obvious ordering, for the winding reason `hullGeometry`
// gives above: written the natural way, every one of these six faces points
// into the box.
quad([x0, y1, z0], [x1, y1, z0], [x1, y0, z0], [x0, y0, z0], band);
quad([x1, y1, z1], [x0, y1, z1], [x0, y0, z1], [x1, y0, z1], band);
quad([x0, y1, z1], [x0, y1, z0], [x0, y0, z0], [x0, y0, z1], band);
quad([x1, y1, z0], [x1, y1, z1], [x1, y0, z1], [x1, y0, z0], band);
quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], band);
quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], band);
/**
* The harbour tug: sixty-four triangles, and the mass in the wrong place on
* purpose.
*
* ### Why a tug needs its own solid at all
*
* Because the generic hull is a *merchant* hull, and the two silhouettes are
* opposites. A cargo ship carries its block aft — house and funnel over the
* screw, a long clear foredeck ahead of them — and drawn small that reads as a
* bar with a lump at one end. A tug carries its block **forward and nearly the
* full beam**, with the clear low towing deck aft, and drawn small it reads as a
* lump with a bar behind it. Using the merchant solid for a tug produced a
* thirty-metre container ship, which is a thing that does not exist, and it was
* the only hull on the board whose proportions were wrong rather than merely
* simple.
*
* ### It came off a photograph, not out of a catalogue
*
* The proportions below are read from the asset the owner voted up
* (`tug-20260823-043140-7dbe8e`) rather than invented here, and specifically
* from the three things that survive being shrunk to a few pixels: the deckhouse
* runs from about a quarter to about three quarters of the length measured from
* the stern; it is roughly 0.84 of the beam, which is nearly full width where a
* merchant house is barely half; and the wheelhouse on top of it carries an
* overhanging roof that is the widest thing above the deck. Everything else in
* that render — the tyre fenders, the tow rope, the lit windows, the mast — is
* hero detail for a camera two boat-lengths away and is sub-pixel here, so none
* of it is modelled. **Nothing of that component is pasted:** it brought fifteen
* `MeshStandardMaterial`s and four emissive navigation lights, and both would
* violate the one-material rule this layer is built on and CONTRACT §4.
*
* ### Sixty-four triangles, and why that is free
*
* Twenty-four more than the merchant hull, on a board that holds at most a
* handful of tugs at once — under two hundred triangles for the whole harbour's
* tug fleet. What it costs is **one draw call**, and only on a board that has a
* tug on it, because the layer adds an `InstancedMesh` to the group only when
* its count is above zero.
*/
function tugHull(): THREE.BufferGeometry {
const f = hullFaces();
const bx = 0.5;
const zBow = -0.5;
// Bluffer than the merchant hull's 0.22: a tug's entry is short and full,
// because it is built to push rather than to make twenty knots economically.
const zShoulder = -0.36;
const zStern = 0.5;
const stem = 0.42;
// ---- Body: five quads, ten triangles. A flat transom, which is what a tug
// has and what the towing deck is built out to.
f.quad([-bx, 0, zShoulder], [-bx, 0, zStern], [-bx, 1, zStern], [-bx, 1, zShoulder], SIDE);
f.quad([bx, 0, zStern], [bx, 0, zShoulder], [bx, 1, zShoulder], [bx, 1, zStern], SIDE);
f.quad([-bx, 0, zStern], [bx, 0, zStern], [bx, 1, zStern], [-bx, 1, zStern], SIDE);
f.quad([-bx, 0, zShoulder], [bx, 0, zShoulder], [bx, 0, zStern], [-bx, 0, zStern], SIDE);
f.quad([-bx, 1, zStern], [bx, 1, zStern], [bx, 1, zShoulder], [-bx, 1, zShoulder], DECK);
// ---- Bow: six triangles.
f.quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
f.quad([bx, 1, zShoulder], [bx, 0, zShoulder], [0, stem, zBow], [0, 1, zBow], SIDE);
f.tri([-bx, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, 0.35], [1, 0.35], [0.5, 0.64]);
f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, 0.02], [0.5, 0.31], [1, 0.02]);
// ---- Deckhouse: forward, and 0.84 of the beam. The one measurement that
// decides whether this reads as a tug rather than as a small freighter.
f.box(-0.42, 0.42, 1, 1.42, -0.3, 0.1, HOUSE);
// ---- Wheelhouse, set a little aft of the house front so the bridge wings
// look down the tow.
f.box(-0.36, 0.36, 1.42, 1.78, -0.24, 0.04, HOUSE);
// ---- The wheelhouse roof, overhanging on every side. Twelve triangles for a
// slab, and worth them: from the vertical pose this board is mostly looked at
// from, the roof plan *is* the tug.
f.box(-0.4, 0.4, 1.78, 1.84, -0.28, 0.08, HOUSE);
// ---- Funnel: low, fat and aft of the house, standing no higher than the
// wheelhouse. A tug whose stack out-tops its bridge is a tug from 1950.
f.box(-0.14, 0.14, 1.42, 1.66, 0.16, 0.3, HOUSE);
// Aft of z = 0.3 the deck is deliberately clear. That empty quarter is the
// towing deck and it is half of the silhouette.
return f.finish("vessel-hull-tug");
}
/**
@@ -626,8 +767,6 @@ export function createVesselLayer(
});
hullMaterial.name = "vessel-hull";
const geometry = hullGeometry("generic");
/**
* One `InstancedMesh` per hull shape, built up front and added to the group
* only when it has hulls in it.
@@ -636,9 +775,19 @@ export function createVesselLayer(
* `Float32Array` of sixteen floats per instance and rebuilding it whenever the
* feed answers would allocate and discard 12 KB every fifteen minutes for the
* life of the page. `count` is the dial instead, which is what it is for.
*
* **A geometry per shape, and one material for all of them.** The two are not
* symmetric and the asymmetry is the whole budget: a second *geometry* is a
* second draw call only on a board that has that shape floating on it, while a
* second *material* would be a second draw call on every board for ever. So
* the tug gets its own solid and wears the same atlas, and its colour arrives
* as an `instanceColor` tint like every other hull's.
*/
const geometries = new Map<HullShape, THREE.BufferGeometry>();
const hullMeshes = new Map<HullShape, THREE.InstancedMesh>();
for (const shape of HULL_SHAPES) {
const geometry = hullGeometry(shape);
geometries.set(shape, geometry);
const mesh = new THREE.InstancedMesh(geometry, hullMaterial, VESSEL_HULL_CAPACITY);
mesh.name = `vessels:${shape}`;
mesh.count = 0;
@@ -938,7 +1087,8 @@ export function createVesselLayer(
},
dispose() {
geometry.dispose();
for (const geometry of geometries.values()) geometry.dispose();
geometries.clear();
hullMaterial.dispose();
atlas?.dispose();
wakeGeometry.dispose();
+762 -74
View File
@@ -89,6 +89,30 @@
* layer's twenty-two orange marks in a nicer costume: plausible, specific, and a
* claim about a named commercial vessel behind which this deployment has no
* licensed feed. Identity arrives with a licence entry or it does not arrive.
*
* ### The harbour has a working day, and the day is a plan
*
* The first version of it was a diorama: berth occupancy was a time-invariant
* hash and the handful of hulls in the channel looped it on a ninety-minute
* carousel, so a port that takes fifteen ships a day never received one. It now
* runs a schedule — a ship stands in from open water, takes a tug inside the
* breakwater, lies alongside, works, and leaves — and the schedule is a **plan
* evaluated at an instant** rather than a simulation that is running: nothing in
* this file holds a clock or an accumulator, and `modelHarbour(ports, { atMs })`
* is a closed form of that number. `server/wire.ts` makes the same argument for
* the simulated sky and for the same reasons, and the two useful consequences
* are identical: two people on two machines see the same ships, and `look.mjs
* --at` shoots the same frame twice.
*
* One number in it is compressed and it is said out loud in both the body's
* attribution and `vesselSummary`'s sentence: **the rate of arrivals**, at
* roughly seven times a real day's, because a truthful rate gives a board that
* looks identical from breakfast to bedtime. Nothing else is. The channel is the
* charted one, the berths are the authored ones, and every speed is real —
* which is not a nicety, because a fix's `sog` is what a consumer dead-reckons
* along, so a speed that is not the derivative of its own position is a lie the
* renderer will draw faithfully. `harbourDay.test.ts` asserts that derivative
* numerically.
*/
import type { Berth, Port, Vessel, VesselKind, VesselStatus } from "../engine/types.ts";
@@ -535,7 +559,24 @@ export function readVessel(
const heading = aisHeading(row.heading);
const course = aisCourse(row.course);
const berth = nearestBerth(lat, lng, berths);
/**
* A hull is bound to a berth only when it is **not making way**.
*
* `nearestBerth` reaches four hundred metres, and `resolveBearing` lets the
* quay win outright over a reported course — which is right for a ship lying
* alongside and wrong for one steaming past. Long Beach's Pier T berths sit
* 190 m off their own channel centreline, so before this line a ship doing ten
* knots up the channel was silently swung to the quay's bearing and drawn
* crabbing sideways with her wake off the beam. It only became visible once
* ships started arriving; the picture found it in the first frame.
*
* "Alongside" therefore now means what the word means, and
* `VesselPromotion.alongside` counts hulls that are actually stopped at a
* berth. A ship creeping the last fifty metres in at under half a knot is not
* making way by `isMakingWay`, so she binds and lies the way the quay does —
* which is the moment she should.
*/
const berth = isMakingWay(speed) ? null : nearestBerth(lat, lng, berths);
const bearing = resolveBearing({
heading,
course,
@@ -652,8 +693,23 @@ export function vesselSummary(promotion: VesselPromotion): string {
return "No vessel feed is configured for this deployment, so no ships are drawn.";
}
const modelled = source === "modelled";
/**
* The modelled clause names **both halves**, and the second half is new.
*
* The harbour now has a working day in it: ships stand in from open water,
* take a tug, lie alongside and leave. A reader watching a berth change hands
* twice in an afternoon would reasonably conclude that is how often San Pedro
* changes hands, and it is not — the rate is the one thing here that is a
* modelling choice rather than the board's own geometry. Every other number in
* the picture is true: the channel is the charted one, the berths are the
* authored ones, and a ship comes up the channel at thirteen knots because
* that is how fast she comes up the channel.
*
* Saying so costs a clause. Not saying so is the fire layer's twenty-two
* orange marks again, in a slower costume.
*/
const provenance = modelled
? "Modelled from this board's own berths and channels — anonymous hulls, no names and no MMSIs, because the live AIS feed is not configured."
? "Modelled from this board's own berths and channels — anonymous hulls, no names and no MMSIs, because the live AIS feed is not configured. Ship speeds, the channel and the berths are true; arrivals run at about seven times a real day's rate, so the harbour changes while you watch."
: "Live AIS.";
if (drawn.length === 0) {
const parts: string[] = ["The feed answered and no ship is on this board."];
@@ -679,33 +735,375 @@ export function vesselSummary(promotion: VesselPromotion): string {
return parts.join(" ");
}
// ---- The modelled harbour -------------------------------------------------
// ---- The modelled harbour, and its working day ----------------------------
/** What `modelHarbour` needs to be reproducible. */
export interface ModelledHarbourOptions {
/**
* The seed, so two people see the same harbour and a capture script shoots the
* same frame twice. Everything below is a hash of this and a stable string
* (a berth id, a port id), never a call to `Math.random`.
* (a berth id, a port id, a call number), never a call to `Math.random`.
*/
seed?: number;
/** Wall clock for the body's `fetchedAt`, and the phase of the moving hulls. */
/**
* Wall clock for the body's `fetchedAt`, **and the instant the working day is
* evaluated at**.
*
* This is the whole of the simulator's state. `harbourCalls` is a plan — a
* berth, a route and a repeating slot — and `modelHarbour` is that plan
* evaluated as a closed form of this number, which is exactly the trick
* `FlightsPlanBody` plays with `t0` and for exactly the same reason: two
* people on two machines looking at the same instant see the same ships, and a
* capture script that shoots the Southland twice gets the same photograph.
* There is no accumulator anywhere in this file, so there is nothing that can
* drift, and nothing that has to be replayed to reach a given moment.
*/
atMs?: number;
/** Sample interval to declare. 900 s, matching the store this stands in for. */
/** Sample interval to declare. See `MODELLED_INTERVAL_SECONDS`. */
intervalSeconds?: number;
/** How many hulls are under way per port with a channel. */
/**
* Roughly how many hulls are under way per port at any instant.
*
* A *target*, not a guarantee, and the difference is the honest one: berth
* slots are laid out evenly around the cycle so the channel carries a steady
* stream rather than a convoy, but the legs differ in length — the West Basin
* is eight kilometres up the Main Channel and Pier 400 is two — so the count
* breathes by one either side. `harbourDay.test.ts` asserts the band rather
* than a number.
*/
underWayPerPort?: number;
/** What proportion of a port's berths are occupied, 0..1. */
occupancy?: number;
}
/**
* A harbour built from a board's own authored geometry.
* The interval the modelled source declares, in seconds.
*
* Berths carry hulls; channels carry the handful making way. Both are things the
* pack already declares, which is what makes this a *model* of the board rather
* than a fiction laid on top of it: a berth with no ship on it is an empty berth
* you can see, and moving a berth moves the ship.
* **A minute, and not the nine hundred seconds a real AIS listener would
* declare** — that number moved, and the reason it moved is the most useful
* thing in this file.
*
* `intervalSeconds` is a property *of the source*. It says how long a consumer
* may dead-reckon a fix before the fix is stale, and for the store this stands
* in for it is fifteen minutes because upstream listens for thirty seconds every
* fifteen minutes. This source is not that: it is a closed-form function of the
* clock, it can be asked for any instant at any time, and declaring a quarter of
* an hour was mimicry rather than description.
*
* It also drew a ship over the land. `engine/vessels.ts` dead-reckons **along a
* straight reported course** — correctly, because a course is all a fix carries
* — so a hull at thirteen knots reckoned for nine hundred seconds runs six
* kilometres in a straight line. The Main Channel bends about twenty degrees in
* that distance, and the arriving ships this working day added therefore sailed
* up over Terminal Island for the last third of every interval, dead-reckoned
* exactly as instructed. A minute is 400 m, which is under a scene unit.
*
* None of that softens when a real feed lands. `/api/sea` will hand over a body
* carrying its own `intervalSeconds`, main.ts already reads it off the body, and
* a fifteen-minute AIS feed will dead-reckon for fifteen minutes and overshoot
* the bends — which is a true fact about a fifteen-minute feed and belongs in
* the panel's sentence rather than in a smoothing filter. What must never happen
* is the other repair: splining between two fixes to hide it.
*/
export const MODELLED_INTERVAL_SECONDS = 60;
/**
* How far seaward of the charted channel a ship is picked up, in metres.
*
* The pack's channel is the dredged water and stops where the dredging does, a
* kilometre or so outside the breakwater. A ship that appeared exactly there
* would pop into being at the gate; extending the first leg back out to sea by
* two and a half kilometres means an arrival is first seen against open water,
* standing in toward the entrance, which is what an arrival looks like.
*
* Extended **in the simulator and not in the pack**, because `engine/ports.ts`
* draws `Port.channel` as dredged water and this is not dredged water. The
* picture would gain a dark strip two miles out to sea that no chart has.
*/
export const APPROACH_SEAWARD_METRES = 2500;
/**
* How far off the charted channel a berth may be and still be given a route, in
* metres.
*
* **This is a land check standing in for the land check this file cannot do.**
* A berth's route is the channel as far as the point nearest the berth and then
* a straight run in, and a straight run of three kilometres from the Main
* Channel to the East Basin crosses Terminal Island — a container ship driven
* over a container yard, at ten knots, with a wake. There is no water mask in
* `Port`, so the honest gate is distance: a berth the channel reaches keeps a
* working day, and a berth it does not reach keeps a hull lying alongside and
* takes no calls.
*
* At Los Angeles that admits Pier 400 and the West Basin and holds back Pier 300
* and the East Basin; at Long Beach it admits all five. The fix is not a bigger
* number — it is per-berth approach geometry in the pack, which wants a field on
* `Berth` that does not exist yet.
*/
export const BERTH_APPROACH_REACH_METRES = 1100;
/**
* The shape of the run in, as an exponent.
*
* Distance made good is `L * (1 - (1-x)^k)` and speed is its derivative, so a
* ship enters at `k * L / T` and arrives at nothing. That is not a fade for
* looks: it is how a ship berths, and it is also what keeps the dead reckoner
* honest, because the hull whose fix could be extrapolated furthest — the one
* closest to a quay it must not be drawn on top of — is the one moving slowest.
*
* 1.6 rather than 2 because a square root of a decay spends too much of the leg
* crawling; at 1.6 a ship holds better than half her entry speed for the first
* two thirds of the channel and is down to two knots at the berth.
*/
export const APPROACH_EASE = 1.6;
/** Entry speed at the seaward end of the run in, m/s. Thirteen knots. */
export const INBOUND_PEAK_MPS = 6.7;
/** Speed at the seaward end of the run out, m/s. Fourteen knots. */
export const OUTBOUND_PEAK_MPS = 7.2;
/** The least clear water between one ship leaving a berth and the next arriving. */
const MIN_BERTH_GAP_SECONDS = 600;
/**
* When the tug joins an arriving ship, and when it lets a departing one go, as a
* fraction of the leg.
*
* The escort is the second half of the run in and the first third of the run
* out, which is inside the breakwater in both cases — a harbour tug meets a ship
* in sheltered water, not at sea.
*/
const TUG_MEETS_AT = 0.55;
const TUG_LEAVES_AT = 0.94;
const TUG_DEPARTURE_UNTIL = 0.3;
/** How long before the meeting the tug is seen running seaward to make it. */
const TUG_RUN_OUT = 0.13;
/** Where the tug lies between jobs, as a fraction along the berth's own route. */
const TUG_STATION = 0.88;
/** Where the tug sits relative to the ship it is attending, in metres. */
const TUG_STATION_ASTERN = 220;
const TUG_STATION_ABEAM = 110;
/** One berth's endlessly repeating port call: a route, and a slot in the day. */
export interface HarbourCall {
portId: string;
berthId: string;
/** The berth's own bearing, so a ship alongside lies the way the quay does. */
berthBearing: number;
maxLength: number;
/**
* Open water, then the charted channel, then **where the ship lies** — which
* is not the berth's own coordinate. See `lyingPosition`.
*/
approach: readonly [number, number][];
approachMetres: number;
inboundSeconds: number;
dwellSeconds: number;
outboundSeconds: number;
/** Inbound, alongside, outbound, and the empty berth before the next ship. */
cycleSeconds: number;
/** Seconds after the epoch at which this berth's call zero starts inbound. */
offsetSeconds: number;
}
/** Which leg of her call a berth's ship is on. `empty` is a berth with no ship. */
export type HarbourPhase = "inbound" | "alongside" | "outbound" | "empty";
export interface HarbourMoment {
phase: HarbourPhase;
/** Which call this is, counting from the epoch. Half of the ship's identity. */
index: number;
/** 0..1 through whichever leg `phase` names. */
progress: number;
}
/**
* The plan: one repeating call per berth the channel can reach.
*
* Pure, and a function of the port's own geometry and the seed alone — no clock
* reaches this function, which is what makes it a *plan* rather than a state.
* `harbourMoment` is the evaluator.
*
* ### The slots are even, and that is a claim about ports rather than a shortcut
*
* Every berth in a port shares one cycle and the offsets are laid out evenly
* around it, so arrivals come at a steady drumbeat instead of in clumps. Real
* ports do allocate berth windows this way — a ship books a slot and is charged
* for missing it — and the alternative here, a random offset per berth, gives a
* harbour that is deserted for an hour and then has five ships in one channel.
* The variety is in the ships instead: kind, length, dwell and tug all come out
* of the hash of the berth id and the call number.
*
* ### The rate is compressed, and the panel says so
*
* Los Angeles and Long Beach between them take on the order of fourteen deep-sea
* calls a day and a box ship lies alongside for one to three days. Drawn
* truthfully, this board would show one arrival every hour and a half and a quay
* that looked identical from breakfast to bedtime. `underWayPerPort` sets the
* compression and the default runs roughly seven times a real day's call rate,
* which is a stated modelling choice and is written into the body's own
* attribution — not a claim about how busy San Pedro is.
*
* **What is not compressed is the motion.** Every speed below is a real speed, so
* a ship takes twenty-five to forty-five minutes to come up the channel because
* that is how long it takes, and — the part that matters downstream — the sog a
* fix reports is the derivative of the position that fix reports. A simulator
* that sped the hulls up while reporting truthful knots would have the dead
* reckoner and the schedule disagree, and the disagreement would land as a jump
* on every fix.
*/
export function harbourCalls(
port: Port,
options: ModelledHarbourOptions = {},
): HarbourCall[] {
const seed = options.seed ?? 115;
const occupancy = clamp01(options.occupancy ?? 0.72);
/**
* The target, clamped to half the berths.
*
* A berth is under way for `inbound + outbound` of every cycle, so asking for
* three of a four-berth port in the channel at once leaves at most a quarter of
* the cycle to lie alongside in — and once the minimum clear water between one
* ship leaving and the next arriving is taken out, none. A port where most of
* the fleet is steaming and the quays are bare is not a busy port, it is a
* parade. Half is the ceiling; a caller asking for more gets a working harbour
* instead of the number it asked for.
*/
const asked = Math.max(0, Math.floor(options.underWayPerPort ?? 2));
const target = Math.min(asked, Math.max(1, Math.floor((port.berths ?? []).length / 2)));
const channel = port.channel ?? [];
const berths = port.berths ?? [];
if (channel.length < 2 || berths.length === 0 || target === 0) return [];
const fairway = seawardApproach(channel);
/** Berth, route and the two transit times, before the slots are laid out. */
const routed: {
berth: Berth;
approach: [number, number][];
metres: number;
inbound: number;
outbound: number;
}[] = [];
for (const berth of berths) {
const approach = berthApproach(fairway, berth);
if (approach === null) continue;
const metres = pathMetres(approach);
if (metres <= 0) continue;
routed.push({
berth,
approach,
metres,
inbound: (APPROACH_EASE * metres) / INBOUND_PEAK_MPS,
outbound: (APPROACH_EASE * metres) / OUTBOUND_PEAK_MPS,
});
}
if (routed.length === 0) return [];
/**
* One cycle for the whole port, sized so that the transits add up to the
* target.
*
* A berth is under way for `inbound + outbound` of every cycle, so the number
* of hulls moving at any instant is the sum of those over the cycle. Solving
* that for the cycle is the one line that turns "about two ships in the
* channel" into a schedule.
*/
const transit = routed.reduce((total, r) => total + r.inbound + r.outbound, 0);
const cycleSeconds = Math.max(transit / routed.length, transit / target);
const calls: HarbourCall[] = [];
routed.forEach((route, index) => {
const legs = route.inbound + route.outbound;
// The dwell the occupancy asks for, or the longest one that still leaves the
// berth clear water before the next ship — whichever is shorter. A long leg
// eats its own berth's dwell rather than overrunning the slot behind it.
const dwellSeconds = Math.max(
0,
Math.min(occupancy * cycleSeconds, cycleSeconds - legs - MIN_BERTH_GAP_SECONDS),
);
calls.push({
portId: port.id,
berthId: route.berth.id,
berthBearing: route.berth.bearing,
maxLength: route.berth.maxLength > 0 ? route.berth.maxLength : 0,
approach: route.approach,
approachMetres: route.metres,
inboundSeconds: route.inbound,
dwellSeconds,
outboundSeconds: route.outbound,
cycleSeconds,
// Evenly spaced, and rotated by a hash of the port so that Los Angeles and
// Long Beach are not in step with each other.
offsetSeconds:
((index / routed.length) + hash01(seed, `${port.id}:rotation`)) * cycleSeconds,
});
});
return calls;
}
/** Where a berth's call has got to at `atSeconds` after the epoch. */
export function harbourMoment(call: HarbourCall, atSeconds: number): HarbourMoment {
const cycle = call.cycleSeconds;
if (!(cycle > 0)) return { phase: "empty", index: 0, progress: 0 };
const since = atSeconds - call.offsetSeconds;
const index = Math.floor(since / cycle);
const u = since - index * cycle;
if (u < call.inboundSeconds) {
return { phase: "inbound", index, progress: u / call.inboundSeconds };
}
const afterDwell = call.inboundSeconds + call.dwellSeconds;
if (u < afterDwell) {
return {
phase: "alongside",
index,
progress: call.dwellSeconds > 0 ? (u - call.inboundSeconds) / call.dwellSeconds : 0,
};
}
const afterOut = afterDwell + call.outboundSeconds;
if (u < afterOut) {
return { phase: "outbound", index, progress: (u - afterDwell) / call.outboundSeconds };
}
return { phase: "empty", index, progress: (u - afterOut) / Math.max(1, cycle - afterOut) };
}
/**
* Distance made good along the route, and the speed that is making it.
*
* The speed is the analytic derivative of the distance rather than a plausible
* number written beside it, which is the property the whole seam rests on: a fix
* carries `sog` and a consumer is licensed to dead-reckon along it, so a `sog`
* that is not the derivative of the position it arrives with is a lie that the
* renderer will faithfully draw.
*/
export function approachRun(
metres: number,
seconds: number,
progress: number,
leg: "inbound" | "outbound",
): { arcMetres: number; speedMps: number } {
if (!(metres > 0) || !(seconds > 0)) return { arcMetres: 0, speedMps: 0 };
const x = clamp01(progress);
const k = APPROACH_EASE;
if (leg === "inbound") {
const left = 1 - x;
return {
arcMetres: metres * (1 - left ** k),
speedMps: (metres * k * left ** (k - 1)) / seconds,
};
}
return {
arcMetres: metres * (1 - x ** k),
speedMps: (metres * k * x ** (k - 1)) / seconds,
};
}
/**
* A harbour built from a board's own authored geometry, at one instant.
*
* Berths carry hulls; the channel carries the ones arriving and leaving; a tug
* comes out to meet each of them. All of it is a closed form of `atMs`, so the
* same instant gives the same harbour on every machine and at every replay.
*
* Deliberately absent, and the absences are the design: no name, no MMSI, no
* callsign, no destination, and no laden state. The output is a `VesselsBody`
@@ -718,80 +1116,82 @@ export function modelHarbour(
): VesselsBody {
const seed = options.seed ?? 115;
const atMs = options.atMs ?? 0;
const intervalSeconds = options.intervalSeconds ?? 900;
const intervalSeconds = options.intervalSeconds ?? MODELLED_INTERVAL_SECONDS;
const occupancy = clamp01(options.occupancy ?? 0.72);
const underWayPerPort = Math.max(0, Math.floor(options.underWayPerPort ?? 3));
const atSeconds = atMs / 1000;
const vessels: WireVessel[] = [];
for (const port of ports ?? []) {
const calls = harbourCalls(port, { ...options, seed, occupancy });
const scheduled = new Set(calls.map((call) => call.berthId));
/**
* A berth the channel does not reach keeps a hull alongside.
*
* The same static occupancy this file drew before there was a working day,
* kept for exactly the berths a route cannot honestly be drawn to — see
* `BERTH_APPROACH_REACH_METRES`. It is the difference between a quay that is
* quiet and a quay that is empty, and the empty one would read as a bug.
*/
for (const berth of port.berths ?? []) {
if (scheduled.has(berth.id)) continue;
const key = `${port.id}:${berth.id}`;
if (hash01(seed, `${key}:occupied`) > occupancy) continue;
const kind = berthKind(seed, key, berth);
const fallback = DEFAULT_HULL[kind];
const maxLength = berth.maxLength > 0 ? berth.maxLength : fallback.length;
/**
* 70-88% of the berth, and the ceiling is what stops a terminal reading
* as one continuous wall of steel.
*
* Photographed: Pier 400's authored berths are 356 m apart and it fills to
* 400 m, so at 97% two consecutive hulls touched stem to stern and the two
* vehicle carriers alongside read as one 700 m object. A berth whose hull
* exactly fills it every time also reads as a diagram rather than as a
* working quay.
*/
const length = Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
vessels.push({
id: `m-${key}`,
kind,
lat: berth.lat,
lon: berth.lng,
speed: 0,
course: null,
/**
* `null`, always, and this is the most deliberate line in the simulator.
*
* Half the fleet at rest reports no heading, so a modelled harbour whose
* every hull volunteered one would exercise the easy path and leave the
* berth-supplied orientation — the thing this workstream exists to get
* right — permanently untested by the picture.
*/
heading: null,
navStatus: 5,
length,
beam: Math.round(beamFor(kind, length)),
ageSeconds: 0,
});
const length = berthLength(seed, key, berth, kind);
// Off the wall, exactly as a scheduled one is. The water side comes from
// the channel rather than from a route, because this berth has none — the
// fairway is the one thing on a port that is certainly afloat.
const afloat = nearestOnPath(port.channel ?? [], berth.lat, berth.lng);
const lying = afloat
? lyingPosition(berth, afloat.lat, afloat.lng)
: { lat: berth.lat, lng: berth.lng };
vessels.push(alongsideFix(`m-${key}`, kind, lying.lat, lying.lng, length));
}
const channel = port.channel ?? [];
if (channel.length < 2 || underWayPerPort === 0) continue;
for (let i = 0; i < underWayPerPort; i++) {
const key = `${port.id}:under-way:${i}`;
const kind = i === underWayPerPort - 1 ? "tug" : underWayKind(seed, key);
const fallback = DEFAULT_HULL[kind];
const length = Math.round(fallback.length * (0.85 + 0.3 * hash01(seed, `${key}:length`)));
// Speed first, because it is what the phase is measured in: a tug at six
// knots and a container ship at twelve are at different places on the same
// channel a minute later, which is the whole reason the wakes differ.
const speed = (kind === "tug" ? 3.2 : 6.4) * (0.8 + 0.4 * hash01(seed, `${key}:speed`));
const phase = (hash01(seed, `${key}:phase`) + (atMs / 1000 / (intervalSeconds * 6))) % 1;
const along = i % 2 === 0 ? phase : 1 - phase;
const point = alongPath(channel, along);
if (!point) continue;
for (const call of calls) {
const moment = harbourMoment(call, atSeconds);
if (moment.phase === "empty") continue;
const key = `${call.portId}:${call.berthId}:${moment.index}`;
const kind = callKind(seed, key, call);
const length = callLength(seed, key, call, kind);
if (moment.phase === "alongside") {
const lying = call.approach[call.approach.length - 1];
if (!lying) continue;
vessels.push(alongsideFix(`m-${key}`, kind, lying[0], lying[1], length));
continue;
}
const leg = moment.phase;
const seconds = leg === "inbound" ? call.inboundSeconds : call.outboundSeconds;
const run = approachRun(call.approachMetres, seconds, moment.progress, leg);
const at = alongPath(call.approach, run.arcMetres / call.approachMetres);
if (!at) continue;
// The route is authored seaward-end-first, so its bearing at any point is
// the inbound course and a departure is the reciprocal of it.
const course = leg === "inbound" ? at.bearing : normaliseDegrees(at.bearing + 180);
vessels.push({
id: `m-${key}`,
kind,
lat: point.lat,
lon: point.lng,
speed,
course: i % 2 === 0 ? point.bearing : normaliseDegrees(point.bearing + 180),
lat: at.lat,
lon: at.lng,
speed: run.speedMps,
course,
// `null`, always, and this is the most deliberate line in the simulator.
// Half the fleet at rest reports no heading, so a modelled harbour whose
// every hull volunteered one would exercise the easy path and leave the
// berth-supplied orientation — the thing this workstream exists to get
// right — permanently untested by the picture.
heading: null,
navStatus: 0,
length,
beam: Math.round(beamFor(kind, length)),
ageSeconds: 0,
});
const tug = attendingTug(call, moment, run, seed, key);
if (tug) vessels.push(tug);
}
}
@@ -803,10 +1203,305 @@ export function modelHarbour(
ttlSeconds: intervalSeconds,
attribution: [
"Modelled from this board's authored berths and channels. Not an observation of any vessel.",
"Ship speeds, the channel and the berths are true; the rate of arrivals is compressed to about seven times a real day's so the harbour changes while you watch.",
],
};
}
/**
* The tug attending one arriving or departing ship, or `null`.
*
* A tug is the smallest hull on the board and almost always the one that is
* moving, which at 391 m to the scene unit makes it the hull that reads: a
* thirty-metre hull is a twelfth of a unit and invisible, and the V behind it is
* four hundred metres of foam and is not. So the tug is here for the wake as
* much as for the ship it is attending.
*
* Three sub-legs, and the first is the one worth having: for a short window
* before the meeting the tug is drawn **running seaward**, out from its station
* up-harbour and down past the incoming ship, on a reciprocal course at ten
* knots. Two wakes crossing in opposite directions in a channel is a harbour
* doing something, where a tug that simply materialised alongside is a decal.
*/
function attendingTug(
call: HarbourCall,
moment: HarbourMoment,
run: { arcMetres: number; speedMps: number },
seed: number,
key: string,
): WireVessel | null {
const inbound = moment.phase === "inbound";
const x = moment.progress;
const station = TUG_STATION * call.approachMetres;
const length = Math.round(26 + 12 * hash01(seed, `${key}:tug`));
const escortArc = Math.max(0, run.arcMetres - TUG_STATION_ASTERN);
let arcMetres: number;
let speedMps: number;
let outbound: boolean;
if (inbound && x >= TUG_MEETS_AT - TUG_RUN_OUT && x < TUG_MEETS_AT) {
// Running out to meet her: from the station down-channel to the rendezvous,
// over the window, at whatever speed that distance and that window imply.
const meeting = approachRun(call.approachMetres, call.inboundSeconds, TUG_MEETS_AT, "inbound");
const target = Math.max(0, meeting.arcMetres - TUG_STATION_ASTERN);
const t = (x - (TUG_MEETS_AT - TUG_RUN_OUT)) / TUG_RUN_OUT;
arcMetres = station + (target - station) * t;
speedMps = Math.abs(station - target) / (TUG_RUN_OUT * call.inboundSeconds);
outbound = station > target;
} else if (inbound && x >= TUG_MEETS_AT && x < TUG_LEAVES_AT) {
arcMetres = escortArc;
speedMps = run.speedMps;
outbound = false;
} else if (!inbound && x <= TUG_DEPARTURE_UNTIL) {
arcMetres = Math.min(call.approachMetres, run.arcMetres + TUG_STATION_ASTERN);
speedMps = run.speedMps;
outbound = true;
} else if (!inbound && x <= TUG_DEPARTURE_UNTIL + TUG_RUN_OUT) {
// Letting her go and running home, back up the channel toward the station.
const release = approachRun(
call.approachMetres,
call.outboundSeconds,
TUG_DEPARTURE_UNTIL,
"outbound",
);
const from = Math.min(call.approachMetres, release.arcMetres + TUG_STATION_ASTERN);
const t = (x - TUG_DEPARTURE_UNTIL) / TUG_RUN_OUT;
arcMetres = from + (station - from) * t;
speedMps = Math.abs(station - from) / (TUG_RUN_OUT * call.outboundSeconds);
outbound = station < from;
} else {
return null;
}
if (!isMakingWay(speedMps)) return null;
const at = alongPath(call.approach, arcMetres / call.approachMetres);
if (!at) return null;
const course = outbound ? normaliseDegrees(at.bearing + 180) : at.bearing;
// Off the ship's quarter rather than in her wake, so both Vs are drawn rather
// than one on top of the other.
const abeam = offsetMetres(at.lat, at.lng, normaliseDegrees(course + 90), TUG_STATION_ABEAM);
return {
id: `m-${key}:tug`,
kind: "tug",
lat: abeam.lat,
lon: abeam.lng,
speed: speedMps,
course,
heading: null,
navStatus: 0,
length,
beam: Math.round(beamFor("tug", length)),
ageSeconds: 0,
};
}
/** A hull lying alongside: no speed, no course, and therefore no wake. */
function alongsideFix(
id: string,
kind: VesselKind,
lat: number,
lng: number,
length: number,
): WireVessel {
return {
id,
kind,
lat,
lon: lng,
/**
* Zero, and it is the load-bearing zero in this file.
*
* A wake is a function of speed through water, so a ship that has just tied
* up must lose hers in the same fix that puts her on the berth — a quay
* lined with wakeless hulls and one long V curving in past the breakwater is
* a picture of a working harbour, and a moored ship trailing foam is a
* picture of a bug. `engine/vessels.ts` gates the wake on this number and
* nothing else.
*/
speed: 0,
course: null,
heading: null,
navStatus: 5,
length,
beam: Math.round(beamFor(kind, length)),
ageSeconds: 0,
};
}
/**
* The channel with a seaward leg on the front of it.
*
* The extension runs back along the reciprocal of the first charted leg, so a
* ship stands in on the course the channel is already pointing at rather than
* arriving from an invented direction.
*/
function seawardApproach(channel: readonly [number, number][]): [number, number][] {
const first = channel[0];
const second = channel[1];
if (!first || !second) return channel.map(([lat, lng]) => [lat, lng]);
const inbound = bearingBetween(first[0], first[1], second[0], second[1]);
const out = offsetMetres(
first[0],
first[1],
normaliseDegrees(inbound + 180),
APPROACH_SEAWARD_METRES,
);
return [[out.lat, out.lng], ...channel.map(([lat, lng]): [number, number] => [lat, lng])];
}
/**
* One berth's route in: the fairway as far as the point nearest the berth, then
* a straight run alongside. `null` when the channel does not reach it.
*/
function berthApproach(
fairway: readonly [number, number][],
berth: Berth,
): [number, number][] | null {
const best = nearestOnPath(fairway, berth.lat, berth.lng);
if (best === null || best.metres > BERTH_APPROACH_REACH_METRES) return null;
const leave: [number, number] = [best.lat, best.lng];
const path: [number, number][] = [];
for (let i = 0; i < best.index; i++) {
const point = fairway[i];
if (point) path.push([point[0], point[1]]);
}
const tail = path[path.length - 1];
if (!tail || metresBetween(leave[0], leave[1], tail[0], tail[1]) > 1) path.push(leave);
// The route ends where the ship lies, not on the wall she lies against, so
// that the last minute of the run in and the hours alongside are the same
// point and she does not step sideways the moment she is reported moored.
const lying = lyingPosition(berth, leave[0], leave[1]);
path.push([lying.lat, lying.lng]);
return path.length >= 2 ? path : null;
}
/**
* Where a hull lying at a berth actually floats: half a beam off the quay.
*
* `Berth.lat/lng` is a point **on the wall**, which is what a berth is — the
* quay's own coordinate, authored with the concrete. A hull centred on it has
* half its width inside the terminal, and the first photograph of the working
* day showed exactly that: eleven ships reported alongside and barely a hull
* visible, because each was buried to the centreline in its own quay and roofed
* by a crane rail.
*
* Which way is water is not in `Berth` and is not guessed. It is taken from a
* point that is definitely afloat — the place the ship left the fairway, or for
* a berth with no route the nearest point on the channel — and then squared up:
* the offset runs along whichever perpendicular to the **quay's own bearing**
* agrees with that direction, so a ship lies parallel to the wall however
* oblique her approach was.
*
* The offset is sized from the berth rather than from the ship, and that is
* deliberate: where a hull lies is a property of the fender line, so every ship
* on a 400 m berth lies on the same line whether she is 280 m or 350 m long. The
* beam used is the widest hull the berth can take — a full-length container ship
* is the broadest thing in `beamFor` that a deep-sea berth ever sees — so no
* ship's plating ever reaches back over the coping.
*/
function lyingPosition(
berth: Pick<Berth, "lat" | "lng" | "bearing" | "maxLength">,
towardLat: number,
towardLng: number,
): { lat: number; lng: number } {
const seaward = bearingBetween(berth.lat, berth.lng, towardLat, towardLng);
const side = normaliseDegrees(berth.bearing + 90);
const water = Math.abs(signedDelta(side, seaward)) <= 90 ? side : normaliseDegrees(side + 180);
const widest = berth.maxLength > 0 ? berth.maxLength : DEFAULT_HULL.container.length;
const beam = beamFor("container", widest * 0.88);
return offsetMetres(berth.lat, berth.lng, water, beam / 2 + BERTH_STANDOFF_METRES);
}
/** Fenders, camels and the gap a ship actually lies off a wall at, in metres. */
const BERTH_STANDOFF_METRES = 6;
/** The point on a polyline nearest a place, and how far off it is, in metres. */
function nearestOnPath(
path: readonly [number, number][],
lat: number,
lng: number,
): { lat: number; lng: number; index: number; t: number; metres: number } | null {
let best: { index: number; t: number; metres: number } | null = null;
for (let i = 1; i < path.length; i++) {
const a = path[i - 1];
const b = path[i];
if (!a || !b) continue;
const scale = Math.cos(((a[0] + b[0]) / 2) * DEG);
const bx = (b[1] - a[1]) * METRES_PER_DEGREE_LAT * scale;
const by = (b[0] - a[0]) * METRES_PER_DEGREE_LAT;
const px = (lng - a[1]) * METRES_PER_DEGREE_LAT * scale;
const py = (lat - a[0]) * METRES_PER_DEGREE_LAT;
const square = bx * bx + by * by;
const t = square > 0 ? clamp01((px * bx + py * by) / square) : 0;
const metres = Math.hypot(px - bx * t, py - by * t);
if (best === null || metres < best.metres) best = { index: i, t, metres };
}
if (best === null) return null;
const a = path[best.index - 1];
const b = path[best.index];
if (!a || !b) return null;
return {
lat: a[0] + (b[0] - a[0]) * best.t,
lng: a[1] + (b[1] - a[1]) * best.t,
index: best.index,
t: best.t,
metres: best.metres,
};
}
/** Total length of a polyline, in metres. */
export function pathMetres(path: readonly [number, number][]): number {
let total = 0;
for (let i = 1; i < path.length; i++) {
const a = path[i - 1];
const b = path[i];
if (!a || !b) continue;
total += metresBetween(a[0], a[1], b[0], b[1]);
}
return total;
}
/** A point `metres` away on a true bearing. Flat-earth, over a few kilometres. */
export function offsetMetres(
lat: number,
lng: number,
bearingDegrees: number,
metres: number,
): { lat: number; lng: number } {
const radians = bearingDegrees * DEG;
const north = (Math.cos(radians) * metres) / METRES_PER_DEGREE_LAT;
const perDegreeLng = METRES_PER_DEGREE_LAT * Math.cos(lat * DEG);
const east = perDegreeLng > 1 ? (Math.sin(radians) * metres) / perDegreeLng : 0;
return { lat: lat + north, lng: lng + east };
}
/** A berthed hull's length: most of the berth, never all of it. */
function berthLength(seed: number, key: string, berth: Berth, kind: VesselKind): number {
const fallback = DEFAULT_HULL[kind];
const maxLength = berth.maxLength > 0 ? berth.maxLength : fallback.length;
/**
* 70-88% of the berth, and the ceiling is what stops a terminal reading as one
* continuous wall of steel.
*
* Photographed: Pier 400's authored berths are 356 m apart and it fills to
* 400 m, so at 97% two consecutive hulls touched stem to stern and the two
* vehicle carriers alongside read as one 700 m object.
*/
return Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
}
/** The kind of ship this call brought, from the berth it is for. */
function callKind(seed: number, key: string, call: HarbourCall): VesselKind {
return berthKind(seed, key, { maxLength: call.maxLength } as Berth);
}
function callLength(seed: number, key: string, call: HarbourCall, kind: VesselKind): number {
const fallback = DEFAULT_HULL[kind];
const maxLength = call.maxLength > 0 ? call.maxLength : fallback.length;
return Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
}
// ---- Arithmetic -----------------------------------------------------------
function readWireSpeed(speed: number | null | undefined): number | null {
@@ -964,10 +1659,3 @@ function berthKind(seed: number, key: string, berth: Berth | BerthAnchor): Vesse
return "vehicle-carrier";
}
function underWayKind(seed: number, key: string): VesselKind {
const roll = hash01(seed, `${key}:kind`);
if (roll < 0.55) return "container";
if (roll < 0.75) return "tanker";
if (roll < 0.9) return "bulk";
return "vehicle-carrier";
}
+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",
);
});
});