@ifc-lite/renderer 1.35.1 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/chunk-grid.d.ts +60 -0
  2. package/dist/chunk-grid.d.ts.map +1 -0
  3. package/dist/chunk-grid.js +47 -0
  4. package/dist/chunk-grid.js.map +1 -0
  5. package/dist/contribution-cull.d.ts +75 -0
  6. package/dist/contribution-cull.d.ts.map +1 -0
  7. package/dist/contribution-cull.js +63 -0
  8. package/dist/contribution-cull.js.map +1 -0
  9. package/dist/device.d.ts.map +1 -1
  10. package/dist/device.js +24 -1
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.d.ts +28 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +190 -10
  15. package/dist/index.js.map +1 -1
  16. package/dist/lod-simplify.d.ts +36 -0
  17. package/dist/lod-simplify.d.ts.map +1 -0
  18. package/dist/lod-simplify.js +101 -0
  19. package/dist/lod-simplify.js.map +1 -0
  20. package/dist/pipeline.d.ts +13 -0
  21. package/dist/pipeline.d.ts.map +1 -1
  22. package/dist/pipeline.js +63 -2
  23. package/dist/pipeline.js.map +1 -1
  24. package/dist/quantize.d.ts +49 -0
  25. package/dist/quantize.d.ts.map +1 -0
  26. package/dist/quantize.js +137 -0
  27. package/dist/quantize.js.map +1 -0
  28. package/dist/render-stats.d.ts +85 -0
  29. package/dist/render-stats.d.ts.map +1 -0
  30. package/dist/render-stats.js +31 -0
  31. package/dist/render-stats.js.map +1 -0
  32. package/dist/residency.d.ts +52 -0
  33. package/dist/residency.d.ts.map +1 -0
  34. package/dist/residency.js +28 -0
  35. package/dist/residency.js.map +1 -0
  36. package/dist/scene.d.ts +186 -0
  37. package/dist/scene.d.ts.map +1 -1
  38. package/dist/scene.js +818 -31
  39. package/dist/scene.js.map +1 -1
  40. package/dist/shaders/main.wgsl.d.ts +1 -1
  41. package/dist/shaders/main.wgsl.d.ts.map +1 -1
  42. package/dist/shaders/main.wgsl.js +53 -0
  43. package/dist/shaders/main.wgsl.js.map +1 -1
  44. package/dist/types.d.ts +47 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +2 -2
package/dist/scene.js CHANGED
@@ -4,6 +4,11 @@
4
4
  import { BATCH_CONSTANTS } from './constants.js';
5
5
  import { prepareRayDirInv, raycastBoundingBoxes, raycastTriangles, rayIntersectsBox, } from './scene-raycaster.js';
6
6
  import { mergeGeometry, splitMeshDataForBufferLimit, colorSaltByte, packEntityLane } from './scene-geometry.js';
7
+ import { sumResidentGpuBytes } from './render-stats.js';
8
+ import { simplifyIndicesByClustering, lodCellSizeForBounds, LOD_MIN_TRIANGLES } from './lod-simplify.js';
9
+ import { quantizeInterleaved } from './quantize.js';
10
+ import { bucketBaseKeyFor } from './chunk-grid.js';
11
+ import { selectEvictions } from './residency.js';
7
12
  import { OPAQUE_ALPHA_CUTOFF } from './overlay-routing.js';
8
13
  import { prepareInstancedRender, INSTANCE_STRIDE_BYTES, INSTANCE_COLOR_OFFSET, INSTANCE_FLAGS_OFFSET, INSTANCE_FLAG_SELECTED, INSTANCE_FLAG_HIDDEN, } from './instanced-render.js';
