@ifc-lite/renderer 2.0.1 → 3.0.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 (180) hide show
  1. package/README.md +11 -1
  2. package/dist/appearance-batch-cohorts.d.ts +33 -0
  3. package/dist/appearance-batch-cohorts.d.ts.map +1 -0
  4. package/dist/appearance-batch-cohorts.js +126 -0
  5. package/dist/appearance-batch-cohorts.js.map +1 -0
  6. package/dist/appearance-companions.d.ts +30 -0
  7. package/dist/appearance-companions.d.ts.map +1 -0
  8. package/dist/appearance-companions.js +138 -0
  9. package/dist/appearance-companions.js.map +1 -0
  10. package/dist/appearance-partition.d.ts +32 -0
  11. package/dist/appearance-partition.d.ts.map +1 -0
  12. package/dist/appearance-partition.js +122 -0
  13. package/dist/appearance-partition.js.map +1 -0
  14. package/dist/appearance-preview.d.ts +97 -0
  15. package/dist/appearance-preview.d.ts.map +1 -0
  16. package/dist/appearance-preview.js +276 -0
  17. package/dist/appearance-preview.js.map +1 -0
  18. package/dist/appearance-uvs.d.ts +13 -0
  19. package/dist/appearance-uvs.d.ts.map +1 -0
  20. package/dist/appearance-uvs.js +136 -0
  21. package/dist/appearance-uvs.js.map +1 -0
  22. package/dist/bvh.d.ts.map +1 -1
  23. package/dist/bvh.js +3 -0
  24. package/dist/bvh.js.map +1 -1
  25. package/dist/deviation/deviation-computer.js +1 -1
  26. package/dist/deviation/deviation-computer.js.map +1 -1
  27. package/dist/index.d.ts +60 -18
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +186 -233
  30. package/dist/index.js.map +1 -1
  31. package/dist/merged-mesh-extract.d.ts +18 -0
  32. package/dist/merged-mesh-extract.d.ts.map +1 -0
  33. package/dist/merged-mesh-extract.js +73 -0
  34. package/dist/merged-mesh-extract.js.map +1 -0
  35. package/dist/model-bounds-tracker.d.ts +1 -1
  36. package/dist/model-bounds-tracker.d.ts.map +1 -1
  37. package/dist/model-bounds-tracker.js +3 -2
  38. package/dist/model-bounds-tracker.js.map +1 -1
  39. package/dist/model-placement-bounds.d.ts +29 -0
  40. package/dist/model-placement-bounds.d.ts.map +1 -0
  41. package/dist/model-placement-bounds.js +55 -0
  42. package/dist/model-placement-bounds.js.map +1 -0
  43. package/dist/model-translation.d.ts +57 -0
  44. package/dist/model-translation.d.ts.map +1 -0
  45. package/dist/model-translation.js +223 -0
  46. package/dist/model-translation.js.map +1 -0
  47. package/dist/partial-batch-cache.d.ts +65 -0
  48. package/dist/partial-batch-cache.d.ts.map +1 -0
  49. package/dist/partial-batch-cache.js +88 -0
  50. package/dist/partial-batch-cache.js.map +1 -0
  51. package/dist/partial-batch-requests.d.ts +77 -0
  52. package/dist/partial-batch-requests.d.ts.map +1 -0
  53. package/dist/partial-batch-requests.js +97 -0
  54. package/dist/partial-batch-requests.js.map +1 -0
  55. package/dist/picking-manager.js +2 -2
  56. package/dist/picking-manager.js.map +1 -1
  57. package/dist/point-picker.d.ts +2 -2
  58. package/dist/point-picker.d.ts.map +1 -1
  59. package/dist/point-picker.js +27 -21
  60. package/dist/point-picker.js.map +1 -1
  61. package/dist/pointcloud/point-cloud-node.d.ts +2 -0
  62. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
  63. package/dist/pointcloud/point-cloud-node.js +12 -0
  64. package/dist/pointcloud/point-cloud-node.js.map +1 -1
  65. package/dist/pointcloud/point-cloud-placement.d.ts +20 -0
  66. package/dist/pointcloud/point-cloud-placement.d.ts.map +1 -0
  67. package/dist/pointcloud/point-cloud-placement.js +69 -0
  68. package/dist/pointcloud/point-cloud-placement.js.map +1 -0
  69. package/dist/pointcloud/point-cloud-renderer.d.ts +14 -14
  70. package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -1
  71. package/dist/pointcloud/point-cloud-renderer.js +47 -63
  72. package/dist/pointcloud/point-cloud-renderer.js.map +1 -1
  73. package/dist/raycast-engine.d.ts +11 -13
  74. package/dist/raycast-engine.d.ts.map +1 -1
  75. package/dist/raycast-engine.js +28 -56
  76. package/dist/raycast-engine.js.map +1 -1
  77. package/dist/raycaster.d.ts +7 -1
  78. package/dist/raycaster.d.ts.map +1 -1
  79. package/dist/raycaster.js +20 -5
  80. package/dist/raycaster.js.map +1 -1
  81. package/dist/reference-image-hit.d.ts +5 -0
  82. package/dist/reference-image-hit.d.ts.map +1 -0
  83. package/dist/reference-image-hit.js +28 -0
  84. package/dist/reference-image-hit.js.map +1 -0
  85. package/dist/reference-image-host.d.ts +5 -0
  86. package/dist/reference-image-host.d.ts.map +1 -0
  87. package/dist/reference-image-host.js +26 -0
  88. package/dist/reference-image-host.js.map +1 -0
  89. package/dist/reference-image-pipeline.d.ts +15 -0
  90. package/dist/reference-image-pipeline.d.ts.map +1 -0
  91. package/dist/reference-image-pipeline.js +104 -0
  92. package/dist/reference-image-pipeline.js.map +1 -0
  93. package/dist/reference-image-types.d.ts +27 -0
  94. package/dist/reference-image-types.d.ts.map +1 -0
  95. package/dist/reference-image-types.js +5 -0
  96. package/dist/reference-image-types.js.map +1 -0
  97. package/dist/reference-images.d.ts +27 -0
  98. package/dist/reference-images.d.ts.map +1 -0
  99. package/dist/reference-images.js +134 -0
  100. package/dist/reference-images.js.map +1 -0
  101. package/dist/renderer-viewport.d.ts +3 -0
  102. package/dist/renderer-viewport.d.ts.map +1 -0
  103. package/dist/renderer-viewport.js +20 -0
  104. package/dist/renderer-viewport.js.map +1 -0
  105. package/dist/rgba-texture-pool.d.ts +12 -0
  106. package/dist/rgba-texture-pool.d.ts.map +1 -0
  107. package/dist/rgba-texture-pool.js +51 -0
  108. package/dist/rgba-texture-pool.js.map +1 -0
  109. package/dist/scene-appearance-buckets.d.ts +41 -0
  110. package/dist/scene-appearance-buckets.d.ts.map +1 -0
  111. package/dist/scene-appearance-buckets.js +164 -0
  112. package/dist/scene-appearance-buckets.js.map +1 -0
  113. package/dist/scene-appearance-instances.d.ts +42 -0
  114. package/dist/scene-appearance-instances.d.ts.map +1 -0
  115. package/dist/scene-appearance-instances.js +143 -0
  116. package/dist/scene-appearance-instances.js.map +1 -0
  117. package/dist/scene-appearance-preview.d.ts +30 -0
  118. package/dist/scene-appearance-preview.d.ts.map +1 -0
  119. package/dist/scene-appearance-preview.js +149 -0
  120. package/dist/scene-appearance-preview.js.map +1 -0
  121. package/dist/scene-appearance-resources.d.ts +16 -0
  122. package/dist/scene-appearance-resources.d.ts.map +1 -0
  123. package/dist/scene-appearance-resources.js +41 -0
  124. package/dist/scene-appearance-resources.js.map +1 -0
  125. package/dist/scene-authored-owner.d.ts +9 -0
  126. package/dist/scene-authored-owner.d.ts.map +1 -0
  127. package/dist/scene-authored-owner.js +73 -0
  128. package/dist/scene-authored-owner.js.map +1 -0
  129. package/dist/scene-batch-upload.d.ts +12 -0
  130. package/dist/scene-batch-upload.d.ts.map +1 -0
  131. package/dist/scene-batch-upload.js +147 -0
  132. package/dist/scene-batch-upload.js.map +1 -0
  133. package/dist/scene-contents.d.ts +41 -0
  134. package/dist/scene-contents.d.ts.map +1 -1
  135. package/dist/scene-geometry.d.ts +16 -0
  136. package/dist/scene-geometry.d.ts.map +1 -1
  137. package/dist/scene-geometry.js +26 -0
  138. package/dist/scene-geometry.js.map +1 -1
  139. package/dist/scene-instance-materialization.d.ts +18 -0
  140. package/dist/scene-instance-materialization.d.ts.map +1 -0
  141. package/dist/scene-instance-materialization.js +57 -0
  142. package/dist/scene-instance-materialization.js.map +1 -0
  143. package/dist/scene-instance-suppression.d.ts +22 -0
  144. package/dist/scene-instance-suppression.d.ts.map +1 -0
  145. package/dist/scene-instance-suppression.js +100 -0
  146. package/dist/scene-instance-suppression.js.map +1 -0
  147. package/dist/scene-instance-types.d.ts +57 -0
  148. package/dist/scene-instance-types.d.ts.map +1 -0
  149. package/dist/scene-instance-types.js +2 -0
  150. package/dist/scene-instance-types.js.map +1 -0
  151. package/dist/scene-model-translation.d.ts +49 -0
  152. package/dist/scene-model-translation.d.ts.map +1 -0
  153. package/dist/scene-model-translation.js +100 -0
  154. package/dist/scene-model-translation.js.map +1 -0
  155. package/dist/scene-stream-split.d.ts +4 -0
  156. package/dist/scene-stream-split.d.ts.map +1 -0
  157. package/dist/scene-stream-split.js +54 -0
  158. package/dist/scene-stream-split.js.map +1 -0
  159. package/dist/scene.d.ts +105 -85
  160. package/dist/scene.d.ts.map +1 -1
  161. package/dist/scene.js +447 -583
  162. package/dist/scene.js.map +1 -1
  163. package/dist/shadow-occluders.d.ts.map +1 -1
  164. package/dist/shadow-occluders.js +13 -9
  165. package/dist/shadow-occluders.js.map +1 -1
  166. package/dist/snap-detector.d.ts +1 -1
  167. package/dist/snap-detector.d.ts.map +1 -1
  168. package/dist/snap-detector.js +5 -2
  169. package/dist/snap-detector.js.map +1 -1
  170. package/dist/textured-vertices.d.ts +9 -0
  171. package/dist/textured-vertices.d.ts.map +1 -0
  172. package/dist/textured-vertices.js +40 -0
  173. package/dist/textured-vertices.js.map +1 -0
  174. package/dist/types.d.ts +11 -9
  175. package/dist/types.d.ts.map +1 -1
  176. package/dist/xray-alpha.d.ts +131 -0
  177. package/dist/xray-alpha.d.ts.map +1 -0
  178. package/dist/xray-alpha.js +266 -0
  179. package/dist/xray-alpha.js.map +1 -0
  180. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -7,6 +7,11 @@
