@ifc-lite/renderer 1.38.0 → 1.39.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 (35) hide show
  1. package/dist/device.d.ts +10 -0
  2. package/dist/device.d.ts.map +1 -1
  3. package/dist/device.js +27 -0
  4. package/dist/device.js.map +1 -1
  5. package/dist/index.d.ts +34 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +207 -57
  8. package/dist/index.js.map +1 -1
  9. package/dist/pointcloud/point-cloud-renderer.d.ts +15 -6
  10. package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -1
  11. package/dist/pointcloud/point-cloud-renderer.js +7 -4
  12. package/dist/pointcloud/point-cloud-renderer.js.map +1 -1
  13. package/dist/pointcloud/point-cloud-uniforms.d.ts +14 -2
  14. package/dist/pointcloud/point-cloud-uniforms.d.ts.map +1 -1
  15. package/dist/pointcloud/point-cloud-uniforms.js +35 -2
  16. package/dist/pointcloud/point-cloud-uniforms.js.map +1 -1
  17. package/dist/pointcloud/point-pipeline.d.ts +3 -2
  18. package/dist/pointcloud/point-pipeline.d.ts.map +1 -1
  19. package/dist/pointcloud/point-pipeline.js +8 -5
  20. package/dist/pointcloud/point-pipeline.js.map +1 -1
  21. package/dist/pointcloud/point-shader.wgsl.d.ts +1 -1
  22. package/dist/pointcloud/point-shader.wgsl.d.ts.map +1 -1
  23. package/dist/pointcloud/point-shader.wgsl.js +20 -17
  24. package/dist/pointcloud/point-shader.wgsl.js.map +1 -1
  25. package/dist/scene.d.ts +47 -3
  26. package/dist/scene.d.ts.map +1 -1
  27. package/dist/scene.js +206 -52
  28. package/dist/scene.js.map +1 -1
  29. package/dist/types.d.ts +19 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/visibility-epoch.d.ts +30 -0
  32. package/dist/visibility-epoch.d.ts.map +1 -0
  33. package/dist/visibility-epoch.js +64 -0
  34. package/dist/visibility-epoch.js.map +1 -0
  35. package/package.json +2 -2
package/dist/scene.js CHANGED
@@ -8,6 +8,7 @@ import { sumResidentGpuBytes } from './render-stats.js';
8
8
  import { simplifyIndicesByClustering, lodCellSizeForBounds, LOD_MIN_TRIANGLES } from './lod-simplify.js';
9
9
  import { quantizeInterleaved } from './quantize.js';
10
10
  import { bucketBaseKeyFor } from './chunk-grid.js';
11
+ import { VisibilityEpochTracker } from './visibility-epoch.js';
11
12
  import { selectEvictions } from './residency.js';
12
13
  import { OPAQUE_ALPHA_CUTOFF } from './overlay-routing.js';
13
14
  import { prepareInstancedRender, foldOccurrenceWorldBox, INSTANCE_STRIDE_BYTES, INSTANCE_COLOR_OFFSET, INSTANCE_FLAGS_OFFSET, INSTANCE_FLAG_SELECTED, INSTANCE_FLAG_HIDDEN, } from './instanced-render.js';
