feat: one California by default, and a box ship you can see
Two complaints, and the measured cause of each turned out to be a number already written down in this repo rather than anything that needed inventing. **"I see three different maps."** `reconcile.ts` has existed for a round with the four rules that make three packs draw one California, behind a flag whose whole purpose was that the owner could choose from a photograph — and `DEFAULT_RULES` was empty, so nobody ever saw the reconciled board without typing a query string. The photographs are now taken, at HEAD, at a matched 17:10 and a matched pose against the live API, and two of the four rules are on: - `exaggeration`. Relief in frame is 7.24% on California, 6.92% on the Southland and **4.51%** on the Bay: two boards were already on one number and one was not. The rule moves SF and only SF, 3.60 -> 5.78, and leaves the two boards every marketing still was tuned on untouched to the digit. Unreconciled, the Bay draws Tamalpais, the Berkeley hills and Mount Diablo as low smeared mounds; at 5.78 they stand up, and the chapter's own caption — "the Diablo range closing the east" — is true of the picture for the first time. - `projection`. Each pack squashed longitude by the cosine of its *own* centre, so California and the Southland disagreed about Riverside by 3,435 m and about their shared corner by 4,025 m: 3% of the frame at a 109 km stand-off, and a visible sideways slide in any transition showing both. One canvas cannot hold two answers for one place. `ground` stays off: its palette mix pushes the Southland's flats yellow-olive inland, because that pack's `flats` is off the one-parameter family in a direction no mix can reach. Its own comment calls it the riskiest of the four and the photograph agrees. `roads` stays off for the reason argued in the file — it rewrites a column nothing reads on the one board where the complaint shows. **"I don't see boats on our ports."** True, and by construction. `Crane`'s own doc note already had the cause: at SoCal's 3.4x a 130 m gantry stands 1.13 units while a 400 m ship is 1.02 units *long*. Exaggeration multiplies heights and a length takes 1x, so a hull is the one object on this coast that the board flattens while stretching everything around it — and eleven of fifteen hulls in a working harbour are alongside a berth, where a moored hull has no wake by design and so had nothing to be seen by. `FREEBOARD_RATIO` had already named the fix: a laden ULCV "stands about thirty metres out of the water **before you count the container stack, and the stack is what makes the silhouette**". The stack was never drawn. `containerHull` draws it — three bays with the forward one a tier down, eight tiers of high-cube at 0.52 of keel-to-deck depth — which buys height and nothing else. No length, no beam: a new test holds the container solid inside the merchant hull's bounding box in x and z, so a hull still cannot overhang the berth it lies in, and `vesselScale.test.ts` is green to the digit. It arrived through the seam `HULL_SHAPES` said it would — one entry, one case in `hullGeometry`, one arm in `hullShape` — which is the only evidence that seam was real rather than merely claimed. **The band is a modulation map, and the first pass of it was not.** Painted at `PORT_PALETTE`'s real box values under a container ship's own 0x93aac4 tint, the stack photographed near black — darker than the hull under it. `deckAtlas`'s own header had predicted that exact failure two paragraphs above where I was editing. So hue comes from the tint like every other part of every other hull, and the band carries relative value and *structure*: at the 25 px a stack draws at, individual box hues average to one tone and what survives is the shadow between two boxes. Adding a fourth band also had to move the other three, since they occupied 0.02..0.98 with nothing to append to, so band edges are now computed from one layout rather than typed as six literals — and the four hardcoded `v` values in the bow triangles read from it too. Three tests were pinned rather than changed: they measure packs as authored or synthetic one-hill fixtures, and each had an implicit dependency on the flag being off. Following `roadWidth.test.ts`, which already did this. Verified: 1,690 tests pass. All ten performance-budget cells pass with no cap raised — bay-area desktop 2,306,424 triangles of 2,600,000 (the taller board keeping more through the height guard), socal +284 triangles and +1 draw call for the container mesh, of 1,700,000 and 320. Frame time is not claimed either way; this box's GPU never leaves 500 MHz of 2,725. ⚠ This changes the default, so lumbridgecorp.com's 21 stills and 4 films are now pictures of a board the product no longer draws. `npm run refresh` is the whole of that work and it is not optional. `?reconcile=0` asks for the old board. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+43
-1
@@ -189,7 +189,49 @@ function warn(message: string): void {
|
|||||||
* The rule stays available — `?reconcile=roads` — and the real fix for the state
|
* The rule stays available — `?reconcile=roads` — and the real fix for the state
|
||||||
* board's freeway lives in `structures.ts`, not here.
|
* board's freeway lives in `structures.ts`, not here.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_RULES: readonly Rule[] = [];
|
export const DEFAULT_RULES: readonly Rule[] = ["exaggeration", "projection"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## Why those two are on and the other two are not
|
||||||
|
*
|
||||||
|
* The flag was built so the owner could choose from a photograph rather than
|
||||||
|
* from an argument, and the photographs have now been taken at HEAD, at a
|
||||||
|
* matched hour and a matched pose, against the live API. The verdict:
|
||||||
|
*
|
||||||
|
* - **`exaggeration` on.** The table above is the whole case: relief in frame
|
||||||
|
* is 7.24% on California, 6.92% on Southern California and **4.51%** on San
|
||||||
|
* Francisco. Two boards were already on one number and one was not. At a
|
||||||
|
* matched 17:10 the unreconciled Bay draws Tamalpais, the Berkeley hills and
|
||||||
|
* Mount Diablo as low smeared mounds; at 5.78 they stand up and the
|
||||||
|
* chapter's own caption — "the Diablo range closing the east" — becomes true
|
||||||
|
* of the picture for the first time. It moves SF and only SF, by 1.6x, and
|
||||||
|
* leaves the two boards every marketing still was tuned on untouched to the
|
||||||
|
* digit.
|
||||||
|
* - **`projection` on.** This is the one the "one canvas" ask actually needs.
|
||||||
|
* Each pack squashes longitude by the cosine of its *own* centre, so
|
||||||
|
* California and Southern California disagree about where Riverside is by
|
||||||
|
* 3,435 m and about their shared north-east corner by 4,025 m — about 3% of
|
||||||
|
* the frame at a 109 km stand-off, which is a visible sideways slide in any
|
||||||
|
* transition that shows both boards at once. A continuous world cannot have
|
||||||
|
* two answers for one place. The price is 4.25% of local aspect on the
|
||||||
|
* Southland against a registration error that goes to zero by construction,
|
||||||
|
* and that is the right side of the trade for a board that is a crop of
|
||||||
|
* another board.
|
||||||
|
* - **`ground` stays off.** Its own doc comment calls it the riskiest of the
|
||||||
|
* four and the photograph agrees: the palette mix pushes the Southland's
|
||||||
|
* flats distinctly yellow-olive inland, because `socal`'s `flats` is off the
|
||||||
|
* one-parameter family in a direction no mix can reach. That is a look
|
||||||
|
* decision about the board carrying the most imagery, and it is not settled
|
||||||
|
* by the registration argument that settles `projection`.
|
||||||
|
* - **`roads` stays off**, for the reason argued at length above: it rewrites
|
||||||
|
* a column nothing reads on the one board where the complaint is visible.
|
||||||
|
*
|
||||||
|
* ⚠ **This changes the default, so it re-shoots the site.** Every still and film
|
||||||
|
* on lumbridgecorp.com was photographed with all four rules off. `npm run
|
||||||
|
* refresh` is the whole of the work, but it is not optional: leaving it undone
|
||||||
|
* ships a site whose Bay Area pictures are of a board the product no longer
|
||||||
|
* draws. `?reconcile=0` is how a measurement asks for the old board.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which rules are on right now.
|
* Which rules are on right now.
|
||||||
|
|||||||
+257
-37
@@ -256,7 +256,7 @@ const WAKE_COLOR_DAY = 0xe8f0f4;
|
|||||||
* between an enum the factory switches on and a hardcoded builder, and it is why
|
* between an enum the factory switches on and a hardcoded builder, and it is why
|
||||||
* this function exists despite currently having one answer.
|
* this function exists despite currently having one answer.
|
||||||
*/
|
*/
|
||||||
export type HullShape = "generic" | "tug";
|
export type HullShape = "generic" | "tug" | "container";
|
||||||
|
|
||||||
export function hullShape(kind: VesselKind): HullShape {
|
export function hullShape(kind: VesselKind): HullShape {
|
||||||
// The arms are where better geometry lands one kind at a time, which is why
|
// The arms are where better geometry lands one kind at a time, which is why
|
||||||
@@ -266,6 +266,7 @@ export function hullShape(kind: VesselKind): HullShape {
|
|||||||
case "tug":
|
case "tug":
|
||||||
return "tug";
|
return "tug";
|
||||||
case "container":
|
case "container":
|
||||||
|
return "container";
|
||||||
case "tanker":
|
case "tanker":
|
||||||
case "bulk":
|
case "bulk":
|
||||||
case "vehicle-carrier":
|
case "vehicle-carrier":
|
||||||
@@ -277,16 +278,21 @@ export function hullShape(kind: VesselKind): HullShape {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every shape a board may need a mesh for. Two.
|
* Every shape a board may need a mesh for. Three.
|
||||||
*
|
*
|
||||||
* Two shapes is **two draw calls at most and one on a board with no tug**: the
|
* Three shapes is **three draw calls at most, and fewer on most boards**: the
|
||||||
* layer builds an `InstancedMesh` per shape up front and adds it to the group
|
* 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
|
* 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
|
* hull *geometry* exist and never of how many ships are floating. The Bay board
|
||||||
* — the container ship the owner is modelling — is one more entry here, one more
|
* with no tug and no box ship on it pays for one.
|
||||||
* case in `hullGeometry`, and nothing else.
|
*
|
||||||
|
* `container` is the third and it arrived exactly the way this note said it
|
||||||
|
* would — one entry here, one case in `hullGeometry`, one arm in `hullShape` —
|
||||||
|
* which is the only evidence that the seam was real rather than merely claimed.
|
||||||
|
* A fourth (a tanker's flush deck and midships manifold is the obvious next one)
|
||||||
|
* is the same three edits.
|
||||||
*/
|
*/
|
||||||
export const HULL_SHAPES: readonly HullShape[] = ["generic", "tug"];
|
export const HULL_SHAPES: readonly HullShape[] = ["generic", "tug", "container"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draught as a fraction of length, per kind.
|
* Draught as a fraction of length, per kind.
|
||||||
@@ -378,6 +384,8 @@ export function hullGeometry(shape: HullShape = "generic"): THREE.BufferGeometry
|
|||||||
return genericHull();
|
return genericHull();
|
||||||
case "tug":
|
case "tug":
|
||||||
return tugHull();
|
return tugHull();
|
||||||
|
case "container":
|
||||||
|
return containerHull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,9 +397,46 @@ export function hullGeometry(shape: HullShape = "generic"): THREE.BufferGeometry
|
|||||||
* that a second hull shape cannot quietly wear a third set of bands and drift
|
* 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.
|
* out of step with the one canvas they all sample.
|
||||||
*/
|
*/
|
||||||
const SIDE: Band = [0.02, 0.31];
|
/**
|
||||||
const DECK: Band = [0.35, 0.64];
|
* The atlas is four bands now, and they are **computed from one layout** rather
|
||||||
const HOUSE: Band = [0.69, 0.98];
|
* than typed as six literals.
|
||||||
|
*
|
||||||
|
* The fourth band is the container stack, and adding it had to move the other
|
||||||
|
* three: `v` runs 0..1 over the whole canvas, so there was no free space to
|
||||||
|
* append to — the old three occupied 0.02..0.98. That is exactly the edit that
|
||||||
|
* produced this file's worst shipped bug the first time round, when every hull
|
||||||
|
* wore the superstructure's window rows down its side because `flipY` inverted
|
||||||
|
* the bands and it read merely as "the ships are a bit dark". So the edges are
|
||||||
|
* no longer writable by hand: `band(i)` is the only thing that knows where a
|
||||||
|
* band is, `ATLAS_ROWS` is the only thing that knows how tall the canvas is, and
|
||||||
|
* `deckAtlas` paints through the same function the geometry samples through. A
|
||||||
|
* fifth band is one number here.
|
||||||
|
*
|
||||||
|
* `INSET` keeps a band's drawn pixels off its own edges, because a `LinearFilter`
|
||||||
|
* sample at the seam between two bands mixes them: without it the boot-top
|
||||||
|
* bled into the hatch coamings at grazing angles.
|
||||||
|
*/
|
||||||
|
const ATLAS_BANDS = 4;
|
||||||
|
const ATLAS_ROWS = 128;
|
||||||
|
const INSET = 2 / ATLAS_ROWS;
|
||||||
|
|
||||||
|
/** Band `i` of `ATLAS_BANDS`, in `v`, inset from its own edges. */
|
||||||
|
function band(index: number): Band {
|
||||||
|
const height = 1 / ATLAS_BANDS;
|
||||||
|
return [index * height + INSET, (index + 1) * height - INSET];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rows band `i` occupies on the canvas — the same edges, in pixels. */
|
||||||
|
function bandRows(index: number): readonly [number, number] {
|
||||||
|
const [v0, v1] = band(index);
|
||||||
|
return [Math.round(v0 * ATLAS_ROWS), Math.round(v1 * ATLAS_ROWS)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDE: Band = band(0);
|
||||||
|
const DECK: Band = band(1);
|
||||||
|
const HOUSE: Band = band(2);
|
||||||
|
/** Stacked boxes on a hatch cover: the band `containerHull` is drawn through. */
|
||||||
|
const BOX: Band = band(3);
|
||||||
|
|
||||||
type P = readonly [number, number, number];
|
type P = readonly [number, number, number];
|
||||||
type Band = readonly [number, number];
|
type Band = readonly [number, number];
|
||||||
@@ -490,8 +535,8 @@ function genericHull(): THREE.BufferGeometry {
|
|||||||
// ---- Bow: two side quads and two triangles, six triangles.
|
// ---- Bow: two side quads and two triangles, six triangles.
|
||||||
f.quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
|
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.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, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, DECK[0]], [1, DECK[0]], [0.5, DECK[1]]);
|
||||||
f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, 0.02], [0.5, 0.31], [1, 0.02]);
|
f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, SIDE[0]], [0.5, SIDE[1]], [1, SIDE[0]]);
|
||||||
|
|
||||||
// ---- Aft house: a box on the quarterdeck, twelve triangles.
|
// ---- Aft house: a box on the quarterdeck, twelve triangles.
|
||||||
f.box(-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);
|
||||||
@@ -502,6 +547,107 @@ function genericHull(): THREE.BufferGeometry {
|
|||||||
return f.finish("vessel-hull");
|
return f.finish("vessel-hull");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The box ship: seventy-six triangles, and the only hull whose cargo is drawn.
|
||||||
|
*
|
||||||
|
* ### Why this shape had to exist
|
||||||
|
*
|
||||||
|
* Because vertical exaggeration is the one thing that flattens a ship, and
|
||||||
|
* nothing else on this coast. `Crane`'s own doc note says it plainly — at
|
||||||
|
* SoCal's 3.4x a 130 m gantry stands 1.13 scene units while a 400 m ship is
|
||||||
|
* 1.02 units *long*, so the crane is taller than the ship is long. Exaggeration
|
||||||
|
* multiplies heights; a length is a plan measurement and takes 1x. So every
|
||||||
|
* object around a hull is stretched upward by 3.4x and the hull is not, and the
|
||||||
|
* result is the defect the owner reported: at the Harbour pose the cranes, the
|
||||||
|
* yards and the bridge read instantly and the ships do not read at all. Eleven
|
||||||
|
* of fifteen hulls in a busy harbour are alongside a berth, and a moored hull
|
||||||
|
* has no wake by design, so for eleven of them there was nothing to see.
|
||||||
|
*
|
||||||
|
* ### The fix is truthful rather than generous
|
||||||
|
*
|
||||||
|
* `FREEBOARD_RATIO` already says where it is: "a loaded ULCV stands about thirty
|
||||||
|
* metres out of the water **before you count the container stack, and the stack
|
||||||
|
* is what makes the silhouette**". The stack was never drawn. Drawing it adds no
|
||||||
|
* length and no beam — `vesselScale.test.ts` stays green to the digit, and a
|
||||||
|
* hull still cannot overhang the berth it lies in — and it roughly triples the
|
||||||
|
* above-water mass, because it is above-water height and above-water height is
|
||||||
|
* the axis this board multiplies by 3.4.
|
||||||
|
*
|
||||||
|
* Eight tiers of 2.9 m high-cube is 23.2 m of boxes. The unit here is keel-to-
|
||||||
|
* deck depth, which for a container ship is `(0.036 + 0.075) x length`, so the
|
||||||
|
* stack is `23.2 / (0.111 x length)` units tall: **0.52 for a 400 m ULCV**, and
|
||||||
|
* that is the number below. It is length-independent only if a feeder stacks as
|
||||||
|
* high as a ULCV, which it does not — a 200 m feeder at five tiers wants about
|
||||||
|
* 0.65 — so this errs *low* on small ships and never high, which is the safe
|
||||||
|
* direction for a solid that must not out-grow its berth.
|
||||||
|
*
|
||||||
|
* ### Three bays, and one of them a tier down
|
||||||
|
*
|
||||||
|
* Not one slab. Three bays with a gap between them give two shadow lines across
|
||||||
|
* the deck, and from directly above — which is how this board is looked at —
|
||||||
|
* those lines are most of what separates "a ship with cargo on it" from "a
|
||||||
|
* rectangle". The forward bay is a tier lower, the way a real box ship steps its
|
||||||
|
* stack down for visibility over the bow, and it costs nothing but reads as
|
||||||
|
* deliberate from any angle.
|
||||||
|
*
|
||||||
|
* Everything is inside the parallel midbody (`|z| <= 0.27`) on purpose: forward
|
||||||
|
* of the shoulder the hull is narrowing to the stem, and a full-beam bay put
|
||||||
|
* there hangs its outboard corners over open water.
|
||||||
|
*
|
||||||
|
* ### Seventy-six triangles, and it is one draw call
|
||||||
|
*
|
||||||
|
* Body ten, bow six, three bays thirty-six, house twelve, funnel twelve. Against
|
||||||
|
* the merchant hull's forty that is thirty-six more per *geometry*, not per
|
||||||
|
* ship: `HULL_SHAPES` builds one `InstancedMesh` per shape and adds it to the
|
||||||
|
* group only when its count is above zero, so a harbour of thirty box ships
|
||||||
|
* costs seventy-six triangles and one draw call, and a board with no box ship on
|
||||||
|
* it costs neither.
|
||||||
|
*/
|
||||||
|
function containerHull(): THREE.BufferGeometry {
|
||||||
|
const f = hullFaces();
|
||||||
|
|
||||||
|
const bx = 0.5;
|
||||||
|
const zBow = -0.5;
|
||||||
|
// Fuller than the merchant hull's -0.28: a modern box ship carries its deck
|
||||||
|
// line well forward, which is what lets the forward bay sit at -0.27.
|
||||||
|
const zShoulder = -0.32;
|
||||||
|
const zStern = 0.5;
|
||||||
|
const stem = 0.3;
|
||||||
|
|
||||||
|
// ---- Body: five quads, ten triangles.
|
||||||
|
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, DECK[0]], [1, DECK[0]], [0.5, DECK[1]]);
|
||||||
|
f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, SIDE[0]], [0.5, SIDE[1]], [1, SIDE[0]]);
|
||||||
|
|
||||||
|
// ---- The stack: three bays, thirty-six triangles. `STACK` is eight tiers of
|
||||||
|
// high-cube; `STEP` is the forward bay, two tiers down.
|
||||||
|
const STACK = 1.52;
|
||||||
|
const STEP = 1.39;
|
||||||
|
// Boxes overhang the hatch coamings on a ULCV, so the stack is wider than the
|
||||||
|
// house and only just inside the beam.
|
||||||
|
const sx = 0.45;
|
||||||
|
f.box(-0.4, 0.4, 1, STEP, -0.27, -0.1, BOX);
|
||||||
|
f.box(-sx, sx, 1, STACK, -0.07, 0.1, BOX);
|
||||||
|
f.box(-sx, sx, 1, STACK, 0.13, 0.27, BOX);
|
||||||
|
|
||||||
|
// ---- Aft house: right aft, and taller than the merchant hull's, because on a
|
||||||
|
// box ship it has to see over eight tiers of cargo.
|
||||||
|
f.box(-0.34, 0.34, 1, 1.62, 0.31, 0.47, HOUSE);
|
||||||
|
|
||||||
|
// ---- Funnel: twelve triangles.
|
||||||
|
f.box(-0.12, 0.12, 1.62, 1.9, 0.35, 0.45, HOUSE);
|
||||||
|
|
||||||
|
return f.finish("vessel-hull-container");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The harbour tug: sixty-four triangles, and the mass in the wrong place on
|
* The harbour tug: sixty-four triangles, and the mass in the wrong place on
|
||||||
* purpose.
|
* purpose.
|
||||||
@@ -563,8 +709,8 @@ function tugHull(): THREE.BufferGeometry {
|
|||||||
// ---- Bow: six triangles.
|
// ---- Bow: six triangles.
|
||||||
f.quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
|
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.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, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, DECK[0]], [1, DECK[0]], [0.5, DECK[1]]);
|
||||||
f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, 0.02], [0.5, 0.31], [1, 0.02]);
|
f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, SIDE[0]], [0.5, SIDE[1]], [1, SIDE[0]]);
|
||||||
|
|
||||||
// ---- Deckhouse: forward, and 0.84 of the beam. The one measurement that
|
// ---- 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.
|
// decides whether this reads as a tug rather than as a small freighter.
|
||||||
@@ -614,34 +760,108 @@ export function deckAtlas(): THREE.Texture | null {
|
|||||||
if (typeof document === "undefined") return null;
|
if (typeof document === "undefined") return null;
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = 64;
|
canvas.width = 64;
|
||||||
canvas.height = 64;
|
canvas.height = ATLAS_ROWS;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
|
|
||||||
// Side: flat, with a darker boot-top along the waterline. The band's v runs
|
/*
|
||||||
// 0.02..0.31 and the geometry maps y=0 (keel) to the bottom of it — which is
|
* Every band is painted through `bandRows`, which is the same function
|
||||||
// only true because `flipY` is turned off below.
|
* `band()` gives the geometry its `v` from. Nothing below knows a row number.
|
||||||
ctx.fillStyle = "#f4f6f7";
|
*/
|
||||||
ctx.fillRect(0, 0, 64, 21);
|
const fill = (index: number, colour: string): readonly [number, number] => {
|
||||||
ctx.fillStyle = "#9aa0a6";
|
const [top, bottom] = bandRows(index);
|
||||||
ctx.fillRect(0, 0, 64, 5);
|
ctx.fillStyle = colour;
|
||||||
|
ctx.fillRect(0, top, 64, bottom - top);
|
||||||
|
return [top, bottom];
|
||||||
|
};
|
||||||
|
|
||||||
// Deck: bright, with hatch coamings across it. Cross-ship stripes, because
|
// ---- 0. Side: flat, with a darker boot-top along the waterline.
|
||||||
// that is the way hatches and container rows run and it is the one detail that
|
//
|
||||||
// says "this is the top of a ship" from directly above.
|
// The boot-top goes at the TOP of the band in canvas rows, which is the keel
|
||||||
ctx.fillStyle = "#ffffff";
|
// end: `quad` maps the y=0 vertex to `band[0]`, and with `flipY` off a smaller
|
||||||
ctx.fillRect(0, 22, 64, 20);
|
// `v` is a smaller row. Getting this backwards paints the boot-top along the
|
||||||
ctx.fillStyle = "#c8ced4";
|
// deck edge, which looks like a shadow rather than like a mistake.
|
||||||
for (let x = 3; x < 62; x += 6) ctx.fillRect(x, 23, 3, 18);
|
{
|
||||||
ctx.fillStyle = "#e4e9ed";
|
const [top] = fill(0, "#f4f6f7");
|
||||||
ctx.fillRect(0, 30, 64, 2);
|
ctx.fillStyle = "#9aa0a6";
|
||||||
|
ctx.fillRect(0, top, 64, 6);
|
||||||
|
}
|
||||||
|
|
||||||
// Superstructure: pale, with two rows of windows.
|
// ---- 1. Deck: bright, with hatch coamings across it. Cross-ship stripes,
|
||||||
ctx.fillStyle = "#ffffff";
|
// because that is the way hatches and container rows run and it is the one
|
||||||
ctx.fillRect(0, 44, 64, 20);
|
// detail that says "this is the top of a ship" from directly above.
|
||||||
ctx.fillStyle = "#7d858c";
|
{
|
||||||
ctx.fillRect(4, 49, 56, 3);
|
const [top, bottom] = fill(1, "#ffffff");
|
||||||
ctx.fillRect(4, 55, 56, 2);
|
ctx.fillStyle = "#c8ced4";
|
||||||
|
for (let x = 3; x < 62; x += 6) ctx.fillRect(x, top + 1, 3, bottom - top - 2);
|
||||||
|
ctx.fillStyle = "#e4e9ed";
|
||||||
|
ctx.fillRect(0, top + Math.round((bottom - top) * 0.4), 64, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2. Superstructure: pale, with two rows of windows.
|
||||||
|
{
|
||||||
|
const [top, bottom] = fill(2, "#ffffff");
|
||||||
|
const h = bottom - top;
|
||||||
|
ctx.fillStyle = "#7d858c";
|
||||||
|
ctx.fillRect(4, top + Math.round(h * 0.25), 56, 3);
|
||||||
|
ctx.fillRect(4, top + Math.round(h * 0.55), 56, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---- 3. Boxes.
|
||||||
|
*
|
||||||
|
* **This band is a modulation map, not container colours, and the first pass
|
||||||
|
* of it was container colours.** The note at the head of `deckAtlas` already
|
||||||
|
* records why that cannot work — `instanceColor` *multiplies* this texture, so
|
||||||
|
* two mid values multiplied are a dark one, and the first version of this
|
||||||
|
* whole file photographed as a row of black slabs for exactly this reason.
|
||||||
|
* Painted at `PORT_PALETTE`'s real box values (a muted rust of 0x8d6053, a
|
||||||
|
* steel blue of 0x5e6c7b) under a container ship's own 0x93aac4 tint, the
|
||||||
|
* stack came out near black, which is *darker* than the hull it sits on and
|
||||||
|
* the opposite of what a photograph of a laden box ship shows. So the hue of
|
||||||
|
* a stack is the hull's tint, like every other part of every other hull, and
|
||||||
|
* what this band carries is what the rest of the atlas carries: **relative
|
||||||
|
* value**.
|
||||||
|
*
|
||||||
|
* That is not a compromise at the size this draws at. A stack is 25 px on the
|
||||||
|
* long axis at the Harbour pose, and at 25 px the individual box hues average
|
||||||
|
* out to one tone — what survives is the *structure*: the shadow gaps between
|
||||||
|
* bays and rows, and the tier lines up the side. So those are what is drawn,
|
||||||
|
* and they are drawn dark against a base that sits a little under the deck's
|
||||||
|
* white so a laden stack reads a shade below the white house beside it, which
|
||||||
|
* is the relationship a photograph shows.
|
||||||
|
*
|
||||||
|
* `u` runs across a face, so the vertical divisions are the rows of a stack
|
||||||
|
* across the beam and the horizontal ones are tiers. Both are visible from
|
||||||
|
* directly above and from the side, which is what a stack has to survive
|
||||||
|
* because a harbour is looked at from both.
|
||||||
|
*
|
||||||
|
* The variation is a fixed sequence rather than a PRNG, for `yardAtlas`'s
|
||||||
|
* reason: a stack has to look the same on every load, and a hull that
|
||||||
|
* reshuffles its cargo between two screenshots makes the two impossible to
|
||||||
|
* compare.
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
const [top, bottom] = fill(3, "#e6e2dc");
|
||||||
|
const h = bottom - top;
|
||||||
|
// Eight rows across the beam. The values are all inside 0.82..0.98 of white
|
||||||
|
// so the tint stays the colour; they exist to stop the stack reading as one
|
||||||
|
// flat slab, not to be a cargo manifest.
|
||||||
|
const rows = ["#e9e5df", "#d8d4ce", "#efebe5", "#dedad4", "#e4e0da", "#d2cec8", "#ebe7e1", "#dcd8d2"] as const;
|
||||||
|
const width = 64 / rows.length;
|
||||||
|
for (let i = 0; i < rows.length; i += 1) {
|
||||||
|
ctx.fillStyle = rows[i] ?? "#e6e2dc";
|
||||||
|
ctx.fillRect(Math.round(i * width), top, Math.ceil(width), h);
|
||||||
|
}
|
||||||
|
// The shadow between two boxes: what makes a block read as a stack of things
|
||||||
|
// rather than as a slab. Dark, but a modulation dark rather than a black.
|
||||||
|
ctx.fillStyle = "#6e6a64";
|
||||||
|
for (let i = 1; i < rows.length; i += 1) ctx.fillRect(Math.round(i * width) - 1, top, 1, h);
|
||||||
|
ctx.fillStyle = "#a8a49e";
|
||||||
|
for (let tier = 1; tier < 4; tier += 1) {
|
||||||
|
ctx.fillRect(0, top + Math.round((h * tier) / 4) - 1, 64, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const texture = new THREE.CanvasTexture(canvas);
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
texture.name = "vessel-deck-atlas";
|
texture.name = "vessel-deck-atlas";
|
||||||
|
|||||||
@@ -339,8 +339,14 @@ describe("the tug, which is the hull that is usually moving", () => {
|
|||||||
|
|
||||||
it("is drawn from its own solid, which is one more draw call and only when it floats", () => {
|
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("tug"), "tug");
|
||||||
assert.equal(hullShape("container"), "generic");
|
// Three shapes now: `container` left `generic` for its own solid, for the
|
||||||
assert.equal(HULL_SHAPES.length, 2, "a third hull shape is a third draw call");
|
// reason `containerHull` documents — a stack is what makes a box ship read
|
||||||
|
// on a board that multiplies heights by 3.4 and lengths by one. Every other
|
||||||
|
// kind is still the merchant hull, which is the whole point of the seam.
|
||||||
|
assert.equal(hullShape("container"), "container");
|
||||||
|
assert.equal(hullShape("tanker"), "generic");
|
||||||
|
assert.equal(hullShape("bulk"), "generic");
|
||||||
|
assert.equal(HULL_SHAPES.length, 3, "a fourth hull shape is a fourth draw call");
|
||||||
const tug = hullGeometry("tug");
|
const tug = hullGeometry("tug");
|
||||||
const triangles = tug.getAttribute("position").count / 3;
|
const triangles = tug.getAttribute("position").count / 3;
|
||||||
assert.equal(triangles, 64, `the tug is ${triangles} triangles`);
|
assert.equal(triangles, 64, `the tug is ${triangles} triangles`);
|
||||||
@@ -350,6 +356,63 @@ describe("the tug, which is the hull that is usually moving", () => {
|
|||||||
tug.dispose();
|
tug.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives the box ship a stack, and takes not one metre of length for it", () => {
|
||||||
|
/*
|
||||||
|
* The defect this shape exists for is that a hull is flat while everything
|
||||||
|
* around it is stretched 3.4x, so the fix has to buy height and nothing
|
||||||
|
* else. A solid that grew in x or z would overhang the berth it lies in —
|
||||||
|
* which is the failure `vesselScale.test.ts` guards the *scale* against, and
|
||||||
|
* this is the same guard on the *geometry*.
|
||||||
|
*/
|
||||||
|
const box = hullGeometry("container");
|
||||||
|
const generic = hullGeometry("generic");
|
||||||
|
const bounds = (g: THREE.BufferGeometry) => {
|
||||||
|
g.computeBoundingBox();
|
||||||
|
const b = g.boundingBox;
|
||||||
|
assert.ok(b);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
const a = bounds(box);
|
||||||
|
const b = bounds(generic);
|
||||||
|
assert.ok(a.max.x <= b.max.x + 1e-9, `the stack is ${a.max.x} wide against ${b.max.x}`);
|
||||||
|
assert.ok(a.min.x >= b.min.x - 1e-9, "the stack hangs over the port side");
|
||||||
|
assert.ok(a.max.z <= b.max.z + 1e-9 && a.min.z >= b.min.z - 1e-9, "the solid grew fore-aft");
|
||||||
|
// And it does buy height: eight tiers of high-cube over the hatches, which
|
||||||
|
// is what triples the above-water mass the exaggeration then multiplies.
|
||||||
|
assert.ok(a.max.y > 1.85, `only ${a.max.y} of height, so the stack is missing`);
|
||||||
|
|
||||||
|
const triangles = box.getAttribute("position").count / 3;
|
||||||
|
assert.equal(triangles, 76, `the box ship is ${triangles} triangles`);
|
||||||
|
for (const attribute of ["position", "normal", "uv"]) {
|
||||||
|
assert.ok(box.getAttribute(attribute), `the box hull has no ${attribute}`);
|
||||||
|
}
|
||||||
|
box.dispose();
|
||||||
|
generic.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("winds the box ship outward too, by the merchant hull's own flux test", () => {
|
||||||
|
/*
|
||||||
|
* Three more boxes is thirty-six more chances to wind a face inward, and an
|
||||||
|
* inward face reads as "the ships are a bit dark" rather than as a hole —
|
||||||
|
* which is how the first version of the merchant deck shipped.
|
||||||
|
*/
|
||||||
|
const geometry = hullGeometry("container");
|
||||||
|
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 box hull encloses ${flux.toFixed(3)} — a face is inside out`);
|
||||||
|
geometry.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("is wound outward, by the same flux test the merchant hull is held to", () => {
|
it("is wound outward, by the same flux test the merchant hull is held to", () => {
|
||||||
const geometry = hullGeometry("tug");
|
const geometry = hullGeometry("tug");
|
||||||
const position = geometry.getAttribute("position");
|
const position = geometry.getAttribute("position");
|
||||||
|
|||||||
@@ -18,11 +18,22 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
import { setReconcile } from "../../cities/reconcile.ts";
|
||||||
import { createTerrain } from "../../engine/terrain.ts";
|
import { createTerrain } from "../../engine/terrain.ts";
|
||||||
import type { City, ScenePalette } from "../../engine/types.ts";
|
import type { City, ScenePalette } from "../../engine/types.ts";
|
||||||
import { World } from "../../engine/world.ts";
|
import { World } from "../../engine/world.ts";
|
||||||
|
|
||||||
/** A square island in the middle of a one-degree board, at 0.05° per cell. */
|
/** A square island in the middle of a one-degree board, at 0.05° per cell. */
|
||||||
|
/*
|
||||||
|
* Every fixture in this file is a synthetic one-hill board built to isolate one
|
||||||
|
* LOD guard, and `exaggeration` is derived from a pack's own blended peak — so
|
||||||
|
* left on it would rescale a deliberately gentle 40 m swell into something the
|
||||||
|
* height guard fires on, and the colour guard this file is measuring would be
|
||||||
|
* swamped by it. The reconciliation is a statement about the three real packs;
|
||||||
|
* these are not packs.
|
||||||
|
*/
|
||||||
|
setReconcile(false);
|
||||||
|
|
||||||
const BASE: Omit<City, "hills"> = {
|
const BASE: Omit<City, "hills"> = {
|
||||||
id: "lod-board",
|
id: "lod-board",
|
||||||
name: "LOD Board",
|
name: "LOD Board",
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ describe("road width", () => {
|
|||||||
* just under the tighter of the two so that neither board moves.
|
* just under the tighter of the two so that neither board moves.
|
||||||
*/
|
*/
|
||||||
it("sits just under the narrowest road either metro pack authored", () => {
|
it("sits just under the narrowest road either metro pack authored", () => {
|
||||||
|
// "As authored" is the whole subject, and `span` is derived from
|
||||||
|
// `world.lngScale` — which `projection` is now on by default to change. Pin
|
||||||
|
// the flag off so this measures the pack rather than the product default.
|
||||||
|
setReconcile(false);
|
||||||
for (const pack of [SF, SOCAL]) {
|
for (const pack of [SF, SOCAL]) {
|
||||||
const world = new World(pack);
|
const world = new World(pack);
|
||||||
const span = Math.max(
|
const span = Math.max(
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
import { setReconcile } from "../../cities/reconcile.ts";
|
||||||
import { SOCAL_CITY } from "../../cities/socal.ts";
|
import { SOCAL_CITY } from "../../cities/socal.ts";
|
||||||
import { SAN_FRANCISCO_CITY } from "../../cities/sf.ts";
|
import { SAN_FRANCISCO_CITY } from "../../cities/sf.ts";
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +50,15 @@ import { DEFAULT_HULL, beamFor } from "../../server/vessels.ts";
|
|||||||
import type { Vessel } from "../../engine/types.ts";
|
import type { Vessel } from "../../engine/types.ts";
|
||||||
import { World } from "../../engine/world.ts";
|
import { World } from "../../engine/world.ts";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pinned off, and it must be pinned *here* rather than in a hook: `World`'s
|
||||||
|
* constructor is what reconciles, so both worlds below are already built by the
|
||||||
|
* time any `beforeEach` runs. This file asserts the packs' own true-scale
|
||||||
|
* numbers — 390.6 m to the unit, an exaggeration of 3.4 — which are facts about
|
||||||
|
* SoCal as authored and not about whichever rules ship on by default.
|
||||||
|
*/
|
||||||
|
setReconcile(false);
|
||||||
|
|
||||||
/** No `ready()`: every number here is projection, and projection is the constructor. */
|
/** No `ready()`: every number here is projection, and projection is the constructor. */
|
||||||
const socal = new World(SOCAL_CITY);
|
const socal = new World(SOCAL_CITY);
|
||||||
const bay = new World(SAN_FRANCISCO_CITY);
|
const bay = new World(SAN_FRANCISCO_CITY);
|
||||||
|
|||||||
Reference in New Issue
Block a user