1
0

fix: a coarse lot is a parcel, not a 700 m building

The unidentified slab on downtown San Francisco was the lot ladder's
own coarsest rung. Tower fill of 1.5–2.2 assembles a Salesforce-shaped
site from 40 m lots; on a 400 m lot it is a 600–880 m cube, rotated
with the district's street grid, downtown palette. The same pose on
the Bay Area board uses 40 m lots and does not show it.

Cap both axes at NEIGHBOURHOOD_LOT_METRES. Fine rungs are unchanged
because 40 m and 80 m already sit under the cap. The test that would
have named this on the first photograph now exists.
This commit is contained in:
2026-08-24 22:02:47 -07:00
parent 3f0581686f
commit 11d54c48ef
3 changed files with 77 additions and 42 deletions
+15 -5
View File
@@ -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 600880 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.851.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,
+45 -1
View File
@@ -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.52.2 on a 400 m lot is
* a 600880 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`,
);
});