diff --git a/src/cities/reconcile.ts b/src/cities/reconcile.ts index 9668ec1..91fc742 100644 --- a/src/cities/reconcile.ts +++ b/src/cities/reconcile.ts @@ -189,7 +189,49 @@ function warn(message: string): void { * The rule stays available — `?reconcile=roads` — and the real fix for the state * 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. diff --git a/src/engine/vessels.ts b/src/engine/vessels.ts index 09bdde9..11fc438 100644 --- a/src/engine/vessels.ts +++ b/src/engine/vessels.ts @@ -256,7 +256,7 @@ 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" | "tug"; +export type HullShape = "generic" | "tug" | "container"; export function hullShape(kind: VesselKind): HullShape { // 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": return "tug"; case "container": + return "container"; case "tanker": case "bulk": 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 * 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. + * hull *geometry* exist and never of how many ships are floating. The Bay board + * with no tug and no box ship on it pays for one. + * + * `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. @@ -378,6 +384,8 @@ export function hullGeometry(shape: HullShape = "generic"): THREE.BufferGeometry return genericHull(); case "tug": 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 * 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]; +/** + * The atlas is four bands now, and they are **computed from one layout** rather + * 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 Band = readonly [number, number]; @@ -490,8 +535,8 @@ function genericHull(): THREE.BufferGeometry { // ---- 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, 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]); + 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]]); // ---- Aft house: a box on the quarterdeck, twelve triangles. 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"); } +/** + * 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 * purpose. @@ -563,8 +709,8 @@ function tugHull(): THREE.BufferGeometry { // ---- 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]); + 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]]); // ---- 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. @@ -614,34 +760,108 @@ export function deckAtlas(): THREE.Texture | null { if (typeof document === "undefined") return null; const canvas = document.createElement("canvas"); canvas.width = 64; - canvas.height = 64; + canvas.height = ATLAS_ROWS; const ctx = canvas.getContext("2d"); 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 - // only true because `flipY` is turned off below. - ctx.fillStyle = "#f4f6f7"; - ctx.fillRect(0, 0, 64, 21); - ctx.fillStyle = "#9aa0a6"; - ctx.fillRect(0, 0, 64, 5); + /* + * Every band is painted through `bandRows`, which is the same function + * `band()` gives the geometry its `v` from. Nothing below knows a row number. + */ + const fill = (index: number, colour: string): readonly [number, number] => { + const [top, bottom] = bandRows(index); + ctx.fillStyle = colour; + ctx.fillRect(0, top, 64, bottom - top); + return [top, bottom]; + }; - // Deck: bright, with hatch coamings across it. Cross-ship stripes, because - // 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. - ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 22, 64, 20); - ctx.fillStyle = "#c8ced4"; - for (let x = 3; x < 62; x += 6) ctx.fillRect(x, 23, 3, 18); - ctx.fillStyle = "#e4e9ed"; - ctx.fillRect(0, 30, 64, 2); + // ---- 0. Side: flat, with a darker boot-top along the waterline. + // + // The boot-top goes at the TOP of the band in canvas rows, which is the keel + // end: `quad` maps the y=0 vertex to `band[0]`, and with `flipY` off a smaller + // `v` is a smaller row. Getting this backwards paints the boot-top along the + // deck edge, which looks like a shadow rather than like a mistake. + { + const [top] = fill(0, "#f4f6f7"); + ctx.fillStyle = "#9aa0a6"; + ctx.fillRect(0, top, 64, 6); + } - // Superstructure: pale, with two rows of windows. - ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 44, 64, 20); - ctx.fillStyle = "#7d858c"; - ctx.fillRect(4, 49, 56, 3); - ctx.fillRect(4, 55, 56, 2); + // ---- 1. Deck: bright, with hatch coamings across it. Cross-ship stripes, + // because 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. + { + const [top, bottom] = fill(1, "#ffffff"); + 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); texture.name = "vessel-deck-atlas"; diff --git a/src/test/render/harbourDay.test.ts b/src/test/render/harbourDay.test.ts index 54463d0..3fd8fda 100644 --- a/src/test/render/harbourDay.test.ts +++ b/src/test/render/harbourDay.test.ts @@ -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", () => { 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"); + // Three shapes now: `container` left `generic` for its own solid, for the + // 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 triangles = tug.getAttribute("position").count / 3; 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(); }); + 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", () => { const geometry = hullGeometry("tug"); const position = geometry.getAttribute("position"); diff --git a/src/test/render/terrainLod.test.ts b/src/test/render/terrainLod.test.ts index ddf6d2e..cdb505b 100644 --- a/src/test/render/terrainLod.test.ts +++ b/src/test/render/terrainLod.test.ts @@ -18,11 +18,22 @@ import assert from "node:assert/strict"; import test from "node:test"; import * as THREE from "three"; +import { setReconcile } from "../../cities/reconcile.ts"; import { createTerrain } from "../../engine/terrain.ts"; import type { City, ScenePalette } from "../../engine/types.ts"; import { World } from "../../engine/world.ts"; /** 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 = { id: "lod-board", name: "LOD Board", diff --git a/src/test/roadWidth.test.ts b/src/test/roadWidth.test.ts index 32ead82..c486eae 100644 --- a/src/test/roadWidth.test.ts +++ b/src/test/roadWidth.test.ts @@ -57,6 +57,10 @@ describe("road width", () => { * just under the tighter of the two so that neither board moves. */ 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]) { const world = new World(pack); const span = Math.max( diff --git a/src/test/vehicle/vesselScale.test.ts b/src/test/vehicle/vesselScale.test.ts index 7a0dbba..ff21467 100644 --- a/src/test/vehicle/vesselScale.test.ts +++ b/src/test/vehicle/vesselScale.test.ts @@ -35,6 +35,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { setReconcile } from "../../cities/reconcile.ts"; import { SOCAL_CITY } from "../../cities/socal.ts"; import { SAN_FRANCISCO_CITY } from "../../cities/sf.ts"; import { @@ -49,6 +50,15 @@ import { DEFAULT_HULL, beamFor } from "../../server/vessels.ts"; import type { Vessel } from "../../engine/types.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. */ const socal = new World(SOCAL_CITY); const bay = new World(SAN_FRANCISCO_CITY);