7
7
  export { WebGPUDevice } from './device.js';
8
8
  export { RenderPipeline } from './pipeline.js';
9
9
  export { Camera } from './camera.js';
10
+ export { appearanceSourceTriangle, expandAppearanceCorners, equivalentAppearanceGeometry } from './appearance-uvs.js';
11
+ export { sameCompanionParts } from './appearance-companions.js';
12
+ export { invertAppearancePartition, validateAppearancePartition } from './appearance-preview.js';
13
+ import { createReferenceImageManager } from './reference-image-host.js';
14
+ import { resizeRendererViewport } from './renderer-viewport.js';
10
15
  export { pickFitPolicy } from './camera-fit-policy.js';
11
16
  // `Scene` is NOT exported: an exported class republishes every method `SceneContents` above just froze.
12
17
  export { Picker } from './picker.js';
@@ -65,6 +70,7 @@ export { PointPicker, decodePickSample } from './point-picker.js';
65
70
  // Point cloud rendering (Phase 0: IFCx inline; Phase 1+: streaming LAS/LAZ)
66
71
  export { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
67
72
  export { PointRenderPipeline } from './pointcloud/point-pipeline.js';
73
+ import { modelPlacementBounds, sceneMeshBounds } from './model-placement-bounds.js';
68
74
  import { WebGPUDevice } from './device.js';
69
75
  import { RenderPipeline } from './pipeline.js';
70
76
  import { Camera } from './camera.js';
@@ -94,6 +100,8 @@ import { ShadowPass, resolveShadowMapResolution } from './shadow-pass.js';
94
100
  import { fitSunLightMatrix, cameraFrustumFocusCorners } from './shadow-light-matrix.js';
95
101
  import { collectShadowOccluders, classifyBatchVisibility, DEFAULT_MIN_CAST_ALPHA } from './shadow-occluders.js';
96
102
  import { shouldRouteMeshTransparent, shouldRouteBatchTransparent, splitVisibleIdsByPromotion, DEFAULT_GHOST_ALPHA } from './overlay-routing.js';
103
+ import { XRayAlpha, XRayEpochTracker } from './xray-alpha.js';
104
+ import { PartialBatchRequests } from './partial-batch-requests.js';
97
105
  import { colorSaltByte, packEntityLane } from './scene-geometry.js';
98
106
  import { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
99
107
  import { DeviationComputer } from './deviation/deviation-computer.js';
@@ -187,6 +195,8 @@ export class Renderer {
187
195
  },
188
196
  requestRender: () => this.requestRender(),
189
197
  });