@@ -29,6 +30,10 @@ export class Scene {
29
30
  meshDataMap = new Map(); // Map expressId -> MeshData[] (for lazy buffer creation, accumulates multiple pieces)
30
31
  boundingBoxes = new Map(); // Map expressId -> bounding box (computed lazily)
31
32
  texturedMeshes = []; // #961: IFC surface-textured meshes (own buffers/texture/bindGroup)
33
+ /** #1781: GPU textures shared across meshes, keyed by `MeshTextureRef.textureId`
34
+ * (one `IfcImageTexture` → one upload, sampled by every face set mapping it).
35
+ * Refcounted: entries die when the last referencing mesh is removed / on clear(). */
36
+ sharedTextures = new Map();
32
37
  texturedDevice; // #961: cached for textured-mesh re-upload on translate
33
38
  instancedTemplates = []; // GPU-instancing: unique templates + per-occurrence buffers (fed by addInstancedShard)
34
39
  instancedVisible = true; // GPU-instancing: hidden in Types view mode (instanced geometry is class-0 occurrences)
@@ -39,8 +44,11 @@ export class Scene {
39
44
  instancedHidden = new Set(); // currently hidden instanced express_ids (hide/isolate)
40
45
  instancedOverridden = new Set(); // currently colour-overridden instanced express_ids
41
46
  instancedHasTransparent = false; // an override made some instanced occurrence translucent
42
- lastInstancedHiddenIds = null; // ref-equality guard for setInstancedVisibility
43
- lastInstancedIsolatedIds = null;
47
+ // Content-based change guard for setInstancedVisibility — same contract as
48
+ // RenderOptions.hiddenIds (in-place mutation and fresh identical Sets both
49
+ // behave), keeping the instanced path in lockstep with the batched path.
50
+ instancedVisibilityEpochs = new VisibilityEpochTracker();
51
+ lastInstancedVisibilityVersion = -1;
44
52
  instancedVisibilityDirty = false; // set when a new shard adds occurrences → re-apply visibility
45
53
  // Buffer-size-aware bucket splitting: when a single color group's geometry
46
54
  // would exceed the GPU maxBufferSize, overflow is directed to a new
@@ -96,6 +104,11 @@ export class Scene {
96
104
  // This allows rendering partially visible batches as single draw calls instead of 10,000+ individual draws
97
105
  partialBatchCache = new Map();
98
106
  partialBatchCacheKeys = new Map(); // sourceBatchKey -> current cache key (for invalidation)
107
+ // sourceBatchKey -> visibility/override epoch its cached partial batch was
108
+ // built for. Lets getOrCreatePartialBatch return the cached clone WITHOUT
109
+ // re-sorting + re-hashing every visible id each frame while the epoch holds
110
+ // (issue: O(elements) per-frame work under hide/isolate). See render loop.
111
+ partialBatchCacheVersions = new Map();
99
112
  // Color overlay system for lens coloring — NEVER modifies original batches.
100
113
  // Overlay batches render on top using depthCompare 'equal', so they only
101
114
  // paint where original geometry already wrote depth. Clearing is instant.
@@ -103,6 +116,11 @@ export class Scene {
103
116
  // Defensively-typed: the renderer is the sole writer (via setColorOverrides),
104
117
  // external readers go through getColorOverrides() and get a ReadonlyMap.
105
118
  colorOverrides = null;
119
+ // Bumped whenever the colour-override set changes. The partial sub-batch's
120
+ // visible subset depends on override promotion (splitVisibleIdsByPromotion),
121
+ // so the render loop folds this into the partial-batch cache epoch to keep the
122
+ // per-frame fast path correct when overrides change with no visibility change.
123
+ colorOverrideGeneration = 0;
106
124
  // Streaming optimization: track pending batch rebuilds
107
125
  pendingBatchKeys = new Set();
108
126
  // Temporary fragment batches created during streaming for immediate rendering.
@@ -571,7 +589,58 @@ export class Scene {
571
589
  this.partialBatchCache.delete(cacheKey);
572
590
  }
573
591
  this.partialBatchCacheKeys.delete(sourceBatchKey);
592
+ this.partialBatchCacheVersions.delete(sourceBatchKey);
593
+ }
594
+ }
595
+ /** Destroy + drop EVERY cached partial sub-batch. The clones built during
596
+ * hide/isolate are deliberately excluded from the GPU residency budget and
597
+ * are otherwise only freed on clear()/finalize/evict — never when filtering
598
+ * ends. The render loop calls this on the transition back to fully-visible so
599
+ * the ~model-sized clone VRAM is not pinned until the next model reload. Uses
600
+ * the same destroy-then-clear idiom as clear(); safe to call between frames
601
+ * because the previous frame is already submitted (WebGPU defers the free
602
+ * past in-flight work). */
603
+ dropAllPartialCaches() {
604
+ if (this.partialBatchCache.size === 0
605
+ && this.partialBatchCacheKeys.size === 0
606
+ && this.partialBatchCacheVersions.size === 0) {
607
+ return;
608
+ }
609
+ for (const batch of this.partialBatchCache.values())
610
+ destroyGpuResources(batch);
611
+ this.partialBatchCache.clear();
612
+ this.partialBatchCacheKeys.clear();
613
+ this.partialBatchCacheVersions.clear();
614
+ }
615
+ /** Free the hydrated (pick / selection-highlight) individual meshes that are
616
+ * no longer selected, destroying their GPU buffers and dropping them from
617
+ * `this.meshes`. A mesh is kept iff its expressId is in `keep` AND it
618
+ * matches `keepModelIndex` (undefined = any model) — the same predicate the
619
+ * render loop uses to draw selection highlights, so disposal is its exact
620
+ * complement. The model scoping matters for federation: models can share
621
+ * express ids, and an id-only check would strand the OTHER model's hydrated
622
+ * mesh resident and drawing when selection moves across models. Only meshes
623
+ * flagged `hydrated` are touched — authored geometry added via addMesh()
624
+ * and batch geometry are left untouched. Returns how many were freed. */
625
+ disposeHydratedMeshesExcept(keep, keepModelIndex) {
626
+ if (this.meshes.length === 0)
627
+ return 0;
628
+ const kept = [];
629
+ let disposed = 0;
630
+ for (const mesh of this.meshes) {
631
+ const keepMesh = keep.has(mesh.expressId)
632
+ && (keepModelIndex === undefined || mesh.modelIndex === keepModelIndex);
633
+ if (mesh.hydrated && !keepMesh) {
634
+ destroyGpuResources(mesh);
635
+ disposed++;
636
+ }
637
+ else {
638
+ kept.push(mesh);
639
+ }
574
640
  }
641
+ if (disposed > 0)
642
+ this.meshes = kept;
643
+ return disposed;
575
644
  }
576
645
  /**
577
646
  * Bucket BASE key for a mesh: colour key, prefixed with the mesh's grid
@@ -899,10 +968,10 @@ export class Scene {
899
968
  // otherwise a flat-colour copy would be drawn over the texture. Still
900
969
  // register them in meshDataMap (addMeshData) so CPU picking/bbox/frame work.
901
970
  let renderable = meshDataArray;
902
- if (meshDataArray.some((m) => m.texture && m.uvs)) {
971
+ if (meshDataArray.some((m) => Scene.hasRenderableTexture(m))) {
903
972
  renderable = [];
904
973
  for (const meshData of meshDataArray) {
905
- if (meshData.texture && meshData.uvs) {
974
+ if (Scene.hasRenderableTexture(meshData)) {
906
975
  this.createTexturedMesh(meshData, device, pipeline);
907
976
  this.addMeshData(meshData);
908
977
  }
@@ -1070,7 +1139,7 @@ export class Scene {
1070
1139
  tm.vertexBuffer.destroy();
1071
1140
  tm.indexBuffer.destroy();
1072
1141
  tm.uniformBuffer.destroy();
1073
- tm.texture.destroy();
1142
+ this.releaseTexturedMeshTexture(tm);
1074
1143
  this.texturedMeshes.splice(i, 1);
1075
1144
  removedDedicated = true;
1076
1145
  }
@@ -1256,7 +1325,7 @@ export class Scene {
1256
1325
  // re-interleave + re-upload the moved textured parts (paired by expressId,
1257
1326
  // in creation order). Without this a moved textured entity renders stale.
1258
1327
  if (this.texturedDevice && this.texturedMeshes.length > 0) {
1259
- const texturedData = meshDataList.filter((md) => md.texture && md.uvs);
1328
+ const texturedData = meshDataList.filter((md) => Scene.hasRenderableTexture(md));
1260
1329
  if (texturedData.length > 0) {
1261
1330
  const entries = this.texturedMeshes.filter((tm) => tm.expressId === expressId);
1262
1331
  for (let i = 0; i < entries.length && i < texturedData.length; i++) {
@@ -1454,7 +1523,7 @@ export class Scene {
1454
1523
  // #961: textured meshes render from their own GPU vertex buffer — re-upload
1455
1524
  // the rotated parts so they don't render stale (mirrors the translate path).
1456
1525
  if (this.texturedDevice && this.texturedMeshes.length > 0) {
1457
- const texturedData = meshDataList.filter((md) => md.texture && md.uvs);
1526
+ const texturedData = meshDataList.filter((md) => Scene.hasRenderableTexture(md));
1458
1527
  if (texturedData.length > 0) {
1459
1528
  const entries = this.texturedMeshes.filter((tm) => tm.expressId === expressId);
1460
1529
  for (let i = 0; i < entries.length && i < texturedData.length; i++) {
@@ -1718,10 +1787,7 @@ export class Scene {
1718
1787
  this.residencyRestoreQueue.clear();
1719
1788
  this.pendingBatchKeys.clear();
1720
1789
  // Destroy cached partial batches — their colorKeys are now stale
1721
- for (const batch of this.partialBatchCache.values())
1722
- destroyGpuResources(batch);
1723
- this.partialBatchCache.clear();
1724
- this.partialBatchCacheKeys.clear();
1790
+ this.dropAllPartialCaches();
1725
1791
  // Re-seat the carried cold shells in the fresh bucket map (their GPU
1726
1792
  // shells re-enter the flat array via rebuildPendingBatches below).
1727
1793
  for (const [key, bucket] of carriedCold)
@@ -1799,10 +1865,7 @@ export class Scene {
1799
1865
  this.lastDrawnFrame.clear();
1800
1866
  this.residencyRestoreQueue.clear();
1801
1867
  this.pendingBatchKeys.clear();
1802
- for (const batch of this.partialBatchCache.values())
1803
- destroyGpuResources(batch);
1804
- this.partialBatchCache.clear();
1805
- this.partialBatchCacheKeys.clear();
1868
+ this.dropAllPartialCaches();
1806
1869
  // Re-seat the carried cold shells in the fresh bucket map.
1807
1870
  for (const [key, bucket] of carriedCold)
1808
1871
  this.buckets.set(key, bucket);
@@ -1924,10 +1987,7 @@ export class Scene {
1924
1987
  this.coldBuckets.clear();
1925
1988
  this.dirtyBuckets.clear();
1926
1989
  this.pendingBatchKeys.clear();
1927
- for (const batch of this.partialBatchCache.values())
1928
- destroyGpuResources(batch);
1929
- this.partialBatchCache.clear();
1930
- this.partialBatchCacheKeys.clear();
1990
+ this.dropAllPartialCaches();
1931
1991
  this.geometryReleased = true;
1932
1992
  this.ephemeralStreamingMode = false;
1933
1993
  }
@@ -2012,10 +2072,7 @@ export class Scene {
2012
2072
  this.coldBuckets.clear();
2013
2073
  this.dirtyBuckets.clear();
2014
2074
  // 3. Clear partial batch cache (would need mesh data to rebuild)
2015
- for (const batch of this.partialBatchCache.values())
2016
- destroyGpuResources(batch);
2017
- this.partialBatchCache.clear();
2018
- this.partialBatchCacheKeys.clear();
2075
+ this.dropAllPartialCaches();
2019
2076
  this.geometryReleased = true;
2020
2077
  console.log(`[Scene] Released JS geometry data. ${this.boundingBoxes.size} bounding boxes cached. ` +
2021
2078
  `${this.batchedMeshes.length} GPU batches retained.`);
@@ -2321,10 +2378,25 @@ export class Scene {
2321
2378
  * @param pipeline - Rendering pipeline
2322
2379
  * @returns BatchedMesh containing only visible elements, or undefined if no visible elements
2323
2380
  */
2324
- getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, pipeline) {
2381
+ getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, pipeline, visibilityEpoch) {
2325
2382
  // Cannot create partial batches after geometry data has been released
2326
2383
  if (this.geometryReleased)
2327
2384
  return undefined;
2385
+ // Fast path (PERF): while the visibility + colour-override epoch is
2386
+ // unchanged, the visible subset for this sourceBatch is provably identical
2387
+ // to what we cached (the source batch is immutable per id and both hide/
2388
+ // isolate and override promotion are folded into the epoch). Return the
2389
+ // cached clone WITHOUT the O(n) sort + FNV hash below. A rebuilt/evicted
2390
+ // source batch gets a new id → new sourceBatchKey → cache miss here.
2391
+ if (visibilityEpoch !== undefined &&
2392
+ this.partialBatchCacheVersions.get(sourceBatchKey) === visibilityEpoch) {
2393
+ const key = this.partialBatchCacheKeys.get(sourceBatchKey);
2394
+ if (key !== undefined) {
2395
+ const cached = this.partialBatchCache.get(key);
2396
+ if (cached)
2397
+ return cached;
2398
+ }
2399
+ }
2328
2400
  // Create cache key from colorKey + deterministic hash of all visible IDs
2329
2401
  // Using a proper hash over all IDs to avoid collisions when middle IDs differ
2330
2402
  const sortedIds = Array.from(visibleIds).sort((a, b) => a - b);
@@ -2341,8 +2413,13 @@ export class Scene {
2341
2413
  const currentCacheKey = this.partialBatchCacheKeys.get(sourceBatchKey);
2342
2414
  if (currentCacheKey === cacheKey) {
2343
2415
  const cached = this.partialBatchCache.get(cacheKey);
2344
- if (cached)
2416
+ if (cached) {
2417
+ // Record the epoch so subsequent frames take the sort-free fast path.
2418
+ if (visibilityEpoch !== undefined) {
2419
+ this.partialBatchCacheVersions.set(sourceBatchKey, visibilityEpoch);
2420
+ }
2345
2421
  return cached;
2422
+ }
2346
2423
  }
2347
2424
  // Invalidate old cache for this colorKey if visibility changed
2348
2425
  if (currentCacheKey && currentCacheKey !== cacheKey) {
@@ -2381,6 +2458,9 @@ export class Scene {
2381
2458
  // Cache it
2382
2459
  this.partialBatchCache.set(cacheKey, partialBatch);
2383
2460
  this.partialBatchCacheKeys.set(sourceBatchKey, cacheKey);
2461
+ if (visibilityEpoch !== undefined) {
2462
+ this.partialBatchCacheVersions.set(sourceBatchKey, visibilityEpoch);
2463
+ }
2384
2464
  return partialBatch;
2385
2465
  }
2386
2466
  // ─── Color overlay system ────────────────────────────────────────────
@@ -2399,6 +2479,9 @@ export class Scene {
2399
2479
  setColorOverrides(overrides, device, pipeline) {
2400
2480
  // Destroy previous overlay batches
2401
2481
  this.destroyOverrideBatches();
2482
+ // The override set is changing — invalidate the partial-batch cache epoch so
2483
+ // the render loop rebuilds any promotion-split sub-batches (see render loop).
2484
+ this.colorOverrideGeneration++;
2402
2485
  if (this.geometryReleased) {
2403
2486
  console.warn('[Scene] setColorOverrides called after geometry data was released — skipping.');
2404
2487
  this.colorOverrides = null;
@@ -2452,9 +2535,16 @@ export class Scene {
2452
2535
  */
2453
2536
  clearColorOverrides() {
2454
2537
  this.destroyOverrideBatches();
2538
+ this.colorOverrideGeneration++;
2455
2539
  this.colorOverrides = null;
2456
2540
  this.setInstancedColorOverrides(null);
2457
2541
  }
2542
+ /** Monotonic counter that changes whenever the colour-override set changes.
2543
+ * The render loop folds it into the partial sub-batch cache epoch so the
2544
+ * per-frame fast path stays correct across override changes. */
2545
+ getColorOverrideGeneration() {
2546
+ return this.colorOverrideGeneration;
2547
+ }
2458
2548
  /** Get overlay batches for rendering */
2459
2549
  getOverrideBatches() {
2460
2550
  return this.overrideBatches;
@@ -2891,19 +2981,20 @@ export class Scene {
2891
2981
  const device = this.instancedDevice;
2892
2982
  if (!device || this.instancedTemplates.length === 0)
2893
2983
  return;
2894
- // Called every render frame. The viewer passes stable Set references that only
2895
- // change when visibility changes, so a reference-equality guard skips the O(N)
2896
- // set rebuild + allocation during orbit (the common, unchanged case). The dirty
2897
- // flag forces a recompute after a new shard adds occurrences mid-stream, so an
2984
+ // Called every render frame. Change detection is by CONTENT (the tracker
2985
+ // snapshot-compares), matching the RenderOptions.hiddenIds contract: an
2986
+ // in-place mutation of the caller's Set is seen, a fresh identical Set is
2987
+ // not treated as a change, and the O(occurrences) rebuild below still only
2988
+ // runs on a real visibility change (orbit stays cheap). The dirty flag
2989
+ // forces a recompute after a new shard adds occurrences mid-stream, so an
2898
2990
  // active isolate/hide also applies to geometry that streams in afterwards.
2991
+ const visibilityVersion = this.instancedVisibilityEpochs.update(hiddenIds, isolatedIds);
2899
2992
  if (!this.instancedVisibilityDirty &&
2900
- hiddenIds === this.lastInstancedHiddenIds &&
2901
- isolatedIds === this.lastInstancedIsolatedIds) {
2993
+ visibilityVersion === this.lastInstancedVisibilityVersion) {
2902
2994
  return;
2903
2995
  }
2904
2996
  this.instancedVisibilityDirty = false;
2905
- this.lastInstancedHiddenIds = hiddenIds ?? null;
2906
- this.lastInstancedIsolatedIds = isolatedIds ?? null;
2997
+ this.lastInstancedVisibilityVersion = visibilityVersion;
2907
2998
  const isHidden = (eid) => (hiddenIds != null && hiddenIds.has(eid)) ||
2908
2999
  (isolatedIds != null && !isolatedIds.has(eid));
2909
3000
  // Recompute the effective hidden set over all instanced occurrences and diff vs
@@ -3011,6 +3102,15 @@ export class Scene {
3011
3102
  * The per-frame uniform (viewProj/section/flags + colour tint) is written by
3012
3103
  * the renderer each frame, mirroring how colour batches are driven.
3013
3104
  */
3105
+ /** True when the mesh can render through the textured pipeline: UVs plus
3106
+ * either a Rust-decoded image (#961) or a viewer-resolved ImageBitmap for
3107
+ * an external `IfcImageTexture` reference (#1781). A `textureRef` whose
3108
+ * image was NOT resolved (missing zip sibling) renders as ordinary
3109
+ * flat-colour geometry instead. */
3110
+ static hasRenderableTexture(meshData) {
3111
+ return Boolean(meshData.uvs) &&
3112
+ Boolean(meshData.texture || (meshData.textureRef && meshData.textureBitmap));
3113
+ }
3014
3114
  /**
3015
3115
  * Interleave a textured mesh's vertices into the stride-36 layout
3016
3116
  * `[px,py,pz, nx,ny,nz, entityId(u32), u,v]`. Shared by initial upload and
@@ -3019,7 +3119,7 @@ export class Scene {
3019
3119
  */
3020
3120
  interleaveTexturedVertices(meshData) {
3021
3121
  const uvs = meshData.uvs;
3022
- if (!meshData.texture || !uvs)
3122
+ if (!Scene.hasRenderableTexture(meshData) || !uvs)
3023
3123
  return null;
3024
3124
  const positions = meshData.positions;
3025
3125
  const normals = meshData.normals;
@@ -3050,8 +3150,10 @@ export class Scene {
3050
3150
  }
3051
3151
  createTexturedMesh(meshData, device, pipeline) {
3052
3152
  const tex = meshData.texture;
3153
+ const ref = meshData.textureRef;
3154
+ const bitmap = meshData.textureBitmap;
3053
3155
  const interleaved = this.interleaveTexturedVertices(meshData);
3054
- if (!tex || !interleaved)
3156
+ if (!interleaved || !(tex || (ref && bitmap)))
3055
3157
  return;
3056
3158
  this.texturedDevice = device; // reused by translateMeshesForEntity re-upload
3057
3159
  const vertexBuffer = device.createBuffer({
@@ -3064,17 +3166,48 @@ export class Scene {
3064
3166
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
3065
3167
  });
3066
3168
  device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
3067
- // Upload the Rust-decoded RGBA8 verbatim — no image decoding in JS.
3068
- const texture = device.createTexture({
3069
- size: { width: tex.width, height: tex.height },
3070
- format: 'rgba8unorm',
3071
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
3072
- });
3073
- device.queue.writeTexture({ texture }, tex.rgba, { bytesPerRow: tex.width * 4, rowsPerImage: tex.height }, { width: tex.width, height: tex.height });
3169
+ let texture;
3170
+ let sharedTextureKey;
3171
+ if (tex) {
3172
+ // #961: upload the Rust-decoded RGBA8 verbatim — no image decoding in JS.
3173
+ texture = device.createTexture({
3174
+ size: { width: tex.width, height: tex.height },
3175
+ format: 'rgba8unorm',
3176
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
3177
+ });
3178
+ device.queue.writeTexture({ texture }, tex.rgba, { bytesPerRow: tex.width * 4, rowsPerImage: tex.height }, { width: tex.width, height: tex.height });
3179
+ }
3180
+ else {
3181
+ // #1781: external image texture — the viewer decoded the `.ifcZIP`
3182
+ // sibling to an ImageBitmap once per textureId; upload it ONCE and share
3183
+ // the GPU texture across every mesh sampling it (real files map one
3184
+ // 4096² image from dozens of face sets — per-mesh copies would be GBs).
3185
+ const refKey = ref.textureId;
3186
+ const bmp = bitmap;
3187
+ let entry = this.sharedTextures.get(refKey);
3188
+ if (!entry) {
3189
+ const gpuTex = device.createTexture({
3190
+ size: { width: bmp.width, height: bmp.height },
3191
+ format: 'rgba8unorm',
3192
+ // RENDER_ATTACHMENT is required by copyExternalImageToTexture.
3193
+ usage: GPUTextureUsage.TEXTURE_BINDING |
3194
+ GPUTextureUsage.COPY_DST |
3195
+ GPUTextureUsage.RENDER_ATTACHMENT,
3196
+ });
3197
+ device.queue.copyExternalImageToTexture({ source: bmp }, { texture: gpuTex }, { width: bmp.width, height: bmp.height });
3198
+ entry = { texture: gpuTex, refs: 0 };
3199
+ this.sharedTextures.set(refKey, entry);
3200
+ }
3201
+ entry.refs++;
3202
+ texture = entry.texture;
3203
+ sharedTextureKey = refKey;
3204
+ }
3205
+ const repeatS = tex ? tex.repeatS : ref.repeatS;
3206
+ const repeatT = tex ? tex.repeatT : ref.repeatT;
3074
3207
  const wrap = (repeat) => (repeat ? 'repeat' : 'clamp-to-edge');
3075
3208
  const sampler = device.createSampler({
3076
- addressModeU: wrap(tex.repeatS),
3077
- addressModeV: wrap(tex.repeatT),
3209
+ addressModeU: wrap(repeatS),
3210
+ addressModeV: wrap(repeatT),
3078
3211
  magFilter: 'linear',
3079
3212
  minFilter: 'linear',
3080
3213
  mipmapFilter: 'linear',
@@ -3094,8 +3227,26 @@ export class Scene {
3094
3227
  sampler,
3095
3228
  bindGroup,
3096
3229
  color: meshData.color,
3230
+ ...(sharedTextureKey !== undefined ? { sharedTextureKey } : {}),
3097
3231
  });
3098
3232
  }
3233
+ /** Release a textured mesh's GPU texture: shared (#1781) entries decrement
3234
+ * the registry refcount and die with their LAST reference; per-mesh (#961)
3235
+ * uploads are destroyed outright. */
3236
+ releaseTexturedMeshTexture(tm) {
3237
+ if (tm.sharedTextureKey === undefined) {
3238
+ tm.texture.destroy();
3239
+ return;
3240
+ }
3241
+ const entry = this.sharedTextures.get(tm.sharedTextureKey);
3242
+ if (!entry)
3243
+ return;
3244
+ entry.refs--;
3245
+ if (entry.refs <= 0) {
3246
+ entry.texture.destroy();
3247
+ this.sharedTextures.delete(tm.sharedTextureKey);
3248
+ }
3249
+ }
3099
3250
  clear() {
3100
3251
  for (const mesh of this.meshes)
3101
3252
  destroyGpuResources(mesh);
@@ -3105,9 +3256,14 @@ export class Scene {
3105
3256
  tm.vertexBuffer.destroy();
3106
3257
  tm.indexBuffer.destroy();
3107
3258
  tm.uniformBuffer.destroy();
3108
- tm.texture.destroy();
3259
+ this.releaseTexturedMeshTexture(tm);
3109
3260
  }
3110
3261
  this.texturedMeshes = [];
3262
+ // Belt-and-braces: refcounting above should have emptied the registry;
3263
+ // destroy any straggler so clear() can never leak a shared GPU texture.
3264
+ for (const entry of this.sharedTextures.values())
3265
+ entry.texture.destroy();
3266
+ this.sharedTextures.clear();
3111
3267
  // GPU-instancing templates own their vertex/index/instance buffers.
3112
3268
  for (const it of this.instancedTemplates) {
3113
3269
  it.vertexBuffer.destroy();
@@ -3121,13 +3277,13 @@ export class Scene {
3121
3277
  this.instancedHidden.clear();
3122
3278
  this.instancedOverridden.clear();
3123
3279
  this.instancedHasTransparent = false;
3124
- this.lastInstancedHiddenIds = null;
3125
- this.lastInstancedIsolatedIds = null;
3280
+ // Force the next setInstancedVisibility to recompute against fresh state.
3281
+ this.lastInstancedVisibilityVersion = -1;
3126
3282
  this.instancedVisibilityDirty = false;
3127
3283
  this.instancedDevice = undefined;
3128
- // Clear partial batch cache
3129
- for (const batch of this.partialBatchCache.values())
3130
- destroyGpuResources(batch);
3284
+ // Clear partial batch cache (destroys buffers + drops all cache maps)
3285
+ this.dropAllPartialCaches();
3286
+ this.colorOverrideGeneration++;
3131
3287
  // Destroy streaming fragments (already included in batchedMeshes, but tracked separately)
3132
3288
  this.streamingFragments = [];
3133
3289
  this.destroyOverrideBatches();
@@ -3147,8 +3303,6 @@ export class Scene {
3147
3303
  this.dirtyBuckets.clear();
3148
3304
  this.cachedMaxBufferSize = 0;
3149
3305
  this.pendingBatchKeys.clear();
3150
- this.partialBatchCache.clear();
3151
- this.partialBatchCacheKeys.clear();
3152
3306
  this.meshQueue = [];
3153
3307
  this.meshQueueReadIndex = 0;
3154
3308
  this.geometryReleased = false;