1
0

feat: the crane grows a mast, the harbour works a shift, and the site is re-shot

**The Asset Factory verdict, and it mostly went against the vote.** Nine
candidates were thumbed up. One was taken.

TOOK the STS crane. Rebuilt in `ports.ts` from 5 unit boxes to 11 — an A-frame
mast and apex cap, a forestay to the boom, a backstay to the tail, a sill, the
truck-lane portal beam, a machinery house — still exactly ONE InstancedMesh.
What was missing is the thing that makes a gantry a gantry: on a real STS the
tallest part of a WORKING crane is the A-frame apex, not the boom, and a parked
raised boom clears its own apex by only 15-25%. Before, 56 gantries read from
altitude as 56 crosses — two coincident verticals with one bar through them and
nothing above it — so a berth flattened into a picket fence.

Proportions came from both upvoted candidates agreeing independently (hinge ~58 m
under an apex at 99-104 m), taken conservatively because Tera's packs already
author an 82 m hinge against a real 55-60.

The apex beacon came across as EMISSION: `craneLights()` returns bare positions,
`nightlights.ts` turns them into one additive Points cloud, 56 points, one draw
call, night only, no THREE.Light anywhere. 0.09 units was invisible against the
port's own cream emissive; 0.17 — half a bridge head light — is right, and the
screenshot at 0.09 is what condemned it.

REJECTED all three bridges, city-lights and both aircraft: the incumbents won on
the picture, decisively for the bridge.

TWO PARTS WERE BUILT FROM THE APPROVED CANDIDATES, PHOTOGRAPHED, AND CUT. Four
legs: 14 m of quay spacing is 0.036 units at 391 m/unit against a 0.032 member
floor, so 90% overlap. A portal X-brace: the bay is 0.115 wide by 0.38 tall, so
both diagonals come out near-vertical and add a lump at mid-leg. Both are among
the best things about the factory cranes AT THE FACTORY'S FRAMING. Neither
survives at board scale. That gap is the whole reason a factory asset is
reference geometry and not a drop-in.

Fixed a defect the rebuild exposed: the backreach started a full rail-gauge
behind the hinge, leaving a gap over the portal with the beam floating below it.
One unbroken girder now. And every inclined member goes through a `strut()` that
takes two points in the (distance-along-boom, height) plane, so the
vertical-exaggeration bug the module header warns about is no longer reachable —
it needs a length and an angle, and there is now no way to start from those.

**The harbour works a shift.** It was a frozen tableau: 19 hulls placed from the
pack's berths that never changed. Vessels now arrive through the channel, are met
by a tug, berth, work and depart — seeded, so two people see the same harbour and
a capture script shoots the same frame twice. A ship loses its wake when it ties
up, because the wake is the information.

**Every still and film re-shot.** The site was showing a Tera that no longer
existed — SHOTS_COMMIT b7f5c41, FILMS_COMMIT 2aa4049, against an engine that has
since gained fires, the whole state, ports, ships and night infrastructure. Two
frames were bad and are fixed by moving the hour, not by retouching:
`bay-relief-day` and `peninsula-day` were white lids of marine layer. Four
captions described a Tera that no longer existed and are rewritten to the
delivered frame. `california-relief-night` is measurably brighter than the frame
it replaces (canvas mean 7.91 -> 10.57) despite the state being 30% larger.

Ten budget cells pass, run twice. socal 1,422,025 -> 1,429,993 triangles against
1,700,000, 218 draws against 320. The measured delta is double the geometry
because the crane mesh casts shadow, so renderer.info counts it in both passes —
worth knowing before anyone reads that number as geometry.

Tests 1,540 -> 1,570, server 295.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 03:29:58 -07:00
parent e7a8aa028b
commit bcac6aa41a
15 changed files with 2429 additions and 304 deletions
+2 -2
View File
@@ -13,7 +13,7 @@
"kind": "generated-social-card",
"origin": "repository-generated",
"license": "Apache-2.0",
"sha256": "f174ac7a626912b1a6db73232e1c5514fb8673b8872f6c70d99b76c3199b86c7",
"sha256": "bc0ce2e42a653c2589d2afc2b89397d182ecdce8fe4f7d221e00e3280db67df2",
"generator": "scripts/brand-assets/capture.mjs",
"inputs": ["scripts/brand-assets/capture.mjs", "scripts/brand-assets/og.html"],
"intakeDate": "2026-08-22",
@@ -24,7 +24,7 @@
"kind": "generated-social-card",
"origin": "repository-generated",
"license": "Apache-2.0",
"sha256": "60f514b421f7781b14807c44c3375848d52ea3fa3e68b9072ff6c4589b1ba3f1",
"sha256": "3b248a4b8b8566614700e358ca38cd3110e1a4ae8c27b615b0ffcd6833449344",
"generator": "scripts/brand-assets/capture.mjs",
"inputs": ["scripts/brand-assets/capture.mjs", "scripts/brand-assets/og.html"],
"intakeDate": "2026-08-22",
+186 -49
View File
@@ -8,13 +8,11 @@ in an issue, not here.
`src/cities/california.ts` now runs 32.5042.05 N and -124.50 to -114.0 W. Four
things fall out of it that somebody has to pick up.
- **The marketing stills are frames of a board that no longer exists.** Every
`city: "california"` shot in `scripts/brand-assets/shots.mjs` was photographed
at 428 units across with a ruled crop along 38.05 N. Re-shoot with
`npm run refresh`. The `chapter` indices are all still correct — the north
chapter was added at the **end**, index 5, precisely so nothing re-points — but
`california-relief`'s caption says "428 units across at 1,919 metres to the
unit" and that number is now 554. The caption is the only edit needed.
- ~~**The marketing stills are frames of a board that no longer exists.**~~
**Done 2026-08-23.** All 21 shots and all four films were re-shot at `e7a8aa0`
plus this round's uncommitted work; `california-relief`'s caption now says 554
units and names the north. See "The imagery was re-shot" below for what that
run found, and for the two manifests that are still waiting on a clean sha.
- **`scripts/performance-budget.mjs`'s header comment is now stale in one row.**
It records `california/mobile` at 389,843 triangles; the extended board
measures 373,825. The caps in `performance-budgets.json` were rewritten
@@ -277,55 +275,194 @@ on a bridge frame.** On the deck the lamp run, both tower silhouettes and the re
light over each tower head all read clearly at 21:35, where at 20:50 there was no
light on the structure anywhere.
## Re-shoot the time-lapse films
## The imagery was re-shot on 2026-08-23 — what that run found
All 38 product stills were re-shot on 2026-08-22 against the night-infrastructure
work, so the manifest records `SHOTS_COMMIT b7f5c41` with **`SHOTS_DIRTY true`** —
that tree was not committed when the shutter opened, and the manifest says so
rather than naming a commit that would render something else. Re-run
`shots.mjs --manifest-only` once it is committed. The two **share cards** were not
re-shot and did not need to be: `capture.mjs` shoots both at 11:20 and 17:30, so
nothing that changed after dark is in them. **The films were not** — they were
last shot on 2026-08-07 at `9c9e78f`. `films.ts` in lumbridge-v4 records the tera
commit each reel was shot at, so a stale reel is visually stale and no caption edit
fixes it — check that commit against tera HEAD before assuming a reel is current.
`npm run refresh` at `e7a8aa0` + this round's uncommitted work: **21 shots x 2
frames**, both share cards, all four films, both manifests. Every run printed
`ANGLE (AMD, Vulkan … Radeon RX 6700 XT (RADV NAVI22))` and not SwiftShader; no
chapter guard and no `play` guard fired. Timings on this box, for budgeting:
build 7 s, stills 545 s, cards 26 s, films 416 s.
What the reels predate: ACES tone mapping and the runtime PMREM environment rig; a
sea that reflects, with a swell normal map, Fresnel and sun glitter, in place of a
flat `MeshLambertMaterial` that had no specular term at all; a world-space sky dome
replacing the screen-space gradient, which at dusk painted the sunset band along the
*bottom* of the picture, under the board; terrain that casts shadows; a California
board with the Basin and Range, Death Valley, the Salton Sea and San Diego on it;
SFO, LAX, both bridges and a freeway that reads as a road; aircraft that move,
carrying registration and type; a rebuilt LA studio; the studio device hardware; and
a new UI chrome with a first-run flow.
**Both manifests are dirty and that is the one thing still owed.**
`SHOTS_COMMIT` and `FILMS_COMMIT` are `e7a8aa0` with `SHOTS_DIRTY` /
`FILMS_DIRTY` **true**, because the tree was not committed when the shutter
opened — the port, vessel and night-light work of this round is in every frame
and in no commit. That is the manifests being honest rather than naming a sha
that would render something else. Once tera is committed:
```sh
npm run refresh -- --films-only --rough # rough cut first: ~20 s a reel, and it is
# the cheapest way to find out a camera is
# pointed at the wrong thing
npm run refresh -- --films-only # ~90 s per 180-frame film on the Radeon
node scripts/brand-assets/shots.mjs --manifest-only # no browser, no render
node scripts/brand-assets/films.mjs --manifest-only
```
Worth knowing before starting (the rest is in `~/.claude/skills/tera-capture`):
then commit lumbridge-v4 and deploy it.
- Check the run prints the Radeon and not SwiftShader. The software path is ~15x
slower — 22 minutes a film rather than 90 seconds — and it reports success.
- `hero-soma` is a different animal: `chrome: "bare"`, midnight to midnight, and a
two-stage encode (a CRF 18 master, then 1440w CRF 28 and 960w CRF 30 delivered).
- **The hero camera is worth reconsidering.** Whole-board was tried and rejected
because the edge of the terrain plate showed against the sky. That seam no longer
exists: the sea now runs eighteen board spans and ends past the fog's far plane.
- After tuning the hero, run `npm run check:hero` in lumbridge-v4.
- `#onboarding-host` is in `FURNITURE.CLUTTER` now, and `#mode-dock` and
`#play-hud` are in `FURNITURE.BARE`. Every *new* overlay has to be added to one
of those two lists on the day it lands, because a frame with a stray card in it
still renders and still looks deliberate. The mode dock proved it: it shipped
inside the tera share card for a fortnight and nobody noticed.
- **The two capture scripts both serve on port 5210.** Two of them at once is
`EADDRINUSE` a minute into a run, so do not start a film while a stills pass is
going — or give one of them `--site`-style isolation first.
- Commit tera first, then re-run, so the manifest records a clean sha.
### Two new shots, and one of them needs a page
- **`harbour`** — socal chapter 5, aimed at 33.7405/-118.2565 broadside across
Terminal Island. Four crane rails at four bearings, the container yards, hulls
alongside and one under way, the Vincent Thomas across the Main Channel, and at
night a red obstruction light on every gantry apex.
- **`california-fire`** — the state board with the fire feed behind it. See the
section below; it is the only frame here that is not the keyless experience.
Both are in `shots.ts` and both are delivered as WebP. **Neither is on a page.**
Every one of the other nineteen ids is named explicitly by something in
`lumbridge-v4/apps/web/src`, so a new shot appears in the manifest and nowhere
else until somebody places it. That is a handoff, not a defect.
### The fire feed is photographable now, from a fixture
`drawsFire()` gates the whole layer on `access.feeds?.fires === true`, which
comes from `/health`. A keyless capture — which is every other frame here — can
therefore not photograph fire at all, while `tera.lumbridgecorp.com` answers
`"fires":"cloud1"` and every anonymous visitor to the deployed site does get it.
The site was about to ship an imagery set in which the most visible thing the
engine gained this round did not exist.
`shots.mjs` now has `serveFeed()` and a per-shot `feed` field. It fulfils exactly
two routes and 404s the rest of `/api/v1`, and the body is
`scripts/brand-assets/fires-2026-08-23.json` — the verbatim response from the
live feed on 23 August 2026, kept whole. **Do not trim that file.** `promote()`
counts the rows it refuses and the panel prints that number, so a fixture with
the refused rows deleted would draw the same eight fires under a sentence
claiming a gate that never ran.
Three consequences worth knowing before extending this:
- the synthetic `/health` says `auth.mode: "none"`, which resolves the visitor to
`member` and keeps **`#tier-adds`** out of the frame. That card is the sign-in
offer, it is shown only to `anon`, and it is **not** in `FURNITURE.CLUTTER`
so the day this pipeline meets a real API it will arrive in the art silently,
exactly the way `#mode-dock` did;
- `california-fire` is the only shot clocked to **2026-08-23** rather than
2026-08-06, because the panel prints the body's own `fetchedAt` and how long
ago that was. A fixture from the 23rd photographed on the 6th reads "fetched
2026-08-23 · just now";
- the hot-pixel layer draws every FIRMS detection inside `bounds`, and `bounds`
is a lat/lng **rectangle**. On the state board that puts a handful of cyan
points over open water and over Baja in the night frame. They are real
detections and the rectangle is the honest gate the layer has; it is worth
deciding whether a board wants a land test before the count in the panel is
ever quoted as a California number.
### The marine layer decided two of the day frames, and it should decide any more
`atmosphere.ts` models the Pacific marine layer on **apparent solar** time, and
the curve is 0.95 before dawn, 0.6 at nine, 0.2 at eleven, 0.4 again by four. Two
shots were on 09:10 and 09:20 — a little after eight solar, where the layer is at
about three quarters — and at a whole-board or 32 km standoff that is not weather
in the picture, it is a white lid over the entire subject. `bay-relief`'s caption
is about eighty-three named hills and four octaves of value noise, none of which
was visible; `peninsula`'s is about twenty miles of city, likewise.
Both moved to the late afternoon (17:00 and 17:10) and both frames are now
legible. The compromise is forced: the layer is thinnest at solar 11-14, which is
**12:07-15:07 on a wristwatch here** — the exact hours a heightfield has no
shadow. Around five is thin enough to see the ground and low enough that the
ranges still throw.
**Any future wide day frame on the Bay board has this constraint.** Close and low
poses do not — `fidi` at 10:10 and `crow-nav` at 09:20 are both clear, because
the camera is under the deck.
⚠️ **A 1x `--preview` does not predict the cloud in the 2x delivery.** The deck
animates on elapsed time and the two runs have different settles (3000 ms against
6000), so the puffs land somewhere else. Framing, pose and light all preview
faithfully; cloud does not. Judge cloud on the delivered frame, which is how
`peninsula`'s note lost a sentence about SFO that was true of the preview and not
of the render.
### Four captions had been falsified by engine changes, and nothing catches that
A caption is prose next to a camera pose. When the engine moves under it, the
frame still renders, still looks deliberate, and still ships. All four of these
were found by opening the delivered frame and reading its own note back:
- **`california-flight` day** ended "and the Pacific past the wingtip". Aerial
perspective now follows camera altitude, so at 1,413 m the ocean is inside the
haze rather than beyond it and there is no sea in the frame at all.
- **`california-flight` night** promised "the basin's lit ground in the corner"
and "the moon's path broken across the water". The frame is over dark coastal
mountains: no basin, no water. What actually carries it is the aircraft's own
navigation lights and the corridor's road markings.
- **`lax` day** said "the traffic overhead is the real traffic: registration and
type come off the same feed". **It cannot be.** Every frame but
`california-fire` is shot keyless, so `TrafficSource` is the bundled simulator
and not ADS-B. A caption may not borrow a fact from a deployment the shutter
never opened on — and this is the failure mode to watch for now that one shot
*does* have a server.
- **`golden-gate` day** said the towers "throw along the deck". They throw across
the water beside it, which is checkable and was checked.
### `fidi-day`'s day poster had drifted onto the wrong renderer
`poster: 0.86` was chosen as "a few minutes before sunset: the last of the low
sun down the length of Montgomery". On ACES with the world-space sky dome, 0.86
is 20:09 with the sun at **+0.4°** and the towers are unlit silhouettes — the
frame measured mean luminance **33** out of 255 against the night poster's 21.
That is not a pair, and it matters more than a poster usually would: `Film` ships
`preload="none"`, so the poster is what most readers see for most of the time the
film is on their screen. Now `0.76` — twenty past six, sun twenty degrees up,
towers lit and throwing, mean **87**.
The other three films' posters were checked the same way and are fine
(147/24, 106/21, 155/46 day against night). **Check a poster pair by measuring
both, not by reading the clock**: the fractions were all chosen on an older
renderer and this is the one that had moved.
### Smaller things the re-shoot turned up and did not fix
- **`freeway` and `drive-101` are nearly the same photograph.** Both are the
chase camera on the 101 chapter at 09:00; the only difference is where on the
leg they land — 47% against 57%. `freeway` gets there with `settle: 3000`,
which is the duration-not-position trap `drive-101` exists to document. It has
landed on 47% on two consecutive runs, so it is reproducible in practice on
this box, but it is a duration and it is measured from boot. It cannot simply
be given a `play` block: the 101 chapter is *already* in drive mode, and
`enterPlay` clicks the dock button, which would toggle it off.
- **`office-desks` slices the hardware card.** The panel is taller than the
viewport, so `Desk speaker`'s controls are below the fold and its header sits
on the frame edge. That is the exact condition `shot.hide` was invented for,
and it cannot be used here because the hardware *is* the subject.
- **`#sky-section` stays in the product stills, deliberately.** It prints "No
radar feed is configured … that is a fact about this box, not about the sky"
twice, which reads like a hole — but `tera.lumbridgecorp.com` answers
`"radar":"none"` and `"birds":"none"` today, so a real visitor sees the same two
sentences. It is not the `#tier` case (local disagreeing with deployed); it is
the product's own voice. **If either feed is ever wired, this decision has to be
taken again**, because then the local frame and the site would disagree.
- **`#sea-section` stays too**, and it is the one that earns it: the harbour is
modelled identically here and on the deployed site, and its sentence is what
keeps a frame full of ships honest about their being modelled rather than
observed.
- **`PROVENANCE.json`'s two card records now name a date they were not made on.**
`refresh.mjs` re-stamps the share cards narrowly — it substitutes the `sha256`
in the text and nothing else, which is what keeps that diff two lines instead
of a reflow — so `intakeDate` still reads `2026-08-22` for files written on the
23rd. `check-provenance` only validates the format, so nothing catches it. It
is one line in the one document whose entire job is accuracy.
## The vessel layer has two gaps that belong to other files
Both stated in code comments already; recorded here so they are not only there.
- **Pier 300 and the East Basin take no port calls.** The straight run from the
nearest point on `Port.channel` to those berths is 1.9 km and 2.9 km and
crosses Terminal Island, so `BERTH_APPROACH_REACH_METRES` (1100) gates them out
and they keep a static hull alongside rather than driving a container ship over
a container yard. The proper fix is an optional per-berth approach path on
`Berth``approach?: LatLng[]`, water-side first — which `socal.ts` could then
author for Pier 300 as the real route out through Angels Gate and north-east
across the outer harbour. `harbourCalls` would prefer it to the nearest-point
projection; about ten lines once the field exists. The field is on
`src/engine/types.ts`, which the vessel work did not own.
- **The tug's own solid is correct, cheap, and sub-pixel at every SoCal pose we
ship.** Its payoff is banked for the Bay board and for closer poses rather than
realised today.
- `engine/nightlights.ts` now imports `craneLights` from `engine/ports.ts`. No
cycle — `ports.ts` imports only three, terrain, types and world — but the two
files are coupled and want moving, and re-photographing, together.
## The GPU on this box never leaves 500 MHz, and every frame-time number here is a floor
Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 KiB

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 KiB

After

Width:  |  Height:  |  Size: 723 KiB

