PROCITY/web/js/interiors/batch.js
m3ultra 422f17263e Lane C R41 §41.4: the shops get their stock — and the measuring instrument was wrong
110 fittings placed across 12 shop types via new kit41.js (96-row catalogue, 67 ids in
pools) and ONE generic kitProp builder; recipes name pools, never asset ids. glb.js now
honours seeded per-fitting variants, multi-GLB booths, and a MEASURED autoYaw instead of a
110-row facing table.

THE INSTRUMENT WAS WRONG. drawSweep cleared every interior EXCEPT current, so every
historical interior number carried a phantom second room — 'toy', untouched this round,
'gained' 4 draws. R39/R40's celebrated 'worst 188, margin 162' was inflated. Corrected, and
with 110 new fittings placed: worst room in the game is 122 @ dept/auto, MARGIN 228.
Per type: record 61->78, pub 45->59, band_room 39->50, stall 71->82, book 51->61,
pawn 60->70, video 54->63, rsl 52->60, dept 116->122, opshop 102->105, milkbar 68->69,
toy 86->86 (+0, the control).

The pub finally has a bar (plywood_bar), banquettes, pokies, a stage flank. Milk bars get a
bain-marie and pie warmer. The pawn shop gets an 808 and an SP-1200 on the bench. Record
shops get a rigged DJ booth. Market stalls get 2-4 diggable bins each, closing R39's measured
44%-no-crate hole to 0/40.

WARDROBE, route R5: 257.5 KB total (246 KB atlas + 17 KB index), ONE fetch, one material,
one draw per fitting — 52 layers picked BY NAME, alpha-cropped, shelf-packed, 3.7% of source
bytes, lazy behind ?stock=real. LICENCE CALL MADE EXPLICITLY: AUDIT.md says no real brands
ship and the source library is prompted FROM real labels — the adidas jacket renders a
legible trefoil. Screened on a contact sheet, then relabelled in parody voice (Paddy's
Bootleg, Kaymart, Coogee Knits, Mombo, Stoosh).

FIVE DEFECTS FOUND AND FIXED, one long-standing: buyMesh has been INVISIBLE on every GLB
fitting since R9 (hiding every buyable item under ?stock=real) · DJ booths were
nondeterministic (parts attached in network order) · booth gear floated at 0.92 m · batch.js
dropped userData sub-tags on merge, so any post-batch flag read was already a no-op · the
path guarantee could empty a room (pub/wide/4242 returned a bar, a stage and nothing else) —
new restore pass, 129 pulled -> 101 restored over 480 rooms, path re-proved on every restore.

Gates: 288-room sweep x 4 arms all pass; soak 60 rooms avg 3.83 ms worst 10.3 ms, leaks 0;
960-room corpus 0 path-fails 0 carves; determinism byte-equal across fresh contexts
(sha256 c26b4848); ?noassets=1 60 rooms with 0 GLB / 0 manifest / 0 wardrobe / 0 stock
fetches. DJ control nodes recorded on fitting.controlNodes for a future driver (glTF drops
the limit constraints — a driver must clamp travel itself).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:11:07 +10:00

93 lines
4.9 KiB
JavaScript

