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