diff --git a/TODO.md b/TODO.md index ad5aff7..d9cf627 100644 --- a/TODO.md +++ b/TODO.md @@ -992,46 +992,27 @@ Two smaller things, both disclosed by their own tracks and neither blocking: which is why it neither helps nor hurts. The fix is finer cells there, which is terrain level of detail — the piece that still does not exist. -## An unidentified 700 m slab stands on downtown San Francisco +## ~~An unidentified 700 m slab stands on downtown San Francisco~~ — identified and capped -Found 2026-08-24 by photographing the merged board at a close pose, not by any -test. **Reproduce it exactly:** +Found 2026-08-24 by photograph; named 2026-08-24 by reading instance matrices +on the merged board at the same pose, not by walking the scene graph. + +It was never a mystery mesh. `DETAIL_LOT_TIERS`'s coarsest rung is 400 m, and +tower fill of 1.5–2.2 — written to assemble a Salesforce-shaped site from 40 m +lots — on a 400 m lot is a 600–880 m cube, rotated with `gridAngle`, downtown +palette. The same pose on `?city=sf` uses 40 m lots and does not show it. + +The fill is now capped at `NEIGHBOURHOOD_LOT_METRES` (260 m). Fine rungs are +unchanged. `blocksLotLadder.test.ts` holds the cap. + +The reproduction still photographs the pose: ``` node scripts/look.mjs slab --url "/" --lat 37.7897 --lng -122.3972 \ --standoff 2500 --at 2026-08-24T18:30:00Z --wait 11000 ``` -A pale blue-grey slab, roughly 700 m square with visible thickness, rotated onto -San Francisco's street grid, sitting on the Financial District and covering the -buildings under it. It is the most prominent object in that frame. The same pose -on `?city=sf` does not show it, so it is specific to the merged board. - -**What it is not**, each ruled out by reading the code rather than by guessing: - -- Not a marker. The giant marker spheres in the first photograph of this pose - were a separate defect, fixed in this same round (scene-unit pin constants on a - 1,919 m board); with that fixed every sphere is correctly sized and **the slab - is unchanged**. -- Not an office door glyph. `buildingGlyph.ts` is metre-correct (`1 / - world.metresPerUnit` horizontally, `world.metres` vertically) and the three - office sites are 48x42, 54x30 and 36x26 m. -- Not a landmark. San Francisco's largest authored `footprint` is 0.0011° — a - 200 m box, and the pack's landmarks render correctly on their own board. -- Not the freeway corridor. `overviewLayers` withdraws it inside - `DETAIL_STANDOFF_M`, and no corridor ribbon is in the frame. -- Not a port or an airport plate. The merged pack carries two ports, both in San - Pedro Bay, and SFO is 13 km south of a frame about 3 km across. -- Not `office-exterior`. That is the Model X apron and it lives in the office - scene at 1 unit = 1 m. - -**How to find it**, since three attempts at a runtime probe timed out walking the -scene graph: `__teraCamera` deliberately does not expose the scene, so add a -temporary hook beside `lots()` in `main.ts` that traverses -`record.handle.stageScene.scene` and **filters inside `page.evaluate`** — the -traverse itself is fine, but returning every geometry in the board serialises -megabytes and hangs the call. Filter to `boundingBox` width above 250 m in board -metres and return fifteen rows. That names it in one run. - -It is a visual defect on the default board at a pose a visitor reaches by -descending, and it should be fixed before the next round of imagery. +What the original list correctly ruled out (marker, office glyph, landmark, +corridor, port, airport, office-exterior) stays ruled out. The glyph's window +pane thickness (`0.012` / `0.028` scene units → 23 m / 54 m on this board) is +a separate scene-unit leftover and is not this slab. diff --git a/src/engine/blocks.ts b/src/engine/blocks.ts index 8ec268c..4b9dd19 100644 --- a/src/engine/blocks.ts +++ b/src/engine/blocks.ts @@ -246,7 +246,7 @@ export function detailLotMetres(tier: number): number { * 260 m is comfortably above Southern California's 164 and far below * California's 806, so neither of the detailed boards changes at all. */ -const NEIGHBOURHOOD_LOT_METRES = 260; +export const NEIGHBOURHOOD_LOT_METRES = 260; /** * Whether the anonymous city goes into the shadow map, given the lot size and @@ -525,10 +525,20 @@ export function createBlocks( // Towers take several lots. A 260 m tower on one 40 m lot is a 25:1 // needle, and downtown came out looking like a bed of nails; real // towers assemble their sites, and Salesforce Tower is about 5:1. - const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18; - - const width = lot * fill; - const depth = lot * fill * (0.85 + rand() * 0.3); + let fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18; + /* + * Those multipliers assemble a tower site from a neighbourhood lot. + * On a 400 m lot they draw a 600–880 m cube — photographed at FiDi + * from 2.5 km as the unidentified 700 m slab, rotated onto the + * street grid, covering the buildings under it. A coarse lot is a + * parcel: the building sits on it, it is not the building. Fine + * rungs (40 / 80 m) are unchanged, because the cap is larger than + * the fill they already use. Depth is capped too: the 0.85–1.15 + * stretch after fill would otherwise put a 260 m cap at 300 m. + */ + const cap = NEIGHBOURHOOD_LOT_METRES / world.metresPerUnit; + const width = Math.min(lot * fill, cap); + const depth = Math.min(lot * fill * (0.85 + rand() * 0.3), cap); const rotation = angle + (rand() - 0.5) * 0.03; const color = new THREE.Color( palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6, diff --git a/src/test/render/blocksLotLadder.test.ts b/src/test/render/blocksLotLadder.test.ts index 3efd02c..cec27b1 100644 --- a/src/test/render/blocksLotLadder.test.ts +++ b/src/test/render/blocksLotLadder.test.ts @@ -28,7 +28,13 @@ import test from "node:test"; import * as THREE from "three"; import { setReconcile } from "../../cities/reconcile.ts"; -import { createBlocks, detailLotMetres, LOT_BUDGET, updateBlocksDetail } from "../../engine/blocks.ts"; +import { + createBlocks, + detailLotMetres, + LOT_BUDGET, + NEIGHBOURHOOD_LOT_METRES, + updateBlocksDetail, +} from "../../engine/blocks.ts"; import type { City, District } from "../../engine/types.ts"; import { World } from "../../engine/world.ts"; @@ -219,3 +225,41 @@ test("every rung is drawn from the same mesh, geometry and material", async () = assert.equal(blocks.boundingSphere, null, "a stale sphere would hide the city it was not built at"); } }); + +/** + * The largest building footprint currently packed, in scene units. + * + * Same extract as `smallestFootprint`: the first three entries of each 4×4 + * instance matrix are the X-axis of the building, whose length is its width. + * Depth is the Z-axis at offsets 8, 9, 10. + */ +function largestFootprintM(mesh: THREE.InstancedMesh, metresPerUnit: number): number { + const m = mesh.instanceMatrix.array as Float32Array; + let largest = 0; + for (let i = 0; i < mesh.count; i++) { + const at = i * 16; + const width = Math.hypot(m[at]!, m[at + 1]!, m[at + 2]!); + const depth = Math.hypot(m[at + 8]!, m[at + 9]!, m[at + 10]!); + const span = Math.max(width, depth) * metresPerUnit; + if (span > largest) largest = span; + } + return largest; +} + +test("a coarse lot is a parcel, not a 700 m building", async () => { + const world = await built(); + const blocks = createBlocks(world); + /* + * Budget 100 forces the coarsest rung — 400 m lots on this fixture, the + * same rung the merged board picked over FiDi when the 700 m slab was + * photographed. Without the cap, tower fill of 1.5–2.2 on a 400 m lot is + * a 600–880 m cube. With it, nothing on the board is wider than a block. + */ + packAt(world, blocks, 100); + assert.ok(blocks.count > 0, "the coarsest rung packed nothing"); + const widest = largestFootprintM(blocks, world.metresPerUnit); + assert.ok( + widest <= NEIGHBOURHOOD_LOT_METRES + 1, + `a ${widest.toFixed(0)} m building on a coarse lot is the FiDi slab`, + ); +});