198
+ referenceImages = createReferenceImageManager(this);
199
+ getReferenceImages() { return this.referenceImages; }
190
200
  postProcessor = null;
191
201
  interactionEffects = new InteractionEffectsGovernor();
192
202
  edlPass = null;
@@ -383,13 +393,15 @@ export class Renderer {
383
393
  // tracker), so callers may either mutate the same Set in place or pass a
384
394
  // fresh Set per frame — see the RenderOptions.hiddenIds contract.
385
395
  // `_visibilityVersion` drives the per-batch visibility cache;
386
- // `_partialBatchEpoch` additionally folds colour-override changes so the
387
- // partial sub-batch cache fast path stays correct.
396
+ // `_partialBatchEpoch` additionally folds colour-override and X-Ray changes
397
+ // so the partial sub-batch cache fast path stays correct.
388
398
  _visibilityEpochs = new VisibilityEpochTracker();
399
+ _xrayEpochs = new XRayEpochTracker();
389
400
  _visibilityVersion = 0;
390
401
  _partialBatchEpoch = 0;
391
402
  _lastColorOverrideGen = -1;
392
- _lastHadVisibilityFiltering = false;
403
+ _xrayVersion = 0;
404
+ _lastHadPartialSources = false;
393
405
  // Cached per-batch visibility, valid only while `_batchVisibilityEpoch`
394
406
  // matches `_visibilityVersion`. Avoids the O(total element count) recompute
395
407
  // (+ per-batch visible-id Set allocation) every frame while hide/isolate
@@ -407,6 +419,8 @@ export class Renderer {
407
419
  // old model's hydrated mesh.
408
420
  _prevHydratedSelection = new Set();
409
421
  _prevHydratedSelectionModelIndex = undefined;
422
+ _prevHydratedSelectionItemExpressId;
423
+ _prevHydratedSelectionItemId;
410
424
  // One-shot log guard — prints Y-up clip bounds on first section-enable so
411
425
  // users can confirm the slider is operating on the intended range.
412
426
  _loggedSectionBounds = false;
@@ -525,6 +539,7 @@ export class Renderer {
525
539
  this.pipeline = new RenderPipeline(this.device, width, height);
526
540
  this.picker = new Picker(this.device, width, height);
527
541
  this.overlays.init(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
542
+ this.referenceImages.init(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
528
543
  // PostProcessor is optional — if it fails (e.g. mobile GPU lacking
529
544
  // depth TEXTURE_BINDING), rendering still works without post-processing.
530
545
  try {
@@ -707,6 +722,7 @@ export class Renderer {
707
722
  if (this.deviceLost)
708
723
  return;
709
724
  this.deviceLost = true;
725
+ this.referenceImages.destroy();
710
726
  this.deviceLostGeneration = this.initGeneration;
711
727
  this.deviceLostInfo = info;
712
728
  console.warn('[Renderer] GPU device lost — halting rendering until re-init:', info.message);
@@ -840,14 +856,10 @@ export class Renderer {
840
856
  if (!this.pointCloudRenderer) {
841
857
  throw new Error('Renderer not initialized. Call init() first.');
842
858
  }
859
+ for (const asset of assets)
860
+ this.pointCloudRenderer.setModelTranslation(asset.modelIndex ?? 0, this.scene.getModelTranslation(asset.modelIndex ?? 0));
843
861
  this.pointCloudRenderer.setAssets(assets);
844
- // Replace, not append — bounds may have shrunk (e.g. an IFCx
845
- // reload with a smaller scan). `expandForPointClouds`
846
- // alone only grows; recompute from scratch to keep
847
- // fit-to-view + section-plane sliders accurate.
848
- this.modelBoundsTracker.recompute();
849
- this.camera.setSceneBounds(this.modelBounds);
850
- this.requestRender();
862
+ this.refreshPlacementBounds();
851
863
  }
852
864
  /** Append additional point clouds without clearing existing ones. */
853
865
  addPointClouds(assets) {
@@ -855,6 +867,7 @@ export class Renderer {
855
867
  throw new Error('Renderer not initialized. Call init() first.');
856
868
  }
857
869
  for (const asset of assets) {
870
+ this.pointCloudRenderer.setModelTranslation(asset.modelIndex ?? 0, this.scene.getModelTranslation(asset.modelIndex ?? 0));
858
871
  this.pointCloudRenderer.addAsset(asset);
859
872
  }
860
873
  this.modelBoundsTracker.expandForPointClouds();
@@ -872,9 +885,7 @@ export class Renderer {
872
885
  /** Drop all point cloud GPU resources. */
873
886
  clearPointClouds() {
874
887
  this.pointCloudRenderer?.clear();
875
- this.modelBoundsTracker.recompute();
876
- this.camera.setSceneBounds(this.modelBounds);
877
- this.requestRender();
888
+ this.refreshPlacementBounds();
878
889
  }
879
890
  /**
880
891
  * Streaming entry: open an empty asset that will receive chunks via
@@ -903,9 +914,7 @@ export class Renderer {
903
914
  this.pointCloudRenderer?.removeAsset(handle);
904
915
  // Bounds may have shrunk — recompute from scratch so fit-to-view
905
916
  // and section-plane sliders see fresh extents.
906
- this.modelBoundsTracker.recompute();
907
- this.camera.setSceneBounds(this.modelBounds);
908
- this.requestRender();
917
+ this.refreshPlacementBounds();
909
918
  }
910
919
  /**
911
920
  * Reassign a streamed point-cloud's expressId after upload. Use
@@ -918,54 +927,48 @@ export class Renderer {
918
927
  this.pointCloudRenderer?.relabelAsset(handle, newExpressId);
919
928
  this.requestRender();
920
929
  }
921
- /** Aggregate bounds across all batched + individual meshes. Returns
922
- * null if the scene has no mesh geometry. */
930
+ /** Bounds across flat draw batches. */
923
931
  computeMeshBounds() {
924
- let minX = Infinity, minY = Infinity, minZ = Infinity;
925
- let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
926
- let any = false;
927
- for (const batch of this.scene.getBatchedMeshes()) {
928
- if (!batch.bounds)
929
- continue;
930
- any = true;
931
- if (batch.bounds.min[0] < minX)
932
- minX = batch.bounds.min[0];
933
- if (batch.bounds.min[1] < minY)
934
- minY = batch.bounds.min[1];
935
- if (batch.bounds.min[2] < minZ)
936
- minZ = batch.bounds.min[2];
937
- if (batch.bounds.max[0] > maxX)
938
- maxX = batch.bounds.max[0];
939
- if (batch.bounds.max[1] > maxY)
940
- maxY = batch.bounds.max[1];
941
- if (batch.bounds.max[2] > maxZ)
942
- maxZ = batch.bounds.max[2];
943
- }
944
- if (!any)
945
- return null;
946
- return { min: { x: minX, y: minY, z: minZ }, max: { x: maxX, y: maxY, z: maxZ } };
932
+ return sceneMeshBounds(this.scene);
947
933
  }
948
934
  /** Apply rendering options (color mode, fixed override, point size). */
949
935
  setPointCloudOptions(opts) {
950
936
  this.pointCloudRenderer?.setOptions(opts);
951
937
  this.requestRender();
952
938
  }
939
+ getModelPlacementBounds(modelIndex, pointCloudHandle) {
940
+ return modelPlacementBounds(this.scene, this.pointCloudRenderer, modelIndex, pointCloudHandle);
941
+ }
942
+ /** Absolute workspace translation in renderer Y-up metres (#4226). */
943
+ setModelTranslation(modelIndex, translation) {
944
+ this.pointCloudRenderer?.validateModelTranslation(modelIndex, translation);
945
+ this.scene.setModelTranslation(modelIndex, translation);
946
+ this.pointCloudRenderer?.setModelTranslation(modelIndex, translation);
947
+ this.clearCaches();
948
+ this.refreshPlacementBounds();
949
+ }
950
+ /** Streamed clouds are addressed by durable asset handle. */
951
+ setPointCloudTranslation(handle, translation) {
952
+ this.pointCloudRenderer?.setAssetTranslation(handle, translation);
953
+ this.clearCaches();
954
+ this.refreshPlacementBounds();
955
+ }
956
+ refreshPlacementBounds() {
957
+ this.modelBoundsTracker.recompute();
958
+ this.camera.setSceneBounds(this.modelBounds);
959
+ this.requestRender();
960
+ }
961
+ getPointCloudTransform(handle) {
962
+ return this.pointCloudRenderer?.getAssetTransform(handle);
963
+ }
953
964
  /**
954
- * Set (or clear, with `null`) a streamed point-cloud asset's per-vertex
955
- * GPU model matrix (column-major, 16 floats) issue #1804's
956
- * `IfcMapConversion` alignment toggle. Cheap: takes effect on the next
957
- * frame's uniform write, no GPU buffer rewrite.
965
+ * Set/clear a streamed cloud's column-major model matrix (16 floats) for
966
+ * IfcMapConversion alignment (#1804). Applied on the next frame's uniform
967
+ * write without rewriting vertex buffers.
958
968
  */
959
969
  setPointCloudTransform(handle, matrix) {
960
970
  this.pointCloudRenderer?.setAssetTransform(handle, matrix);
961
- // The asset's world-space extents just moved: re-fold the (now
962
- // matrix-aware) point-cloud bounds into the scene bounds and push
963
- // them to the camera (matching every other bounds-mutating
964
- // point-cloud method) so framing / zoom-to-fit targets where the
965
- // points actually render.
966
- this.modelBoundsTracker.recompute();
967
- this.camera.setSceneBounds(this.modelBounds);
968
- this.requestRender();
971
+ this.refreshPlacementBounds();
969
972
  }
970
973
  /**
971
974
  * Compute BIM ↔ scan deviation for every loaded point cloud asset.
@@ -1020,7 +1023,7 @@ export class Renderer {
1020
1023
  const device = this.device.getDevice();
1021
1024
  this.scene.appendToBatches(meshes, device, this.pipeline, false);
1022
1025
  // Calculate and store model bounds for fitToView
1023
- this.modelBoundsTracker.updateFromMeshes(meshes);
1026
+ this.modelBoundsTracker.updateFromMeshes(meshes, (index) => this.scene.getModelTranslation(index));
1024
1027
  console.log(`[Renderer] Loaded ${meshes.length} meshes`);
1025
1028
  // Update camera scene bounds for tight orthographic near/far planes
1026
1029
  this.camera.setSceneBounds(this.modelBounds);
@@ -1040,7 +1043,7 @@ export class Renderer {
1040
1043
  const device = this.device.getDevice();
1041
1044
  this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
1042
1045
  // Update model bounds incrementally
1043
- this.modelBoundsTracker.updateFromMeshes(meshes);
1046
+ this.modelBoundsTracker.updateFromMeshes(meshes, (index) => this.scene.getModelTranslation(index));
1044
1047
  // Update camera scene bounds for tight orthographic near/far planes
1045
1048
  this.camera.setSceneBounds(this.modelBounds);
1046
1049
  }
@@ -1134,7 +1137,10 @@ export class Renderer {
1134
1137
  * partially-hidden OPAQUE batch casts only its visible subset via the SAME
1135
1138
  * cached partial sub-batch the colour pass renders (shared cache key
1136
1139
  * `${colorKey}:${id}` + `_partialBatchEpoch`), so no extra clone memory and no
1137
- * phantom shadow from an individually-hidden element in a shared batch.
1140
+ * phantom shadow from an individually-hidden element in a shared batch. That
1141
+ * sharing lapses while X-Ray splits the same batch further (#4129): the colour
1142
+ * pass then draws `:x0`/`:x1` slots and this slot owns its own clone — right,
1143
+ * since a ghosted element still casts its real shadow.
1138
1144
  *
1139
1145
  * Transparent (glass-like) partially-hidden parents are left to the collector's
1140
1146
  * material-alpha filter — they don't cast at all, so building a visible subset
@@ -1224,7 +1230,7 @@ export class Renderer {
1224
1230
  // We compute the same `world` here. When there's no shared origin yet
1225
1231
  // (legacy / pre-batch), fall back to a plain f64 fold (local + origin).
1226
1232
  const o = meshData.origin;
1227
- const so = this.scene.getSharedFrameOrigin();
1233
+ const so = this.scene.getSharedFrameOrigin(meshData.modelIndex);
1228
1234
  const ox = o ? o[0] : 0, oy = o ? o[1] : 0, oz = o ? o[2] : 0;
1229
1235
  const fr = Math.fround;
1230
1236
  const sox = so ? fr(so[0]) : null, soy = so ? fr(so[1]) : 0, soz = so ? fr(so[2]) : 0;
@@ -1310,10 +1316,10 @@ export class Renderer {
1310
1316
  * must still free the previous model's hydrated mesh (it would otherwise
1311
1317
  * stay resident and keep drawing unhighlighted).
1312
1318
  */
1313
- syncHydratedSelectionMeshes(selected, selectedModelIndex) {
1319
+ syncHydratedSelectionMeshes(selected, selectedModelIndex, itemFilterExpressId, itemFilterItemId) {
1314
1320
  const prev = this._prevHydratedSelection;
1315
- let changed = selected.size !== prev.size
1316
- || selectedModelIndex !== this._prevHydratedSelectionModelIndex;
1321
+ let changed = selected.size !== prev.size || selectedModelIndex !== this._prevHydratedSelectionModelIndex
1322
+ || itemFilterExpressId !== this._prevHydratedSelectionItemExpressId || itemFilterItemId !== this._prevHydratedSelectionItemId;
1317
1323
  if (!changed) {
1318
1324
  for (const id of selected) {
1319
1325
  if (!prev.has(id)) {
@@ -1326,7 +1332,9 @@ export class Renderer {
1326
1332
  return;
1327
1333
  this._prevHydratedSelection = new Set(selected);
1328
1334
  this._prevHydratedSelectionModelIndex = selectedModelIndex;
1329
- this.scene.disposeHydratedMeshesExcept(selected, selectedModelIndex);
1335
+ this._prevHydratedSelectionItemExpressId = itemFilterExpressId;
1336
+ this._prevHydratedSelectionItemId = itemFilterItemId;
1337
+ this.scene.disposeHydratedMeshesExcept(selected, selectedModelIndex, itemFilterExpressId, itemFilterItemId);
1330
1338
  }
1331
1339
  /**
1332
1340
  * Pop the frame's validation error scope, recording any captured validation
@@ -1573,33 +1581,11 @@ export class Renderer {
1573
1581
  const hasHiddenFilter = options.hiddenIds && options.hiddenIds.size > 0;
1574
1582
  const hasIsolatedFilter = options.isolatedIds !== null && options.isolatedIds !== undefined;
1575
1583
  const hasVisibilityFiltering = hasHiddenFilter || hasIsolatedFilter;
1576
- // ─── Visibility / override epoch bookkeeping ────────────────────────
1577
- // The tracker compares hide/isolate CONTENT against a snapshot, so both
1578
- // in-place mutation of the caller's Set and a fresh identical Set per
1579
- // frame behave correctly (see RenderOptions.hiddenIds). Bumping
1580
- // `_visibilityVersion` invalidates the per-batch visibility cache; the
1581
- // partial sub-batch cache additionally depends on colour-override
1582
- // promotion, so its epoch bumps on either.
1583
- const newVisibilityVersion = this._visibilityEpochs.update(options.hiddenIds, options.isolatedIds);
1584
- const visibilityChanged = newVisibilityVersion !== this._visibilityVersion;
1585
- this._visibilityVersion = newVisibilityVersion;
1586
- const colorOverrideGen = this.scene.getColorOverrideGeneration();
1587
- if (visibilityChanged || colorOverrideGen !== this._lastColorOverrideGen) {
1588
- this._lastColorOverrideGen = colorOverrideGen;
1589
- this._partialBatchEpoch++;
1590
- }
1591
- // When hide/isolate turns fully OFF (back to all-visible), release the
1592
- // partial sub-batch clones built while filtering. They are excluded from
1593
- // the GPU residency budget and are otherwise only freed on clear()/
1594
- // finalize/evict — never here — so ~model-sized clone VRAM would stay
1595
- // pinned until the next model reload. Any override-promotion sub-batches
1596
- // dropped alongside are rebuilt on demand next frame (cache miss).
1597
- if (this._lastHadVisibilityFiltering && !hasVisibilityFiltering) {
1598
- this.scene.dropAllPartialCaches();
1599
- }
1600
- this._lastHadVisibilityFiltering = hasVisibilityFiltering;
1601
1584
  // Build the selected-id set once per frame so the X-Ray override paths
1602
1585
  // can keep highlighted entities at full alpha without per-site checks.
1586
+ // Built BEFORE the epoch bookkeeping below, which consumes it: selection
1587
+ // decides which entities are exempt from fading, so it is an input to the
1588
+ // X-Ray split and must reach the sub-batch cache epoch.
1603
1589
  const selectedId = options.selectedId;
1604
1590
  const selectedIds = options.selectedIds;
1605
1591
  const selectedModelIndex = options.selectedModelIndex;
@@ -1612,95 +1598,75 @@ export class Renderer {
1612
1598
  selectedExpressIds.add(id);
1613
1599
  }
1614
1600
  }
1615
- const hasSelected = selectedExpressIds.size > 0;
1601
+ const itemFilterExpressId = (options.selectedItemId !== undefined && selectedId !== undefined && selectedId !== null) ? selectedId : undefined;
1602
+ const itemFilterItemId = itemFilterExpressId !== undefined ? options.selectedItemId : undefined; // #4382
1603
+ // ─── Visibility / override epoch bookkeeping ────────────────────────
1604
+ // The tracker compares hide/isolate CONTENT against a snapshot, so both
1605
+ // in-place mutation of the caller's Set and a fresh identical Set per
1606
+ // frame behave correctly (see RenderOptions.hiddenIds). Bumping
1607
+ // `_visibilityVersion` invalidates the per-batch visibility cache.
1608
+ //
1609
+ // The partial sub-batch epoch must carry EVERYTHING that decides a
1610
+ // sub-batch's membership — hide/isolate, colour-override promotion, and
1611
+ // the X-Ray split incl. selection (#4129) — because
1612
+ // `getOrCreatePartialBatch`'s fast path returns the cached clone without
1613
+ // ever inspecting the id set it was handed. A missing input therefore
1614
+ // shows up as a stale subset on screen, not as an extra rebuild.
1615
+ const newVisibilityVersion = this._visibilityEpochs.update(options.hiddenIds, options.isolatedIds);
1616
+ const visibilityChanged = newVisibilityVersion !== this._visibilityVersion;
1617
+ this._visibilityVersion = newVisibilityVersion;
1618
+ const colorOverrideGen = this.scene.getColorOverrideGeneration();
1619
+ const xrayVersion = this._xrayEpochs.update(options, selectedExpressIds);
1620
+ const partialEpochChanged = visibilityChanged || colorOverrideGen !== this._lastColorOverrideGen || xrayVersion !== this._xrayVersion;
1621
+ if (partialEpochChanged) {
1622
+ this._lastColorOverrideGen = colorOverrideGen;
1623
+ this._xrayVersion = xrayVersion;
1624
+ this._partialBatchEpoch++;
1625
+ }
1626
+ // Every source of partial sub-batches is off (all-visible AND no X-Ray)
1627
+ // — release the clones wholesale, or ~model-sized VRAM stays pinned till
1628
+ // the next model reload (see `partial-batch-cache.ts`). Slots orphaned
1629
+ // WHILE X-Ray is still on are the narrower `retireUnusedAlphaSlots` case.
1630
+ const xrayActive = (options.transparencyOverrides?.size ?? 0) > 0 || options.ghostExceptIds != null;
1631
+ const hasPartialSources = hasVisibilityFiltering || xrayActive;
1632
+ if (this._lastHadPartialSources && !hasPartialSources) {
1633
+ this.scene.dropAllPartialCaches();
1634
+ }
1635
+ this._lastHadPartialSources = hasPartialSources;
1616
1636
  // Free hydrated (pick/selection) individual meshes whose entity is no
1617
1637
  // longer selected BEFORE we snapshot the mesh list, so stale glass
1618
1638
  // doesn't double-draw over its batch copy or accumulate until clear().
1619
1639
  // Only acts on a selection change (avoids per-frame buffer churn) and
1620
1640
  // never touches authored (non-hydrated) or batch geometry.
1621
- this.syncHydratedSelectionMeshes(selectedExpressIds, selectedModelIndex);
1641
+ this.syncHydratedSelectionMeshes(selectedExpressIds, selectedModelIndex, itemFilterExpressId, itemFilterItemId);
1622
1642
  let meshes = this.scene.getMeshes();
1623
1643
  // Keep the GPU-instanced occurrences' per-instance selected flag in sync.
1624
1644
  // The Scene diff makes this a no-op (no writeBuffer) when the set is
1625
1645
  // unchanged, so calling it every frame is cheap; it no-ops entirely when
1626
1646
  // no instanced data is loaded. The flat path handles selection inline
1627
1647
  // below via `selectedExpressIds`.
1628
- this.scene.setInstancedSelection(selectedExpressIds);
1648
+ this.scene.setInstancedSelection(selectedExpressIds, itemFilterExpressId, itemFilterItemId);
1629
1649
  // Mirror hide/isolate onto the instanced occurrences (the flat path filters
1630
1650
  // its mesh list by hiddenIds/isolatedIds below; the instanced pass can't, so
1631
1651
  // it carries a per-instance hidden flag the shader discards on). Diffed → a
1632
1652
  // no-op when visibility is unchanged.
1633
1653
  this.scene.setInstancedVisibility(options.hiddenIds, options.isolatedIds);
1634
1654
  // Per-frame alpha overrides for X-Ray mode. See RenderOptions.transparencyOverrides.
1635
- // Snapshot the caller's map so mid-frame mutation can't desync classification
1636
- // and uniform-write decisions for the same batch/mesh.
1637
- const txOverridesSrc = options.transparencyOverrides;
1638
- const hasTxMap = txOverridesSrc != null && txOverridesSrc.size > 0;
1639
- const txOverrides = hasTxMap ? new Map(txOverridesSrc) : null;
1640
- // X-Ray *context* mode: every non-selected mesh NOT in ghostExceptIds
1641
- // fades to ghostAlpha. It feeds the same alpha-override machinery as
1642
- // transparencyOverrides (explicit per-id entries win), so it routes
1643
- // through the transparent pipeline with no extra call sites — and avoids
1644
- // building a Map over every element just to fade "the rest".
1645
- const ghostExceptIds = options.ghostExceptIds ?? null;
1655
+ // XRayAlpha snapshots the caller's map so mid-frame mutation can't desync
1656
+ // classification and uniform-write decisions for the same batch/mesh, and
1657
+ // owns the per-entity resolution + the mixed-batch partition (#4129).
1658
+ // X-Ray *context* mode (`ghostExceptIds`) feeds the same machinery, so it
1659
+ // routes through the transparent pipeline with no extra call sites — and
1660
+ // avoids building a Map over every element just to fade "the rest".
1646
1661
  const ghostAlpha = options.ghostAlpha ?? DEFAULT_GHOST_ALPHA;
1647
1662
  // X-Ray reaches the instanced pass too (#2606). Without this, ghosting
1648
1663
  // stopped at the flat geometry: on a model whose facade is instanced,
1649
1664
  // the user asked to fade the building and got a solid facade standing
1650
1665
  // in front of a ghosted interior.
1651
- this.scene.setInstancedGhosting(ghostExceptIds, selectedExpressIds, ghostAlpha);
1652
- const hasGhost = ghostExceptIds != null;
1653
- const hasTxOverrides = hasTxMap || hasGhost;
1654
- const alphaForMesh = (expressId, fallback) => {
1655
- if (!hasTxOverrides)
1656
- return fallback;
1657
- // Selected meshes are exempt — the highlight pass renders them last,
1658
- // but exempting here also keeps mesh classification + uniform writes
1659
- // consistent so a selected mesh never enters the transparent pipeline
1660
- // because of its own override entry.
1661
- if (hasSelected && selectedExpressIds.has(expressId))
1662
- return fallback;
1663
- const a = txOverrides?.get(expressId);
1664
- if (a !== undefined)
1665
- return a;
1666
- if (hasGhost && !ghostExceptIds.has(expressId))
1667
- return ghostAlpha;
1668
- return fallback;
1669
- };
1670
- // Cache resolved batch alpha for the frame: classification needs it
1671
- // (opaque vs transparent routing) and renderBatch needs it for the
1672
- // uniform write. Without the cache we'd walk batch.expressIds twice
1673
- // per batch per frame, which becomes the dominant JS cost in X-Ray.
1674
- const batchAlphaCache = hasTxOverrides
1675
- ? new WeakMap()
1676
- : null;
1677
- const alphaForBatch = (batch, fallback) => {
1678
- if (!hasTxOverrides)
1679
- return fallback;
1680
- const cached = batchAlphaCache.get(batch);
1681
- if (cached !== undefined)
1682
- return cached;
1683
- let minAlpha = Infinity;
1684
- for (const eid of batch.expressIds) {
1685
- // Selected ids never drag down a batch's alpha — the highlight
1686
- // pass redraws them on top, but excluding here also means a
1687
- // batch made entirely of selected entities stays opaque.
1688
- if (hasSelected && selectedExpressIds.has(eid))
1689
- continue;
1690
- const a = txOverrides?.get(eid);
1691
- if (a !== undefined) {
1692
- if (a < minAlpha)
1693
- minAlpha = a;
1694
- }
1695
- else if (hasGhost && !ghostExceptIds.has(eid)) {
1696
- if (ghostAlpha < minAlpha)
1697
- minAlpha = ghostAlpha;
1698
- }
1699
- }
1700
- const resolved = minAlpha === Infinity ? fallback : minAlpha;
1701
- batchAlphaCache.set(batch, resolved);
1702
- return resolved;
1703
- };
1666
+ this.scene.setInstancedGhosting(options.ghostExceptIds ?? null, selectedExpressIds, ghostAlpha);
1667
+ const xray = new XRayAlpha(options, selectedExpressIds);
1668
+ const alphaForMesh = (expressId, fallback) => xray.forEntity(expressId, fallback);
1669
+ const alphaForBatch = (batch, fallback) => xray.forBatch(batch, fallback);
1704
1670
  // Lens / Pset color overrides: when an entity has an override, force
1705
1671
  // its base draw through the opaque pipeline so it writes depth. The
1706
1672
  // overlay paint pass uses depthCompare 'equal' and otherwise silently
@@ -2186,52 +2152,13 @@ export class Renderer {
2186
2152
  const opaqueBatches = [];
2187
2153
  const transparentBatches = [];
2188
2154
  // PERFORMANCE FIX: Track partially visible batches for sub-batch rendering
2189
- // Instead of creating 10,000+ individual meshes, we create cached sub-batches
2190
- const partiallyVisibleBatches = [];
2191
- // Push a partial sub-batch entry, splitting by promotion when needed.
2192
- // For transparent parent batches with mixed override membership, this
2193
- // emits two entries (`:promoted` and `:remaining`) so non-overridden
2194
- // batchmates keep their native transparent routing instead of getting
2195
- // dragged opaque alongside the overridden ones.
2196
- const pushVisibleAsPartial = (sourceBatch, visibleIds, isTransparent) => {
2197
- const baseKey = `${sourceBatch.colorKey}:${sourceBatch.id}`;
2198
- if (!isTransparent) {
2199
- partiallyVisibleBatches.push({
2200
- sourceBatchKey: baseKey,
2201
- colorKey: sourceBatch.colorKey,
2202
- visibleIds,
2203
- color: sourceBatch.color,
2204
- });
2205
- return;
2206
- }
2207
- const split = splitVisibleIdsByPromotion(visibleIds, colorOverrides);
2208
- // No promotion or every visible id promoted → single sub-batch,
2209
- // classifier downstream routes via shouldRouteBatchTransparent.
2210
- if (split == null || split.remaining.size === 0) {
2211
- partiallyVisibleBatches.push({
2212
- sourceBatchKey: baseKey,
2213
- colorKey: sourceBatch.colorKey,
2214
- visibleIds,
2215
- color: sourceBatch.color,
2216
- });
2217
- return;
2218
- }
2219
- // Mixed — emit one promoted (opaque-routed) and one remaining
2220
- // (transparent-routed) sub-batch. Distinct sourceBatchKeys so the
2221
- // partial-batch cache can hold both simultaneously.
2222
- partiallyVisibleBatches.push({
2223
- sourceBatchKey: `${baseKey}:promoted`,
2224
- colorKey: sourceBatch.colorKey,
2225
- visibleIds: split.promoted,
2226
- color: sourceBatch.color,
2227
- });
2228
- partiallyVisibleBatches.push({
2229
- sourceBatchKey: `${baseKey}:remaining`,
2230
- colorKey: sourceBatch.colorKey,
2231
- visibleIds: split.remaining,
2232
- color: sourceBatch.color,
2233
- });
2234
- };
2155
+ // Instead of creating 10,000+ individual meshes, we create cached sub-batches.
2156
+ // The collector also owns the override-promotion split (#677) and the
2157
+ // per-entity X-Ray alpha split (#4129) see partial-batch-requests.ts.
2158
+ const partialRequests = new PartialBatchRequests(colorOverrides, xray, (b) => this.scene.canPartitionBatch(b));
2159
+ const partiallyVisibleBatches = partialRequests.items;
2160
+ const pushVisibleAsPartial = partialRequests.pushVisible.bind(partialRequests);
2161
+ const pushAlphaSplit = partialRequests.pushAlphaSplit.bind(partialRequests);
2235
2162
  for (const batch of allBatchedMeshes) {
2236
2163
  // Frustum culling: skip batches entirely outside the camera view
2237
2164
  if (batch.bounds) {
@@ -2269,7 +2196,7 @@ export class Renderer {
2269
2196
  // The visible subset was computed once for this
2270
2197
  // visibility epoch (cached) — reuse it, don't rebuild.
2271
2198
  const visibleIds = vis.visibleIds;
2272
- if (visibleIds && visibleIds.size > 0) {
2199
+ if (visibleIds && visibleIds.size > 0 && !pushAlphaSplit(batch, visibleIds)) {
2273
2200
  pushVisibleAsPartial(batch, visibleIds, nativelyTransparent);
2274
2201
  }
2275
2202
  // A COLD parent has no CPU meshData, so the partial
@@ -2293,6 +2220,11 @@ export class Renderer {
2293
2220
  continue;
2294
2221
  }
2295
2222
  this.scene.recordBatchDrawn(batch);
2223
+ // X-Ray names entities, not batches: a batch whose entities
2224
+ // resolve to different alphas splits per alpha (#4129) rather
2225
+ // than fading whole to the minimum.
2226
+ if (pushAlphaSplit(batch, null))
2227
+ continue;
2296
2228
  // Transparent batches with mixed
2297
2229
  // override membership must be split so non-overridden batchmates
2298
2230
  // stay transparent — see splitVisibleIdsByPromotion / issue #677.
@@ -2312,6 +2244,15 @@ export class Renderer {
2312
2244
  opaqueBatches.push(batch);
2313
2245
  }
2314
2246
  }
2247
+ // Retire the alpha-split slots this frame's classification did NOT
2248
+ // ask for (#4129 review): an X-Ray edit that makes a batch uniform
2249
+ // again, or that shrinks its group count, orphans slots the draw
2250
+ // loop below never revisits. Only on an epoch change — while the
2251
+ // state holds, the split shape is stable and nothing can be
2252
+ // orphaned, so the steady-state cost is zero.
2253
+ if (partialEpochChanged) {
2254
+ this.scene.retireUnusedAlphaSlots(partialRequests.requestedKeys());
2255
+ }
2315
2256
  // Build a uniform template ONCE per frame — shared across all batches.
2316
2257
  // Only the 4-float color (offset 32) differs per batch; everything else
2317
2258
  // (viewProj, identity model, material, section plane, flags) is identical.
@@ -2570,6 +2511,11 @@ export class Renderer {
2570
2511
  // would show open, un-capped cut holes.
2571
2512
  const opaqueSubBatches = [];
2572
2513
  if (partiallyVisibleBatches.length > 0) {
2514
+ // Transparent sub-batches are deferred to a second pass below:
2515
+ // they write no depth, so an opaque sub-batch drawn after one
2516
+ // paints straight over it. That is routine since #4129 — an
2517
+ // X-Rayed batch emits a faded group AND a solid one.
2518
+ const transparentSubBatches = [];
2573
2519
  for (const { sourceBatchKey, colorKey, visibleIds, color } of partiallyVisibleBatches) {
2574
2520
  // Get or create a cached sub-batch for this visibility state
2575
2521
  const subBatch = this.scene.getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, this.pipeline, this._partialBatchEpoch);
@@ -2580,21 +2526,24 @@ export class Renderer {
2580
2526
  // lens/Pset colour override, so the overlay paint pass finds depth.
2581
2527
  const isTransparent = shouldRouteBatchTransparent(alphaForBatch(subBatch, color[3]), subBatch.expressIds, colorOverrides);
2582
2528
  if (isTransparent) {
2583
- pass.setPipeline(pipeFor(subBatch, 'transparent'));
2584
- }
2585
- else {
2586
- // Opaque (incl. material-layer slices): double-sided.
2587
- // Layer slices are NOT culled — since #1311 they are
2588
- // open watertight-skin bands with unreliable winding,
2589
- // so culling punched holes (wall read hollow). See the
2590
- // full-batch path above.
2591
- pass.setPipeline(pipeFor(subBatch, 'opaque'));
2592
- opaqueSubBatches.push(subBatch);
2529
+ transparentSubBatches.push(subBatch);
2530
+ continue;
2593
2531
  }
2532
+ // Opaque (incl. material-layer slices): double-sided.
2533
+ // Layer slices are NOT culled — since #1311 they are
2534
+ // open watertight-skin bands with unreliable winding,
2535
+ // so culling punched holes (wall read hollow). See the
2536
+ // full-batch path above.
2537
+ pass.setPipeline(pipeFor(subBatch, 'opaque'));
2538
+ opaqueSubBatches.push(subBatch);
2594
2539
  // Render the sub-batch as a single draw call
2595
2540
  renderBatch(subBatch);
2596
2541
  }
2597
2542
  }
2543
+ for (const subBatch of transparentSubBatches) {
2544
+ pass.setPipeline(pipeFor(subBatch, 'transparent'));
2545
+ renderBatch(subBatch);
2546
+ }
2598
2547
  // Reset to opaque pipeline for subsequent rendering
2599
2548
  pass.setPipeline(this.pipeline.getPipeline());
2600
2549
  }
@@ -2650,7 +2599,8 @@ export class Renderer {
2650
2599
  existingPieceCounts.set(key, (existingPieceCounts.get(key) ?? 0) + 1);
2651
2600
  }
2652
2601
  for (const selId of visibleSelectedIds) {
2653
- const pieces = this.scene.getMeshDataPieces(selId, selectedModelIndex);
2602
+ const pieceItemId = selId === itemFilterExpressId ? itemFilterItemId : undefined; // #4382
2603
+ const pieces = this.scene.getMeshDataPieces(selId, selectedModelIndex, pieceItemId);
2654
2604
  if (!pieces || pieces.length === 0)
2655
2605
  continue;
2656
2606
  const seenOrdinalsByKey = new Map();
@@ -2671,6 +2621,8 @@ export class Renderer {
2671
2621
  return false;
2672
2622
  if (selectedModelIndex !== undefined && mesh.modelIndex !== selectedModelIndex)
2673
2623
  return false;
2624
+ if (mesh.expressId === itemFilterExpressId && mesh.geometryItemId !== itemFilterItemId)
2625
+ return false; // #4382
2674
2626
  return true;
2675
2627
  })
2676
2628
  : [];
@@ -2857,6 +2809,7 @@ export class Renderer {
2857
2809
  // Section-plane gizmo, 2D section cap and every standalone 3D
2858
2810
  // overlay (annotation / alignment / grid / DXF / clash / symbolic
2859
2811
  // text). One draw call into the pass — see RendererOverlays.draw().
2812
+ this.referenceImages.draw(pass, viewProj);
2860
2813
  this.overlays.draw(pass, {
2861
2814
  options,
2862
2815
  viewProj,
@@ -3021,15 +2974,13 @@ export class Renderer {
3021
2974
  return new Set();
3022
2975
  return this.pickingManager.pickRect(x0, y0, x1, y1, options, this.activePickClip());
3023
2976
  }
3024
- /**
3025
- * Raycast into the scene to get precise 3D intersection point
3026
- * This is more accurate than pick() as it returns the exact surface point
3027
- *
3028
- * Note: x, y are CSS pixel coordinates relative to the canvas element.
3029
- * These are scaled internally to match the actual canvas pixel dimensions.
3030
- */
2977
+ /** Whether the last rendered frame clipped surfaces (section, terrain or box). */
2978
+ hasActiveClipping() {
2979
+ return this._activePickSection !== null || this._activePickClipBox !== null;
2980
+ }
2981
+ /** Exact visible-surface raycast in CSS canvas coordinates. */
3031
2982
  raycastScene(x, y, options) {
3032
- return this.raycastEngine.raycastScene(x, y, options);
2983
+ return this.raycastEngine.raycastScene(x, y, options, this.activePickClip());
3033
2984
  }
3034
2985
  /**
3035
2986
  * Raycast with magnetic edge snapping behavior
@@ -3088,20 +3039,21 @@ export class Renderer {
3088
3039
  * Resize canvas
3089
3040
  */
3090
3041
  resize(width, height) {
3091
- // `canvas.width` is an IDL `unsigned long`, so it silently coerces a
3092
- // non-finite or negative argument to **0** — a zero drawing buffer
3093
- // that every pick guard in this package misses, because they all
3094
- // check the bounding rect rather than the buffer. `unprojectToRay`
3095
- // then divides by it. This is documented public API of a published
3096
- // package (`docs/api/typescript.md`), so an external caller wiring a
3097
- // ResizeObserver to it is the reachable route; both in-repo callers
3098
- // already floor their own values. Keep the last usable size, the same
3099
- // policy `setAspect` uses for the ratio it derives (#2473).
3100
- if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
3101
- return;
3102
- this.canvas.width = width;
3103
- this.canvas.height = height;
3104
- this.camera.setAspect(width / height);
3042
+ resizeRendererViewport(this.canvas, this.camera, width, height);
3043
+ }
3044
+ /** Stage one new owner; borrowed mesh buffers must remain immutable until disposal. */
3045
+ prepareAuthoredOwner(parts) {
3046
+ if (!this.device.isInitialized() || !this.pipeline)
3047
+ throw new Error('Renderer is not initialized.');
3048
+ const prepared = this.scene.prepareAuthoredOwner(parts, this.device.getDevice(), this.pipeline);
3049
+ return { commit: () => { prepared.commit(); this.refreshPlacementBounds(); this.invalidateBVHCache(); this.requestRender(); }, dispose: prepared.dispose };
3050
+ }
3051
+ prepareTexturedOwner(mesh) { if (!mesh.uvs || !(mesh.texture || (mesh.textureRef && mesh.textureBitmap)))
3052
+ throw new Error('A new textured owner requires an image and UVs.'); return this.prepareAuthoredOwner([mesh]); }
3053
+ getAppearancePreview() {
3054
+ if (!this.pipeline)
3055
+ throw new Error('Renderer must be initialized before previewing appearance');
3056
+ return this.scene.appearancePreview(this.device.getDevice(), this.pipeline);
3105
3057
  }
3106
3058
  getCamera() {
3107
3059
  return this.camera;
@@ -3384,6 +3336,7 @@ export class Renderer {
3384
3336
  // Section-plane gizmo, 2D section overlay and the symbolic annotation
3385
3337
  // pipelines — see RendererOverlays.destroy().
3386
3338
  this.overlays.destroy();
3339
+ this.referenceImages.destroy();
3387
3340
  // Point cloud GPU resources
3388
3341
  this.pointCloudRenderer?.clear();
3389
3342
  this.pointCloudRenderer = null;