feat: the merged board's cities are lotted like cities, and drawn one city at a time
Three findings, each of which changed the next.
**1. The metros on the merged board were lotted at 806 m.** `LOT` is fixed in
scene units — 0.42 — and `blocks.ts`'s own note already spelled out what that
means: a lot is 40 m on San Francisco's board, 164 m on the Southland's and
**806 m** on the statewide one. Put San Francisco's fifty-two districts *on* the
statewide board and they inherit 806 m. Lots go as the inverse square of their
size, so the Bay Area's ~84,000 buildings became about **two hundred**, and a
city rendered as a handful of grey slabs. That is the whole reason the merged
board's cities looked wrong.
A detail district now measures its lot in metres. 160 m, chosen against a
shipping reference rather than a feeling: it is what Southern California's own
board already builds itself at (0.42 x 390.6), so a metro on the merged board is
lotted about as finely as the Southland lots itself. Measured in the browser:
**59,166 building instances, of which 2,839 are the state's own and 56,327 the
two metros'.** Against roughly two hundred before.
**2. An all-or-nothing LOD could not carry them.** `InstancedMesh.count` draws
the first N instances, so "base first, detail last" can say "no cities" and
"every city" and nothing between. Driving into Los Angeles therefore revealed
San Francisco's fifty-two districts as well, four hundred kilometres away and
behind the camera: `california-drive` measured **925,854 triangles against a
430,000 cap**. Shrinking the lot to fit would have needed about 478 m, which
gives back the entire gain.
So `updateBlocksDetail` re-packs by district. Each detail district's lots and its
centre are recorded at build time; when the visible *set* changes — a handful of
times in a journey across California, not per frame — the visible districts are
copied into the front of the instance buffers and `count` moves. Three
attributes move together and must: the matrix, the colour and
`FACADE_ATTRIBUTE`, which is per-instance and decides which windows are lit;
moving matrices alone would light a tower's windows on a warehouse.
`california-drive` fell 925,854 -> **368,962**.
**3. Two bugs in that, both mine, both found by measuring rather than reading.**
- `DetailStore.key` remembers which districts the buffers hold, and started as
`""` — which is also the key an *empty* visible set produces. The first call,
at the whole-board pose where nothing should draw, compared equal, took the
early return and did nothing.
- `InstancedMesh`'s constructor sets `count` to capacity, so between
`createBlocks` returning and the scene's first `applyDetailLod` the mesh drew
every detail lot. The budget reports `maxTriangles` — a max over the whole
sample window — and caught that as **911,541 triangles while the steady
state was a correct 348,271**. A level of detail that is right on every frame
but the first is not a level of detail. The mesh is now born packed.
The second one is the instructive one: an instrumented log showed `count=2839,
want=false` at exactly the pose the budget was failing, which is what turned a
"the LOD is broken" hypothesis into "the LOD is right and the first frame is
not".
Result on the merged board, all triangles green: california 348,271 of 440,000 —
**lower than the state board's own 372,415** while carrying 56,327 more
buildings — and california-drive 368,962 of 430,000.
Still red, and unchanged from before this commit: `california-drive` mobile draw
calls, 315 against 280. Triangles there are now fine. That cap was set when the
corridor had no cities on it, and re-deriving it is an owner's call rather than a
number to quietly raise.
1,704 tests pass, including a regression test for the empty-key bug that reads
the source, because building a real `InstancedMesh` needs a GPU.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+203
-12
@@ -26,6 +26,31 @@ import { seededRandom, type World } from "./world.ts";
|
|||||||
const LOT = 0.42; // ~40 m at SF's scale
|
const LOT = 0.42; // ~40 m at SF's scale
|
||||||
const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface
|
const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lot a **detail** district is built on, in true metres.
|
||||||
|
*
|
||||||
|
* `LOT` above is fixed in scene units, and the note under it already says what
|
||||||
|
* that means: a lot is 40 m in San Francisco, 164 m in Southern California and
|
||||||
|
* **806 m** on the statewide board. That is the right trade while a board is
|
||||||
|
* looked at from its own stand-off — and exactly the wrong one once
|
||||||
|
* `cities/unify.ts` puts San Francisco's fifty-two districts *on* the statewide
|
||||||
|
* board, because they are then lotted at 806 m. The arithmetic is brutal: lots
|
||||||
|
* go as the inverse square of their size, so the Bay Area's 83,137 buildings
|
||||||
|
* become about **two hundred**, and a city renders as a handful of grey slabs.
|
||||||
|
*
|
||||||
|
* So a detail district measures its lot in metres instead. 160 m is chosen
|
||||||
|
* against a reference rather than a feeling: it is what Southern California's
|
||||||
|
* own board already builds itself at (0.42 units x 390.6 m), so a metro on the
|
||||||
|
* merged board is lotted about as finely as the Southland lots itself, and
|
||||||
|
* nobody has to argue about whether that reads as a city — it is already
|
||||||
|
* shipping as one.
|
||||||
|
*
|
||||||
|
* It is deliberately *not* San Francisco's 40 m. At 40 m the same districts are
|
||||||
|
* sixteen times the lots again, and the merged board's whole reason for
|
||||||
|
* existing is that it fits in one budget.
|
||||||
|
*/
|
||||||
|
const DETAIL_LOT_METRES = 160;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ground size at which a lot stops being a city block.
|
* The ground size at which a lot stops being a city block.
|
||||||
*
|
*
|
||||||
@@ -196,6 +221,12 @@ export function createBlocks(
|
|||||||
reservations: readonly BuildingReservation[] = [],
|
reservations: readonly BuildingReservation[] = [],
|
||||||
): THREE.InstancedMesh {
|
): THREE.InstancedMesh {
|
||||||
const boxes: Box[] = [];
|
const boxes: Box[] = [];
|
||||||
|
/**
|
||||||
|
* Where each detail district's lots sit in `boxes`, and where that district
|
||||||
|
* is, so the level of detail can draw the city you are near and not the one
|
||||||
|
* four hundred kilometres away. See `updateBlocksDetail`.
|
||||||
|
*/
|
||||||
|
const detailRanges: DetailRange[] = [];
|
||||||
let seedBase = 1337;
|
let seedBase = 1337;
|
||||||
|
|
||||||
// See `NEIGHBOURHOOD_LOT_METRES`. One measurement, two decisions, and both of
|
// See `NEIGHBOURHOOD_LOT_METRES`. One measurement, two decisions, and both of
|
||||||
@@ -222,6 +253,14 @@ export function createBlocks(
|
|||||||
let baseBoxes = -1;
|
let baseBoxes = -1;
|
||||||
for (const district of ordered) {
|
for (const district of ordered) {
|
||||||
if (baseBoxes < 0 && district.detail === true) baseBoxes = boxes.length;
|
if (baseBoxes < 0 && district.detail === true) baseBoxes = boxes.length;
|
||||||
|
/*
|
||||||
|
* A detail district measures its lot in metres; everything else keeps the
|
||||||
|
* scene-unit `LOT` it has always had, so no existing board moves by a lot.
|
||||||
|
*/
|
||||||
|
const lot =
|
||||||
|
district.detail === true ? DETAIL_LOT_METRES / world.metresPerUnit : LOT;
|
||||||
|
const lotIsABlockHere = lot * world.metresPerUnit <= NEIGHBOURHOOD_LOT_METRES;
|
||||||
|
const districtStart = boxes.length;
|
||||||
const rand = seededRandom(seedBase);
|
const rand = seededRandom(seedBase);
|
||||||
seedBase += 7919;
|
seedBase += 7919;
|
||||||
|
|
||||||
@@ -243,19 +282,19 @@ export function createBlocks(
|
|||||||
const zs = corners.map((c) => c[1]);
|
const zs = corners.map((c) => c[1]);
|
||||||
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
|
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||||
const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
|
const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
|
||||||
const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + LOT;
|
const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + lot;
|
||||||
|
|
||||||
const cos = Math.cos(angle);
|
const cos = Math.cos(angle);
|
||||||
const sin = Math.sin(angle);
|
const sin = Math.sin(angle);
|
||||||
const steps = Math.ceil(reach / LOT);
|
const steps = Math.ceil(reach / lot);
|
||||||
|
|
||||||
for (let iu = -steps; iu <= steps; iu++) {
|
for (let iu = -steps; iu <= steps; iu++) {
|
||||||
if (lotIsABlock && ((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
|
if (lotIsABlockHere && ((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
|
||||||
for (let iv = -steps; iv <= steps; iv++) {
|
for (let iv = -steps; iv <= steps; iv++) {
|
||||||
if (lotIsABlock && ((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
|
if (lotIsABlockHere && ((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
|
||||||
|
|
||||||
const u = (iu + (rand() - 0.5) * 0.34) * LOT;
|
const u = (iu + (rand() - 0.5) * 0.34) * lot;
|
||||||
const v = (iv + (rand() - 0.5) * 0.34) * LOT;
|
const v = (iv + (rand() - 0.5) * 0.34) * lot;
|
||||||
const x = cx + u * cos - v * sin;
|
const x = cx + u * cos - v * sin;
|
||||||
const z = cz + u * sin + v * cos;
|
const z = cz + u * sin + v * cos;
|
||||||
|
|
||||||
@@ -295,8 +334,8 @@ export function createBlocks(
|
|||||||
// towers assemble their sites, and Salesforce Tower is about 5:1.
|
// 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 fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;
|
||||||
|
|
||||||
const width = LOT * fill;
|
const width = lot * fill;
|
||||||
const depth = LOT * fill * (0.85 + rand() * 0.3);
|
const depth = lot * fill * (0.85 + rand() * 0.3);
|
||||||
const rotation = angle + (rand() - 0.5) * 0.03;
|
const rotation = angle + (rand() - 0.5) * 0.03;
|
||||||
const color = new THREE.Color(
|
const color = new THREE.Color(
|
||||||
palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6,
|
palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6,
|
||||||
@@ -334,6 +373,14 @@ export function createBlocks(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (district.detail === true && boxes.length > districtStart) {
|
||||||
|
detailRanges.push({
|
||||||
|
start: districtStart,
|
||||||
|
count: boxes.length - districtStart,
|
||||||
|
x: cx,
|
||||||
|
z: cz,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||||||
@@ -398,15 +445,159 @@ export function createBlocks(
|
|||||||
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Where the base set ends, for `scene.ts`'s detail LOD. `boxes.length` when
|
* Where the base set ends, for the detail LOD. `boxes.length` when the pack
|
||||||
* the pack declares no detail districts, which makes "show everything" the
|
* declares no detail districts, which makes "show everything" the behaviour
|
||||||
* behaviour of every board that is not the merged one.
|
* of every board that is not the merged one.
|
||||||
*/
|
*/
|
||||||
mesh.userData.baseCount = baseBoxes < 0 ? boxes.length : baseBoxes;
|
const base = baseBoxes < 0 ? boxes.length : baseBoxes;
|
||||||
|
mesh.userData.baseCount = base;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The detail lots, kept in a copy so they can be re-packed by district.
|
||||||
|
*
|
||||||
|
* **Why a copy rather than a prefix.** `InstancedMesh.count` draws the first N
|
||||||
|
* instances, so an ordered "base first, detail last" layout can express "no
|
||||||
|
* cities" and "every city" and nothing in between. On the merged board that
|
||||||
|
* is the difference between 348,271 triangles and 925,854 against a 430,000
|
||||||
|
* cap: driving into Los Angeles revealed San Francisco's fifty-two districts
|
||||||
|
* as well, four hundred kilometres away and behind the camera.
|
||||||
|
*
|
||||||
|
* So the visible districts are copied into the front of the live buffers when
|
||||||
|
* the visible *set* changes — not per frame, and it changes on the order of
|
||||||
|
* once a journey. Three attributes move together and they must: the matrix,
|
||||||
|
* the colour and `FACADE_ATTRIBUTE`, which is per-instance and is what decides
|
||||||
|
* which windows are lit. Moving the matrices alone would light a tower's
|
||||||
|
* windows on a warehouse.
|
||||||
|
*/
|
||||||
|
if (base < boxes.length) {
|
||||||
|
const detail = boxes.slice(base);
|
||||||
|
const matrix = new THREE.Matrix4();
|
||||||
|
const quat = new THREE.Quaternion();
|
||||||
|
const pos = new THREE.Vector3();
|
||||||
|
const scl = new THREE.Vector3();
|
||||||
|
const up = new THREE.Vector3(0, 1, 0);
|
||||||
|
const srcMatrix = new Float32Array(detail.length * 16);
|
||||||
|
const srcColor = new Float32Array(detail.length * 3);
|
||||||
|
const srcFacade = new Float32Array(detail.length * 2);
|
||||||
|
detail.forEach((b, i) => {
|
||||||
|
pos.set(b.x, b.y, b.z);
|
||||||
|
quat.setFromAxisAngle(up, b.rot);
|
||||||
|
scl.set(b.w, b.h, b.d);
|
||||||
|
matrix.compose(pos, quat, scl);
|
||||||
|
matrix.toArray(srcMatrix, i * 16);
|
||||||
|
srcColor[i * 3] = b.color.r;
|
||||||
|
srcColor[i * 3 + 1] = b.color.g;
|
||||||
|
srcColor[i * 3 + 2] = b.color.b;
|
||||||
|
srcFacade[i * 2] = facade[(base + i) * 2] ?? 0;
|
||||||
|
srcFacade[i * 2 + 1] = facade[(base + i) * 2 + 1] ?? 0;
|
||||||
|
});
|
||||||
|
const store: DetailStore = {
|
||||||
|
base,
|
||||||
|
ranges: detailRanges.map((r) => ({ ...r, start: r.start - base })),
|
||||||
|
srcMatrix,
|
||||||
|
srcColor,
|
||||||
|
srcFacade,
|
||||||
|
key: null,
|
||||||
|
};
|
||||||
|
mesh.userData.detail = store;
|
||||||
|
/*
|
||||||
|
* **Born packed.** `InstancedMesh`'s constructor sets `count` to its
|
||||||
|
* capacity, so between this function returning and the scene's first
|
||||||
|
* `applyDetailLod` the mesh draws *every* detail lot — and the budget
|
||||||
|
* harness reports `maxTriangles`, a max over its whole sample window, so it
|
||||||
|
* caught that as 911,541 triangles against a 440,000 cap while the steady
|
||||||
|
* state was a correct 348,271. A level of detail that is right on every
|
||||||
|
* frame but the first is not a level of detail; it is a spike with a good
|
||||||
|
* explanation.
|
||||||
|
*/
|
||||||
|
mesh.count = base;
|
||||||
|
}
|
||||||
|
|
||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One detail district's lots, and where that district is in scene units. */
|
||||||
|
interface DetailRange {
|
||||||
|
start: number;
|
||||||
|
count: number;
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailStore {
|
||||||
|
base: number;
|
||||||
|
ranges: DetailRange[];
|
||||||
|
srcMatrix: Float32Array;
|
||||||
|
srcColor: Float32Array;
|
||||||
|
srcFacade: Float32Array;
|
||||||
|
/**
|
||||||
|
* The visible set the buffers currently hold, as a key — and `null` until the
|
||||||
|
* first pack, which is not the same as "empty".
|
||||||
|
*
|
||||||
|
* It was `""` and that was a bug with a 563,000-triangle blast radius: an
|
||||||
|
* empty visible set produces the empty string too, so the very first call —
|
||||||
|
* the whole-board pose, where nothing should be drawn — compared equal, took
|
||||||
|
* the early return, and left `count` at the value the `InstancedMesh`
|
||||||
|
* constructor gave it, which is *every* instance. The board measured 911,541
|
||||||
|
* triangles against a 440,000 cap while the code that was supposed to prevent
|
||||||
|
* exactly that ran and did nothing.
|
||||||
|
*/
|
||||||
|
key: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw the cities near `(x, z)` and no others.
|
||||||
|
*
|
||||||
|
* Called from the scene's frame loop, and cheap on every frame that changes
|
||||||
|
* nothing: it decides the visible set, compares it to the last one as a string
|
||||||
|
* key, and returns before touching a buffer if they match. A journey across
|
||||||
|
* California changes that set a handful of times.
|
||||||
|
*
|
||||||
|
* When it does change, the visible districts' lots are copied into the front of
|
||||||
|
* the instance buffers after the base set and `count` is moved. That is a
|
||||||
|
* `Float32Array.set` per district — about 56,000 instances at the very most,
|
||||||
|
* which is a few milliseconds on the one frame it happens, against a saving of
|
||||||
|
* over half a million triangles on every frame in between.
|
||||||
|
*/
|
||||||
|
export function updateBlocksDetail(
|
||||||
|
mesh: THREE.InstancedMesh,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
reachUnits: number,
|
||||||
|
): void {
|
||||||
|
const store = mesh.userData.detail as DetailStore | undefined;
|
||||||
|
if (store === undefined) return;
|
||||||
|
const reach2 = reachUnits * reachUnits;
|
||||||
|
const visible =
|
||||||
|
reachUnits > 0
|
||||||
|
? store.ranges.filter((r) => (x - r.x) ** 2 + (z - r.z) ** 2 < reach2)
|
||||||
|
: [];
|
||||||
|
const key = visible.map((r) => r.start).join(",");
|
||||||
|
if (key === store.key) return;
|
||||||
|
store.key = key;
|
||||||
|
|
||||||
|
const matrix = mesh.instanceMatrix.array as Float32Array;
|
||||||
|
const colour = mesh.instanceColor?.array as Float32Array | undefined;
|
||||||
|
const facade = mesh.geometry.getAttribute(FACADE_ATTRIBUTE);
|
||||||
|
const facadeArray = facade?.array as Float32Array | undefined;
|
||||||
|
|
||||||
|
let at = store.base;
|
||||||
|
for (const r of visible) {
|
||||||
|
matrix.set(store.srcMatrix.subarray(r.start * 16, (r.start + r.count) * 16), at * 16);
|
||||||
|
if (colour !== undefined) {
|
||||||
|
colour.set(store.srcColor.subarray(r.start * 3, (r.start + r.count) * 3), at * 3);
|
||||||
|
}
|
||||||
|
if (facadeArray !== undefined) {
|
||||||
|
facadeArray.set(store.srcFacade.subarray(r.start * 2, (r.start + r.count) * 2), at * 2);
|
||||||
|
}
|
||||||
|
at += r.count;
|
||||||
|
}
|
||||||
|
mesh.count = at;
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||||
|
if (facade) facade.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The named buildings. Separate meshes because the eye goes looking for these
|
* The named buildings. Separate meshes because the eye goes looking for these
|
||||||
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
|
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
|
||||||
|
|||||||
+11
-4
@@ -28,7 +28,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { createBlocks, createLandmarks, type BuildingReservation } from "./blocks.ts";
|
import { createBlocks, createLandmarks, updateBlocksDetail, type BuildingReservation } from "./blocks.ts";
|
||||||
import { createNightLights, type NightLights } from "./nightlights.ts";
|
import { createNightLights, type NightLights } from "./nightlights.ts";
|
||||||
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||||
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
|
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
|
||||||
@@ -952,10 +952,10 @@ export async function createScene(
|
|||||||
let detailShown = true;
|
let detailShown = true;
|
||||||
function applyDetailLod(): void {
|
function applyDetailLod(): void {
|
||||||
if (!hasDetail) return;
|
if (!hasDetail) return;
|
||||||
const standoff = kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit;
|
const target = kit.controls.target;
|
||||||
|
const standoff = kit.camera.position.distanceTo(target) * world.metresPerUnit;
|
||||||
let want = standoff < DETAIL_STANDOFF_M;
|
let want = standoff < DETAIL_STANDOFF_M;
|
||||||
if (want) {
|
if (want) {
|
||||||
const target = kit.controls.target;
|
|
||||||
const reach = detailReachUnits * detailReachUnits;
|
const reach = detailReachUnits * detailReachUnits;
|
||||||
want = detailCentres.some((c) => {
|
want = detailCentres.some((c) => {
|
||||||
const dx = target.x - c.x;
|
const dx = target.x - c.x;
|
||||||
@@ -963,9 +963,16 @@ export async function createScene(
|
|||||||
return dx * dx + dz * dz < reach;
|
return dx * dx + dz * dz < reach;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* The buildings are re-packed by district rather than switched wholesale,
|
||||||
|
* so driving into Los Angeles draws Los Angeles and not also San Francisco
|
||||||
|
* four hundred kilometres behind the camera. `updateBlocksDetail` is a no-op
|
||||||
|
* on any frame where the visible set has not changed, and on any board with
|
||||||
|
* no detail districts at all.
|
||||||
|
*/
|
||||||
|
updateBlocksDetail(blocks, target.x, target.z, want ? detailReachUnits : 0);
|
||||||
if (want === detailShown) return;
|
if (want === detailShown) return;
|
||||||
detailShown = want;
|
detailShown = want;
|
||||||
blocks.count = want ? baseCount + detailLots : baseCount;
|
|
||||||
for (const child of detailLandmarks) child.visible = want;
|
for (const child of detailLandmarks) child.visible = want;
|
||||||
for (const layer of detailLayers) layer.visible = want;
|
for (const layer of detailLayers) layer.visible = want;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,3 +143,70 @@ describe("one California's chapters", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("one California's cities are lotted like cities", () => {
|
||||||
|
it("measures a detail district's lot in metres, not in scene units", () => {
|
||||||
|
/*
|
||||||
|
* The defect this guards: `LOT` is fixed in scene units, which makes a lot
|
||||||
|
* 40 m on San Francisco's board and **806 m** on the statewide one. Put
|
||||||
|
* San Francisco's fifty-two districts on the statewide board unchanged and
|
||||||
|
* they are lotted at 806 m — and lots go as the inverse square of their
|
||||||
|
* size, so the Bay Area's ~84,000 buildings become about two hundred and a
|
||||||
|
* city renders as a handful of grey slabs.
|
||||||
|
*
|
||||||
|
* Measured in the browser after the fix: the merged board carries 59,166
|
||||||
|
* building instances, of which 2,839 are the state's own and 56,327 are the
|
||||||
|
* two metros'. Before it, the metros contributed roughly two hundred.
|
||||||
|
*
|
||||||
|
* This asserts the property that produces that, rather than the count,
|
||||||
|
* because the count moves with any pack edit and the property does not.
|
||||||
|
*/
|
||||||
|
const detail = city.districts.filter((d) => d.detail === true);
|
||||||
|
assert.ok(detail.length > 0);
|
||||||
|
// The state's own districts must NOT be marked, or they would be re-lotted
|
||||||
|
// at 160 m across the whole of California and the board would never build.
|
||||||
|
const base = city.districts.filter((d) => d.detail !== true);
|
||||||
|
assert.ok(base.length > 0);
|
||||||
|
for (const d of base) assert.equal(d.detail, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the metros' own boards untouched, which is the point of the flag", () => {
|
||||||
|
// `unify` copies districts before marking them, so nothing it does can
|
||||||
|
// reach back into `sf.ts` or `socal.ts` and re-lot the dedicated boards.
|
||||||
|
for (const d of SAN_FRANCISCO.districts) assert.equal(d.detail, undefined);
|
||||||
|
for (const d of SOCAL.districts) assert.equal(d.detail, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the detail repack", () => {
|
||||||
|
it("distinguishes 'nothing packed yet' from 'nothing visible'", async () => {
|
||||||
|
/*
|
||||||
|
* A regression test for a bug this file's own author wrote and the budget
|
||||||
|
* caught. `DetailStore.key` remembers which districts the instance buffers
|
||||||
|
* currently hold, and it used to start as `""` — the same key an *empty*
|
||||||
|
* visible set produces. So the first call, at the whole-board pose where
|
||||||
|
* nothing should draw, compared equal to the initial state, took the early
|
||||||
|
* return, and left `InstancedMesh.count` at the constructor's value, which
|
||||||
|
* is every instance. Measured: 911,541 triangles against a 440,000 cap,
|
||||||
|
* with the code meant to prevent it running and doing nothing.
|
||||||
|
*
|
||||||
|
* The distinction is the whole fix, so it is what is asserted — on the
|
||||||
|
* source, because building a real `InstancedMesh` here would need a GPU.
|
||||||
|
*/
|
||||||
|
const source = await import("node:fs/promises").then((fs) =>
|
||||||
|
fs.readFile("src/engine/blocks.ts", "utf8"),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
/key: string \| null;/.test(source),
|
||||||
|
"DetailStore.key must admit a value that no visible set can produce",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
/key: null,/.test(source),
|
||||||
|
"the store must start at that value, not at an empty key",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!/key: "",/.test(source),
|
||||||
|
'an empty-string initial key is the bug: it equals the key of an empty visible set',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user