+20 -5
View File
@@ -171,12 +171,27 @@ const FILMS = [
/**
* Which frame becomes the poster — the one worth stopping on, twice.
*
* 0.86 is eight past eight in the evening, a few minutes before sunset:
* the last of the low sun down the length of Montgomery. 0.935 is half
* past nine, the hour the shot list uses for night, with the sun twelve
* degrees under and the tower windows carrying the frame.
* 0.935 is half past nine, the hour the shot list uses for night, with the
* sun twelve degrees under and the tower windows carrying the frame.
*
* `poster` used to be 0.86 and the comment beside it said "a few minutes
* before sunset: the last of the low sun down the length of Montgomery".
* That reading was true of the renderer it was chosen on and is not true of
* this one. 0.86 is 20:09 with the sun at **+0.4°** — on the ACES pipeline
* and the world-space sky dome the towers at that instant are unlit
* silhouettes, and the frame measures a mean luminance of 33 out of 255
* against the night poster's 21. A *day* poster is what a light-mode reader
* is served, and it is what most readers of this film see for most of the
* time it is on their screen, because `preload="none"` means the poster is
* usually the whole film. A pair that is 33 and 21 is not a pair.
*
* 0.76 is twenty past six, sun about twenty degrees up: the towers are lit,
* they are throwing down the length of the grid, the Bay Bridge is in the
* frame, and it measures 86. Checked by extracting the candidates from the
* CRF-18 master rather than by arithmetic, which is the same discipline the
* night poster already had.
*/
poster: 0.86,
poster: 0.76,
posterNight: 0.935,
place: "Financial District, San Francisco",
caption:
File diff suppressed because one or more lines are too long
+250 -17
View File
@@ -42,7 +42,7 @@
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdir, writeFile, access } from "node:fs/promises";
import { mkdir, writeFile, readFile, access } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { serve, launch, clockShim, FURNITURE, hide } from "./harness.mjs";
@@ -101,9 +101,25 @@ const SHOTS = [
caption:
"Everything else in the engine stands on this heightfield: 83 named hills, four octaves of value noise, and overlapping peaks combined as tallest-plus-35%-of-the-rest — the one rule that makes a run of summits read as a ridge instead of stacking into a fictional mountain.",
day: {
at: "2026-08-06T09:10:00-07:00",
note: "Ten past nine, sun thirty degrees up: high enough that the marine layer has burned back off the bay, low enough that the East Bay hills still rake shadow west across the flats.",
alt: "The San Francisco Bay Area seen from above in mid-morning light, the peninsula and the East Bay hills casting shadows west, scattered cloud over the water.",
at: "2026-08-06T17:00:00-07:00",
/*
* Five in the afternoon, and it used to be ten past nine — which was the
* right instinct about shadow and the wrong one about cloud. `atmosphere.ts`
* models the Pacific marine layer on *apparent solar* time, and its diurnal
* curve is 0.95 before dawn, 0.6 at nine, 0.2 at eleven and back to 0.4 by
* four. At 09:10 PDT — a little after eight solar — the layer is at about
* three quarters, and the frame it produced was a white lid with a board
* somewhere under it: the caption underneath was about eighty-three named
* hills and four octaves of value noise, none of which was visible.
*
* This is the compromise the curve allows. Physically the layer is thinnest
* at solar eleven to fourteen, which is 12:07-15:07 on a wristwatch here —
* the exact hours a heightfield has no shadow at all. 17:00 PDT is solar
* 15.9, thin enough that the board is out from under it, and low enough at
* 36.6 degrees that both ranges still throw.
*/
note: "Five in the afternoon, which on this coast is a decision about cloud before it is one about shadow. The marine layer here is modelled rather than painted — thickest before dawn, burnt back through the late morning, on its way in again by six, all of it on apparent solar time — so at nine this board is a white lid, and at five it is out from under it with both ranges throwing.",
alt: "The San Francisco Bay Area seen from above in late afternoon light: the peninsula between the Pacific and the bay, the East Bay hills beyond, both crossings over the water, and scattered cloud out over the ocean.",
},
night: {
at: "2026-08-06T21:35:00-07:00",
@@ -161,9 +177,17 @@ const SHOTS = [
caption:
"Twenty miles of city between the water and the ridge, never more than four wide. The camera is about 32 km back — the city scene's orbit limit — which is why the region ships as five chapters rather than one impossible wide shot.",
day: {
at: "2026-08-06T09:20:00-07:00",
note: "Twenty past nine, with an aircraft on approach drawing the line across the bay.",
alt: "The San Francisco peninsula from above in morning light, the Santa Cruz mountains on one side, the bay on the other, and an aircraft trail crossing the water.",
at: "2026-08-06T17:10:00-07:00",
/*
* Moved off 09:20 for `bay-relief`'s reason, and this shot is the sharper
* case of it: at a thirty-two kilometre standoff the modelled marine layer
* is not weather in the picture, it is a lid over the whole subject, and
* the twenty miles of city this caption is about were simply not in the
* frame. Ten past five is thin enough to see the ground and low enough to
* model the ridge.
*/
note: "Ten past five, the sun down the length of the bay and low enough that the Santa Cruz ridge has a lit side and a dark one. Nothing here is a fixed number of miles from anything: the standoff is the city scene's own orbit limit, which is why the region ships as five chapters and not as one impossible wide shot.",
alt: "The San Francisco peninsula from above in late afternoon light: the Santa Cruz mountains green along one side, the built ground between them and the bay, sun glitter across the water and haze closing the far end of the bay.",
},
night: {
at: "2026-08-06T21:35:00-07:00",
@@ -224,7 +248,7 @@ const SHOTS = [
"One kit draws both of the Bay's crossings, because a suspension bridge is arithmetic over a path and a list of towers: a main cable hanging between the tower tops, a regular series of hangers down to the deck, and piers under every metre that hangs from nothing. Here that resolves to two cross-braced towers 227 m over the strait, one main span, and international orange. Every reach is classified before it is drawn — the previous builder hung a catenary between every pair of towers, which is how the Bay Bridge ended up with a two-and-a-half kilometre span over dry land.",
day: {
at: "2026-08-06T17:40:00-07:00",
note: "Twenty to six, raking down the strait, so the towers throw along the deck and the bridge puts its own shadow on the water. The road does not stop at the anchorages: both ends run onto land, because started at the waterline the deck appeared to float with its approaches missing.",
note: "Twenty to six, raking down the strait, so both towers lay their own shadows out across the water beside the deck. The road does not stop at the anchorages: both ends run onto land, because started at the waterline the deck appeared to float with its approaches missing.",
alt: "The Golden Gate Bridge from above in late afternoon light, international orange, its two towers cross-braced and the main cable sagging to mid-span between them, with the Marin headlands on one side and the Presidio on the other.",
},
night: {
@@ -320,11 +344,11 @@ const SHOTS = [
expect: "State",
place: "California",
caption:
"A third board, and the first that is a state rather than a city: 428 units across at 1,919 metres to the unit, which is the same heightfield code the Bay Area runs at 94. The Central Valley is the flat green band between the Sierra crest and the Coast Ranges; the Basin and Range steps away east of the Sierra, Death Valley is the pale pan below sea level inside it, and the Salton Sea is the water in the south-east corner. Adding a board is a data contribution — coastlines, ranges, roads, landmarks and chapters in one file with no code in it.",
"A third board, and the first that is a state rather than a city: the whole of California, Mexico to Oregon, 554 units across at 1,919 metres to the unit the same heightfield code the Bay Area runs at 94. The Central Valley is the flat green band between the Sierra crest and the Coast Ranges, running the length of the board; the Basin and Range steps away east of the Sierra, Death Valley is the pale pan below sea level inside it, the Salton Sea is the water in the south-east corner, and in the north the Sierra hands over to the Cascade arc with the Klamath knot west of it. Adding a board is a data contribution — coastlines, ranges, roads, landmarks and chapters in one file with no code in it.",
day: {
at: "2026-08-06T17:30:00-07:00",
note: "Half past five, and every ridge in the Basin and Range is throwing its shadow east. That is the hour the steps between them separate at all: at midday the sun is behind the camera and the whole state is one sheet of sand.",
alt: "The state of California rendered from above as a relief board in late afternoon light, the Sierra Nevada and the Coast Ranges either side of the Central Valley, the Basin and Range stepping east into shadow, and the Salton Sea in the south.",
alt: "The whole state of California rendered from above as a relief board in late afternoon light, the Sierra Nevada and the Coast Ranges either side of the Central Valley, the Basin and Range stepping east into shadow, the Salton Sea in the south and the Cascade and Klamath country closing the board in the north.",
},
night: {
at: "2026-08-06T21:10:00-07:00",
@@ -332,6 +356,49 @@ const SHOTS = [
alt: "The same board at night, the land dark and the built-up ground picked out around Los Angeles and San Francisco.",
},
},
{
id: "california-fire",
door: "tera",
city: "california",
chapter: 0,
expect: "State",
/*
* The one shot with a server behind it. See the long note beside
* `serveFeed`: the fire layer is gated on `/health` naming a fire source, so
* a keyless capture cannot photograph it at all, while every real visitor to
* tera.lumbridgecorp.com gets it. The body is a fixture in this repo rather
* than the live feed, so the picture is reproducible at the commit the
* manifest names.
*
* The whole board rather than a close pass over one fire, and that is the
* feature's own argument rather than a framing preference. There are no
* perimeters in the store — every fire in it is a point — so the mark is
* drawn as extent *without* shape, a radial gradient with no edge anywhere,
* because a crisp circle would claim a boundary that would be wrong at every
* point on it. That reads as designed at board scale, where it is a mark on a
* map sized by acreage; close up it is a blur claiming nothing, and the eight
* marks and the list that names them stop being one picture.
*
* The clock is 23 August rather than 6 August, unlike every other frame here,
* because the panel prints the body's own `fetchedAt` and how long ago that
* was. A fixture from the 23rd photographed on the 6th reads "fetched
* 2026-08-23 · just now", which looks like a bug and is one.
*/
feed: "fires",
place: "California",
caption:
"The same board with a feed behind it. Eight fires are drawn here out of seventy-three live rows, and the gate that refused the other sixty-five is the whole feature: under ten acres or no acreage at all is not a small fire, it is nobody having said it is a fire; eighty per cent contained is finished; a prescribed burn is not a wildfire; and a row the collector stopped seeing but never deleted is a ghost. That decision is made once, in server code with no GL context in it, and this layer draws what it is handed and never one thing more. There are no perimeters in the store — every incident is a point — so a mark carries its acreage as extent without shape, a radial gradient with no edge, rather than a circle that would claim a boundary wrong at every point on it. The hot pixels underneath are evidence and not incidents, and the ninety-seven known flare stacks, kilns and landfills among them are counted and not drawn.",
day: {
at: "2026-08-23T09:40:00-07:00",
note: "Twenty to ten in the morning, eight hours after the collector last answered — which the panel says out loud, because a board with no feed behind it and a board with nothing burning on it are indistinguishable without a timestamp. Every mark is sized by its own acreage, from the Timber Fire's 8,665 in Monterey down to a ten-acre start in San Bernardino, and the soft ground under each one is that acreage drawn as extent.",
alt: "The whole state of California as a relief board in morning light with eight orange fire marks on it, each sitting in a soft glow sized to its acreage, and a panel down the left listing the incidents by name, acreage, containment and county.",
},
night: {
at: "2026-08-23T21:10:00-07:00",
note: "Ten past nine, and the marks are the only warm thing left on a board whose relief has gone. Nothing here is a THREE light — a wildfire is the most tempting exception in the codebase and a point light is one line — so the glow is emission in an over-range colour taken through ACES, which is both cheaper than a rig and a better picture of it.",
alt: "The same board after dark, the land unlit and the eight fire marks glowing orange against it, with the lit ground of the two city clusters and the same incident list down the left.",
},
},
{
id: "freeway",
door: "tera",
@@ -376,8 +443,17 @@ const SHOTS = [
"The same airport kit, on the second city pack, producing an airport nobody would mistake for the first: four parallels, two either side of the terminal horseshoe, instead of two crossing pairs. The property is a cardinal rectangle — Westchester Parkway north, Imperial Highway south, Aviation Boulevard east, the dunes above Dockweiler west — with the runway pattern set about seven degrees off it, and that disagreement between the fence and the pavement is the thing that reads as LAX from altitude.",
day: {
at: "2026-08-06T17:50:00-07:00",
note: "Ten to six, with the basin's grid running out to the San Gabriels behind and the Santa Monica Bay in front. The traffic overhead is the real traffic: registration and type come off the same feed.",
alt: "Los Angeles International Airport from above in late afternoon light: four parallel runways either side of a horseshoe of terminals, the street grid of the basin all around it and the Pacific beyond.",
/*
* This note used to end "the traffic overhead is the real traffic:
* registration and type come off the same feed", and that sentence cannot
* be true of this picture. Every frame here but `california-fire` is shot
* keyless — no `/health`, so `access.feeds` is `null`, so `TrafficSource`
* is the bundled simulator and not ADS-B. The deployed site does have the
* feed; this photograph does not, and a caption may not borrow a fact from
* a deployment the shutter never opened on.
*/
note: "Ten to six, and the disagreement in this frame is the subject: the property is a cardinal rectangle, the pavement inside it is set about seven degrees off, and the basin's street grid outside the fence goes back to the cardinal. That is what reads as LAX from altitude — long before any building does.",
alt: "Los Angeles International Airport from above in late afternoon light: four parallel runways either side of a horseshoe of terminals, set at a slight angle inside a rectangular property, with the street grid of the basin all around it and the Pacific beyond.",
},
night: {
at: "2026-08-06T21:20:00-07:00",
@@ -385,6 +461,44 @@ const SHOTS = [
alt: "The same airport at night, its dark field surrounded by the lights of the Los Angeles basin running to the horizon.",
},
},
{
id: "harbour",
door: "tera",
city: "socal",
chapter: 5,
expect: "Harbour",
/*
* The chapter's own stance is a wide plan of the whole bight from about
* forty units up, which puts Terminal Island in the middle of a lot of
* water and makes the cranes a fringe. The subject is the cranes, so this
* comes down three notches to about the closest standoff the board allows
* and drags forty-five degrees of azimuth to look across the island rather
* than down at it — which is what stands the gantries up against the water
* instead of laying them flat on the yard.
*
* The seek is a point in the West Basin rather than the middle of the
* island, for `bay-bridge`'s reason: the panel eats the left 310 px, so the
* midpoint of *what has to fit* is west and north of the midpoint of the
* thing. From here the Vincent Thomas crosses the Main Channel in the lower
* left with the channel running out under it, all four of the Los Angeles
* crane rails are in frame at their four different bearings, and San Pedro
* closes the bottom of the picture so the harbour has a town on it.
*/
aim: { lat: 33.7405, lng: -118.2565, zoom: -3, azimuth: 45, elevation: 4 },
place: "San Pedro Bay",
caption:
"Two ports around one basin — Los Angeles and Long Beach — behind thirteen kilometres of federal breakwater. The gantry crane is the hero of this kit and vertical exaggeration is why: at 3.4× on a board of 390 metres to the scene unit a 130 m crane stands 1.13 units tall while an entire 400 m container ship is 1.02 units long, so the crane is the taller object and it is what makes a port read as a port from altitude. All fifty-six gantries on the board are one InstancedMesh with a boom angle per instance — lowered over a berth reads as working, raised to near-vertical reads as stood down — and the four rails are set at four different bearings, because a port where every boom points the same way reads as a fence. The yards are the opposite call: a forty-foot box is one and a half pixels from the closest pose this board offers, so a terminal is one painted quad indexing one canvas atlas rather than ten thousand instanced boxes, banded at exactly the empty share the data claims.",
day: {
at: "2026-08-06T17:50:00-07:00",
note: "Ten to six, low enough that every boom throws its own shadow back across the yard it stands on and the four rails read as four different bearings. The hulls alongside are modelled from this board's own berths and channels rather than observed — anonymous, no names and no MMSIs — because the live AIS feed is not configured and a panel that said otherwise would be the only dishonest thing in the frame.",
alt: "The Los Angeles and Long Beach port complex from above in late afternoon light: container terminals on Terminal Island with rows of white gantry cranes along their quays, stacked container yards beside them, ships lying alongside and one under way in the Main Channel, the green Vincent Thomas Bridge crossing the channel and the breakwater running across the outer harbour.",
},
night: {
at: "2026-08-06T21:20:00-07:00",
note: "Twenty past nine, and nothing in a container terminal has a window to switch on. What marks one from the air after dark is the red obstruction light at the apex of every gantry — one per crane, the same fixture and the same red the bridge tower heads carry, drawn as points in the board's one additive cloud rather than as fifty-six lights nobody could afford.",
alt: "The same harbour after dark: the terminals reading as pale decks against black water, a red light standing at the top of each gantry crane, the Vincent Thomas Bridge picked out by the lamps down its deck, and the lit ground of San Pedro and Long Beach around the basin.",
},
},
/*
* ---- The three that take a body ----------------------------------------
*
@@ -499,13 +613,20 @@ const SHOTS = [
"The third body and the only one with a published route: the distributed-electric aircraft starts at Los Angeles on a three-waypoint corridor to San Francisco, and Fly takes it off its own autopilot and gives you the yoke. The HUD is in knots, metres and watt-hours because that is what an aircraft has, and the battery is the reason the environment is not a pure altitude race — Arena's california-flight scores waypoint progress against energy, and climbing is the cheapest way to farm a number that is not the goal.",
day: {
at: "2026-08-06T18:00:00-07:00",
note: "Six in the evening, five seconds off the Los Angeles waypoint on a heading of 320°, with the coast ranges throwing across the corridor and the Pacific past the wingtip. The altitude on the readout is above sea level, not above the ridge under it — the controller keeps both, and the one that terminates an episode is the clearance.",
alt: "A distributed-electric aircraft seen from behind and above, flying north-west over green coastal mountains in late afternoon light with a freeway threading the valley below and the Pacific beyond, and a heads-up strip along the top reading its airspeed, altitude, flight mode and battery.",
/*
* This note used to end "and the Pacific past the wingtip", and the sea has
* since gone out of the frame — not because the aircraft moved but because
* the air did. Fog now follows camera altitude, so at 1,413 m the far ridges
* grey out in the order they recede and the ocean behind them is inside the
* haze rather than beyond it. Rewritten to the frame that is delivered.
*/
note: "Six in the evening, five seconds off the Los Angeles waypoint on a heading of 320°, low over the Coast Ranges with the corridor threading the valley underneath. The ridges grey out in the order they recede rather than all at once, because the fog follows the camera's own altitude now — aerial perspective that only exists once something is flying through it. The altitude on the readout is above sea level, not above the ridge under it: the controller keeps both, and the one that terminates an episode is the clearance.",
alt: "A distributed-electric aircraft seen from behind and above, flying north-west over green coastal mountains in late afternoon light, a freeway threading the valley below and the far ridges fading one behind another into haze, with a heads-up strip along the top reading its airspeed, altitude, flight mode and battery.",
},
night: {
at: "2026-08-06T21:20:00-07:00",
note: "Twenty past nine, and three things are carrying a frame that has no daylight in it: the basin's lit ground in the corner, the moon's path broken across the water, and the aircraft's own lights — red on the port wingtip, green on the starboard, white strobes and a tail light — which are unlit materials, so they are the only part of this picture that does not care what the sun is doing.",
alt: "The same aircraft at night over the lights of the Los Angeles basin, the same heads-up strip along the top.",
note: "Twenty past nine, and there is no daylight anywhere in this frame. Two things carry it. The aircraft's own lights — red on the port wingtip, green on the starboard, white strobes out along the leading edge and a light under the tail — are unlit materials, so they are the only part of this picture that does not care what the sun is doing. And the corridor under the wing, where the road's edge lines and cat's eyes are now the brightest thing left on the ground.",
alt: "The same aircraft at night over dark coastal mountains, its red, green and white navigation lights the brightest thing in the air, the freeway below picked out by its own lane markings, and the same heads-up strip along the top.",
},
},
{
@@ -1119,6 +1240,110 @@ async function chapter(page, index, expect, { click = false } = {}) {
}
}
/**
* ---- The one shot that needs a server behind it ----------------------------
*
* Every other frame here is the **keyless** experience, and that is the right
* default rather than a limitation. `harness.mjs` serves `dist/` and nothing
* else, `/api/v1/health` 404s, `access.ts` resolves with `feeds: null`, and what
* is photographed is what CONTRACT §0's stranger sees on a box with no API.
* Almost all of this product is that picture.
*
* Fire is the exception, and it is not a small one. `drawsFire()` in `main.ts`
* gates the entire layer on `access.feeds?.fires === true`, which comes from
* `/health`'s `sources` block — so a keyless capture cannot photograph it at
* all, while `tera.lumbridgecorp.com` answers `"fires":"cloud1"` and every
* anonymous visitor to the deployed site does get it. Without this the site
* would have shipped an imagery set in which the most visible thing the engine
* gained this round did not exist, and the pictures would have looked entirely
* deliberate while doing it.
*
* ### A fixture in this repo, not a proxy to the live feed
*
* `scripts/look.mjs --api` points a browser at a running server, which is the
* right tool for judging a layer against real data. It is the wrong tool for
* *delivering* a picture. This script's whole claim is that anything on the site
* can be reproduced by re-running it at the commit the manifest names, and a
* frame whose content is whatever California is doing today does not have that
* property: it re-renders with different fires, a different count and a
* different list under an unchanged caption, and nothing says so.
*
* So `fires-2026-08-23.json` is the verbatim body `GET /api/v1/fires` returned
* on 23 August 2026, kept whole — seventy-three incident rows, three hundred and
* sixteen hot pixels, both attribution lines, nothing trimmed. Trimming it would
* have been the tempting thing and would have quietly falsified the panel:
* `promote()` in `server/fires.ts` counts the rows it *refuses* and the panel
* prints that number, so a fixture with the refused rows removed would draw the
* same eight fires under a sentence claiming a gate that never ran.
*
* ### The health body says as little as it can
*
* Synthetic, and deliberately minimal. Every source but `fires` is `"none"`, so
* exactly one thing changes in the frame and nothing else in the picture becomes
* a claim about a feed this capture does not have. `auth.mode` is `"none"`,
* which `access.ts` resolves to `member` — the self-host-with-the-door-open
* case — and that keeps `#tier-adds` out of the shot. That card is the sign-in
* offer, it is shown only to `anon`, and it is **not** in `FURNITURE.CLUTTER`:
* it would have arrived in this frame silently, the way `#mode-dock` arrived in
* the share card and stayed for a fortnight.
*
* Anything under `/api/v1` that is not these two answers 404, which is the state
* every watcher in the app is already written for.
*/
const FEEDS = {
fires: {
fixture: "fires-2026-08-23.json",
route: "/api/v1/fires",
sources: {
weather: "none",
flights: "none",
satellites: "none",
markers: "none",
devices: "none",
fires: "cloud1",
radar: "none",
birds: "none",
},
},
};
/**
* Put one fixture behind `/api/v1`, before the page is navigated.
*
* Installed per page rather than per context, so a shot without `feed` cannot
* inherit it — `shoot()` opens a fresh page for every frame, which makes the
* isolation free.
*
* `route.fulfill` rather than a redirect to a second server, for `look.mjs`'s
* reason: the response is same-origin as far as the page is concerned, so no
* CORS header has to exist anywhere for a screenshot to work.
*/
async function serveFeed(page, name) {
const feed = FEEDS[name];
if (feed === undefined) throw new Error(`no such feed fixture: ${name}`);
const body = await readFile(join(HERE, feed.fixture), "utf8");
const health = JSON.stringify({
ok: true,
service: "tera-api",
version: `fixture:${feed.fixture}`,
uptimeSeconds: 0,
sources: feed.sources,
auth: { mode: "none" },
regions: [],
degraded: [],
});
await page.route("**/api/v1/**", async (route) => {
const path = new URL(route.request().url()).pathname;
const answer = path.endsWith("/health") ? health : path === feed.route ? body : null;
await route.fulfill(
answer === null
? { status: 404, contentType: "application/json", body: "{}" }
: { status: 200, contentType: "application/json", body: answer },
);
});
}
async function shoot(browser, shot, frame) {
const host = shot.door === "office" ? "office.lumbridgecorp.com" : "tera.lumbridgecorp.com";
const query = shot.city ? `?city=${shot.city}` : "";
@@ -1144,6 +1369,9 @@ async function shoot(browser, shot, frame) {
page.on("pageerror", (e) => problems.push(String(e)));
try {
await page.addInitScript(clockShim(frame.at));
// Before the navigation: `resolveAccess()` asks `/health` during boot, and a
// route installed after `goto` would miss it and photograph a keyless board.
if (shot.feed) await serveFeed(page, shot.feed);
await page.goto(`http://${host}:5210/${query}`, { waitUntil: "networkidle" });
/**
* Wait for the app to say it is up rather than for a number of seconds.
@@ -1297,7 +1525,12 @@ if (listOnly) {
const played = shot.play
? ` playing ${shot.play.mode} → HUD "${shot.play.expect}", ${waited}`
: "";
console.log(` ${shot.id.padEnd(20)} ${where.padEnd(22)} chapter ${shot.chapter} "${shot.expect}"${aimed}${played}`);
// Same argument as `play` above: a shot that is *not* the keyless
// experience has to say so in the plan, because "this frame had a server
// behind it" is the single fact that most changes what it is a photograph
// of, and `refresh --dry-run` is where somebody reads the plan.
const fed = shot.feed ? ` against the ${shot.feed} fixture ${FEEDS[shot.feed].fixture}` : "";
console.log(` ${shot.id.padEnd(20)} ${where.padEnd(22)} chapter ${shot.chapter} "${shot.expect}"${aimed}${played}${fed}`);
for (const variant of VARIANTS) console.log(` ${variant.padEnd(6)} ${shot[variant].at}`);
}
process.exit(0);
+59 -1
View File
@@ -40,6 +40,7 @@ import * as THREE from "three";
import { nightFactor } from "./atmosphere.ts";
import { FACADE_ATTRIBUTE } from "./blocks.ts";
import { bridgeLights } from "./bridges.ts";
import { craneLights } from "./ports.ts";
import { seededRandom, type World } from "./world.ts";
export interface NightLightsOptions {
@@ -50,6 +51,8 @@ export interface NightLightsOptions {
streetLamps?: boolean;
/** Lamps down the deck of every crossing, and lights at the tower heads. On by default. */
bridgeLamps?: boolean;
/** Obstruction lights on the container gantries. On by default. */
craneLamps?: boolean;
/** Metres between street lamps. */
lampSpacingM?: number;
/**
@@ -194,6 +197,25 @@ const HEAD_LIGHT_COLOR = 0xff3b30;
const BRIDGE_LAMP_SPREAD = 2.4;
const HEAD_LIGHT_SPREAD = 3.4;
/**
* Glow radius of a gantry's obstruction light, in scene units.
*
* Not a multiple of anything, unlike every other size in this file, and the
* reason is that there is nothing to take a multiple of: a bridge lamp is scaled
* off its own deck's half-width and a crane has no such length. So it is picked
* against the *rail spacing* instead. Pier 400's eighteen gantries stand over
* 1.4 km, which is 0.20 scene units apart on this board, and 0.17 is the largest
* sprite that still leaves eighteen separate lights rather than one bar.
*
* It was 0.09 first, chosen off the 0.115 rail gauge, and the frame said no: at
* 0.09 the light is smaller than the crane it is on and disappears against
* `ports.ts`'s own cream emissive, which is already lighting the gantry it was
* meant to mark. Half a bridge head light (0.306) is about right — a gantry is a
* smaller object than a 227 m tower and its light should read smaller, but not
* invisible.
*/
const CRANE_LIGHT_SIZE = 0.17;
/**
* When the lamps come on, in degrees of solar elevation.
*
@@ -248,7 +270,14 @@ export function createNightLights(options: NightLightsOptions): NightLights {
const crossings = (options.bridgeLamps ?? true) ? buildBridgeLamps(world) : [];
for (const cloud of crossings) group.add(cloud.points);
const clouds = [...(lamps ? [lamps] : []), ...crossings];
const gantries = (options.craneLamps ?? true) ? buildCraneLamps(world) : null;
if (gantries) group.add(gantries.points);
const clouds = [
...(lamps ? [lamps] : []),
...crossings,
...(gantries ? [gantries] : []),
];
let strength = 0;
@@ -554,6 +583,35 @@ function buildBridgeLamps(world: World): Lamps[] {
return out;
}
/**
* The container terminals' obstruction lights: one red point per gantry apex.
*
* `ports.ts` decides where they are; this decides what they look like, which is
* the same seam `buildBridgeLamps` sits on. They deliberately share
* `HEAD_LIGHT_COLOR` with the bridge tower heads — the fixture on a 143 m crane
* and the fixture on a 227 m tower are the same fixture doing the same job, and
* a board with two different reds on it for that reason would be a board that
* had stopped meaning anything by its reds.
*
* They do not share the *cloud*, because a `PointsMaterial` carries one size for
* the whole of one and the two want different ones: a bridge head is sized off
* the deck it stands over, and a gantry has no deck. `CRANE_LIGHT_SIZE` is in
* scene units and is the one number here that is not derived, because a crane
* row's spacing is authored in the pack rather than implied by anything this
* module can see. 0.09 is a shade under the 0.115 rail gauge, so a row of
* eighteen at Pier 400 reads as eighteen lights rather than as one bar.
*
* The whole board is fifty-six points in one draw call. If a pack ever declares
* a terminal with a thousand gantries in it, it is still one draw call.
*/
function buildCraneLamps(world: World): Lamps | null {
const heads: number[] = [];
for (const port of world.city.ports ?? []) {
for (const value of craneLights(world, port).heads) heads.push(value);
}
return glowCloud("craneheads", heads, HEAD_LIGHT_COLOR, CRANE_LIGHT_SIZE);
}
// ---- The night level, for anything that is not part of the city -----------
/**
+234 -49
View File
@@ -850,6 +850,32 @@ const CRANE_OUTREACH_FLOOR = 0.26;
/** Degrees the boom stands at when it is raised. A stowed gantry is near vertical. */
const CRANE_BOOM_IDLE_DEG = 74;
/**
* The A-frame mast, as a fraction of the portal height, and how far back over
* the portal its apex sits.
*
* Both are read off real gantries and off the two crane candidates the owner
* approved out of the asset factory, which independently agreed to within a few
* per cent: a hinge at 58 m under an apex at 99104 m is a mast 0.710.78 of the
* portal again, with the apex 714 m — roughly a third of a rail gauge — behind
* the waterside rail. 0.62 is the conservative end of that band, chosen because
* this board's packs already author a gantry taller than a real one (82 m at
* Pier 400 against a real 5560), and taking the full 0.75 on top of an
* already-tall portal would put an apex at 143 m where nothing has ever stood.
*
* The setback is not decoration: the forestay runs from the apex to the boom,
* and an apex level with or ahead of the hinge would draw a stay that pulls the
* boom *down*.
*/
const CRANE_MAST_RISE = 0.62;
const CRANE_APEX_SETBACK = 0.4;
/** Where the truck lane's head beam crosses the portal, as a fraction of it. */
const CRANE_PORTAL_BEAM = 0.44;
/** Backreach length, as a fraction of outreach, measured from the landside rail. */
const CRANE_BACKREACH = 0.55;
/** Every coordinate a port's graded plate has to be at least as high as. */
function plateSamples(port: Port): LatLng[] {
const samples: LatLng[] = [];
@@ -1101,15 +1127,45 @@ function buildPort(
/**
* Every box of every gantry on one rail, as instance matrices.
*
* **The instanced geometry is a unit box and each crane contributes five of
* **The instanced geometry is a unit box and each crane contributes eleven of
* them.** That is what makes a per-instance boom angle possible inside a single
* `InstancedMesh`: an instance matrix can rotate a boom about its pivot, but it
* cannot rotate one limb of a rigid crane geometry relative to the rest. Five
* boxes, sixty triangles, one draw call for every gantry on the board, and the
* boom angle is free. A `Group` per crane — the obvious shape — would be five
* draws times fifty-six gantries against a mobile cap with thirty spare, which
* is the ~34-draw suspension bridge and the twelve unmergeable freeways all over
* again. `ports.test.ts` asserts no crane is a `Group`.
* cannot rotate one limb of a rigid crane geometry relative to the rest. Boxes,
* one draw call for every gantry on the board, and the boom angle is free. A
* `Group` per crane — the obvious shape — would be eleven draws times
* fifty-six gantries against a mobile cap with thirty spare, which is the
* ~34-draw suspension bridge and the twelve unmergeable freeways all over again.
* `ports.test.ts` asserts no crane is a `Group`.
*
* ### Why eleven and not five
*
* It was five — two legs, a portal beam, a boom and a backreach — and from
* altitude fifty-six of those read as **fifty-six crosses**. Two coincident
* verticals, one horizontal bar through them, nothing above the bar. The
* silhouette a container terminal is recognised by has one more move in it than
* that, and it is the one thing the five-box crane had none of: **the A-frame
* mast standing above the girder, with the boom slung from it on stays.** A
* ship-to-shore gantry's hinge is around 58 m and its apex around 100 — the
* mast is two thirds of the portal again on top of it — so the tallest part of
* a *working* crane is not the boom at all, it is the mast, and the raised boom
* of a parked one clears its own apex by only fifteen to twenty-five per cent.
* Without the mast a working crane has no height above its own deck line and a
* whole berth flattens into a picket fence.
*
* The other six boxes are what stop the mast reading as a flagpole: a sill
* beam so the legs stand on something, a truck-lane beam so the two legs read
* as one portal frame, an apex cap, the machinery house over the tail, and the
* two stays that make the boom look *held*. Where the parts came from:
* geometry approved out of the asset factory (`sts-crane-…-eaff8b` and
* `…-df0f72`, both upvoted) was measured for its proportions and rebuilt here,
* rather than pasted — the factory's cranes are three hundred lattice members
* and five materials apiece, which is the right answer for a hero shot of one
* crane and the wrong one for fifty-six of them 1.1 units tall.
*
* Cost, which is the whole reason this is a matrix list and not a model: 132
* triangles a gantry against 60, so 7,392 for San Pedro Bay against 3,360 —
* **+4,032 on a board that draws 1.42 million**, and still exactly one draw
* call. The census in `ports.test.ts` is the ledger.
*
* ### The board stretches height and not width, so the boom is composed
*
@@ -1117,7 +1173,10 @@ function buildPort(
* not, so a 70 m boom is 0.18 units lying flat and 0.61 units standing up. Its
* apparent length and apparent angle are therefore computed from the two
* components separately — never from one length and a rotation — or a raised
* boom would be three times too short.
* boom would be three times too short. Everything inclined below goes through
* `strut`, which takes two points in the (distance along the boom axis, height)
* plane and is therefore composed by construction: there is no way to hand it a
* length and an angle and get the exaggeration wrong.
*/
function pushCraneMatrices(
world: World,
@@ -1162,60 +1221,186 @@ function pushCraneMatrices(
const [rx, rz] = world.project(station.at[0], station.at[1]);
const base = plate;
const top = base + portal;
// The girder line: boom, backreach and mast foot all meet here, one member
// above the leg tops. Everything above the portal is measured from it.
const girderY = top + member * 1.4;
// 1 & 2: the legs. The waterside pair stands on the rail; the landside pair
// is a gauge back from it, away from the water.
/**
* A part placed by where its two ends are, in the vertical plane the boom
* lies in.
*
* `s` is signed distance along the boom's bearing from the waterside rail —
* positive over the water, negative over the yard — and `y` is a scene
* height that has already been through `world.metres`. Both ends given, the
* length and the tilt fall out, which is the point: the exaggeration bug
* this module's header warns about is only reachable by *starting* from a
* length and an angle, and there is no way to do that here.
*/
const strut = (
s0: number,
y0: number,
s1: number,
y1: number,
thick: number,
wide = thick,
) => {
const ds = s1 - s0;
const dy = y1 - y0;
const length = Math.hypot(ds, dy);
if (length <= 0) return;
const s = s0 + ds / 2;
push(
rx + boomX * s,
y0 + dy / 2,
rz + boomZ * s,
length,
thick,
wide,
Math.atan2(dy, ds),
);
};
/** A level member, from `s0` to `s1` at one height. */
const beam = (s0: number, s1: number, y: number, thick: number, wide: number) =>
strut(s0, y, s1, y, thick, wide);
// ---- The portal: two legs, a sill under them, a frame between them ----
//
// Still two legs and not four. A real gantry has a leg at each corner of a
// 27 × 30 m footprint, and at SoCal's 391 m to the unit that 14 m along the
// quay is 0.036 units while the member floor that keeps a leg visible at
// all is 0.032 — the pair would overlap by ninety per cent and cost 56
// extra boxes to draw one slightly fatter leg. The board's own scale is the
// argument, not the triangle count.
push(rx, base + portal / 2, rz, member, portal, member);
push(
rx - boomX * gauge,
base + portal / 2,
rz - boomZ * gauge,
member,
portal,
member,
);
push(rx - boomX * gauge, base + portal / 2, rz - boomZ * gauge, member, portal, member);
// 3: the portal beam and machinery house, spanning the gauge at the top.
push(
rx - boomX * gauge * 0.5,
top + member * 0.7,
rz - boomZ * gauge * 0.5,
gauge + member,
member * 1.4,
member * 1.15,
);
// The sill. Without it the legs end at the quay with nothing under them and
// a crane appears to be standing on two pins.
beam(member * 0.4, -gauge - member * 0.4, base + member * 0.45, member * 0.5, member * 1.15);
// 4: the boom, pivoting at the top of the waterside leg.
// The truck lane's head beam. A gantry straddles a road, and this is the
// thing trucks pass under; it is also the only rung the two legs get, which
// is what makes them read as one frame rather than as two posts that happen
// to stand near each other.
//
// An X of diagonals above it was built, photographed and cut. The bay it
// would brace is 0.115 units wide and 0.38 tall on this board, so the two
// diagonals come out within a few degrees of vertical, land inside the legs'
// own silhouette, and add a lump at mid-leg rather than an X. Two boxes a
// gantry for a smudge is the wrong trade, and *at this scale* is the whole
// reason — the same X at the factory's framing is one of the best things
// about the crane it came from.
beam(0, -gauge, base + portal * CRANE_PORTAL_BEAM, member * 0.62, member * 0.9);
// ---- The girder: boom forward, backreach aft, in one line ----
//
// The backreach starts at the hinge rather than a full gauge behind it, so
// boom and backreach are one unbroken girder over the portal. They used to
// leave a gap the width of the gauge with the portal beam sitting below it,
// which from the air read as a bar floating clear of a second bar.
const angle = station.idle ? CRANE_BOOM_IDLE_DEG * DEG : 0;
const reachX = outreach * Math.cos(angle);
// Vertical is the exaggerated axis; horizontal is not. Composed, not rotated.
const reachY = world.metres(crane.outreach * Math.sin(angle));
const boomLength = Math.hypot(reachX, reachY);
const boomTilt = Math.atan2(reachY, reachX);
push(
rx + boomX * (reachX / 2),
top + member * 1.4 + reachY / 2,
rz + boomZ * (reachX / 2),
boomLength,
member * 0.85,
member * 0.85,
boomTilt,
strut(0, girderY, reachX, girderY + reachY, member * 0.85);
const backLength = outreach * CRANE_BACKREACH;
const tailS = -(gauge + backLength);
beam(0, tailS, girderY, member * 0.75, member * 0.75);
// The machinery house, over the tail. It is the one heavy mass on a gantry
// and it is what tells you which end is the land.
beam(
-gauge - backLength * 0.2,
tailS + backLength * 0.1,
girderY + member * 1.05,
member * 1.6,
member * 1.45,
);
// 5: the backreach, always down over the yard. It is what stops a raised
// boom reading as a flagpole and it is where the boxes land.
const backLength = outreach * 0.55;
push(
rx - boomX * (gauge + backLength / 2),
top + member * 1.4,
rz - boomZ * (gauge + backLength / 2),
backLength,
member * 0.75,
member * 0.75,
);
// ---- The mast, and the two stays that make the boom look held ----
//
// See the header. The apex is `CRANE_MAST_RISE` of the portal height above
// the girder and set back over the portal, which is where a real one sits:
// it has to be behind the hinge for the forestay to pull the boom *up*.
const apexS = -gauge * CRANE_APEX_SETBACK;
// Through `craneApexY`, not `girderY + portal * CRANE_MAST_RISE` written out
// again here: `craneLights` hangs the obstruction light off the same number
// and a second copy of it is a row of red dots hovering over the cranes.
const apexY = craneApexY(world, crane, base);
strut(-gauge * 0.12, girderY, apexS, apexY, member * 0.72);
beam(apexS - member * 0.7, apexS + member * 0.7, apexY + member * 0.25, member * 0.45, member * 0.8);
// Forestay to the boom and backstay to the tail. Drawn at a third of a
// member, which is several times a real pendant's diameter and the smallest
// thing that survives a 391 m scene unit.
strut(apexS, apexY, reachX * 0.6, girderY + reachY * 0.6, member * 0.32);
strut(apexS, apexY, tailS * 0.94, girderY - member * 0.15, member * 0.32);
}
}
/**
* Where a terminal's obstruction lights go, as bare positions.
*
* The twin of `bridges.ts`'s `bridgeLights`, and for the same reason. San Pedro
* Bay at 21:35 is a graded plate the colour of the water it sticks out of, with
* fifty-six pale gantries on it that the moon barely finds; the yards come up on
* their own emissive and the cranes do not come up at all. What actually marks a
* container terminal from the air after dark is the row of **red obstruction
* lights on the crane apexes** — one per gantry, at the top of the A-frame,
* which is what CFR 14 §77 asks for on anything this tall near a channel.
*
* **These are positions, not lights.** CONTRACT.md §4 gives `Atmosphere` the
* only rig there is, and fifty-six point lights is not a slow version of this,
* it is an impossible one. `nightlights.ts` turns what comes back into one
* additive point cloud in one draw call, exactly as it already does for the
* bridge tower heads — and it reuses that cloud's own red, because a light on a
* 143 m crane and a light on a 227 m tower are the same fixture doing the same
* job and there is no reason for the board to have two reds in it.
*
* A pure function of the pack, so it is identical across frames and reloads, and
* it recomputes the apex from the same constants `pushCraneMatrices` uses rather
* than reading them back off the matrices — a lamp derived from a second,
* separately-maintained idea of where the top of a crane is is exactly how a row
* of lights ends up floating beside the cranes instead of on them. Both call the
* same `craneApexY`.
*/
export function craneLights(world: World, port: Port): { heads: number[] } {
const heads: number[] = [];
const plate = plateHeight(world, port);
const gauge = Math.max(CRANE_RAIL_GAUGE_M / world.metresPerUnit, CRANE_GAUGE_FLOOR);
const member = Math.max(CRANE_MEMBER_M / world.metresPerUnit, CRANE_MEMBER_FLOOR);
const setback = gauge * CRANE_APEX_SETBACK;
for (const crane of port.cranes ?? []) {
// Clear of the apex cap, for the reason `bridges.ts` lifts its head lights
// clear of the saddle: a sprite drawn at exactly the top of the thing it
// marks is half-eaten by that thing's own depth, and the half it loses is
// the bottom half — so it reads as dimmer *and* as sitting high.
const y = craneApexY(world, crane, plate) + member;
// Same decomposition `pushCraneMatrices` uses: the apex is a setback back
// along the boom's bearing from the waterside rail, and `boomZ` is `-north`.
const [east, north] = bearingVector(crane.bearing);
for (const station of craneStations(crane)) {
const [rx, rz] = world.project(station.at[0], station.at[1]);
heads.push(rx - east * setback, y, rz + north * setback);
}
}
return { heads };
}
/**
* The height of a gantry's mast apex, in scene units.
*
* The one place that arithmetic lives. `pushCraneMatrices` draws the mast to it
* and `craneLights` hangs the obstruction light off it; a second copy of
* `girder + portal * rise` in either would be a light hovering above or inside
* the crane it is supposed to be on, and nothing but a screenshot would catch it.
*/
function craneApexY(world: World, crane: Crane, plate: number): number {
const member = Math.max(CRANE_MEMBER_M / world.metresPerUnit, CRANE_MEMBER_FLOOR);
const portal = world.metres(crane.height);
return plate + portal + member * 1.4 + portal * CRANE_MAST_RISE;
}
/**
* Every port on a board, as one small set of merged meshes.
*
+247 -97
View File
@@ -38,21 +38,36 @@
* you can see them" is a reasonable-sounding change that would quietly break the
* relationship this layer exists inside.
*
* ### One geometry, one mesh, and the seam for better hulls
* ### Two solids, and the seam that lets there be a third
*
* Every ship on a board is the same ~40-triangle solid — slab, raked bow, aft
* house, funnel — with **length, beam and depth as per-instance scale**. A 30 m
* tug and a 400 m box ship are the same forty triangles at thirteen times the
* size. Deck detail is a band of a canvas atlas rather than geometry, so a hatch
* run and a pipe rack are the same six triangles with different pixels.
* Every merchant ship on a board is the same 40-triangle solid — slab, raked
* bow, aft house, funnel — with **length, beam and depth as per-instance
* scale**, so a 90 m coaster and a 400 m box ship are the same forty triangles
* at four times the size. Deck detail is a band of a canvas atlas rather than
* geometry, so a hatch run and a pipe rack are the same six triangles with
* different pixels.
*
* `hullShape(kind)` is the seam for the better geometry the owner is modelling
* separately: it maps a `VesselKind` to a shape id, every kind currently maps to
* `"generic"`, and a real container-ship mesh arrives as a new shape id plus a
* case in `hullGeometry`. The layer builds one `InstancedMesh` per shape that
* has hulls in it, so the draw count is a function of **how many kinds of hull
* geometry exist**, never of how many ships are on the board — the same property
* `airports.ts` gets by merging buckets across airports rather than per airport.
* The **tug** is the exception and now has its own 64-triangle solid, because
* the merchant hull is not a small ship, it is a *differently shaped* ship: a
* cargo hull carries its block aft over the screw and a tug carries it forward
* and nearly full beam, with the low towing deck astern. Drawn at a tug's size
* the merchant solid produced a thirty-metre container ship, and that is the one
* hull on the board whose proportions were wrong rather than merely simple. See
* `tugHull`, whose numbers come off the component the owner voted up rather than
* out of a catalogue.
*
* `hullShape(kind)` is the seam and the arms are how better geometry lands one
* kind at a time: it maps a `VesselKind` to a shape id, and a real container-ship
* mesh arrives as a new id in `HULL_SHAPES` plus a case in `hullGeometry`. The
* layer builds one `InstancedMesh` per shape and adds it to the group only when
* it has hulls in it, so the draw count is a function of **how many kinds of hull
* geometry are afloat**, never of how many ships are on the board — the same
* property `airports.ts` gets by merging buckets across airports rather than per
* airport. A board with no tug on it still costs one draw.
*
* A shape is a *geometry* and never a material. Two geometries are two draws
* only on a board carrying both; two materials would be two draws on every board
* for ever, so hue stays an `instanceColor` tint over the one shared atlas.
*
* ### What this layer will not do
*
@@ -241,18 +256,19 @@ const WAKE_COLOR_DAY = 0xe8f0f4;
* between an enum the factory switches on and a hardcoded builder, and it is why
* this function exists despite currently having one answer.
*/
export type HullShape = "generic";
export type HullShape = "generic" | "tug";
export function hullShape(kind: VesselKind): HullShape {
// Every arm answers the same thing today and the switch is still written out,
// because the arms are where the better geometry lands one kind at a time. An
// `if` here would have to be replaced; these are added to.
// The arms are where better geometry lands one kind at a time, which is why
// this is a switch and not an `if`: an `if` would have to be replaced, and
// these are added to. `tug` is the first arm to leave home.
switch (kind) {
case "tug":
return "tug";
case "container":
case "tanker":
case "bulk":
case "vehicle-carrier":
case "tug":
case "ferry":
case "fishing":
case "other":
@@ -260,8 +276,17 @@ export function hullShape(kind: VesselKind): HullShape {
}
}
/** Every shape a board may need a mesh for. One, today. */
export const HULL_SHAPES: readonly HullShape[] = ["generic"];
/**
* Every shape a board may need a mesh for. Two.
*
* Two shapes is **two draw calls at most and one on a board with no tug**: the
* layer builds an `InstancedMesh` per shape up front and adds it to the group
* only when it has hulls in it, so the cost is a function of how many kinds of
* hull *geometry* exist and never of how many ships are floating. A third shape
* — the container ship the owner is modelling — is one more entry here, one more
* case in `hullGeometry`, and nothing else.
*/
export const HULL_SHAPES: readonly HullShape[] = ["generic", "tug"];
/**
* Draught as a fraction of length, per kind.
@@ -301,7 +326,17 @@ const FREEBOARD_RATIO: Readonly<Record<VesselKind, number>> = {
tanker: 0.05,
bulk: 0.055,
"vehicle-carrier": 0.14,
tug: 0.17,
/**
* A tug is the one hull whose freeboard is *small*, and 0.17 was wrong.
*
* At 0.17 a 32 m tug stood 5.4 m out of the water, which is a coaster's
* topside on a boat you step down onto from the quay; the real number is under
* three. It mattered once `tugHull` gave the kind its own solid, because the
* whole superstructure is scaled by keel-to-deck depth: an over-deep hull made
* the wheelhouse tower, and the silhouette the owner voted for is a low boat
* under a big house rather than a small ship.
*/
tug: 0.09,
ferry: 0.11,
fishing: 0.16,
other: 0.07,
@@ -341,40 +376,101 @@ export function hullGeometry(shape: HullShape = "generic"): THREE.BufferGeometry
switch (shape) {
case "generic":
return genericHull();
case "tug":
return tugHull();
}
}
/** The one solid this build has. See `hullGeometry` for what replaces it. */
function genericHull(): THREE.BufferGeometry {
/**
* Atlas bands, shared by every hull solid.
*
* `v` runs 0 at the top of the canvas to 1 at the bottom, which is only true
* because `deckAtlas` turns `flipY` off. Module-level rather than per-solid so
* that a second hull shape cannot quietly wear a third set of bands and drift
* out of step with the one canvas they all sample.
*/
const SIDE: Band = [0.02, 0.31];
const DECK: Band = [0.35, 0.64];
const HOUSE: Band = [0.69, 0.98];
type P = readonly [number, number, number];
type Band = readonly [number, number];
type UV = readonly [number, number];
/**
* A face writer, shared by every hull solid in this file.
*
* Non-indexed on purpose, and the reason is `airports.ts:43`'s scar:
* `mergeGeometries` silently drops a bucket whose attribute sets disagree, and
* the only way to be certain a hull carries position, normal *and* uv is to
* write all three. Non-indexed also gives flat facets for free, which is what a
* slab-sided ship is.
*
* Every face handed to `quad` and `tri` must be wound counter-clockwise **seen
* from outside**. `computeVertexNormals` reads the winding, so a face wound the
* other way is both back-face culled and lit from inside. The first photograph
* of the generic hull had a deck wound downward — you saw straight through it to
* the inside of the bottom plating, and it read merely as "the ships are a bit
* dark" rather than as a hole. It took a picture to find and one sign to fix,
* and `vessels.test.ts` now measures the outward flux so the next one is caught
* by the suite instead.
*/
function hullFaces(): {
quad(a: P, b: P, c: P, d: P, band: Band): void;
tri(a: P, b: P, c: P, uvA: UV, uvB: UV, uvC: UV): void;
box(x0: number, x1: number, y0: number, y1: number, z0: number, z1: number, band: Band): void;
finish(name: string): THREE.BufferGeometry;
} {
const positions: number[] = [];
const uvs: number[] = [];
/**
* Atlas bands. `v` runs 0 at the top of the canvas to 1 at the bottom, which
* is only true because `deckAtlas` turns `flipY` off.
*/
const SIDE: [number, number] = [0.02, 0.31];
const DECK: [number, number] = [0.35, 0.64];
const HOUSE: [number, number] = [0.69, 0.98];
type P = readonly [number, number, number];
const quad = (a: P, b: P, c: P, d: P, band: [number, number]) => {
const tri = (a: P, b: P, c: P, uvA: UV, uvB: UV, uvC: UV): void => {
positions.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]);
uvs.push(uvA[0], uvA[1], uvB[0], uvB[1], uvC[0], uvC[1]);
};
const quad = (a: P, b: P, c: P, d: P, band: Band): void => {
const [v0, v1] = band;
tri(a, b, c, [0, v0], [1, v0], [1, v1]);
tri(a, c, d, [0, v0], [1, v1], [0, v1]);
};
const tri = (
a: P,
b: P,
c: P,
uvA: [number, number],
uvB: [number, number],
uvC: [number, number],
) => {
positions.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]);
uvs.push(uvA[0], uvA[1], uvB[0], uvB[1], uvC[0], uvC[1]);
const box = (
x0: number,
x1: number,
y0: number,
y1: number,
z0: number,
z1: number,
band: Band,
): void => {
// Reversed against the obvious ordering, for the winding reason above:
// written the natural way, every one of these six faces points into the box.
quad([x0, y1, z0], [x1, y1, z0], [x1, y0, z0], [x0, y0, z0], band);
quad([x1, y1, z1], [x0, y1, z1], [x0, y0, z1], [x1, y0, z1], band);
quad([x0, y1, z1], [x0, y1, z0], [x0, y0, z0], [x0, y0, z1], band);
quad([x1, y1, z0], [x1, y1, z1], [x1, y0, z1], [x1, y0, z0], band);
quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], band);
quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], band);
};
return {
quad,
tri,
box,
finish(name: string): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
geometry.name = name;
return geometry;
},
};
}
/** The merchant hull: forty triangles, house and funnel aft. */
function genericHull(): THREE.BufferGeometry {
const f = hullFaces();
// Bow at -z, stern at +z, so a bearing of zero points the ship north — the
// board's north is -z, and `bearingRotation` below is the only place that
// convention is turned into a rotation.
@@ -384,67 +480,112 @@ function genericHull(): THREE.BufferGeometry {
const zStern = 0.5;
const stem = 0.34; // how high up the stem the forefoot starts
/**
* Every face below is wound counter-clockwise **seen from outside**, and that
* is not pedantry: `computeVertexNormals` reads the winding, so a face wound
* the other way is both back-face culled and lit from inside. The first
* photograph of this hull had a deck wound downward — you saw straight through
* it to the inside of the bottom plating, and it read merely as "the ships are
* a bit dark" rather than as a hole. It took a picture to find and one sign to
* fix.
*/
// ---- Body: five quads, ten triangles.
quad([-bx, 0, zShoulder], [-bx, 0, zStern], [-bx, 1, zStern], [-bx, 1, zShoulder], SIDE);
quad([bx, 0, zStern], [bx, 0, zShoulder], [bx, 1, zShoulder], [bx, 1, zStern], SIDE);
quad([-bx, 0, zStern], [bx, 0, zStern], [bx, 1, zStern], [-bx, 1, zStern], SIDE);
quad([-bx, 0, zShoulder], [bx, 0, zShoulder], [bx, 0, zStern], [-bx, 0, zStern], SIDE);
quad([-bx, 1, zStern], [bx, 1, zStern], [bx, 1, zShoulder], [-bx, 1, zShoulder], DECK);
f.quad([-bx, 0, zShoulder], [-bx, 0, zStern], [-bx, 1, zStern], [-bx, 1, zShoulder], SIDE);
f.quad([bx, 0, zStern], [bx, 0, zShoulder], [bx, 1, zShoulder], [bx, 1, zStern], SIDE);
f.quad([-bx, 0, zStern], [bx, 0, zStern], [bx, 1, zStern], [-bx, 1, zStern], SIDE);
f.quad([-bx, 0, zShoulder], [bx, 0, zShoulder], [bx, 0, zStern], [-bx, 0, zStern], SIDE);
f.quad([-bx, 1, zStern], [bx, 1, zStern], [bx, 1, zShoulder], [-bx, 1, zShoulder], DECK);
// ---- Bow: two side quads and two triangles, six triangles.
quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
quad([bx, 1, zShoulder], [bx, 0, zShoulder], [0, stem, zBow], [0, 1, zBow], SIDE);
tri([-bx, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, 0.35], [1, 0.35], [0.5, 0.64]);
tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, 0.02], [0.5, 0.31], [1, 0.02]);
f.quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
f.quad([bx, 1, zShoulder], [bx, 0, zShoulder], [0, stem, zBow], [0, 1, zBow], SIDE);
f.tri([-bx, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, 0.35], [1, 0.35], [0.5, 0.64]);
f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, 0.02], [0.5, 0.31], [1, 0.02]);
// ---- Aft house: a box on the quarterdeck, twelve triangles.
box(quad, -0.34, 0.34, 1, 1.55, 0.26, 0.46, HOUSE);
f.box(-0.34, 0.34, 1, 1.55, 0.26, 0.46, HOUSE);
// ---- Funnel: twelve triangles, and the reason a ship reads as a ship.
box(quad, -0.12, 0.12, 1.55, 1.86, 0.32, 0.42, HOUSE);
f.box(-0.12, 0.12, 1.55, 1.86, 0.32, 0.42, HOUSE);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
geometry.name = "vessel-hull";
return geometry;
return f.finish("vessel-hull");
}
function box(
quad: (
a: readonly [number, number, number],
b: readonly [number, number, number],
c: readonly [number, number, number],
d: readonly [number, number, number],
band: [number, number],
) => void,
x0: number,
x1: number,
y0: number,
y1: number,
z0: number,
z1: number,
band: [number, number],
): void {
// Reversed against the obvious ordering, for the winding reason `hullGeometry`
// gives above: written the natural way, every one of these six faces points
// into the box.
quad([x0, y1, z0], [x1, y1, z0], [x1, y0, z0], [x0, y0, z0], band);
quad([x1, y1, z1], [x0, y1, z1], [x0, y0, z1], [x1, y0, z1], band);
quad([x0, y1, z1], [x0, y1, z0], [x0, y0, z0], [x0, y0, z1], band);
quad([x1, y1, z0], [x1, y1, z1], [x1, y0, z1], [x1, y0, z0], band);
quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], band);
quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], band);
/**
* The harbour tug: sixty-four triangles, and the mass in the wrong place on
* purpose.
*
* ### Why a tug needs its own solid at all
*
* Because the generic hull is a *merchant* hull, and the two silhouettes are
* opposites. A cargo ship carries its block aft — house and funnel over the
* screw, a long clear foredeck ahead of them — and drawn small that reads as a
* bar with a lump at one end. A tug carries its block **forward and nearly the
* full beam**, with the clear low towing deck aft, and drawn small it reads as a
* lump with a bar behind it. Using the merchant solid for a tug produced a
* thirty-metre container ship, which is a thing that does not exist, and it was
* the only hull on the board whose proportions were wrong rather than merely
* simple.
*
* ### It came off a photograph, not out of a catalogue
*
* The proportions below are read from the asset the owner voted up
* (`tug-20260823-043140-7dbe8e`) rather than invented here, and specifically
* from the three things that survive being shrunk to a few pixels: the deckhouse
* runs from about a quarter to about three quarters of the length measured from
* the stern; it is roughly 0.84 of the beam, which is nearly full width where a
* merchant house is barely half; and the wheelhouse on top of it carries an
* overhanging roof that is the widest thing above the deck. Everything else in
* that render — the tyre fenders, the tow rope, the lit windows, the mast — is
* hero detail for a camera two boat-lengths away and is sub-pixel here, so none
* of it is modelled. **Nothing of that component is pasted:** it brought fifteen
* `MeshStandardMaterial`s and four emissive navigation lights, and both would
* violate the one-material rule this layer is built on and CONTRACT §4.
*
* ### Sixty-four triangles, and why that is free
*
* Twenty-four more than the merchant hull, on a board that holds at most a
* handful of tugs at once — under two hundred triangles for the whole harbour's
* tug fleet. What it costs is **one draw call**, and only on a board that has a
* tug on it, because the layer adds an `InstancedMesh` to the group only when
* its count is above zero.
*/
function tugHull(): THREE.BufferGeometry {
const f = hullFaces();
const bx = 0.5;
const zBow = -0.5;
// Bluffer than the merchant hull's 0.22: a tug's entry is short and full,
// because it is built to push rather than to make twenty knots economically.
const zShoulder = -0.36;
const zStern = 0.5;
const stem = 0.42;
// ---- Body: five quads, ten triangles. A flat transom, which is what a tug
// has and what the towing deck is built out to.
f.quad([-bx, 0, zShoulder], [-bx, 0, zStern], [-bx, 1, zStern], [-bx, 1, zShoulder], SIDE);
f.quad([bx, 0, zStern], [bx, 0, zShoulder], [bx, 1, zShoulder], [bx, 1, zStern], SIDE);
f.quad([-bx, 0, zStern], [bx, 0, zStern], [bx, 1, zStern], [-bx, 1, zStern], SIDE);
f.quad([-bx, 0, zShoulder], [bx, 0, zShoulder], [bx, 0, zStern], [-bx, 0, zStern], SIDE);
f.quad([-bx, 1, zStern], [bx, 1, zStern], [bx, 1, zShoulder], [-bx, 1, zShoulder], DECK);
// ---- Bow: six triangles.
f.quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
f.quad([bx, 1, zShoulder], [bx, 0, zShoulder], [0, stem, zBow], [0, 1, zBow], SIDE);
f.tri([-bx, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, 0.35], [1, 0.35], [0.5, 0.64]);
f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, 0.02], [0.5, 0.31], [1, 0.02]);
// ---- Deckhouse: forward, and 0.84 of the beam. The one measurement that
// decides whether this reads as a tug rather than as a small freighter.
f.box(-0.42, 0.42, 1, 1.42, -0.3, 0.1, HOUSE);
// ---- Wheelhouse, set a little aft of the house front so the bridge wings
// look down the tow.
f.box(-0.36, 0.36, 1.42, 1.78, -0.24, 0.04, HOUSE);
// ---- The wheelhouse roof, overhanging on every side. Twelve triangles for a
// slab, and worth them: from the vertical pose this board is mostly looked at
// from, the roof plan *is* the tug.
f.box(-0.4, 0.4, 1.78, 1.84, -0.28, 0.08, HOUSE);
// ---- Funnel: low, fat and aft of the house, standing no higher than the
// wheelhouse. A tug whose stack out-tops its bridge is a tug from 1950.
f.box(-0.14, 0.14, 1.42, 1.66, 0.16, 0.3, HOUSE);
// Aft of z = 0.3 the deck is deliberately clear. That empty quarter is the
// towing deck and it is half of the silhouette.
return f.finish("vessel-hull-tug");
}
/**
@@ -626,8 +767,6 @@ export function createVesselLayer(
});
hullMaterial.name = "vessel-hull";
const geometry = hullGeometry("generic");
/**
* One `InstancedMesh` per hull shape, built up front and added to the group
* only when it has hulls in it.
@@ -636,9 +775,19 @@ export function createVesselLayer(
* `Float32Array` of sixteen floats per instance and rebuilding it whenever the
* feed answers would allocate and discard 12 KB every fifteen minutes for the
* life of the page. `count` is the dial instead, which is what it is for.
*
* **A geometry per shape, and one material for all of them.** The two are not
* symmetric and the asymmetry is the whole budget: a second *geometry* is a
* second draw call only on a board that has that shape floating on it, while a
* second *material* would be a second draw call on every board for ever. So
* the tug gets its own solid and wears the same atlas, and its colour arrives
* as an `instanceColor` tint like every other hull's.
*/
const geometries = new Map<HullShape, THREE.BufferGeometry>();
const hullMeshes = new Map<HullShape, THREE.InstancedMesh>();
for (const shape of HULL_SHAPES) {
const geometry = hullGeometry(shape);
geometries.set(shape, geometry);
const mesh = new THREE.InstancedMesh(geometry, hullMaterial, VESSEL_HULL_CAPACITY);
mesh.name = `vessels:${shape}`;
mesh.count = 0;
@@ -938,7 +1087,8 @@ export function createVesselLayer(
},
dispose() {
geometry.dispose();
for (const geometry of geometries.values()) geometry.dispose();
geometries.clear();
hullMaterial.dispose();
atlas?.dispose();
wakeGeometry.dispose();
+762 -74
View File
@@ -89,6 +89,30 @@
* layer's twenty-two orange marks in a nicer costume: plausible, specific, and a
* claim about a named commercial vessel behind which this deployment has no
* licensed feed. Identity arrives with a licence entry or it does not arrive.
*
* ### The harbour has a working day, and the day is a plan
*
* The first version of it was a diorama: berth occupancy was a time-invariant
* hash and the handful of hulls in the channel looped it on a ninety-minute
* carousel, so a port that takes fifteen ships a day never received one. It now
* runs a schedule — a ship stands in from open water, takes a tug inside the
* breakwater, lies alongside, works, and leaves — and the schedule is a **plan
* evaluated at an instant** rather than a simulation that is running: nothing in
* this file holds a clock or an accumulator, and `modelHarbour(ports, { atMs })`
* is a closed form of that number. `server/wire.ts` makes the same argument for
* the simulated sky and for the same reasons, and the two useful consequences
* are identical: two people on two machines see the same ships, and `look.mjs
* --at` shoots the same frame twice.
*
* One number in it is compressed and it is said out loud in both the body's
* attribution and `vesselSummary`'s sentence: **the rate of arrivals**, at
* roughly seven times a real day's, because a truthful rate gives a board that
* looks identical from breakfast to bedtime. Nothing else is. The channel is the
* charted one, the berths are the authored ones, and every speed is real —
* which is not a nicety, because a fix's `sog` is what a consumer dead-reckons
* along, so a speed that is not the derivative of its own position is a lie the
* renderer will draw faithfully. `harbourDay.test.ts` asserts that derivative
* numerically.
*/
import type { Berth, Port, Vessel, VesselKind, VesselStatus } from "../engine/types.ts";
@@ -535,7 +559,24 @@ export function readVessel(
const heading = aisHeading(row.heading);
const course = aisCourse(row.course);
const berth = nearestBerth(lat, lng, berths);
/**
* A hull is bound to a berth only when it is **not making way**.
*
* `nearestBerth` reaches four hundred metres, and `resolveBearing` lets the
* quay win outright over a reported course — which is right for a ship lying
* alongside and wrong for one steaming past. Long Beach's Pier T berths sit
* 190 m off their own channel centreline, so before this line a ship doing ten
* knots up the channel was silently swung to the quay's bearing and drawn
* crabbing sideways with her wake off the beam. It only became visible once
* ships started arriving; the picture found it in the first frame.
*
* "Alongside" therefore now means what the word means, and
* `VesselPromotion.alongside` counts hulls that are actually stopped at a
* berth. A ship creeping the last fifty metres in at under half a knot is not
* making way by `isMakingWay`, so she binds and lies the way the quay does —
* which is the moment she should.
*/
const berth = isMakingWay(speed) ? null : nearestBerth(lat, lng, berths);
const bearing = resolveBearing({
heading,
course,
@@ -652,8 +693,23 @@ export function vesselSummary(promotion: VesselPromotion): string {
return "No vessel feed is configured for this deployment, so no ships are drawn.";
}
const modelled = source === "modelled";
/**
* The modelled clause names **both halves**, and the second half is new.
*
* The harbour now has a working day in it: ships stand in from open water,
* take a tug, lie alongside and leave. A reader watching a berth change hands
* twice in an afternoon would reasonably conclude that is how often San Pedro
* changes hands, and it is not — the rate is the one thing here that is a
* modelling choice rather than the board's own geometry. Every other number in
* the picture is true: the channel is the charted one, the berths are the
* authored ones, and a ship comes up the channel at thirteen knots because
* that is how fast she comes up the channel.
*
* Saying so costs a clause. Not saying so is the fire layer's twenty-two
* orange marks again, in a slower costume.
*/
const provenance = modelled
? "Modelled from this board's own berths and channels — anonymous hulls, no names and no MMSIs, because the live AIS feed is not configured."
? "Modelled from this board's own berths and channels — anonymous hulls, no names and no MMSIs, because the live AIS feed is not configured. Ship speeds, the channel and the berths are true; arrivals run at about seven times a real day's rate, so the harbour changes while you watch."
: "Live AIS.";
if (drawn.length === 0) {
const parts: string[] = ["The feed answered and no ship is on this board."];
@@ -679,33 +735,375 @@ export function vesselSummary(promotion: VesselPromotion): string {
return parts.join(" ");
}
// ---- The modelled harbour -------------------------------------------------
// ---- The modelled harbour, and its working day ----------------------------
/** What `modelHarbour` needs to be reproducible. */
export interface ModelledHarbourOptions {
/**
* The seed, so two people see the same harbour and a capture script shoots the
* same frame twice. Everything below is a hash of this and a stable string
* (a berth id, a port id), never a call to `Math.random`.
* (a berth id, a port id, a call number), never a call to `Math.random`.
*/
seed?: number;
/** Wall clock for the body's `fetchedAt`, and the phase of the moving hulls. */
/**
* Wall clock for the body's `fetchedAt`, **and the instant the working day is
* evaluated at**.
*
* This is the whole of the simulator's state. `harbourCalls` is a plan — a
* berth, a route and a repeating slot — and `modelHarbour` is that plan
* evaluated as a closed form of this number, which is exactly the trick
* `FlightsPlanBody` plays with `t0` and for exactly the same reason: two
* people on two machines looking at the same instant see the same ships, and a
* capture script that shoots the Southland twice gets the same photograph.
* There is no accumulator anywhere in this file, so there is nothing that can
* drift, and nothing that has to be replayed to reach a given moment.
*/
atMs?: number;
/** Sample interval to declare. 900 s, matching the store this stands in for. */
/** Sample interval to declare. See `MODELLED_INTERVAL_SECONDS`. */
intervalSeconds?: number;
/** How many hulls are under way per port with a channel. */
/**
* Roughly how many hulls are under way per port at any instant.
*
* A *target*, not a guarantee, and the difference is the honest one: berth
* slots are laid out evenly around the cycle so the channel carries a steady
* stream rather than a convoy, but the legs differ in length — the West Basin
* is eight kilometres up the Main Channel and Pier 400 is two — so the count
* breathes by one either side. `harbourDay.test.ts` asserts the band rather
* than a number.
*/
underWayPerPort?: number;
/** What proportion of a port's berths are occupied, 0..1. */
occupancy?: number;
}
/**
* A harbour built from a board's own authored geometry.
* The interval the modelled source declares, in seconds.
*
* Berths carry hulls; channels carry the handful making way. Both are things the
* pack already declares, which is what makes this a *model* of the board rather
* than a fiction laid on top of it: a berth with no ship on it is an empty berth
* you can see, and moving a berth moves the ship.
* **A minute, and not the nine hundred seconds a real AIS listener would
* declare** — that number moved, and the reason it moved is the most useful
* thing in this file.
*
* `intervalSeconds` is a property *of the source*. It says how long a consumer
* may dead-reckon a fix before the fix is stale, and for the store this stands
* in for it is fifteen minutes because upstream listens for thirty seconds every
* fifteen minutes. This source is not that: it is a closed-form function of the
* clock, it can be asked for any instant at any time, and declaring a quarter of
* an hour was mimicry rather than description.
*
* It also drew a ship over the land. `engine/vessels.ts` dead-reckons **along a
* straight reported course** — correctly, because a course is all a fix carries
* — so a hull at thirteen knots reckoned for nine hundred seconds runs six
* kilometres in a straight line. The Main Channel bends about twenty degrees in
* that distance, and the arriving ships this working day added therefore sailed
* up over Terminal Island for the last third of every interval, dead-reckoned
* exactly as instructed. A minute is 400 m, which is under a scene unit.
*
* None of that softens when a real feed lands. `/api/sea` will hand over a body
* carrying its own `intervalSeconds`, main.ts already reads it off the body, and
* a fifteen-minute AIS feed will dead-reckon for fifteen minutes and overshoot
* the bends — which is a true fact about a fifteen-minute feed and belongs in
* the panel's sentence rather than in a smoothing filter. What must never happen
* is the other repair: splining between two fixes to hide it.
*/
export const MODELLED_INTERVAL_SECONDS = 60;
/**
* How far seaward of the charted channel a ship is picked up, in metres.
*
* The pack's channel is the dredged water and stops where the dredging does, a
* kilometre or so outside the breakwater. A ship that appeared exactly there
* would pop into being at the gate; extending the first leg back out to sea by
* two and a half kilometres means an arrival is first seen against open water,
* standing in toward the entrance, which is what an arrival looks like.
*
* Extended **in the simulator and not in the pack**, because `engine/ports.ts`
* draws `Port.channel` as dredged water and this is not dredged water. The
* picture would gain a dark strip two miles out to sea that no chart has.
*/
export const APPROACH_SEAWARD_METRES = 2500;
/**
* How far off the charted channel a berth may be and still be given a route, in
* metres.
*
* **This is a land check standing in for the land check this file cannot do.**
* A berth's route is the channel as far as the point nearest the berth and then
* a straight run in, and a straight run of three kilometres from the Main
* Channel to the East Basin crosses Terminal Island — a container ship driven
* over a container yard, at ten knots, with a wake. There is no water mask in
* `Port`, so the honest gate is distance: a berth the channel reaches keeps a
* working day, and a berth it does not reach keeps a hull lying alongside and
* takes no calls.
*
* At Los Angeles that admits Pier 400 and the West Basin and holds back Pier 300
* and the East Basin; at Long Beach it admits all five. The fix is not a bigger
* number — it is per-berth approach geometry in the pack, which wants a field on
* `Berth` that does not exist yet.
*/
export const BERTH_APPROACH_REACH_METRES = 1100;
/**
* The shape of the run in, as an exponent.
*
* Distance made good is `L * (1 - (1-x)^k)` and speed is its derivative, so a
* ship enters at `k * L / T` and arrives at nothing. That is not a fade for
* looks: it is how a ship berths, and it is also what keeps the dead reckoner
* honest, because the hull whose fix could be extrapolated furthest — the one
* closest to a quay it must not be drawn on top of — is the one moving slowest.
*
* 1.6 rather than 2 because a square root of a decay spends too much of the leg
* crawling; at 1.6 a ship holds better than half her entry speed for the first
* two thirds of the channel and is down to two knots at the berth.
*/
export const APPROACH_EASE = 1.6;
/** Entry speed at the seaward end of the run in, m/s. Thirteen knots. */
export const INBOUND_PEAK_MPS = 6.7;
/** Speed at the seaward end of the run out, m/s. Fourteen knots. */
export const OUTBOUND_PEAK_MPS = 7.2;
/** The least clear water between one ship leaving a berth and the next arriving. */
const MIN_BERTH_GAP_SECONDS = 600;
/**
* When the tug joins an arriving ship, and when it lets a departing one go, as a
* fraction of the leg.
*
* The escort is the second half of the run in and the first third of the run
* out, which is inside the breakwater in both cases — a harbour tug meets a ship
* in sheltered water, not at sea.
*/
const TUG_MEETS_AT = 0.55;
const TUG_LEAVES_AT = 0.94;
const TUG_DEPARTURE_UNTIL = 0.3;
/** How long before the meeting the tug is seen running seaward to make it. */
const TUG_RUN_OUT = 0.13;
/** Where the tug lies between jobs, as a fraction along the berth's own route. */
const TUG_STATION = 0.88;
/** Where the tug sits relative to the ship it is attending, in metres. */
const TUG_STATION_ASTERN = 220;
const TUG_STATION_ABEAM = 110;
/** One berth's endlessly repeating port call: a route, and a slot in the day. */
export interface HarbourCall {
portId: string;
berthId: string;
/** The berth's own bearing, so a ship alongside lies the way the quay does. */
berthBearing: number;
maxLength: number;
/**
* Open water, then the charted channel, then **where the ship lies** — which
* is not the berth's own coordinate. See `lyingPosition`.
*/
approach: readonly [number, number][];
approachMetres: number;
inboundSeconds: number;
dwellSeconds: number;
outboundSeconds: number;
/** Inbound, alongside, outbound, and the empty berth before the next ship. */
cycleSeconds: number;
/** Seconds after the epoch at which this berth's call zero starts inbound. */
offsetSeconds: number;
}
/** Which leg of her call a berth's ship is on. `empty` is a berth with no ship. */
export type HarbourPhase = "inbound" | "alongside" | "outbound" | "empty";
export interface HarbourMoment {
phase: HarbourPhase;
/** Which call this is, counting from the epoch. Half of the ship's identity. */
index: number;
/** 0..1 through whichever leg `phase` names. */
progress: number;
}
/**
* The plan: one repeating call per berth the channel can reach.
*
* Pure, and a function of the port's own geometry and the seed alone — no clock
* reaches this function, which is what makes it a *plan* rather than a state.
* `harbourMoment` is the evaluator.
*
* ### The slots are even, and that is a claim about ports rather than a shortcut
*
* Every berth in a port shares one cycle and the offsets are laid out evenly
* around it, so arrivals come at a steady drumbeat instead of in clumps. Real
* ports do allocate berth windows this way — a ship books a slot and is charged
* for missing it — and the alternative here, a random offset per berth, gives a
* harbour that is deserted for an hour and then has five ships in one channel.
* The variety is in the ships instead: kind, length, dwell and tug all come out
* of the hash of the berth id and the call number.
*
* ### The rate is compressed, and the panel says so
*
* Los Angeles and Long Beach between them take on the order of fourteen deep-sea
* calls a day and a box ship lies alongside for one to three days. Drawn
* truthfully, this board would show one arrival every hour and a half and a quay
* that looked identical from breakfast to bedtime. `underWayPerPort` sets the
* compression and the default runs roughly seven times a real day's call rate,
* which is a stated modelling choice and is written into the body's own
* attribution — not a claim about how busy San Pedro is.
*
* **What is not compressed is the motion.** Every speed below is a real speed, so
* a ship takes twenty-five to forty-five minutes to come up the channel because
* that is how long it takes, and — the part that matters downstream — the sog a
* fix reports is the derivative of the position that fix reports. A simulator
* that sped the hulls up while reporting truthful knots would have the dead
* reckoner and the schedule disagree, and the disagreement would land as a jump
* on every fix.
*/
export function harbourCalls(
port: Port,
options: ModelledHarbourOptions = {},
): HarbourCall[] {
const seed = options.seed ?? 115;
const occupancy = clamp01(options.occupancy ?? 0.72);
/**
* The target, clamped to half the berths.
*
* A berth is under way for `inbound + outbound` of every cycle, so asking for
* three of a four-berth port in the channel at once leaves at most a quarter of
* the cycle to lie alongside in — and once the minimum clear water between one
* ship leaving and the next arriving is taken out, none. A port where most of
* the fleet is steaming and the quays are bare is not a busy port, it is a
* parade. Half is the ceiling; a caller asking for more gets a working harbour
* instead of the number it asked for.
*/
const asked = Math.max(0, Math.floor(options.underWayPerPort ?? 2));
const target = Math.min(asked, Math.max(1, Math.floor((port.berths ?? []).length / 2)));
const channel = port.channel ?? [];
const berths = port.berths ?? [];
if (channel.length < 2 || berths.length === 0 || target === 0) return [];
const fairway = seawardApproach(channel);
/** Berth, route and the two transit times, before the slots are laid out. */
const routed: {
berth: Berth;
approach: [number, number][];
metres: number;
inbound: number;
outbound: number;
}[] = [];
for (const berth of berths) {
const approach = berthApproach(fairway, berth);
if (approach === null) continue;
const metres = pathMetres(approach);
if (metres <= 0) continue;
routed.push({
berth,
approach,
metres,
inbound: (APPROACH_EASE * metres) / INBOUND_PEAK_MPS,
outbound: (APPROACH_EASE * metres) / OUTBOUND_PEAK_MPS,
});
}
if (routed.length === 0) return [];
/**
* One cycle for the whole port, sized so that the transits add up to the
* target.
*
* A berth is under way for `inbound + outbound` of every cycle, so the number
* of hulls moving at any instant is the sum of those over the cycle. Solving
* that for the cycle is the one line that turns "about two ships in the
* channel" into a schedule.
*/
const transit = routed.reduce((total, r) => total + r.inbound + r.outbound, 0);
const cycleSeconds = Math.max(transit / routed.length, transit / target);
const calls: HarbourCall[] = [];
routed.forEach((route, index) => {
const legs = route.inbound + route.outbound;
// The dwell the occupancy asks for, or the longest one that still leaves the
// berth clear water before the next ship — whichever is shorter. A long leg
// eats its own berth's dwell rather than overrunning the slot behind it.
const dwellSeconds = Math.max(
0,
Math.min(occupancy * cycleSeconds, cycleSeconds - legs - MIN_BERTH_GAP_SECONDS),
);
calls.push({
portId: port.id,
berthId: route.berth.id,
berthBearing: route.berth.bearing,
maxLength: route.berth.maxLength > 0 ? route.berth.maxLength : 0,
approach: route.approach,
approachMetres: route.metres,
inboundSeconds: route.inbound,
dwellSeconds,
outboundSeconds: route.outbound,
cycleSeconds,
// Evenly spaced, and rotated by a hash of the port so that Los Angeles and
// Long Beach are not in step with each other.
offsetSeconds:
((index / routed.length) + hash01(seed, `${port.id}:rotation`)) * cycleSeconds,
});
});
return calls;
}
/** Where a berth's call has got to at `atSeconds` after the epoch. */
export function harbourMoment(call: HarbourCall, atSeconds: number): HarbourMoment {
const cycle = call.cycleSeconds;
if (!(cycle > 0)) return { phase: "empty", index: 0, progress: 0 };
const since = atSeconds - call.offsetSeconds;
const index = Math.floor(since / cycle);
const u = since - index * cycle;
if (u < call.inboundSeconds) {
return { phase: "inbound", index, progress: u / call.inboundSeconds };
}
const afterDwell = call.inboundSeconds + call.dwellSeconds;
if (u < afterDwell) {
return {
phase: "alongside",
index,
progress: call.dwellSeconds > 0 ? (u - call.inboundSeconds) / call.dwellSeconds : 0,
};
}
const afterOut = afterDwell + call.outboundSeconds;
if (u < afterOut) {
return { phase: "outbound", index, progress: (u - afterDwell) / call.outboundSeconds };
}
return { phase: "empty", index, progress: (u - afterOut) / Math.max(1, cycle - afterOut) };
}
/**
* Distance made good along the route, and the speed that is making it.
*
* The speed is the analytic derivative of the distance rather than a plausible
* number written beside it, which is the property the whole seam rests on: a fix
* carries `sog` and a consumer is licensed to dead-reckon along it, so a `sog`
* that is not the derivative of the position it arrives with is a lie that the
* renderer will faithfully draw.
*/
export function approachRun(
metres: number,
seconds: number,
progress: number,
leg: "inbound" | "outbound",
): { arcMetres: number; speedMps: number } {
if (!(metres > 0) || !(seconds > 0)) return { arcMetres: 0, speedMps: 0 };
const x = clamp01(progress);
const k = APPROACH_EASE;
if (leg === "inbound") {
const left = 1 - x;
return {
arcMetres: metres * (1 - left ** k),
speedMps: (metres * k * left ** (k - 1)) / seconds,
};
}
return {
arcMetres: metres * (1 - x ** k),
speedMps: (metres * k * x ** (k - 1)) / seconds,
};
}
/**
* A harbour built from a board's own authored geometry, at one instant.
*
* Berths carry hulls; the channel carries the ones arriving and leaving; a tug
* comes out to meet each of them. All of it is a closed form of `atMs`, so the
* same instant gives the same harbour on every machine and at every replay.
*
* Deliberately absent, and the absences are the design: no name, no MMSI, no
* callsign, no destination, and no laden state. The output is a `VesselsBody`
@@ -718,80 +1116,82 @@ export function modelHarbour(
): VesselsBody {
const seed = options.seed ?? 115;
const atMs = options.atMs ?? 0;
const intervalSeconds = options.intervalSeconds ?? 900;
const intervalSeconds = options.intervalSeconds ?? MODELLED_INTERVAL_SECONDS;
const occupancy = clamp01(options.occupancy ?? 0.72);
const underWayPerPort = Math.max(0, Math.floor(options.underWayPerPort ?? 3));
const atSeconds = atMs / 1000;
const vessels: WireVessel[] = [];
for (const port of ports ?? []) {
const calls = harbourCalls(port, { ...options, seed, occupancy });
const scheduled = new Set(calls.map((call) => call.berthId));
/**
* A berth the channel does not reach keeps a hull alongside.
*
* The same static occupancy this file drew before there was a working day,
* kept for exactly the berths a route cannot honestly be drawn to — see
* `BERTH_APPROACH_REACH_METRES`. It is the difference between a quay that is
* quiet and a quay that is empty, and the empty one would read as a bug.
*/
for (const berth of port.berths ?? []) {
if (scheduled.has(berth.id)) continue;
const key = `${port.id}:${berth.id}`;
if (hash01(seed, `${key}:occupied`) > occupancy) continue;
const kind = berthKind(seed, key, berth);
const fallback = DEFAULT_HULL[kind];
const maxLength = berth.maxLength > 0 ? berth.maxLength : fallback.length;
/**
* 70-88% of the berth, and the ceiling is what stops a terminal reading
* as one continuous wall of steel.
*
* Photographed: Pier 400's authored berths are 356 m apart and it fills to
* 400 m, so at 97% two consecutive hulls touched stem to stern and the two
* vehicle carriers alongside read as one 700 m object. A berth whose hull
* exactly fills it every time also reads as a diagram rather than as a
* working quay.
*/
const length = Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
vessels.push({
id: `m-${key}`,
kind,
lat: berth.lat,
lon: berth.lng,
speed: 0,
course: null,
/**
* `null`, always, and this is the most deliberate line in the simulator.
*
* Half the fleet at rest reports no heading, so a modelled harbour whose
* every hull volunteered one would exercise the easy path and leave the
* berth-supplied orientation — the thing this workstream exists to get
* right — permanently untested by the picture.
*/
heading: null,
navStatus: 5,
length,
beam: Math.round(beamFor(kind, length)),
ageSeconds: 0,
});
const length = berthLength(seed, key, berth, kind);
// Off the wall, exactly as a scheduled one is. The water side comes from
// the channel rather than from a route, because this berth has none — the
// fairway is the one thing on a port that is certainly afloat.
const afloat = nearestOnPath(port.channel ?? [], berth.lat, berth.lng);
const lying = afloat
? lyingPosition(berth, afloat.lat, afloat.lng)
: { lat: berth.lat, lng: berth.lng };
vessels.push(alongsideFix(`m-${key}`, kind, lying.lat, lying.lng, length));
}
const channel = port.channel ?? [];
if (channel.length < 2 || underWayPerPort === 0) continue;
for (let i = 0; i < underWayPerPort; i++) {
const key = `${port.id}:under-way:${i}`;
const kind = i === underWayPerPort - 1 ? "tug" : underWayKind(seed, key);
const fallback = DEFAULT_HULL[kind];
const length = Math.round(fallback.length * (0.85 + 0.3 * hash01(seed, `${key}:length`)));
// Speed first, because it is what the phase is measured in: a tug at six
// knots and a container ship at twelve are at different places on the same
// channel a minute later, which is the whole reason the wakes differ.
const speed = (kind === "tug" ? 3.2 : 6.4) * (0.8 + 0.4 * hash01(seed, `${key}:speed`));
const phase = (hash01(seed, `${key}:phase`) + (atMs / 1000 / (intervalSeconds * 6))) % 1;
const along = i % 2 === 0 ? phase : 1 - phase;
const point = alongPath(channel, along);
if (!point) continue;
for (const call of calls) {
const moment = harbourMoment(call, atSeconds);
if (moment.phase === "empty") continue;
const key = `${call.portId}:${call.berthId}:${moment.index}`;
const kind = callKind(seed, key, call);
const length = callLength(seed, key, call, kind);
if (moment.phase === "alongside") {
const lying = call.approach[call.approach.length - 1];
if (!lying) continue;
vessels.push(alongsideFix(`m-${key}`, kind, lying[0], lying[1], length));
continue;
}
const leg = moment.phase;
const seconds = leg === "inbound" ? call.inboundSeconds : call.outboundSeconds;
const run = approachRun(call.approachMetres, seconds, moment.progress, leg);
const at = alongPath(call.approach, run.arcMetres / call.approachMetres);
if (!at) continue;
// The route is authored seaward-end-first, so its bearing at any point is
// the inbound course and a departure is the reciprocal of it.
const course = leg === "inbound" ? at.bearing : normaliseDegrees(at.bearing + 180);
vessels.push({
id: `m-${key}`,
kind,
lat: point.lat,
lon: point.lng,
speed,
course: i % 2 === 0 ? point.bearing : normaliseDegrees(point.bearing + 180),
lat: at.lat,
lon: at.lng,
speed: run.speedMps,
course,
// `null`, always, and this is the most deliberate line in the simulator.
// Half the fleet at rest reports no heading, so a modelled harbour whose
// every hull volunteered one would exercise the easy path and leave the
// berth-supplied orientation — the thing this workstream exists to get
// right — permanently untested by the picture.
heading: null,
navStatus: 0,
length,
beam: Math.round(beamFor(kind, length)),
ageSeconds: 0,
});
const tug = attendingTug(call, moment, run, seed, key);
if (tug) vessels.push(tug);
}
}
@@ -803,10 +1203,305 @@ export function modelHarbour(
ttlSeconds: intervalSeconds,
attribution: [
"Modelled from this board's authored berths and channels. Not an observation of any vessel.",
"Ship speeds, the channel and the berths are true; the rate of arrivals is compressed to about seven times a real day's so the harbour changes while you watch.",
],
};
}
/**
* The tug attending one arriving or departing ship, or `null`.
*
* A tug is the smallest hull on the board and almost always the one that is
* moving, which at 391 m to the scene unit makes it the hull that reads: a
* thirty-metre hull is a twelfth of a unit and invisible, and the V behind it is
* four hundred metres of foam and is not. So the tug is here for the wake as
* much as for the ship it is attending.
*
* Three sub-legs, and the first is the one worth having: for a short window
* before the meeting the tug is drawn **running seaward**, out from its station
* up-harbour and down past the incoming ship, on a reciprocal course at ten
* knots. Two wakes crossing in opposite directions in a channel is a harbour
* doing something, where a tug that simply materialised alongside is a decal.
*/
function attendingTug(
call: HarbourCall,
moment: HarbourMoment,
run: { arcMetres: number; speedMps: number },
seed: number,
key: string,
): WireVessel | null {
const inbound = moment.phase === "inbound";
const x = moment.progress;
const station = TUG_STATION * call.approachMetres;
const length = Math.round(26 + 12 * hash01(seed, `${key}:tug`));
const escortArc = Math.max(0, run.arcMetres - TUG_STATION_ASTERN);
let arcMetres: number;
let speedMps: number;
let outbound: boolean;
if (inbound && x >= TUG_MEETS_AT - TUG_RUN_OUT && x < TUG_MEETS_AT) {
// Running out to meet her: from the station down-channel to the rendezvous,
// over the window, at whatever speed that distance and that window imply.
const meeting = approachRun(call.approachMetres, call.inboundSeconds, TUG_MEETS_AT, "inbound");
const target = Math.max(0, meeting.arcMetres - TUG_STATION_ASTERN);
const t = (x - (TUG_MEETS_AT - TUG_RUN_OUT)) / TUG_RUN_OUT;
arcMetres = station + (target - station) * t;
speedMps = Math.abs(station - target) / (TUG_RUN_OUT * call.inboundSeconds);
outbound = station > target;
} else if (inbound && x >= TUG_MEETS_AT && x < TUG_LEAVES_AT) {
arcMetres = escortArc;
speedMps = run.speedMps;
outbound = false;
} else if (!inbound && x <= TUG_DEPARTURE_UNTIL) {
arcMetres = Math.min(call.approachMetres, run.arcMetres + TUG_STATION_ASTERN);
speedMps = run.speedMps;
outbound = true;
} else if (!inbound && x <= TUG_DEPARTURE_UNTIL + TUG_RUN_OUT) {
// Letting her go and running home, back up the channel toward the station.
const release = approachRun(
call.approachMetres,
call.outboundSeconds,
TUG_DEPARTURE_UNTIL,
"outbound",
);
const from = Math.min(call.approachMetres, release.arcMetres + TUG_STATION_ASTERN);
const t = (x - TUG_DEPARTURE_UNTIL) / TUG_RUN_OUT;
arcMetres = from + (station - from) * t;
speedMps = Math.abs(station - from) / (TUG_RUN_OUT * call.outboundSeconds);
outbound = station < from;
} else {
return null;
}
if (!isMakingWay(speedMps)) return null;
const at = alongPath(call.approach, arcMetres / call.approachMetres);
if (!at) return null;
const course = outbound ? normaliseDegrees(at.bearing + 180) : at.bearing;
// Off the ship's quarter rather than in her wake, so both Vs are drawn rather
// than one on top of the other.
const abeam = offsetMetres(at.lat, at.lng, normaliseDegrees(course + 90), TUG_STATION_ABEAM);
return {
id: `m-${key}:tug`,
kind: "tug",
lat: abeam.lat,
lon: abeam.lng,
speed: speedMps,
course,
heading: null,
navStatus: 0,
length,
beam: Math.round(beamFor("tug", length)),
ageSeconds: 0,
};
}
/** A hull lying alongside: no speed, no course, and therefore no wake. */
function alongsideFix(
id: string,
kind: VesselKind,
lat: number,
lng: number,
length: number,
): WireVessel {
return {
id,
kind,
lat,
lon: lng,
/**
* Zero, and it is the load-bearing zero in this file.
*
* A wake is a function of speed through water, so a ship that has just tied
* up must lose hers in the same fix that puts her on the berth — a quay
* lined with wakeless hulls and one long V curving in past the breakwater is
* a picture of a working harbour, and a moored ship trailing foam is a
* picture of a bug. `engine/vessels.ts` gates the wake on this number and
* nothing else.
*/
speed: 0,
course: null,
heading: null,
navStatus: 5,
length,
beam: Math.round(beamFor(kind, length)),
ageSeconds: 0,
};
}
/**
* The channel with a seaward leg on the front of it.
*
* The extension runs back along the reciprocal of the first charted leg, so a
* ship stands in on the course the channel is already pointing at rather than
* arriving from an invented direction.
*/
function seawardApproach(channel: readonly [number, number][]): [number, number][] {
const first = channel[0];
const second = channel[1];
if (!first || !second) return channel.map(([lat, lng]) => [lat, lng]);
const inbound = bearingBetween(first[0], first[1], second[0], second[1]);
const out = offsetMetres(
first[0],
first[1],
normaliseDegrees(inbound + 180),
APPROACH_SEAWARD_METRES,
);
return [[out.lat, out.lng], ...channel.map(([lat, lng]): [number, number] => [lat, lng])];
}
/**
* One berth's route in: the fairway as far as the point nearest the berth, then
* a straight run alongside. `null` when the channel does not reach it.
*/
function berthApproach(
fairway: readonly [number, number][],
berth: Berth,
): [number, number][] | null {
const best = nearestOnPath(fairway, berth.lat, berth.lng);
if (best === null || best.metres > BERTH_APPROACH_REACH_METRES) return null;
const leave: [number, number] = [best.lat, best.lng];
const path: [number, number][] = [];
for (let i = 0; i < best.index; i++) {
const point = fairway[i];
if (point) path.push([point[0], point[1]]);
}
const tail = path[path.length - 1];
if (!tail || metresBetween(leave[0], leave[1], tail[0], tail[1]) > 1) path.push(leave);
// The route ends where the ship lies, not on the wall she lies against, so
// that the last minute of the run in and the hours alongside are the same
// point and she does not step sideways the moment she is reported moored.
const lying = lyingPosition(berth, leave[0], leave[1]);
path.push([lying.lat, lying.lng]);
return path.length >= 2 ? path : null;
}
/**
* Where a hull lying at a berth actually floats: half a beam off the quay.
*
* `Berth.lat/lng` is a point **on the wall**, which is what a berth is — the
* quay's own coordinate, authored with the concrete. A hull centred on it has
* half its width inside the terminal, and the first photograph of the working
* day showed exactly that: eleven ships reported alongside and barely a hull
* visible, because each was buried to the centreline in its own quay and roofed
* by a crane rail.
*
* Which way is water is not in `Berth` and is not guessed. It is taken from a
* point that is definitely afloat — the place the ship left the fairway, or for
* a berth with no route the nearest point on the channel — and then squared up:
* the offset runs along whichever perpendicular to the **quay's own bearing**
* agrees with that direction, so a ship lies parallel to the wall however
* oblique her approach was.
*
* The offset is sized from the berth rather than from the ship, and that is
* deliberate: where a hull lies is a property of the fender line, so every ship
* on a 400 m berth lies on the same line whether she is 280 m or 350 m long. The
* beam used is the widest hull the berth can take — a full-length container ship
* is the broadest thing in `beamFor` that a deep-sea berth ever sees — so no
* ship's plating ever reaches back over the coping.
*/
function lyingPosition(
berth: Pick<Berth, "lat" | "lng" | "bearing" | "maxLength">,
towardLat: number,
towardLng: number,
): { lat: number; lng: number } {
const seaward = bearingBetween(berth.lat, berth.lng, towardLat, towardLng);
const side = normaliseDegrees(berth.bearing + 90);
const water = Math.abs(signedDelta(side, seaward)) <= 90 ? side : normaliseDegrees(side + 180);
const widest = berth.maxLength > 0 ? berth.maxLength : DEFAULT_HULL.container.length;
const beam = beamFor("container", widest * 0.88);
return offsetMetres(berth.lat, berth.lng, water, beam / 2 + BERTH_STANDOFF_METRES);
}
/** Fenders, camels and the gap a ship actually lies off a wall at, in metres. */
const BERTH_STANDOFF_METRES = 6;
/** The point on a polyline nearest a place, and how far off it is, in metres. */
function nearestOnPath(
path: readonly [number, number][],
lat: number,
lng: number,
): { lat: number; lng: number; index: number; t: number; metres: number } | null {
let best: { index: number; t: number; metres: number } | null = null;
for (let i = 1; i < path.length; i++) {
const a = path[i - 1];
const b = path[i];
if (!a || !b) continue;
const scale = Math.cos(((a[0] + b[0]) / 2) * DEG);
const bx = (b[1] - a[1]) * METRES_PER_DEGREE_LAT * scale;
const by = (b[0] - a[0]) * METRES_PER_DEGREE_LAT;
const px = (lng - a[1]) * METRES_PER_DEGREE_LAT * scale;
const py = (lat - a[0]) * METRES_PER_DEGREE_LAT;
const square = bx * bx + by * by;
const t = square > 0 ? clamp01((px * bx + py * by) / square) : 0;
const metres = Math.hypot(px - bx * t, py - by * t);
if (best === null || metres < best.metres) best = { index: i, t, metres };
}
if (best === null) return null;
const a = path[best.index - 1];
const b = path[best.index];
if (!a || !b) return null;
return {
lat: a[0] + (b[0] - a[0]) * best.t,
lng: a[1] + (b[1] - a[1]) * best.t,
index: best.index,
t: best.t,
metres: best.metres,
};
}
/** Total length of a polyline, in metres. */
export function pathMetres(path: readonly [number, number][]): number {
let total = 0;
for (let i = 1; i < path.length; i++) {
const a = path[i - 1];
const b = path[i];
if (!a || !b) continue;
total += metresBetween(a[0], a[1], b[0], b[1]);
}
return total;
}
/** A point `metres` away on a true bearing. Flat-earth, over a few kilometres. */
export function offsetMetres(
lat: number,
lng: number,
bearingDegrees: number,
metres: number,
): { lat: number; lng: number } {
const radians = bearingDegrees * DEG;
const north = (Math.cos(radians) * metres) / METRES_PER_DEGREE_LAT;
const perDegreeLng = METRES_PER_DEGREE_LAT * Math.cos(lat * DEG);
const east = perDegreeLng > 1 ? (Math.sin(radians) * metres) / perDegreeLng : 0;
return { lat: lat + north, lng: lng + east };
}
/** A berthed hull's length: most of the berth, never all of it. */
function berthLength(seed: number, key: string, berth: Berth, kind: VesselKind): number {
const fallback = DEFAULT_HULL[kind];
const maxLength = berth.maxLength > 0 ? berth.maxLength : fallback.length;
/**
* 70-88% of the berth, and the ceiling is what stops a terminal reading as one
* continuous wall of steel.
*
* Photographed: Pier 400's authored berths are 356 m apart and it fills to
* 400 m, so at 97% two consecutive hulls touched stem to stern and the two
* vehicle carriers alongside read as one 700 m object.
*/
return Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
}
/** The kind of ship this call brought, from the berth it is for. */
function callKind(seed: number, key: string, call: HarbourCall): VesselKind {
return berthKind(seed, key, { maxLength: call.maxLength } as Berth);
}
function callLength(seed: number, key: string, call: HarbourCall, kind: VesselKind): number {
const fallback = DEFAULT_HULL[kind];
const maxLength = call.maxLength > 0 ? call.maxLength : fallback.length;
return Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
}
// ---- Arithmetic -----------------------------------------------------------
function readWireSpeed(speed: number | null | undefined): number | null {
@@ -964,10 +1659,3 @@ function berthKind(seed: number, key: string, berth: Berth | BerthAnchor): Vesse
return "vehicle-carrier";
}
function underWayKind(seed: number, key: string): VesselKind {
const roll = hash01(seed, `${key}:kind`);
if (roll < 0.55) return "container";
if (roll < 0.75) return "tanker";
if (roll < 0.9) return "bulk";
return "vehicle-carrier";
}
+23 -3
View File
@@ -33,6 +33,7 @@ import {
isMakingWay,
metresBetween,
modelHarbour,
MODELLED_INTERVAL_SECONDS,
promoteVessels,
reckonVessel,
resolveBearing,
@@ -415,7 +416,17 @@ describe("the modelled harbour, which is what runs this round", () => {
it("says it is modelled, and the panel says so too", () => {
const modelled = modelHarbour([PORT], { seed: 115 });
assert.equal(modelled.source, "modelled");
assert.equal(modelled.intervalSeconds, 900);
/**
* A minute, not the fifteen a real AIS listener declares.
*
* `intervalSeconds` is a property of the *source*, and this source is a
* closed form of the clock that can be asked for any instant — see
* `MODELLED_INTERVAL_SECONDS`. Fifteen minutes was mimicry, and it drew
* arriving ships over Terminal Island, because a consumer dead-reckons along
* a straight course and the Main Channel bends. A real feed still arrives
* declaring its own 900 and is still dead-reckoned for 900.
*/
assert.equal(modelled.intervalSeconds, MODELLED_INTERVAL_SECONDS);
const promotion = promoteVessels(modelled, SAN_PEDRO, berthAnchors([PORT]));
assert.match(vesselSummary(promotion), /Modelled/);
assert.match(vesselSummary(promotion), /no names and no MMSIs/);
@@ -441,8 +452,17 @@ describe("the modelled harbour, which is what runs this round", () => {
SAN_PEDRO,
berthAnchors([PORT]),
);
assert.equal(promotion.makingWay, 3);
assert.ok(promotion.alongside >= 2, "the quays came out empty");
/**
* A band, not a number, and the band is the honest assertion.
*
* `underWayPerPort` sizes the berth cycle so that the transits add up to the
* target, but the legs differ in length and each arriving or departing ship
* may have a tug attending her, so the count breathes. What must hold is
* that the channel is neither empty nor a traffic jam.
*/
assert.ok(promotion.makingWay >= 2, `only ${promotion.makingWay} under way`);
assert.ok(promotion.makingWay <= 8, `${promotion.makingWay} under way is a jam`);
assert.ok(promotion.alongside >= 1, "the quays came out empty");
// Every moving hull has a course, or the layer could not reckon it and would
// not draw a wake — which is the one thing that reads at board scale.
for (const drawn of promotion.drawn) {
+166
View File
@@ -0,0 +1,166 @@
/**
* The gantries' obstruction lights, and the one thing about them a picture
* cannot check.
*
* A screenshot tells you there are red dots over San Pedro Bay. It does not
* tell you they are on the masts *this build drew* rather than on a mast height
* computed a second time in a second file — and that is the whole failure mode,
* because it does not look like a failure. A light hung off an independently
* recomputed apex is a row of red dots hovering a few metres over a row of
* cranes, which at any framing this board is looked at from is indistinguishable
* from a row of red dots on a row of cranes. It would ship.
*
* So the assertion is the *identity*: for every gantry the pack declares, there
* is exactly one light, and its height is the height of the tallest mast box in
* that gantry's own instance list plus the clearance. Nothing here re-derives
* the apex; both sides come out of `ports.ts`, which is the point. This is the
* twin of `nightInfrastructure.test.ts`'s argument about `bridgeLights` and the
* deck it hangs lamps off.
*
* `craneLights` is also the reason `nightlights.ts` may import `ports.ts` at
* all, so the second thing asserted here is that this seam stays what
* CONTRACT §4 allows it to be: **positions, not lights.** The function returns
* numbers. If it ever returns something that can illuminate a surface, this
* fails at compile time and then again here.
*
* The world is Southern California's real projection — `latScale: 285` puts one
* scene unit at 390.6 m, and heights carry the pack's 3.4x exaggeration. A tidy
* 1:1 fake would pass while every apex sat 3.4 times too high, which is exactly
* the class of bug `ports.ts` already has a comment about.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import { craneLights, craneStations, createPorts } from "../../engine/ports.ts";
import { LOS_ANGELES, LONG_BEACH, PORTS } from "../../cities/socal.ts";
import type { World } from "../../engine/world.ts";
/** `socal.ts`: centre 33.82 / -118.05, `latScale: 285`, exaggeration 3.4. */
function socalWorld(): World {
const centre = { lat: 33.82, lng: -118.05 };
const latScale = 285;
const lngScale = latScale * Math.cos((centre.lat * Math.PI) / 180);
const metresPerUnit = 111_320 / latScale;
return {
project(lat: number, lng: number): [number, number] {
return [(lng - centre.lng) * lngScale, -(lat - centre.lat) * latScale];
},
groundAt: () => 0,
metresPerUnit,
metres(value: number): number {
return (value / metresPerUnit) * 3.4;
},
} as unknown as World;
}
/** Every gantry the two packs declare, across both ports. */
function gantryCount(): number {
return PORTS.flatMap((port) => port.cranes ?? []).reduce((total, row) => total + row.count, 0);
}
describe("the container gantries mark themselves after dark", () => {
it("puts one light over every gantry on the board, and no more", () => {
const world = socalWorld();
let lights = 0;
for (const port of PORTS) lights += craneLights(world, port).heads.length / 3;
assert.equal(gantryCount(), 56, "San Pedro Bay is authored with fifty-six gantries");
assert.equal(lights, 56, "one obstruction light per gantry, never per crane *row*");
});
it("hands back numbers, not anything that could light a surface", () => {
const world = socalWorld();
const { heads } = craneLights(world, LOS_ANGELES);
assert.ok(heads.length > 0);
assert.equal(heads.length % 3, 0, "flat xyz triples, as `bridgeLights` returns");
for (const value of heads) {
assert.equal(typeof value, "number");
assert.ok(Number.isFinite(value), "a light off the end of a board is a light nobody finds");
}
});
/**
* The identity this file exists for.
*
* `createPorts` builds the mast; `craneLights` builds the lamp. They must
* agree, and the only honest way to check that is to measure the drawn mesh
* rather than to recompute the arithmetic a third time here — a test that
* recomputes it is a test that passes when all three copies drift together.
*
* The apex is found as the highest point of any instance box, using the box's
* true rotated half-extent: the mast is a strut standing at near eighty
* degrees, so most of its length is in world Y and almost none in the local X
* it is long on.
*/
it("hangs each light on the mast the same build drew, not on a second guess at it", () => {
const world = socalWorld();
// One port, one row, all booms down, so the tallest thing on the board is
// unambiguously a mast and not somebody's raised boom.
const row = (LONG_BEACH.cranes ?? [])[0];
assert.ok(row);
const port = { ...LONG_BEACH, cranes: [{ ...row, idleFraction: 0 }] };
const group = createPorts(world, [port]);
let mesh: THREE.InstancedMesh | null = null;
group.traverse((object) => {
if (object instanceof THREE.InstancedMesh && object.name === "ports:cranes") mesh = object;
});
assert.ok(mesh, "no crane mesh to measure against");
const gantries = mesh as THREE.InstancedMesh;
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();
const basis = new THREE.Matrix4();
let apex = -Infinity;
for (let i = 0; i < gantries.count; i += 1) {
gantries.getMatrixAt(i, matrix);
matrix.decompose(position, quaternion, scale);
basis.makeRotationFromQuaternion(quaternion);
const e = basis.elements;
const halfY =
0.5 * (scale.x * Math.abs(e[1]!) + scale.y * Math.abs(e[5]!) + scale.z * Math.abs(e[9]!));
apex = Math.max(apex, position.y + halfY);
}
assert.ok(Number.isFinite(apex));
const { heads } = craneLights(world, port);
assert.equal(heads.length / 3, craneStations(row).length);
// Every light at the same height — one row of gantries is one height — and
// that height within a member of the drawn apex. The clearance is what stops
// the sprite being half-eaten by the apex cap's own depth; more than a
// member above it and the light has come off the crane.
const member = Math.max(5 / world.metresPerUnit, 0.032);
for (let i = 1; i < heads.length; i += 3) {
const y = heads[i]!;
assert.ok(
y > apex - member * 1.2 && y < apex + member * 1.6,
`light at ${y.toFixed(4)} against a drawn apex of ${apex.toFixed(4)}` +
"the lamp and the mast have stopped agreeing, which looks like nothing at all",
);
}
});
/**
* A light is a *marker*, so it has to be over the mast rather than over the
* boom — the boom moves and the mast does not, and a lamp that followed the
* boom would swing out over the water on a working crane and stand over the
* yard on a parked one. Working and parked rows must agree.
*/
it("does not move when the boom does", () => {
const world = socalWorld();
const row = (LOS_ANGELES.cranes ?? [])[0];
assert.ok(row);
const working = craneLights(world, { ...LOS_ANGELES, cranes: [{ ...row, idleFraction: 0 }] });
const parked = craneLights(world, { ...LOS_ANGELES, cranes: [{ ...row, idleFraction: 1 }] });
assert.equal(working.heads.length, parked.heads.length);
for (let i = 0; i < working.heads.length; i += 1) {
assert.ok(
Math.abs(working.heads[i]! - parked.heads[i]!) < 1e-9,
"an obstruction light followed the boom; it belongs on the mast",
);
}
});
});
+427
View File
@@ -0,0 +1,427 @@
/**
* The harbour's working day: ships arriving, berthing, working and leaving.
*
* The owner's ask was "simulate the boats coming in", and the thing that makes
* that hard is not the animation — it is that this board is supposed to be an
* instrument. A harbour that is *alive* and a harbour that is *honest* pull in
* opposite directions, and every assertion below is on the seam between them.
*
* Four properties carry the file:
*
* 1. **It is a plan, not a state.** `harbourCalls` never sees a clock;
* `modelHarbour` is a closed form of `atMs`. So two people on two machines
* see the same ships, `look.mjs --at` shoots the same frame twice, and — the
* part that is easy to lose — asking for a later instant and then an earlier
* one gives the earlier one back unchanged, because there is no accumulator
* anywhere to have moved. `server/wire.ts` makes exactly this argument for
* the simulated sky, and the sea is the same shape.
*
* 2. **`sog` is the derivative of the position it arrives with.** This is the
* seam that lets a real AIS feed replace the simulator without the renderer
* changing a line. A consumer is licensed to dead-reckon along a reported
* course at a reported speed, so a fix whose speed is not the derivative of
* its own track is a lie the renderer will draw faithfully. It is asserted
* numerically, against the simulator's own next position.
*
* 3. **A moored ship has no wake, and loses it the moment she is tied up.**
* A wake is a function of speed through water; at 391 m to the scene unit it
* is also most of what a ship *is* on this board. An arriving ship that kept
* her V alongside would be the layer claiming motion it has no evidence for.
*
* 4. **Nothing is drawn where a ship cannot float.** A hull lies half a beam off
* the wall rather than centred on it, and a berth the channel cannot reach
* gets no route at all rather than a straight line across a container yard.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import {
BERTH_APPROACH_REACH_METRES,
MODELLED_INTERVAL_SECONDS,
approachRun,
berthAnchors,
harbourCalls,
harbourMoment,
metresBetween,
modelHarbour,
promoteVessels,
reckonVessel,
vesselSummary,
} from "../../server/vessels.ts";
import {
HULL_SHAPES,
hullGeometry,
hullShape,
wakeLengthMetres,
} from "../../engine/vessels.ts";
import SOCAL from "../../cities/socal.ts";
import type { Port } from "../../engine/types.ts";
const PORTS = SOCAL.ports ?? [];
const ANCHORS = berthAnchors(PORTS);
const SEED = 115;
/** An arbitrary but fixed instant. Every sweep below is relative to it. */
const EPOCH = Date.parse("2026-08-23T20:00:00Z");
function harbourAt(atMs: number) {
const body = modelHarbour(PORTS, { seed: SEED, atMs });
return { body, promotion: promoteVessels(body, SOCAL.bounds, ANCHORS, atMs) };
}
/** Every call the two SoCal ports schedule, flattened. */
function allCalls() {
return PORTS.flatMap((port: Port) => harbourCalls(port, { seed: SEED }));
}
describe("the harbour is a plan evaluated at an instant, not a thing that runs", () => {
it("gives the same harbour twice for the same instant", () => {
assert.deepEqual(harbourAt(EPOCH).body, harbourAt(EPOCH).body);
});
it("has no memory: going forward and coming back lands on the same harbour", () => {
// The property a `setInterval` and an accumulator cannot have, and the one
// that makes a scrubbed clock and a capture script agree. Ask for an hour
// later, then two hours earlier, then the original instant again.
const first = harbourAt(EPOCH).body;
harbourAt(EPOCH + 3_600_000);
harbourAt(EPOCH - 7_200_000);
assert.deepEqual(harbourAt(EPOCH).body, first);
});
it("is seeded, so a different seed is a different harbour", () => {
const a = modelHarbour(PORTS, { seed: 115, atMs: EPOCH });
const b = modelHarbour(PORTS, { seed: 116, atMs: EPOCH });
assert.notDeepEqual(a.vessels, b.vessels);
});
it("says out loud that the rate of arrivals is modelled and the speeds are not", () => {
// The SEA panel's standard: name what is drawn from the board's own geometry
// and name the one thing that is a modelling choice, in the same breath.
const { body, promotion } = harbourAt(EPOCH);
const attribution = (body.attribution ?? []).join(" ");
assert.match(attribution, /Not an observation of any vessel/);
assert.match(attribution, /compressed/);
assert.match(vesselSummary(promotion), /Modelled from this board's own berths and channels/);
assert.match(vesselSummary(promotion), /no names and no MMSIs/);
// The panel, not just the licence sheet: a reader watching a berth change
// hands twice in an afternoon must be told that rate is the modelled part.
assert.match(vesselSummary(promotion), /arrivals run at about seven times a real day's rate/);
assert.match(vesselSummary(promotion), /Ship speeds, the channel and the berths are true/);
});
});
describe("a working day: in through the channel, alongside, and out again", () => {
it("walks every berth through inbound, alongside, outbound and empty, in that order", () => {
const call = allCalls()[0];
assert.ok(call, "no berth was given a call at all");
const seen: string[] = [];
for (let i = 0; i < 600; i++) {
const phase = harbourMoment(call, call.offsetSeconds + (i * call.cycleSeconds) / 600).phase;
if (seen[seen.length - 1] !== phase) seen.push(phase);
}
assert.deepEqual(seen, ["inbound", "alongside", "outbound", "empty"]);
});
it("brings an arriving ship steadily closer to her berth and never past it", () => {
const call = allCalls()[0];
assert.ok(call);
const berth = call.approach[call.approach.length - 1];
assert.ok(berth);
let previous = Infinity;
for (let i = 0; i <= 50; i++) {
const at = call.offsetSeconds + (i / 50) * call.inboundSeconds * 0.999;
const moment = harbourMoment(call, at);
assert.equal(moment.phase, "inbound");
const ship = shipOf(call, at);
assert.ok(ship, "an inbound leg with no ship on it");
const metres = metresBetween(ship.lat, ship.lon, berth[0], berth[1]);
assert.ok(metres <= previous + 1, `the ship went backwards at ${i}`);
previous = metres;
}
// And arrives: within a ship's length of the quay by the end of the leg.
assert.ok(previous < 200, `she stopped ${previous.toFixed(0)} m short`);
});
it("gives a berth a fresh ship each cycle rather than the same one for ever", () => {
const call = allCalls()[0];
assert.ok(call);
const first = harbourMoment(call, call.offsetSeconds + 60);
const next = harbourMoment(call, call.offsetSeconds + call.cycleSeconds + 60);
assert.equal(next.index, first.index + 1);
assert.notEqual(
shipOf(call, call.offsetSeconds + 60)?.id,
shipOf(call, call.offsetSeconds + call.cycleSeconds + 60)?.id,
);
});
it("keeps the channel busy without turning it into a parade", () => {
// Swept rather than sampled once: the interesting failure is a schedule that
// is right at one instant and has nine ships in one channel at another.
let most = 0;
let fewest = Infinity;
for (let minute = 0; minute < 240; minute += 5) {
const { promotion } = harbourAt(EPOCH + minute * 60_000);
most = Math.max(most, promotion.makingWay);
fewest = Math.min(fewest, promotion.makingWay);
assert.ok(promotion.drawn.length <= 32, `${promotion.drawn.length} hulls on the board`);
assert.equal(promotion.offBoard, 0, "a modelled hull fell off its own board");
assert.equal(promotion.withoutOrientation, 0, "a modelled hull would not say which way it faced");
}
assert.ok(fewest >= 1, "the harbour went completely still");
assert.ok(most <= 12, `${most} hulls under way at once is a parade`);
});
});
describe("the seam a real AIS feed has to slot into", () => {
it("reports a speed that is the derivative of the position it reports", () => {
/**
* The load-bearing assertion in this file.
*
* `reckonVessel` advances a fix along its own reported course at its own
* reported speed, and that is the only motion the renderer is licensed to
* draw. So for every moving hull the simulator emits, dead-reckoning it one
* declared interval forward has to land near where the simulator itself puts
* it an interval later. The tolerance is a scene unit — 391 m on SoCal —
* because the ship is also *turning* over that interval and a straight
* course cannot follow a bend. That residual is a true property of a
* course-and-speed feed and is exactly why the declared interval is a minute
* and not the fifteen a real listener will send.
*/
const step = MODELLED_INTERVAL_SECONDS;
const before = harbourAt(EPOCH).body.vessels;
const after = new Map(harbourAt(EPOCH + step * 1000).body.vessels.map((v) => [v.id, v]));
let checked = 0;
for (const fix of before) {
if (fix.speed < 0.257 || fix.course === null) continue;
const later = after.get(fix.id);
if (!later) continue;
const reckoned = reckonVessel(
{ lat: fix.lat, lng: fix.lon, speed: fix.speed, course: fix.course },
step,
);
const error = metresBetween(reckoned.lat, reckoned.lng, later.lat, later.lon);
assert.ok(error < 391, `${fix.id} dead-reckoned ${error.toFixed(0)} m off its own next fix`);
checked += 1;
}
assert.ok(checked >= 2, `only ${checked} moving hulls were checkable`);
});
it("declares a minute, because a straight course cannot follow a bent channel", () => {
assert.equal(harbourAt(EPOCH).body.intervalSeconds, MODELLED_INTERVAL_SECONDS);
assert.ok(MODELLED_INTERVAL_SECONDS <= 60);
});
it("never volunteers a heading, so the berths keep doing the orienting", () => {
for (const fix of harbourAt(EPOCH).body.vessels) assert.equal(fix.heading, null);
const { promotion } = harbourAt(EPOCH);
assert.ok(promotion.alongside > 0, "no modelled hull found its berth");
});
it("carries no name, no MMSI, no callsign and no destination", () => {
for (const fix of harbourAt(EPOCH).body.vessels) {
for (const forbidden of ["name", "mmsi", "callsign", "destination", "laden"]) {
assert.equal(forbidden in fix, false, `a modelled vessel carried ${forbidden}`);
}
}
});
});
describe("the wake is the ship, so a berthed ship must not have one", () => {
it("drops the wake to nothing in the same fix that puts her alongside", () => {
const call = allCalls()[0];
assert.ok(call);
// The last minute of the run in, then the first of the lie alongside.
const arriving = shipOf(call, call.offsetSeconds + call.inboundSeconds * 0.98);
const berthed = shipOf(call, call.offsetSeconds + call.inboundSeconds + 60);
assert.ok(arriving && berthed);
assert.ok(wakeLengthMetres(arriving.speed, arriving.length ?? 300) >= 0);
assert.equal(berthed.speed, 0);
assert.equal(wakeLengthMetres(berthed.speed, berthed.length ?? 300), 0);
});
it("leaves no hull at rest with a wake anywhere on the board, at any hour", () => {
for (let minute = 0; minute < 240; minute += 7) {
for (const drawn of harbourAt(EPOCH + minute * 60_000).promotion.drawn) {
if (drawn.berthId === undefined) continue;
assert.equal(drawn.speed, 0, `${drawn.id} is alongside and moving`);
assert.equal(wakeLengthMetres(drawn.speed, drawn.length), 0);
}
}
});
it("gives an arriving ship a wake long enough to read at board scale", () => {
// 391 m to the unit: a 300 m hull is 0.77 units and a fleck, and its wake at
// full speed is over three units and is the thing that says "moving".
const call = allCalls().find((c) => c.approachMetres > 6000);
assert.ok(call);
const early = shipOf(call, call.offsetSeconds + call.inboundSeconds * 0.1);
assert.ok(early);
const wake = wakeLengthMetres(early.speed, early.length ?? 300);
assert.ok(wake > 3 * 391, `an entering ship's wake is only ${wake.toFixed(0)} m`);
});
});
describe("nothing is drawn where a ship cannot float", () => {
it("lies a berthed hull off the wall rather than through it", () => {
let checked = 0;
for (let minute = 0; minute < 180; minute += 11) {
for (const drawn of harbourAt(EPOCH + minute * 60_000).promotion.drawn) {
if (drawn.berthId === undefined) continue;
const anchor = ANCHORS.find((a) => a.id === drawn.berthId);
if (!anchor) continue;
const call = allCalls().find((c) => c.berthId === drawn.berthId);
if (!call) continue; // an unscheduled berth still lies on its own point
const off = metresBetween(drawn.lat, drawn.lng, anchor.lat, anchor.lng);
assert.ok(off >= drawn.beam / 2, `${drawn.id} is ${off.toFixed(0)} m off a ${drawn.beam} m beam`);
checked += 1;
}
}
assert.ok(checked > 0, "no scheduled berth was ever occupied");
});
it("refuses to route to a berth the channel does not reach", () => {
// Los Angeles' East Basin is three kilometres off the Main Channel, and the
// straight line between them crosses Terminal Island. A berth like that
// keeps a hull alongside and takes no calls — see the constant's own note.
const lax = PORTS.find((p: Port) => p.id === "USLAX");
assert.ok(lax);
const scheduled = new Set(harbourCalls(lax, { seed: SEED }).map((c) => c.berthId));
assert.ok(scheduled.has("lax-401"), "Pier 400 is on the channel and should be worked");
assert.equal(scheduled.has("lax-232"), false, "the East Basin was routed to across the island");
// And the quay is not left bare: the unreachable berths still carry hulls.
const alongside = harbourAt(EPOCH).promotion.drawn.filter((v) => v.berthId?.startsWith("lax-2"));
assert.ok(alongside.length > 0, "the East Basin came out empty");
});
it("keeps every routed berth inside the reach the routing promises", () => {
for (const call of allCalls()) {
const berth = call.approach[call.approach.length - 1];
const from = call.approach[call.approach.length - 2];
assert.ok(berth && from);
const cross = metresBetween(berth[0], berth[1], from[0], from[1]);
assert.ok(
cross <= BERTH_APPROACH_REACH_METRES + 1,
`${call.berthId} runs ${cross.toFixed(0)} m off the channel`,
);
}
});
});
describe("the tug, which is the hull that is usually moving", () => {
it("comes out to meet an arriving ship and is gone once she is tied up", () => {
const call = allCalls()[0];
assert.ok(call);
const tugAt = (x: number) =>
modelHarbour(PORTS, { seed: SEED, atMs: (call.offsetSeconds + x * call.inboundSeconds) * 1000 })
.vessels.find((v) => v.id === `m-${call.portId}:${call.berthId}:0:tug`);
assert.equal(tugAt(0.2), undefined, "a tug was out before there was anything to meet");
assert.ok(tugAt(0.7), "no tug met the arriving ship");
assert.equal(tugAt(0.99), undefined, "the tug was still under way after she berthed");
});
it("runs seaward to make the meeting, against the ship it is meeting", () => {
// The sub-leg worth having: two wakes crossing in opposite directions is a
// harbour doing something, where a tug that materialises alongside is a decal.
const call = allCalls()[0];
assert.ok(call);
const at = (call.offsetSeconds + 0.47 * call.inboundSeconds) * 1000;
const fleet = modelHarbour(PORTS, { seed: SEED, atMs: at }).vessels;
const ship = fleet.find((v) => v.id === `m-${call.portId}:${call.berthId}:0`);
const tug = fleet.find((v) => v.id === `m-${call.portId}:${call.berthId}:0:tug`);
assert.ok(ship && tug, "no ship-and-tug pair at the meeting");
assert.ok(ship.course !== null && tug.course !== null);
const between = Math.abs(((((tug.course - ship.course) % 360) + 540) % 360) - 180);
assert.ok(between > 120, `the tug is running with her, ${between.toFixed(0)} apart`);
assert.ok(tug.speed > ship.speed * 0.5, "the tug is loitering rather than running");
});
it("is drawn from its own solid, which is one more draw call and only when it floats", () => {
assert.equal(hullShape("tug"), "tug");
assert.equal(hullShape("container"), "generic");
assert.equal(HULL_SHAPES.length, 2, "a third hull shape is a third draw call");
const tug = hullGeometry("tug");
const triangles = tug.getAttribute("position").count / 3;
assert.equal(triangles, 64, `the tug is ${triangles} triangles`);
for (const attribute of ["position", "normal", "uv"]) {
assert.ok(tug.getAttribute(attribute), `the tug hull has no ${attribute}`);
}
tug.dispose();
});
it("is wound outward, by the same flux test the merchant hull is held to", () => {
const geometry = hullGeometry("tug");
const position = geometry.getAttribute("position");
let flux = 0;
for (let t = 0; t < position.count; t += 3) {
const a = new THREE.Vector3().fromBufferAttribute(position, t);
const b = new THREE.Vector3().fromBufferAttribute(position, t + 1);
const c = new THREE.Vector3().fromBufferAttribute(position, t + 2);
const face = new THREE.Vector3()
.subVectors(b, a)
.cross(new THREE.Vector3().subVectors(c, a))
.multiplyScalar(0.5);
flux += a.clone().add(b).add(c).divideScalar(3).dot(face);
}
assert.ok(flux > 0, `the tug hull encloses ${flux.toFixed(3)} — a face is inside out`);
geometry.dispose();
});
it("carries the mass forward, which is the whole of the silhouette", () => {
/**
* The one measurement that separates a tug from a small freighter, asserted
* because "tidy up the hull" is exactly the change that would undo it. The
* bow is at -z, so the superstructure's centre of area must sit *forward* of
* amidships — the opposite of the merchant hull, whose house and funnel are
* aft over the screw.
*/
const centroid = (shape: "generic" | "tug") => {
const position = hullGeometry(shape).getAttribute("position");
let sum = 0;
let n = 0;
for (let i = 0; i < position.count; i++) {
if (position.getY(i) <= 1.05) continue; // hull only below the main deck
sum += position.getZ(i);
n += 1;
}
return n > 0 ? sum / n : 0;
};
assert.ok(centroid("tug") < 0, "the tug's house is aft, which makes it a small ship");
assert.ok(centroid("generic") > 0, "the merchant house moved forward");
});
});
// ---- Helpers ---------------------------------------------------------------
/** The ship this call has on the board at `atSeconds`, from the whole body. */
function shipOf(call: ReturnType<typeof allCalls>[number], atSeconds: number) {
const moment = harbourMoment(call, atSeconds);
return modelHarbour(PORTS, { seed: SEED, atMs: atSeconds * 1000 }).vessels.find(
(v) => v.id === `m-${call.portId}:${call.berthId}:${moment.index}`,
);
}
describe("the arithmetic under the run in", () => {
it("enters at speed and arrives at nothing", () => {
const fast = approachRun(9000, 2150, 0.02, "inbound");
const slow = approachRun(9000, 2150, 0.98, "inbound");
assert.ok(fast.speedMps > 6, `entering at ${fast.speedMps.toFixed(1)} m/s`);
assert.ok(slow.speedMps < 1, `berthing at ${slow.speedMps.toFixed(1)} m/s`);
assert.ok(fast.arcMetres < slow.arcMetres);
});
it("leaves at nothing and departs at speed, which is the run in backwards", () => {
const slipping = approachRun(9000, 2000, 0.02, "outbound");
const away = approachRun(9000, 2000, 0.98, "outbound");
assert.ok(slipping.speedMps < 1);
assert.ok(away.speedMps > 6);
// Arc is measured from the seaward end, so a departure counts down.
assert.ok(slipping.arcMetres > away.arcMetres);
});
it("is total: a zero-length or zero-time leg is still a number", () => {
assert.deepEqual(approachRun(0, 100, 0.5, "inbound"), { arcMetres: 0, speedMps: 0 });
assert.deepEqual(approachRun(100, 0, 0.5, "outbound"), { arcMetres: 0, speedMps: 0 });
});
});
+52 -7
View File
@@ -9,8 +9,8 @@
* merge-across-ports property is asserted here rather than assumed: **four ports
* must produce exactly the same mesh count as one.**
*
* **No crane is a `Group`.** Fifty-six gantries at five boxes each is 280
* matrices in one `InstancedMesh` or 280 draw calls, and this repo has already
* **No crane is a `Group`.** Fifty-six gantries at eleven boxes each is 616
* matrices in one `InstancedMesh` or 616 draw calls, and this repo has already
* made the second mistake twice a suspension bridge at ~34 draw calls, and
* twelve identical asphalt freeways that could never merge because a fresh
* material was allocated per ribbon. A test is the only thing that keeps the
@@ -175,7 +175,7 @@ describe("every gantry on the board is one InstancedMesh", () => {
assert.deepEqual(groups, [], `a crane became a Group: ${groups.join(", ")}`);
});
it("puts five boxes per gantry in a single instanced mesh", () => {
it("puts eleven boxes per gantry in a single instanced mesh", () => {
const group = createPorts(socalWorld(), PORTS);
const cranes = meshes(group).filter((mesh) => mesh.name === "ports:cranes");
assert.equal(cranes.length, 1, "there must be exactly one crane mesh for the whole board");
@@ -186,7 +186,20 @@ describe("every gantry on the board is one InstancedMesh", () => {
0,
);
assert.equal(gantries, 56, "San Pedro Bay is authored with fifty-six gantries");
assert.equal((mesh as THREE.InstancedMesh).count, gantries * 5);
// The census, and the reason it is a number and not a range. A gantry was
// five boxes — two legs, a portal beam, a boom, a backreach — and fifty-six
// of those read from altitude as fifty-six crosses. It is eleven now, the
// six new ones being the A-frame mast and its apex cap, the two stays that
// sling the boom off it, the sill under the legs and the machinery house
// over the tail. 132 triangles a gantry against 60: **7,392 for the whole
// of San Pedro Bay against 3,360**, on a board whose desktop budget is 1.7
// million and which measures 1.43 million with this in it. The count is
// pinned because the thing being defended is not the triangles, it is that
// adding a part to a crane must stay an entry in a matrix list — the moment
// it becomes a child mesh this is 616 draw calls.
assert.equal((mesh as THREE.InstancedMesh).count, gantries * 11);
const triangles = (mesh as THREE.InstancedMesh).count * 12;
assert.equal(triangles, 7392);
});
it("raises exactly the booms the pack asked for, from the far end of the rail", () => {
@@ -210,23 +223,55 @@ describe("every gantry on the board is one InstancedMesh", () => {
const idle = createPorts(world, [
{ ...LOS_ANGELES, cranes: [{ ...(LOS_ANGELES.cranes ?? [])[0]!, idleFraction: 1 }] },
]);
/**
* The true world-space top of a gantry: every box's own axis-aligned
* extent, not `position.y + scale.x / 2`.
*
* The cheap version was right when the only tilted box was the boom, whose
* long axis is X. It is wrong for the mast and the two stays a strut
* standing at eighty degrees has almost all of its length in Y and none of
* it in the X the shortcut reads. Measuring the rotated half-extent is four
* more lines and cannot be fooled by which axis a part happens to be long
* on.
*/
const topOf = (group: THREE.Object3D) => {
const mesh = meshes(group).find((m) => m.name === "ports:cranes") as THREE.InstancedMesh;
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const scale = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const basis = new THREE.Matrix4();
let highest = -Infinity;
for (let i = 0; i < mesh.count; i += 1) {
mesh.getMatrixAt(i, matrix);
matrix.decompose(position, quaternion, scale);
highest = Math.max(highest, position.y + scale.x / 2);
basis.makeRotationFromQuaternion(quaternion);
const e = basis.elements;
// Row 1 of the rotation, dotted with the half-extents' magnitudes.
const halfY =
0.5 *
(scale.x * Math.abs(e[1]!) + scale.y * Math.abs(e[5]!) + scale.z * Math.abs(e[9]!));
highest = Math.max(highest, position.y + halfY);
}
return highest;
};
/**
* 1.1, where it used to be 1.4, and the mast is the whole reason.
*
* A working gantry's tallest part is no longer its own deck line it is
* the apex of the A-frame, `CRANE_MAST_RISE` of the portal above the
* girder. So the ratio this asserts is now the ratio between a raised boom
* tip and an apex, and on real hardware that is 1.16 (a 104 m apex under a
* 121 m boom tip) to 1.25 (99 m under 133), not 1.4. Asking for 1.4 would
* be asking for a mast shorter than any gantry has.
*
* It still catches the bug it was written for. Rotating the boom instead of
* composing it takes the tip through the *unexaggerated* horizontal, which
* on this board lands it below the apex ratio under 1, and a failure.
*/
assert.ok(
topOf(idle) > topOf(working) * 1.4,
"a raised boom must reach well above a lowered one; the vertical axis is exaggerated and the horizontal is not, so the boom has to be composed rather than rotated",
topOf(idle) > topOf(working) * 1.1,
"a raised boom must reach well above the mast apex; the vertical axis is exaggerated and the horizontal is not, so the boom has to be composed rather than rotated",
);
});
});