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