9
14
  function destroyGpuResources(m) {
@@ -11,6 +16,8 @@ function destroyGpuResources(m) {
11
16
  m.indexBuffer.destroy();
12
17
  if (m.uniformBuffer)
13
18
  m.uniformBuffer.destroy();
19
+ if (m.lod1IndexBuffer)
20
+ m.lod1IndexBuffer.destroy();
14
21
  }
15
22
  /** Shared empty result for getInstancedTemplates() when the instanced pass is hidden. */
16
23
  const EMPTY_INSTANCED_TEMPLATES = [];
@@ -40,6 +47,40 @@ export class Scene {
40
47
  // sub-bucket with a suffixed key (e.g. "500|500|500|1000#1"). This keeps
41
48
  // all downstream maps single-valued and the rendering code unchanged.
42
49
  activeBucketKey = new Map(); // base colorKey -> current active bucket key
50
+ // Spatial chunking (issue #1682 phase 2): when set, bucket base keys gain a
51
+ // grid-cell prefix so batches are spatially compact and cullable. Null = off
52
+ // (plain colour bucketing, the historical behaviour).
53
+ spatialChunking = null;
54
+ // GPU residency budget (issue #1682 phase 3a): when set, bucket-owned
55
+ // batches not drawn recently are evicted (GPU buffers destroyed, CPU
56
+ // meshData + metadata shell kept) once their combined bytes exceed the
57
+ // budget, and rebuilt on demand when the draw loop wants them again.
58
+ gpuBudgetBytes = null;
59
+ residencyFrame = 0; // bumped once per render()
60
+ lastDrawnFrame = new Map(); // batch.id -> residencyFrame
61
+ residencyRestoreQueue = new Set(); // bucket keys awaiting re-upload
62
+ residencyOverBudgetWarned = false;
63
+ // Cold tier (issue #1682 phase 3b): warm buckets (GPU-evicted, CPU kept)
64
+ // can additionally drop their CPU meshData when a HOST budget is set and a
65
+ // cold-storage provider (v13 cache chunks) can restore it on demand.
66
+ // hot = GPU+CPU, warm = CPU only, cold = metadata shell only.
67
+ coldProvider = null;
68
+ hostBudgetBytes = null;
69
+ coldBuckets = new Set(); // CPU dropped, provider-restorable
70
+ dirtyBuckets = new Set(); // diverged from disk (recolour/move/remove)
71
+ coldRestoresInFlight = new Map();
72
+ hostOverBudgetWarned = false;
73
+ hostEnforceCountdown = 0;
74
+ // LOD1 builds (issue #1682 phase 5): off unless the app enables them.
75
+ lodBuildsEnabled = false;
76
+ // 12-byte quantized batch vertices (issue #1682 phase 6): off unless the
77
+ // renderer probed its quantized pipelines and enabled it.
78
+ quantizedBatchesEnabled = false;
79
+ // True while a (possibly time-sliced) finalize rebuild is running. The
80
+ // preamble clears streamingFragments synchronously, so hasStreamingFragments
81
+ // alone under-reports "still settling" — settle-sensitive consumers
82
+ // (post-load telemetry) must also check this.
83
+ finalizeInProgress = false;
43
84
  nextSplitId = 0; // Monotonic counter for sub-bucket keys
44
85
  nextBatchId = 0; // Monotonic counter for unique batch identifiers
45
86
  // Shared local-frame origin for ALL batches (set from the first batch's world
@@ -103,6 +144,446 @@ export class Scene {
103
144
  getSharedFrameOrigin() {
104
145
  return this.sharedFrameOrigin;
105
146
  }
147
+ /**
148
+ * Enable/disable spatial chunk bucketing (issue #1682 phase 2). When set,
149
+ * colour buckets are additionally partitioned by world grid cell, making
150
+ * batches spatially compact so per-batch frustum/contribution culling
151
+ * fires at chunk granularity. Pure reorganization: same triangles, same
152
+ * shared frame origin, same draw path — only the batch partition changes.
153
+ *
154
+ * Set BEFORE geometry loads. Existing buckets keep their keys (keys are
155
+ * opaque downstream), so flipping mid-model only affects meshes routed
156
+ * afterwards; the next finalize/recolour re-groups stragglers.
157
+ */
158
+ setSpatialChunking(config) {
159
+ if (config && !(Number.isFinite(config.cellSize) && config.cellSize > 0)) {
160
+ console.warn('[Scene] ignoring invalid spatial chunking cellSize:', config.cellSize);
161
+ return;
162
+ }
163
+ this.spatialChunking = config;
164
+ }
165
+ getSpatialChunking() {
166
+ return this.spatialChunking;
167
+ }
168
+ // ─── GPU residency (issue #1682 phase 3a) ──────────────────────────────
169
+ // The budget applies to bucket-owned colour/chunk batches (the evictable
170
+ // set). Streaming fragments, partial sub-batches, overlay batches,
171
+ // textured meshes and instanced templates are never evicted: fragments are
172
+ // transient, the rest are small or lack a rebuild source. Enforcement
173
+ // no-ops while geometry is released or in ephemeral streaming mode (no CPU
174
+ // meshData to rebuild from — that is phase 3b's evict-to-disk territory).
175
+ /** Set (or clear) the GPU residency budget in bytes. */
176
+ setGpuResidencyBudget(bytes) {
177
+ if (bytes !== null && !(Number.isFinite(bytes) && bytes > 0)) {
178
+ console.warn('[Scene] ignoring invalid GPU residency budget:', bytes);
179
+ return;
180
+ }
181
+ this.gpuBudgetBytes = bytes;
182
+ this.residencyOverBudgetWarned = false;
183
+ }
184
+ getGpuResidencyBudget() {
185
+ return this.gpuBudgetBytes;
186
+ }
187
+ /** Called once at the start of every Renderer.render() — residency ages
188
+ * are measured in RENDERED frames, so idle scenes never age out. */
189
+ beginResidencyFrame() {
190
+ this.residencyFrame++;
191
+ }
192
+ /** Record that the draw loop drew this batch this frame. */
193
+ recordBatchDrawn(batch) {
194
+ if (this.gpuBudgetBytes === null)
195
+ return;
196
+ this.lastDrawnFrame.set(batch.id, this.residencyFrame);
197
+ }
198
+ /**
199
+ * The draw loop wants an evicted batch back on the GPU. Queues its bucket
200
+ * for a time-budgeted rebuild in processResidencyRestores (driven by the
201
+ * app's animation loop) — the batch is skipped this frame and pops back in
202
+ * within a frame or two.
203
+ */
204
+ requestBatchResidency(batch) {
205
+ const bucket = this.buckets.get(batch.colorKey);
206
+ if (!bucket || bucket.batchedMesh !== batch)
207
+ return;
208
+ // Warm (CPU kept) OR cold (disk-restorable) — both are restorable.
209
+ if (bucket.meshData.length > 0 || this.coldBuckets.has(bucket.key)) {
210
+ this.residencyRestoreQueue.add(bucket.key);
211
+ }
212
+ }
213
+ hasResidencyRestoreWork() {
214
+ return this.residencyRestoreQueue.size > 0;
215
+ }
216
+ /**
217
+ * Rebuild evicted batches from their buckets' CPU meshData, up to
218
+ * `budgetMs` per call (same time-slicing philosophy as flushPending).
219
+ * Returns the number of batches restored.
220
+ */
221
+ processResidencyRestores(device, pipeline, budgetMs = 6) {
222
+ if (this.residencyRestoreQueue.size === 0)
223
+ return 0;
224
+ const start = performance.now();
225
+ let restored = 0;
226
+ for (const key of this.residencyRestoreQueue) {
227
+ this.residencyRestoreQueue.delete(key);
228
+ const bucket = this.buckets.get(key);
229
+ const old = bucket?.batchedMesh;
230
+ // Only restore a still-evicted bucket batch — a recolour/finalize may
231
+ // have rebuilt (or emptied) it in the meantime.
232
+ if (!bucket || !old || old.gpuResident !== false)
233
+ continue;
234
+ // Cold bucket: geometry is on disk — kick off the async provider fetch
235
+ // (it re-queues the key as warm when the meshes land).
236
+ if (bucket.meshData.length === 0) {
237
+ if (this.coldBuckets.has(key))
238
+ this.startColdRestore(key);
239
+ continue;
240
+ }
241
+ const rebuilt = this.createBatchedMesh(bucket.meshData, bucket.meshData[0].color, device, pipeline, key);
242
+ bucket.batchedMesh = rebuilt;
243
+ const idx = this.batchedMeshes.indexOf(old);
244
+ if (idx >= 0)
245
+ this.batchedMeshes[idx] = rebuilt;
246
+ else
247
+ this.batchedMeshes.push(rebuilt);
248
+ this.lastDrawnFrame.delete(old.id);
249
+ // Seed as just-drawn so the budget pass can't evict it before the
250
+ // frame that asked for it gets to draw it.
251
+ this.lastDrawnFrame.set(rebuilt.id, this.residencyFrame);
252
+ restored++;
253
+ if (performance.now() - start >= budgetMs)
254
+ break;
255
+ }
256
+ return restored;
257
+ }
258
+ // ─── Cold tier (issue #1682 phase 3b) ──────────────────────────────────
259
+ /** Wire the cold-storage source (v13 cache chunks). Null disables the tier. */
260
+ setColdGeometryProvider(provider) {
261
+ this.coldProvider = provider;
262
+ }
263
+ /**
264
+ * Enable LOD1 builds (issue #1682 phase 5): bucket batches built from now
265
+ * on (finalize, rebuild, residency restore) get a simplified second index
266
+ * range when it pays. Set BEFORE geometry loads; streaming fragments and
267
+ * partial/overlay sub-batches never build LOD.
268
+ */
269
+ setLodBuildsEnabled(enabled) {
270
+ this.lodBuildsEnabled = enabled;
271
+ }
272
+ /**
273
+ * Enable 12-byte lattice-quantized batch vertices (issue #1682 phase 6).
274
+ * ONLY call after the renderer verified its quantized pipeline variants
275
+ * exist (see Renderer.enableQuantizedBatches) — quantized buffers are
276
+ * undrawable without them. Applies to batches built from now on; every
277
+ * createBatchedMesh output (buckets, fragments, partial + override
278
+ * batches) quantizes onto the SAME 2^-10 lattice, so depth-equal overlay
279
+ * matching and cross-batch coincidence are preserved bit-exactly. Batches
280
+ * whose extent exceeds the u16 lattice range fall back to f32 silently.
281
+ */
282
+ setQuantizedBatches(enabled) {
283
+ this.quantizedBatchesEnabled = enabled;
284
+ }
285
+ /**
286
+ * Whether THIS mesh's source batch renders quantized — drives the
287
+ * hydrated-mesh lattice snap in createMeshFromData (a mesh whose batch
288
+ * fell back to f32, e.g. >64m extent, must NOT snap). Falls back to the
289
+ * global flag when the mesh isn't bucketed (mid-stream hydration).
290
+ */
291
+ isMeshQuantized(meshData) {
292
+ if (!this.quantizedBatchesEnabled)
293
+ return false;
294
+ const bucket = this.meshDataBucket.get(meshData);
295
+ if (bucket?.batchedMesh)
296
+ return bucket.batchedMesh.quantized !== undefined;
297
+ return true;
298
+ }
299
+ /** Set (or clear) the HOST budget in bytes for bucket CPU geometry. */
300
+ setHostResidencyBudget(bytes) {
301
+ if (bytes !== null && !(Number.isFinite(bytes) && bytes > 0)) {
302
+ console.warn('[Scene] ignoring invalid host residency budget:', bytes);
303
+ return;
304
+ }
305
+ this.hostBudgetBytes = bytes;
306
+ this.hostOverBudgetWarned = false;
307
+ }
308
+ /** CPU bytes held by bucket meshData (positions + normals + indices). */
309
+ getResidentCpuBytes() {
310
+ let total = 0;
311
+ for (const bucket of this.buckets.values()) {
312
+ for (const md of bucket.meshData) {
313
+ total += md.positions.byteLength + md.normals.byteLength + md.indices.byteLength;
314
+ }
315
+ }
316
+ return total;
317
+ }
318
+ /** A bucket whose content diverged from what the cache entry holds
319
+ * (recolour / move / removal) must never be cold-evicted: restoring it
320
+ * from disk would resurrect the pre-edit geometry. */
321
+ markBucketDirty(key) {
322
+ this.dirtyBuckets.add(key);
323
+ }
324
+ /**
325
+ * Demote warm buckets (GPU-evicted, CPU kept) to cold (shell only) until
326
+ * bucket CPU bytes fit the host budget. Same LRU policy as the GPU tier.
327
+ * Eligibility is strict: pristine, non-overflow ("#N" sub-buckets are
328
+ * excluded — their piece membership cannot be re-derived unambiguously),
329
+ * GPU-evicted, provider present. Cold eviction removes the bucket's meshes
330
+ * from meshDataMap/meshDataBucket too — that is what actually frees the
331
+ * typed arrays.
332
+ */
333
+ enforceHostBudget() {
334
+ const budget = this.hostBudgetBytes;
335
+ if (budget === null || !this.coldProvider)
336
+ return;
337
+ if (this.geometryReleased || this.ephemeralStreamingMode)
338
+ return;
339
+ if (this.streamingFragments.length > 0)
340
+ return;
341
+ const residentBytes = this.getResidentCpuBytes();
342
+ if (residentBytes <= budget)
343
+ return;
344
+ const shells = [];
345
+ for (const bucket of this.buckets.values()) {
346
+ const b = bucket.batchedMesh;
347
+ if (!b || b.gpuResident !== false)
348
+ continue; // hot buckets stay warm-skippable
349
+ if (bucket.meshData.length === 0)
350
+ continue; // already cold
351
+ if (bucket.key.includes('#'))
352
+ continue; // overflow sub-bucket
353
+ if (this.dirtyBuckets.has(bucket.key))
354
+ continue; // diverged from disk
355
+ // Colour-merged meshes (per-vertex entityIds) are registered in
356
+ // meshDataMap under EVERY contained id; evicting only the primary id's
357
+ // entry would leave the typed arrays reachable (no memory freed) and a
358
+ // later restore would duplicate the object. Ineligible.
359
+ let colorMerged = false;
360
+ let bytes = 0;
361
+ for (const md of bucket.meshData) {
362
+ if (md.entityIds && md.entityIds.length > 0) {
363
+ colorMerged = true;
364
+ break;
365
+ }
366
+ bytes += md.positions.byteLength + md.normals.byteLength + md.indices.byteLength;
367
+ }
368
+ if (colorMerged)
369
+ continue;
370
+ shells.push({
371
+ key: bucket.key,
372
+ bytes,
373
+ lastDrawnFrame: this.lastDrawnFrame.get(b.id) ?? -1,
374
+ });
375
+ }
376
+ const evictKeys = selectEvictions(shells, residentBytes, budget, this.residencyFrame);
377
+ let evictedBytes = 0;
378
+ for (const key of evictKeys) {
379
+ const bucket = this.buckets.get(key);
380
+ if (!bucket || bucket.meshData.length === 0)
381
+ continue;
382
+ for (const md of bucket.meshData) {
383
+ evictedBytes += md.positions.byteLength + md.normals.byteLength + md.indices.byteLength;
384
+ this.meshDataBucket.delete(md);
385
+ // Remove THIS object from the entity's piece list (identity match:
386
+ // other pieces of the entity may live in other, still-warm buckets).
387
+ const pieces = this.meshDataMap.get(md.expressId);
388
+ if (pieces) {
389
+ const idx = pieces.indexOf(md);
390
+ if (idx >= 0)
391
+ pieces.splice(idx, 1);
392
+ if (pieces.length === 0)
393
+ this.meshDataMap.delete(md.expressId);
394
+ }
395
+ }
396
+ bucket.meshData = [];
397
+ bucket.vertexBytes = 0;
398
+ this.coldBuckets.add(key);
399
+ }
400
+ if (residentBytes - evictedBytes > budget && !this.hostOverBudgetWarned) {
401
+ this.hostOverBudgetWarned = true;
402
+ console.warn(`[Scene] host residency budget ${(budget / 1048576).toFixed(0)}MB exceeded ` +
403
+ `(${((residentBytes - evictedBytes) / 1048576).toFixed(0)}MB CPU resident) — ` +
404
+ `remaining buckets are hot, dirty, or overflow sub-buckets. Rendering is unaffected.`);
405
+ }
406
+ }
407
+ /**
408
+ * Restore EVERY cold bucket to warm (used before the cold provider goes
409
+ * away, e.g. a federated add invalidates the entry-backed provider while
410
+ * primary chunks are cold — without this they would be stranded shells).
411
+ * Resolves when all in-flight restores settle; failures are logged by the
412
+ * per-bucket restore path and leave those buckets cold.
413
+ */
414
+ async drainColdTier() {
415
+ if (this.coldBuckets.size === 0)
416
+ return;
417
+ for (const key of Array.from(this.coldBuckets)) {
418
+ this.startColdRestore(key);
419
+ }
420
+ await Promise.all(Array.from(this.coldRestoresInFlight.values()));
421
+ }
422
+ /** Kick off the async disk restore for a cold bucket the draw loop wants.
423
+ * On completion the bucket is warm again and re-queued for GPU rebuild. */
424
+ startColdRestore(key) {
425
+ if (this.geometryReleased || this.ephemeralStreamingMode)
426
+ return;
427
+ if (this.coldRestoresInFlight.has(key))
428
+ return;
429
+ const bucket = this.buckets.get(key);
430
+ const shell = bucket?.batchedMesh;
431
+ const provider = this.coldProvider;
432
+ if (!bucket || !shell || !shell.bounds || !provider)
433
+ return;
434
+ const promise = provider
435
+ .loadMeshesInBounds(shell.bounds.min, shell.bounds.max)
436
+ .then((meshes) => {
437
+ // Re-validate: a clear()/finalize may have replaced the world.
438
+ const current = this.buckets.get(key);
439
+ if (!current || current !== bucket || !this.coldBuckets.has(key))
440
+ return;
441
+ const baseKey = this.baseColorKey(key);
442
+ const idSet = new Set(shell.expressIds);
443
+ const members = meshes.filter((m) => idSet.has(m.expressId) && this.bucketBaseKey(m) === baseKey);
444
+ for (const m of members) {
445
+ bucket.meshData.push(m);
446
+ bucket.vertexBytes += (m.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
447
+ this.meshDataBucket.set(m, bucket);
448
+ this.addMeshData(m);
449
+ }
450
+ this.coldBuckets.delete(key);
451
+ if (members.length > 0) {
452
+ // Warm now — re-queue so the next restore tick rebuilds the GPU batch.
453
+ this.residencyRestoreQueue.add(key);
454
+ }
455
+ else {
456
+ console.warn(`[Scene] cold restore for ${key} found no members — bucket stays a shell`);
457
+ }
458
+ })
459
+ .catch((err) => {
460
+ console.warn('[Scene] cold restore failed (bucket stays cold, will retry on demand):', err);
461
+ })
462
+ .finally(() => {
463
+ this.coldRestoresInFlight.delete(key);
464
+ });
465
+ this.coldRestoresInFlight.set(key, promise);
466
+ }
467
+ /**
468
+ * Synchronously rebuild EVERY evicted bucket batch (no time budget) —
469
+ * for one-shot capture renders (IDS/clash/BCF snapshots) whose isolation
470
+ * options may reveal batches that aged out under the budget. The live
471
+ * view never needs this: visible batches are never evicted. The budget
472
+ * pass re-evicts unused batches after the usual idle age.
473
+ * Returns the number of batches restored.
474
+ */
475
+ restoreAllEvicted(device, pipeline) {
476
+ if (this.geometryReleased || this.ephemeralStreamingMode)
477
+ return 0;
478
+ let restored = 0;
479
+ for (const bucket of this.buckets.values()) {
480
+ const old = bucket.batchedMesh;
481
+ if (!old || old.gpuResident !== false || bucket.meshData.length === 0)
482
+ continue;
483
+ const rebuilt = this.createBatchedMesh(bucket.meshData, bucket.meshData[0].color, device, pipeline, bucket.key);
484
+ bucket.batchedMesh = rebuilt;
485
+ const idx = this.batchedMeshes.indexOf(old);
486
+ if (idx >= 0)
487
+ this.batchedMeshes[idx] = rebuilt;
488
+ else
489
+ this.batchedMeshes.push(rebuilt);
490
+ this.lastDrawnFrame.delete(old.id);
491
+ this.lastDrawnFrame.set(rebuilt.id, this.residencyFrame);
492
+ this.residencyRestoreQueue.delete(bucket.key);
493
+ restored++;
494
+ }
495
+ return restored;
496
+ }
497
+ /**
498
+ * Evict least-recently-drawn bucket batches until the resident set fits
499
+ * the budget. Called after each frame's submit; destroying just-submitted
500
+ * buffers is safe (WebGPU defers destruction past in-flight work). Never
501
+ * evicts a batch drawn this frame — a visible set larger than the budget
502
+ * renders correctly and stays over budget (warned once).
503
+ */
504
+ enforceGpuBudget() {
505
+ // Host (CPU) tier rides the same post-submit hook on a slow cadence —
506
+ // warm->cold demotion is not latency-sensitive and the CPU-bytes walk is
507
+ // O(total meshes).
508
+ if (this.hostBudgetBytes !== null && --this.hostEnforceCountdown <= 0) {
509
+ this.hostEnforceCountdown = 120;
510
+ this.enforceHostBudget();
511
+ }
512
+ const budget = this.gpuBudgetBytes;
513
+ if (budget === null)
514
+ return;
515
+ if (this.geometryReleased || this.ephemeralStreamingMode)
516
+ return;
517
+ // During streaming the batch set churns (fragments + finalize rebuild
518
+ // everything anyway) — start enforcing once the scene is stable.
519
+ if (this.streamingFragments.length > 0)
520
+ return;
521
+ let residentBytes = 0;
522
+ const shells = [];
523
+ for (const bucket of this.buckets.values()) {
524
+ const b = bucket.batchedMesh;
525
+ if (!b || b.gpuResident === false)
526
+ continue;
527
+ const bytes = b.vertexBuffer.size + b.indexBuffer.size + (b.uniformBuffer?.size ?? 0)
528
+ + (b.lod1IndexBuffer?.size ?? 0);
529
+ residentBytes += bytes;
530
+ const lastDrawn = this.lastDrawnFrame.get(b.id) ?? -1;
531
+ if (lastDrawn === this.residencyFrame)
532
+ continue; // drawn this frame: not evictable
533
+ if (bucket.meshData.length === 0)
534
+ continue; // no rebuild source: keep resident
535
+ shells.push({ key: bucket.key, bytes, lastDrawnFrame: lastDrawn });
536
+ }
537
+ if (residentBytes <= budget)
538
+ return;
539
+ const evictKeys = selectEvictions(shells, residentBytes, budget, this.residencyFrame);
540
+ let evictedBytes = 0;
541
+ for (const key of evictKeys) {
542
+ const bucket = this.buckets.get(key);
543
+ const batch = bucket?.batchedMesh;
544
+ if (!bucket || !batch || batch.gpuResident === false)
545
+ continue;
546
+ destroyGpuResources(batch);
547
+ batch.gpuResident = false;
548
+ evictedBytes += batch.vertexBuffer.size + batch.indexBuffer.size + (batch.uniformBuffer?.size ?? 0)
549
+ + (batch.lod1IndexBuffer?.size ?? 0);
550
+ this.lastDrawnFrame.delete(batch.id);
551
+ this.dropPartialCacheForBatch(batch);
552
+ }
553
+ if (residentBytes - evictedBytes > budget && !this.residencyOverBudgetWarned) {
554
+ this.residencyOverBudgetWarned = true;
555
+ console.warn(`[Scene] GPU residency budget ${(budget / 1048576).toFixed(0)}MB exceeded by the ` +
556
+ `recently-drawn set (${((residentBytes - evictedBytes) / 1048576).toFixed(0)}MB resident) — ` +
557
+ `nothing old enough to evict. Rendering is unaffected.`);
558
+ }
559
+ }
560
+ /** Destroy + drop cached partial sub-batches derived from `batch` (their
561
+ * sourceBatchKeys embed the batch id, so they are stale once it is
562
+ * evicted/replaced). */
563
+ dropPartialCacheForBatch(batch) {
564
+ const prefix = `${batch.colorKey}:${batch.id}`;
565
+ for (const [sourceBatchKey, cacheKey] of this.partialBatchCacheKeys) {
566
+ if (!sourceBatchKey.startsWith(prefix))
567
+ continue;
568
+ const cached = this.partialBatchCache.get(cacheKey);
569
+ if (cached) {
570
+ destroyGpuResources(cached);
571
+ this.partialBatchCache.delete(cacheKey);
572
+ }
573
+ this.partialBatchCacheKeys.delete(sourceBatchKey);
574
+ }
575
+ }
576
+ /**
577
+ * Bucket BASE key for a mesh: colour key, prefixed with the mesh's grid
578
+ * cell when spatial chunking is on. EVERY bucket-key derivation
579
+ * (streaming append, fragment grouping, finalize re-group, recolour move,
580
+ * partial-batch piece filter) must go through this so a mesh always
581
+ * resolves to the same bucket. `color` overrides the mesh's own colour for
582
+ * recolour routing.
583
+ */
584
+ bucketBaseKey(meshData, color) {
585
+ return bucketBaseKeyFor(meshData, this.colorKey(color ?? meshData.color), this.spatialChunking);
586
+ }
106
587
  /**
107
588
  * Store MeshData for lazy GPU buffer creation (used for selection highlighting)
108
589
  * This avoids creating 2x GPU buffers during streaming
@@ -430,9 +911,10 @@ export class Scene {
430
911
  }
431
912
  }
432
913
  }
433
- // Route each mesh into a size-aware bucket for its color
914
+ // Route each mesh into a size-aware bucket for its color (and, with
915
+ // spatial chunking on, its grid cell)
434
916
  for (const meshData of renderable) {
435
- const baseKey = this.colorKey(meshData.color);
917
+ const baseKey = this.bucketBaseKey(meshData);
436
918
  const bucketKey = this.resolveActiveBucket(baseKey, meshData);
437
919
  if (retainStreamingGeometry || !isStreaming) {
438
920
  // Accumulate mesh data in the bucket when we need later rebatching or
@@ -571,6 +1053,8 @@ export class Scene {
571
1053
  bucket.vertexBytes = Math.max(0, bucket.vertexBytes - bytes);
572
1054
  }
573
1055
  affectedKeys.add(bucket.key);
1056
+ // Entity removal diverges the bucket from the cache entry.
1057
+ this.markBucketDirty(bucket.key);
574
1058
  }
575
1059
  this.meshDataBucket.delete(meshData);
576
1060
  }
@@ -681,6 +1165,47 @@ export class Scene {
681
1165
  * Translate every flat (non-instanced) mesh for `expressId` by `delta`. See
682
1166
  * {@link translateMeshesForEntity} for the full contract; this is the flat half.
683
1167
  */
1168
+ /**
1169
+ * Mark a mesh's bucket for rebuild after its positions were mutated in
1170
+ * place (move/rotate), migrating it to a new bucket when spatial chunking
1171
+ * is on and the mesh crossed a grid-cell boundary. Without the migration
1172
+ * the mesh would keep its stale cell key, so the partial-batch piece
1173
+ * filter (which re-derives keys from CURRENT positions) would silently
1174
+ * drop it under hide/isolate. Same move mechanics as updateMeshColors.
1175
+ */
1176
+ rebucketMovedMesh(meshData, affectedKeys) {
1177
+ const bucket = this.meshDataBucket.get(meshData);
1178
+ if (bucket) {
1179
+ affectedKeys.add(bucket.key);
1180
+ // Moved geometry diverges from the cache entry — see markBucketDirty.
1181
+ this.markBucketDirty(bucket.key);
1182
+ }
1183
+ if (!this.spatialChunking || !bucket)
1184
+ return;
1185
+ const newBaseKey = this.bucketBaseKey(meshData);
1186
+ if (this.baseColorKey(bucket.key) === newBaseKey)
1187
+ return;
1188
+ const newBucketKey = this.resolveActiveBucket(newBaseKey, meshData);
1189
+ this.markBucketDirty(newBucketKey);
1190
+ // Swap-remove from the old bucket + decrement its byte accounting
1191
+ const idx = bucket.meshData.indexOf(meshData);
1192
+ if (idx >= 0) {
1193
+ const last = bucket.meshData.length - 1;
1194
+ if (idx !== last)
1195
+ bucket.meshData[idx] = bucket.meshData[last];
1196
+ bucket.meshData.pop();
1197
+ }
1198
+ const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
1199
+ bucket.vertexBytes = Math.max(0, bucket.vertexBytes - meshBytes);
1200
+ // Deliberately KEEP an emptied bucket in the map: rebuildPendingBatches
1201
+ // destroys its batchedMesh and deletes the shell. Removing it here would
1202
+ // orphan the live GPU buffers (rebuild skips keys it can't find).
1203
+ // resolveActiveBucket already created the target bucket + tracked bytes
1204
+ const newBucket = this.buckets.get(newBucketKey);
1205
+ newBucket.meshData.push(meshData);
1206
+ this.meshDataBucket.set(meshData, newBucket);
1207
+ affectedKeys.add(newBucketKey);
1208
+ }
684
1209
  translateFlatMeshesForEntity(expressId, delta) {
685
1210
  const meshDataList = this.meshDataMap.get(expressId);
686
1211
  if (!meshDataList || meshDataList.length === 0)
@@ -715,9 +1240,7 @@ export class Scene {
715
1240
  pos[i + 1] += dy;
716
1241
  pos[i + 2] += dz;
717
1242
  }
718
- const bucket = this.meshDataBucket.get(meshData);
719
- if (bucket)
720
- affectedKeys.add(bucket.key);
1243
+ this.rebucketMovedMesh(meshData, affectedKeys);
721
1244
  anyMoved = true;
722
1245
  }
723
1246
  if (!anyMoved)
@@ -852,6 +1375,100 @@ export class Scene {
852
1375
  }
853
1376
  return count;
854
1377
  }
1378
+ /**
1379
+ * Rotate every flat mesh for `expressId` by `angleRad` about the renderer
1380
+ * vertical (+Y) axis through `pivot` (renderer world, Y-up). This is the Y-up
1381
+ * image of an IFC yaw about the storey-up Z axis. Modifies `positions` and
1382
+ * `normals` in place and marks the affected bucket(s) for re-batch.
1383
+ *
1384
+ * Positions may live in a per-element local frame (`MeshData.origin`, world =
1385
+ * origin + position), so the pivot is folded into each mesh's local frame
1386
+ * before rotating; normals are direction vectors and rotate as-is.
1387
+ *
1388
+ * Same colour-merge caveat as `translateFlatMeshesForEntity` (skips meshes
1389
+ * whose vertices belong to more than this entity). GPU-instanced occurrences
1390
+ * are not rotated (the collab edit path only rotates flat/authored meshes).
1391
+ * Returns true when a mesh was modified.
1392
+ */
1393
+ rotateMeshesForEntity(expressId, angleRad, pivot) {
1394
+ const meshDataList = this.meshDataMap.get(expressId);
1395
+ if (!meshDataList || meshDataList.length === 0)
1396
+ return false;
1397
+ if (angleRad === 0)
1398
+ return false;
1399
+ const cos = Math.cos(angleRad);
1400
+ const sin = Math.sin(angleRad);
1401
+ const affectedKeys = new Set();
1402
+ let anyMoved = false;
1403
+ for (const meshData of meshDataList) {
1404
+ // Skip a genuinely shared color-merged mesh (see translateFlatMeshesForEntity).
1405
+ if (meshData.entityIds && meshData.entityIds.length > 0) {
1406
+ let shared = false;
1407
+ for (let i = 0; i < meshData.entityIds.length; i++) {
1408
+ if (meshData.entityIds[i] !== expressId) {
1409
+ shared = true;
1410
+ break;
1411
+ }
1412
+ }
1413
+ if (shared)
1414
+ continue;
1415
+ }
1416
+ // Fold the per-element local-frame origin into the pivot (world = origin + pos).
1417
+ const px = pivot[0] - (meshData.origin?.[0] ?? 0);
1418
+ const pz = pivot[2] - (meshData.origin?.[2] ?? 0);
1419
+ const pos = meshData.positions;
1420
+ for (let i = 0; i < pos.length; i += 3) {
1421
+ const dx = pos[i] - px;
1422
+ const dz = pos[i + 2] - pz;
1423
+ pos[i] = px + dx * cos + dz * sin;
1424
+ pos[i + 2] = pz - dx * sin + dz * cos;
1425
+ }
1426
+ const nrm = meshData.normals;
1427
+ if (nrm) {
1428
+ for (let i = 0; i < nrm.length; i += 3) {
1429
+ const nx = nrm[i];
1430
+ const nz = nrm[i + 2];
1431
+ nrm[i] = nx * cos + nz * sin;
1432
+ nrm[i + 2] = -nx * sin + nz * cos;
1433
+ }
1434
+ }
1435
+ this.rebucketMovedMesh(meshData, affectedKeys);
1436
+ anyMoved = true;
1437
+ }
1438
+ if (!anyMoved)
1439
+ return false;
1440
+ // #961: textured meshes render from their own GPU vertex buffer — re-upload
1441
+ // the rotated parts so they don't render stale (mirrors the translate path).
1442
+ if (this.texturedDevice && this.texturedMeshes.length > 0) {
1443
+ const texturedData = meshDataList.filter((md) => md.texture && md.uvs);
1444
+ if (texturedData.length > 0) {
1445
+ const entries = this.texturedMeshes.filter((tm) => tm.expressId === expressId);
1446
+ for (let i = 0; i < entries.length && i < texturedData.length; i++) {
1447
+ const interleaved = this.interleaveTexturedVertices(texturedData[i]);
1448
+ if (interleaved) {
1449
+ this.texturedDevice.queue.writeBuffer(entries[i].vertexBuffer, 0, interleaved);
1450
+ }
1451
+ }
1452
+ }
1453
+ }
1454
+ this.boundingBoxes.delete(expressId);
1455
+ // Selection-highlight meshes are frozen copies — evict so the highlight
1456
+ // re-extracts from the rotated geometry next frame (same as translate).
1457
+ this.evictHighlightMeshes(expressId);
1458
+ for (const key of affectedKeys) {
1459
+ this.pendingBatchKeys.add(key);
1460
+ }
1461
+ return true;
1462
+ }
1463
+ /** Bulk variant of `rotateMeshesForEntity`. */
1464
+ rotateMeshesForEntities(updates) {
1465
+ let count = 0;
1466
+ for (const [id, { angle, pivot }] of updates) {
1467
+ if (this.rotateMeshesForEntity(id, angle, pivot))
1468
+ count++;
1469
+ }
1470
+ return count;
1471
+ }
855
1472
  // ─── Mesh command queue ──────────────────────────────────────────────
856
1473
  /**
857
1474
  * Queue meshes for deferred GPU upload.
@@ -878,6 +1495,12 @@ export class Scene {
878
1495
  hasStreamingFragments() {
879
1496
  return this.streamingFragments.length > 0;
880
1497
  }
1498
+ /** True while a finalize rebuild (sync or time-sliced) is mid-flight —
1499
+ * the fragment list is already cleared then, so settle-sensitive callers
1500
+ * must check BOTH this and hasStreamingFragments(). */
1501
+ isFinalizeInProgress() {
1502
+ return this.finalizeInProgress;
1503
+ }
881
1504
  /** True when streaming runs in ephemeral mode (huge files) — fragments render
882
1505
  * directly from GPU and geometry is NOT retained for re-batch, so callers
883
1506
  * must NOT finalize (there's nothing to rebuild the batches from). */
@@ -952,11 +1575,14 @@ export class Scene {
952
1575
  createStreamingFragments(meshDataArray, device, pipeline) {
953
1576
  if (meshDataArray.length === 0)
954
1577
  return;
955
- // Group new meshes by color for efficient fragment batches
1578
+ // Group new meshes by color (and grid cell, when chunking) for efficient
1579
+ // fragment batches. Fragments of one mesh share the PARENT's key: they
1580
+ // are vertex subsets of the same element, and the mesh-never-splits rule
1581
+ // applies to cells exactly like it does to buckets.
956
1582
  const colorGroups = new Map();
957
1583
  for (const meshData of meshDataArray) {
1584
+ const key = this.bucketBaseKey(meshData);
958
1585
  for (const fragment of this.splitMeshForStreaming(meshData)) {
959
- const key = this.colorKey(fragment.color);
960
1586
  let group = colorGroups.get(key);
961
1587
  if (!group) {
962
1588
  group = [];
@@ -1038,15 +1664,33 @@ export class Scene {
1038
1664
  finalizeStreaming(device, pipeline) {
1039
1665
  if (this.streamingFragments.length === 0)
1040
1666
  return;
1667
+ this.finalizeInProgress = true;
1668
+ try {
1669
+ this.finalizeStreamingInner(device, pipeline);
1670
+ }
1671
+ finally {
1672
+ this.finalizeInProgress = false;
1673
+ }
1674
+ }
1675
+ finalizeStreamingInner(device, pipeline) {
1041
1676
  // Save references to old fragments/batches — keep them rendering
1042
1677
  // until the new proper batches are fully built (no visual gap).
1043
1678
  const oldFragments = this.streamingFragments;
1044
1679
  const oldBatches = this.batchedMeshes;
1045
1680
  const fragmentSet = new Set(oldFragments);
1046
1681
  this.streamingFragments = [];
1047
- // 1. Collect ALL accumulated meshData before clearing state
1682
+ // 1. Collect ALL accumulated meshData before clearing state.
1683
+ // Cold buckets (issue #1682 phase 3b) hold NO meshData — their
1684
+ // geometry lives on disk — so they are carried through the rebuild as
1685
+ // sealed shells instead of being re-grouped (re-grouping would
1686
+ // silently drop them).
1048
1687
  const allMeshData = [];
1049
- for (const bucket of this.buckets.values()) {
1688
+ const carriedCold = [];
1689
+ for (const [key, bucket] of this.buckets) {
1690
+ if (this.coldBuckets.has(key) && bucket.meshData.length === 0 && bucket.batchedMesh) {
1691
+ carriedCold.push([key, bucket]);
1692
+ continue;
1693
+ }
1050
1694
  for (const md of bucket.meshData)
1051
1695
  allMeshData.push(md);
1052
1696
  }
@@ -1056,18 +1700,24 @@ export class Scene {
1056
1700
  this.buckets.clear();
1057
1701
  this.meshDataBucket = new Map();
1058
1702
  this.activeBucketKey.clear();
1703
+ this.lastDrawnFrame.clear();
1704
+ this.residencyRestoreQueue.clear();
1059
1705
  this.pendingBatchKeys.clear();
1060
1706
  // Destroy cached partial batches — their colorKeys are now stale
1061
1707
  for (const batch of this.partialBatchCache.values())
1062
1708
  destroyGpuResources(batch);
1063
1709
  this.partialBatchCache.clear();
1064
1710
  this.partialBatchCacheKeys.clear();
1065
- // 3. Re-group ALL meshData by their CURRENT color.
1711
+ // Re-seat the carried cold shells in the fresh bucket map (their GPU
1712
+ // shells re-enter the flat array via rebuildPendingBatches below).
1713
+ for (const [key, bucket] of carriedCold)
1714
+ this.buckets.set(key, bucket);
1715
+ // 3. Re-group ALL meshData by their CURRENT color (and grid cell).
1066
1716
  // meshData.color may have been mutated in-place since the mesh was
1067
1717
  // first bucketed, so the original bucket key is stale. Re-grouping
1068
1718
  // by current color ensures batches render with correct colors.
1069
1719
  for (const meshData of allMeshData) {
1070
- const baseKey = this.colorKey(meshData.color);
1720
+ const baseKey = this.bucketBaseKey(meshData);
1071
1721
  const bucketKey = this.resolveActiveBucket(baseKey, meshData);
1072
1722
  let bucket = this.buckets.get(bucketKey);
1073
1723
  if (!bucket) {
@@ -1107,14 +1757,24 @@ export class Scene {
1107
1757
  }
1108
1758
  if (this.streamingFragments.length === 0)
1109
1759
  return Promise.resolve();
1760
+ // Mark the rebuild as in-flight: the preamble empties streamingFragments
1761
+ // synchronously, so settle-sensitive consumers need this flag until the
1762
+ // time-sliced rebuild swaps the new batch array in.
1763
+ this.finalizeInProgress = true;
1110
1764
  // --- Synchronous preamble (fast O(N) bookkeeping) ---
1111
1765
  const oldFragments = this.streamingFragments;
1112
1766
  const oldBatches = this.batchedMeshes;
1113
1767
  const fragmentSet = new Set(oldFragments);
1114
1768
  this.streamingFragments = [];
1115
- // 1. Collect ALL accumulated meshData
1769
+ // 1. Collect ALL accumulated meshData (cold buckets carried as sealed
1770
+ // shells — see the sync finalize for the rationale)
1116
1771
  const allMeshData = [];
1117
- for (const bucket of this.buckets.values()) {
1772
+ const carriedCold = [];
1773
+ for (const [key, bucket] of this.buckets) {
1774
+ if (this.coldBuckets.has(key) && bucket.meshData.length === 0 && bucket.batchedMesh) {
1775
+ carriedCold.push([key, bucket]);
1776
+ continue;
1777
+ }
1118
1778
  for (const md of bucket.meshData)
1119
1779
  allMeshData.push(md);
1120
1780
  }
@@ -1122,14 +1782,19 @@ export class Scene {
1122
1782
  this.buckets.clear();
1123
1783
  this.meshDataBucket = new Map();
1124
1784
  this.activeBucketKey.clear();
1785
+ this.lastDrawnFrame.clear();
1786
+ this.residencyRestoreQueue.clear();
1125
1787
  this.pendingBatchKeys.clear();
1126
1788
  for (const batch of this.partialBatchCache.values())
1127
1789
  destroyGpuResources(batch);
1128
1790
  this.partialBatchCache.clear();
1129
1791
  this.partialBatchCacheKeys.clear();
1130
- // 3. Re-group meshData by current color (fast)
1792
+ // Re-seat the carried cold shells in the fresh bucket map.
1793
+ for (const [key, bucket] of carriedCold)
1794
+ this.buckets.set(key, bucket);
1795
+ // 3. Re-group meshData by current color (and grid cell) — fast
1131
1796
  for (const meshData of allMeshData) {
1132
- const baseKey = this.colorKey(meshData.color);
1797
+ const baseKey = this.bucketBaseKey(meshData);
1133
1798
  const bucketKey = this.resolveActiveBucket(baseKey, meshData);
1134
1799
  let bucket = this.buckets.get(bucketKey);
1135
1800
  if (!bucket) {
@@ -1168,6 +1833,13 @@ export class Scene {
1168
1833
  return;
1169
1834
  }
1170
1835
  }
1836
+ // Carried cold shells stay drawable-when-restored: keep them in the
1837
+ // flat array (their buffers are already destroyed; the draw loop
1838
+ // skips gpuResident === false and the restore path revives them).
1839
+ for (const [, bucket] of carriedCold) {
1840
+ if (bucket.batchedMesh)
1841
+ newBatches.push(bucket.batchedMesh);
1842
+ }
1171
1843
  // All batches built — atomic swap so renderer never sees an empty array
1172
1844
  scene.batchedMeshes = newBatches;
1173
1845
  // Destroy old fragment/batch GPU resources
@@ -1177,6 +1849,7 @@ export class Scene {
1177
1849
  if (!fragmentSet.has(batch))
1178
1850
  destroyGpuResources(batch);
1179
1851
  }
1852
+ scene.finalizeInProgress = false;
1180
1853
  resolve();
1181
1854
  }
1182
1855
  // Start first chunk immediately (no setTimeout delay)
@@ -1232,6 +1905,10 @@ export class Scene {
1232
1905
  // AABBs already live in boundingBoxes, so bbox-raycast still finds instanced ids.
1233
1906
  this.instancedTemplateCpu = [];
1234
1907
  this.activeBucketKey.clear();
1908
+ this.lastDrawnFrame.clear();
1909
+ this.residencyRestoreQueue.clear();
1910
+ this.coldBuckets.clear();
1911
+ this.dirtyBuckets.clear();
1235
1912
  this.pendingBatchKeys.clear();
1236
1913
  for (const batch of this.partialBatchCache.values())
1237
1914
  destroyGpuResources(batch);
@@ -1314,6 +1991,12 @@ export class Scene {
1314
1991
  }
1315
1992
  this.meshDataBucket = new Map();
1316
1993
  this.activeBucketKey.clear();
1994
+ this.lastDrawnFrame.clear();
1995
+ this.residencyRestoreQueue.clear();
1996
+ // Released mode has no restore source at all — drop the cold tier state
1997
+ // (the geometryReleased guards stop any further cold activity).
1998
+ this.coldBuckets.clear();
1999
+ this.dirtyBuckets.clear();
1317
2000
  // 3. Clear partial batch cache (would need mesh data to rebuild)
1318
2001
  for (const batch of this.partialBatchCache.values())
1319
2002
  destroyGpuResources(batch);
@@ -1354,11 +2037,15 @@ export class Scene {
1354
2037
  const meshDataList = this.meshDataMap.get(expressId);
1355
2038
  if (!meshDataList)
1356
2039
  continue;
1357
- const newBaseKey = this.colorKey(newColor);
1358
2040
  for (const meshData of meshDataList) {
2041
+ // Per-mesh, not per-entity: with spatial chunking the base key
2042
+ // carries the mesh's grid cell, which differs between an entity's
2043
+ // pieces. A recolour changes the colour part only — the mesh stays
2044
+ // in its cell.
2045
+ const newBaseKey = this.bucketBaseKey(meshData, newColor);
1359
2046
  // Use reverse-map for O(1) old bucket lookup
1360
2047
  const oldBucket = this.meshDataBucket.get(meshData);
1361
- const oldBucketKey = oldBucket?.key ?? this.colorKey(meshData.color);
2048
+ const oldBucketKey = oldBucket?.key ?? this.bucketBaseKey(meshData);
1362
2049
  // Derive old color from bucket key, NOT meshData.color.
1363
2050
  // meshData.color may have been mutated in-place by external code
1364
2051
  // (applyColorUpdatesToMeshes), making it unreliable for change detection.
@@ -1368,6 +2055,10 @@ export class Scene {
1368
2055
  const newBucketKey = this.resolveActiveBucket(newBaseKey, meshData);
1369
2056
  affectedOldKeys.add(oldBucketKey);
1370
2057
  affectedNewKeys.add(newBucketKey);
2058
+ // Both buckets now diverge from the cache entry: never cold-evict
2059
+ // them (a disk restore would resurrect the pre-recolour geometry).
2060
+ this.markBucketDirty(oldBucketKey);
2061
+ this.markBucketDirty(newBucketKey);
1371
2062
  // Remove from old bucket data using indexOf (O(N) within one color bucket, typically <100 items)
1372
2063
  if (oldBucket) {
1373
2064
  const idx = oldBucket.meshData.indexOf(meshData);
@@ -1379,9 +2070,12 @@ export class Scene {
1379
2070
  }
1380
2071
  oldBucket.meshData.pop();
1381
2072
  }
1382
- if (oldBucket.meshData.length === 0) {
1383
- this.buckets.delete(oldBucketKey);
1384
- }
2073
+ // Do NOT delete an emptied bucket here: it is queued in
2074
+ // affectedOldKeys, and rebuildPendingBatches both destroys its
2075
+ // batchedMesh GPU buffers and removes the shell. Deleting the
2076
+ // map entry early orphaned those buffers (rebuild skips keys it
2077
+ // can't resolve) — a GPU memory leak on every recolour that
2078
+ // emptied a colour group.
1385
2079
  }
1386
2080
  // Decrease old bucket size tracking
1387
2081
  const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
@@ -1436,13 +2130,36 @@ export class Scene {
1436
2130
  // Create vertex buffer (interleaved positions + normals)
1437
2131
  // Use mappedAtCreation to avoid a separate writeBuffer IPC round-trip
1438
2132
  // (significant win on Chrome/Dawn where each writeBuffer is a Mojo IPC call)
1439
- const vertexBuffer = device.createBuffer({
1440
- size: merged.vertexData.byteLength,
1441
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1442
- mappedAtCreation: true,
1443
- });
1444
- new Float32Array(vertexBuffer.getMappedRange()).set(merged.vertexData);
1445
- vertexBuffer.unmap();
2133
+ // Quantized path (issue #1682 phase 6): 12-byte lattice records instead
2134
+ // of the 28-byte f32 layout. Falls back to f32 when the batch exceeds
2135
+ // the u16 lattice range. Order note: the LOD build further down reads
2136
+ // merged.vertexData (the CPU f32 copy) and produces INDICES only, which
2137
+ // are valid for either vertex format.
2138
+ let quantized;
2139
+ let vertexBuffer;
2140
+ const quantizedData = this.quantizedBatchesEnabled
2141
+ ? quantizeInterleaved(merged.vertexData, BATCH_CONSTANTS.BYTES_PER_VERTEX / 4)
2142
+ : null;
2143
+ if (quantizedData) {
2144
+ vertexBuffer = device.createBuffer({
2145
+ size: Math.max(4, quantizedData.vertexData.byteLength),
2146
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
2147
+ mappedAtCreation: true,
2148
+ });
2149
+ new Uint8Array(vertexBuffer.getMappedRange())
2150
+ .set(new Uint8Array(quantizedData.vertexData));
2151
+ vertexBuffer.unmap();
2152
+ quantized = { min: quantizedData.quantMin, step: quantizedData.step };
2153
+ }
2154
+ else {
2155
+ vertexBuffer = device.createBuffer({
2156
+ size: merged.vertexData.byteLength,
2157
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
2158
+ mappedAtCreation: true,
2159
+ });
2160
+ new Float32Array(vertexBuffer.getMappedRange()).set(merged.vertexData);
2161
+ vertexBuffer.unmap();
2162
+ }
1446
2163
  // Create index buffer
1447
2164
  const indexBuffer = device.createBuffer({
1448
2165
  size: merged.indices.byteLength,
@@ -1466,6 +2183,31 @@ export class Scene {
1466
2183
  },
1467
2184
  ],
1468
2185
  });
2186
+ // LOD1 (issue #1682 phase 5): simplified second index range over the SAME
2187
+ // vertex buffer. Bucket-owned batches only (`bucketKey` present) — the
2188
+ // transient streaming fragments and partial/overlay sub-batches never pay
2189
+ // the build. Positions in `merged.vertexData` are relative to the batch
2190
+ // origin, which is fine: clustering is translation-invariant as long as
2191
+ // the cell size comes from the same-space bounds extent.
2192
+ let lod1IndexBuffer;
2193
+ let lod1IndexCount;
2194
+ if (this.lodBuildsEnabled &&
2195
+ bucketKey !== undefined &&
2196
+ merged.bounds &&
2197
+ merged.indices.length >= LOD_MIN_TRIANGLES * 3) {
2198
+ const cellSize = lodCellSizeForBounds(merged.bounds.min, merged.bounds.max);
2199
+ const lodIndices = simplifyIndicesByClustering(merged.vertexData, BATCH_CONSTANTS.BYTES_PER_VERTEX / 4, merged.indices, cellSize);
2200
+ if (lodIndices) {
2201
+ lod1IndexBuffer = device.createBuffer({
2202
+ size: lodIndices.byteLength,
2203
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
2204
+ mappedAtCreation: true,
2205
+ });
2206
+ new Uint32Array(lod1IndexBuffer.getMappedRange()).set(lodIndices);
2207
+ lod1IndexBuffer.unmap();
2208
+ lod1IndexCount = lodIndices.length;
2209
+ }
2210
+ }
1469
2211
  return {
1470
2212
  id: this.nextBatchId++,
1471
2213
  colorKey: bucketKey ?? this.colorKey(color),
@@ -1480,6 +2222,8 @@ export class Scene {
1480
2222
  // Per-batch local frame: positions are stored relative to this; the draw
1481
2223
  // loop applies model = translate(origin) so they land in world space.
1482
2224
  origin: merged.origin,
2225
+ ...(lod1IndexBuffer ? { lod1IndexBuffer, lod1IndexCount } : {}),
2226
+ ...(quantized ? { quantized } : {}),
1483
2227
  };
1484
2228
  }
1485
2229
  /**
@@ -1514,6 +2258,21 @@ export class Scene {
1514
2258
  const bucket = this.buckets.get(bucketKey);
1515
2259
  const currentBytes = bucket?.vertexBytes ?? 0;
1516
2260
  const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
2261
+ // A COLD bucket is sealed (its content lives on disk and its shell's
2262
+ // expressIds are the restore contract) — route new arrivals (e.g. a
2263
+ // federated add landing in the same cell+colour) to an overflow
2264
+ // sub-bucket instead of corrupting the sealed one.
2265
+ if (bucket && this.coldBuckets.has(bucketKey)) {
2266
+ bucketKey = `${baseColorKey}#${this.nextSplitId++}`;
2267
+ this.activeBucketKey.set(baseColorKey, bucketKey);
2268
+ let target = this.buckets.get(bucketKey);
2269
+ if (!target) {
2270
+ target = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
2271
+ this.buckets.set(bucketKey, target);
2272
+ }
2273
+ target.vertexBytes += meshBytes;
2274
+ return bucketKey;
2275
+ }
1517
2276
  if (currentBytes > 0 && currentBytes + meshBytes > this.cachedMaxBufferSize) {
1518
2277
  // Overflow — create a new sub-bucket
1519
2278
  bucketKey = `${baseColorKey}#${this.nextSplitId++}`;
@@ -1580,8 +2339,11 @@ export class Scene {
1580
2339
  }
1581
2340
  }
1582
2341
  // Collect MeshData for visible elements
1583
- // Use base color key (strip bucket suffix) for piece filtering, since
1584
- // meshData stores the original color, not the bucket key.
2342
+ // Use the base key (strip "#N" bucket suffix) for piece filtering, since
2343
+ // meshData stores the original color, not the bucket key. Pieces are
2344
+ // matched through bucketBaseKey so the comparison stays correct with
2345
+ // spatial chunking on (base key = "cell~colour" then, and a piece only
2346
+ // belongs to this batch when BOTH its cell and colour match).
1585
2347
  const baseKey = this.baseColorKey(colorKey);
1586
2348
  const visibleMeshData = [];
1587
2349
  for (const expressId of visibleIds) {
@@ -1589,8 +2351,8 @@ export class Scene {
1589
2351
  if (pieces) {
1590
2352
  // Add all pieces for this element
1591
2353
  for (const piece of pieces) {
1592
- // Only include pieces that match this batch's color
1593
- if (this.colorKey(piece.color) === baseKey) {
2354
+ // Only include pieces that match this batch's cell + color
2355
+ if (this.bucketBaseKey(piece) === baseKey) {
1594
2356
  visibleMeshData.push(piece);
1595
2357
  }
1596
2358
  }
@@ -1717,6 +2479,27 @@ export class Scene {
1717
2479
  getTexturedMeshes() {
1718
2480
  return this.texturedMeshes;
1719
2481
  }
2482
+ /**
2483
+ * GPU bytes currently held by the scene's mesh collections (issue #1682
2484
+ * observability). Sums actual `GPUBuffer.size` values across colour batches
2485
+ * (streaming fragments are members of `batchedMeshes`, so they are counted
2486
+ * exactly once), cached partial sub-batches, hydrated individual meshes,
2487
+ * textured meshes (plus a 4 B/texel texture estimate) and instanced
2488
+ * templates. Instanced templates are counted even while hidden in the Types
2489
+ * view: hiding does not free their buffers. O(collections) walk with no GPU
2490
+ * calls, intended for on-demand telemetry, not per-frame use.
2491
+ */
2492
+ getResidentGpuBytes() {
2493
+ return sumResidentGpuBytes({
2494
+ // Evicted batches are metadata shells — their destroyed buffers still
2495
+ // report .size, so they must be excluded from the resident sum.
2496
+ batches: this.batchedMeshes.filter((b) => b.gpuResident !== false),
2497
+ partialBatches: this.partialBatchCache.values(),
2498
+ meshes: this.meshes,
2499
+ textured: this.texturedMeshes,
2500
+ instanced: this.instancedTemplates,
2501
+ });
2502
+ }
1720
2503
  /**
1721
2504
  * Toggle the instanced draw pass. Instanced geometry is class-0 occurrences
1722
2505
  * (the Model view); hide it in the Types view mode, where the flat path shows
@@ -2292,6 +3075,10 @@ export class Scene {
2292
3075
  this.meshDataMap.clear();
2293
3076
  this.boundingBoxes.clear();
2294
3077
  this.activeBucketKey.clear();
3078
+ this.lastDrawnFrame.clear();
3079
+ this.residencyRestoreQueue.clear();
3080
+ this.coldBuckets.clear();
3081
+ this.dirtyBuckets.clear();
2295
3082
  this.cachedMaxBufferSize = 0;
2296
3083
  this.pendingBatchKeys.clear();
2297
3084
  this.partialBatchCache.clear();