// PROCITY Lane C — draw-call batcher (round 6, decision #2: ≤350 draws/room is law).
//
// Interiors were 1 draw per stock item (a book barn = ~600 spine meshes; a multi-material box cost
// one draw PER face). Two-tier merge collapses hundreds of draws into dozens:
//
// • STOCK (products: sleeves/spines/boxes/garments/snacks/treasures — tagged userData.isStock) is
// merged at the ROOM level, grouped by material, across ALL fittings. World transforms are baked
// so the merged meshes sit in the room group. A hall full of clothes racks → ~8 garment meshes
// total, not ~8 per rack.
// • FIXTURE frames (shelf uprights, cabinets, counters) are merged WITHIN each fitting group, so the
// group + its userData survive (record bins stay raycast targets for ?dig=1) and the GLB upgrade
// can still hide the fixture (isFrame) while the room-level stock rides on top of the GLB.
//
// Constraints honoured: determinism (merge follows deterministic child order), dig-compat (bins keep
// their frame as a hit target; the dig regenerates its own sleeves), leak-free (merged geometries are
// ctx-tracked, clones disposed immediately), keeper/stock/API contracts unchanged. Meshes whose
// material is unique in their scope are LEFT in place (nothing to merge).
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
// A visual signature for a single-material mesh; same key ⇒ mergeable into one draw.
function matKey(m) {
if (!m || Array.isArray(m)) return null; // multi-material meshes are not merged (there are none post-fix)
const c = m.color ? m.color.getHexString() : '------';
const map = m.map ? m.map.uuid : '';
return `${map}|${c}|${m.transparent ? 1 : 0}|${m.side}|${m.alphaTest || 0}|${m.emissive ? m.emissive.getHexString() : ''}`;
}
// Merge each material bucket of ≥2 meshes into one mesh appended to `target`; ≤1 stays put.
// `bake(mesh)` returns the geometry-space matrix for a mesh (room-local for stock, group-local for frames).
function flushBuckets(ctx, target, buckets, stockTag, bake) {
const THREE = ctx.THREE;
let saved = 0;
for (const b of buckets.values()) {
if (b.meshes.length < 2) continue; // unique material — leave the mesh in place
const geos = b.meshes.map(m => { const g = m.geometry.clone(); g.applyMatrix4(bake(m)); return g; });
let merged;
try { merged = mergeGeometries(geos, false); } catch (e) { merged = null; }
geos.forEach(g => g.dispose());
if (!merged) continue;
ctx._geometries.add(merged);
for (const m of b.meshes) m.parent && m.parent.remove(m);
const mm = new THREE.Mesh(merged, b.material);
mm.userData = stockTag ? { isStock: true, batched: true } : { isFrame: true, batched: true };
// [R41 §41.4] CARRY UNANIMOUS SUB-TAGS THROUGH THE MERGE. A frame mesh can be marked for later
// selective hiding (`boothGear` — the primitive DJ gear a GLB replaces; `tillPrimitive` — the till
// that `cash_register` replaces). Merging rebuilt userData from scratch and dropped those marks, so
// the hide that depends on them silently became a no-op. Only propagate when EVERY mesh in the
// bucket carries the tag: a bucket is one material, and a mark that isn't unanimous isn't a mark.
if (!stockTag)
for (const tag of ['boothGear', 'tillPrimitive'])
if (b.meshes.every(m => m.userData && m.userData[tag])) mm.userData[tag] = true;
target.add(mm);
saved += b.meshes.length - 1;
}
return saved;
}
function bucketPush(map, key, mesh) {
let b = map.get(key); if (!b) { b = { material: mesh.material, meshes: [] }; map.set(key, b); }
b.meshes.push(mesh);
}
export function batchRoom(ctx, roomGroup) {
const THREE = ctx.THREE;
roomGroup.updateMatrixWorld(true);
const roomInv = roomGroup.matrixWorld.clone().invert();
const stock = new Map(); // room-level stock, keyed by material
const frames = []; // [{group, buckets}] per fitting
for (const fit of roomGroup.children) {
if (!fit.isGroup) continue; // shell meshes + wall decor stay as-is
fit.updateMatrixWorld(true);
const frame = new Map();
for (const mesh of fit.children) {
if (!mesh.isMesh) continue;
const key = matKey(mesh.material);
if (key == null || (mesh.userData && mesh.userData.noBatch)) continue;
if (mesh.userData && mesh.userData.isStock) bucketPush(stock, key, mesh);
else bucketPush(frame, key, mesh);
}
frames.push({ group: fit, buckets: frame });
}
const tmp = new THREE.Matrix4();
const stockBake = (m) => { m.updateMatrixWorld(true); return tmp.multiplyMatrices(roomInv, m.matrixWorld); };
const frameBake = (m) => { m.updateMatrix(); return m.matrix; };
let saved = flushBuckets(ctx, roomGroup, stock, true, stockBake); // room-level stock
for (const f of frames) saved += flushBuckets(ctx, f.group, f.buckets, false, frameBake); // per-fitting frames
return saved;
}