@ifc-lite/renderer 1.49.1 → 2.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 (141) hide show
  1. package/README.md +4 -0
  2. package/dist/camera-animation.d.ts.map +1 -1
  3. package/dist/camera-animation.js +37 -27
  4. package/dist/camera-animation.js.map +1 -1
  5. package/dist/camera-controls.d.ts +26 -15
  6. package/dist/camera-controls.d.ts.map +1 -1
  7. package/dist/camera-controls.js +32 -18
  8. package/dist/camera-controls.js.map +1 -1
  9. package/dist/camera-framing.d.ts +60 -21
  10. package/dist/camera-framing.d.ts.map +1 -1
  11. package/dist/camera-framing.js +168 -131
  12. package/dist/camera-framing.js.map +1 -1
  13. package/dist/camera-preset-view.d.ts +2 -2
  14. package/dist/camera-preset-view.d.ts.map +1 -1
  15. package/dist/camera-preset-view.js +52 -42
  16. package/dist/camera-preset-view.js.map +1 -1
  17. package/dist/camera.d.ts +50 -6
  18. package/dist/camera.d.ts.map +1 -1
  19. package/dist/camera.js +113 -13
  20. package/dist/camera.js.map +1 -1
  21. package/dist/device.d.ts +11 -0
  22. package/dist/device.d.ts.map +1 -1
  23. package/dist/device.js +71 -5
  24. package/dist/device.js.map +1 -1
  25. package/dist/federation-registry.d.ts.map +1 -1
  26. package/dist/federation-registry.js +19 -2
  27. package/dist/federation-registry.js.map +1 -1
  28. package/dist/frame-timing-cpu.d.ts +31 -0
  29. package/dist/frame-timing-cpu.d.ts.map +1 -0
  30. package/dist/frame-timing-cpu.js +25 -0
  31. package/dist/frame-timing-cpu.js.map +1 -0
  32. package/dist/frame-timing-gpu.d.ts +144 -0
  33. package/dist/frame-timing-gpu.d.ts.map +1 -0
  34. package/dist/frame-timing-gpu.js +176 -0
  35. package/dist/frame-timing-gpu.js.map +1 -0
  36. package/dist/frame-timing-stats.d.ts +100 -0
  37. package/dist/frame-timing-stats.d.ts.map +1 -0
  38. package/dist/frame-timing-stats.js +116 -0
  39. package/dist/frame-timing-stats.js.map +1 -0
  40. package/dist/frame-timing.d.ts +102 -0
  41. package/dist/frame-timing.d.ts.map +1 -0
  42. package/dist/frame-timing.js +112 -0
  43. package/dist/frame-timing.js.map +1 -0
  44. package/dist/index.d.ts +77 -59
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +272 -73
  47. package/dist/index.js.map +1 -1
  48. package/dist/instanced-override-color.d.ts +17 -0
  49. package/dist/instanced-override-color.d.ts.map +1 -0
  50. package/dist/instanced-override-color.js +22 -0
  51. package/dist/instanced-override-color.js.map +1 -0
  52. package/dist/instanced-render.d.ts +10 -0
  53. package/dist/instanced-render.d.ts.map +1 -1
  54. package/dist/instanced-render.js +13 -3
  55. package/dist/instanced-render.js.map +1 -1
  56. package/dist/lod-simplify.d.ts +0 -9
  57. package/dist/lod-simplify.d.ts.map +1 -1
  58. package/dist/lod-simplify.js +45 -7
  59. package/dist/lod-simplify.js.map +1 -1
  60. package/dist/pick-resolve.d.ts +125 -0
  61. package/dist/pick-resolve.d.ts.map +1 -0
  62. package/dist/pick-resolve.js +127 -0
  63. package/dist/pick-resolve.js.map +1 -0
  64. package/dist/picker.d.ts.map +1 -1
  65. package/dist/picker.js +20 -46
  66. package/dist/picker.js.map +1 -1
  67. package/dist/picking-manager.d.ts.map +1 -1
  68. package/dist/picking-manager.js +13 -1
  69. package/dist/picking-manager.js.map +1 -1
  70. package/dist/pipeline.d.ts +16 -0
  71. package/dist/pipeline.d.ts.map +1 -1
  72. package/dist/pipeline.js +89 -3
  73. package/dist/pipeline.js.map +1 -1
  74. package/dist/pointcloud/point-cloud-spatial-index.d.ts +81 -9
  75. package/dist/pointcloud/point-cloud-spatial-index.d.ts.map +1 -1
  76. package/dist/pointcloud/point-cloud-spatial-index.js +140 -29
  77. package/dist/pointcloud/point-cloud-spatial-index.js.map +1 -1
  78. package/dist/raycast-engine.d.ts +4 -4
  79. package/dist/raycast-engine.d.ts.map +1 -1
  80. package/dist/raycast-engine.js +10 -3
  81. package/dist/raycast-engine.js.map +1 -1
  82. package/dist/renderer-overlays.d.ts +3 -21
  83. package/dist/renderer-overlays.d.ts.map +1 -1
  84. package/dist/renderer-overlays.js +57 -85
  85. package/dist/renderer-overlays.js.map +1 -1
  86. package/dist/renderer-symbolic-overlays.d.ts +29 -2
  87. package/dist/renderer-symbolic-overlays.d.ts.map +1 -1
  88. package/dist/renderer-symbolic-overlays.js +61 -16
  89. package/dist/renderer-symbolic-overlays.js.map +1 -1
  90. package/dist/scene-contents.d.ts +132 -0
  91. package/dist/scene-contents.d.ts.map +1 -0
  92. package/dist/scene-contents.js +5 -0
  93. package/dist/scene-contents.js.map +1 -0
  94. package/dist/scene-raycaster.d.ts +14 -8
  95. package/dist/scene-raycaster.d.ts.map +1 -1
  96. package/dist/scene-raycaster.js +6 -2
  97. package/dist/scene-raycaster.js.map +1 -1
  98. package/dist/scene.d.ts.map +1 -1
  99. package/dist/scene.js +63 -12
  100. package/dist/scene.js.map +1 -1
  101. package/dist/section-2d-overlay.d.ts +52 -63
  102. package/dist/section-2d-overlay.d.ts.map +1 -1
  103. package/dist/section-2d-overlay.js +75 -133
  104. package/dist/section-2d-overlay.js.map +1 -1
  105. package/dist/section-plane.d.ts +11 -0
  106. package/dist/section-plane.d.ts.map +1 -1
  107. package/dist/section-plane.js.map +1 -1
  108. package/dist/shaders/main.wgsl.d.ts +1 -1
  109. package/dist/shaders/main.wgsl.d.ts.map +1 -1
  110. package/dist/shaders/main.wgsl.js +87 -4
  111. package/dist/shaders/main.wgsl.js.map +1 -1
  112. package/dist/shaders/section-2d-overlay.wgsl.d.ts +2 -2
  113. package/dist/shaders/section-2d-overlay.wgsl.d.ts.map +1 -1
  114. package/dist/shaders/section-2d-overlay.wgsl.js +2 -2
  115. package/dist/shaders/section-2d-overlay.wgsl.js.map +1 -1
  116. package/dist/shaders/shadow.wgsl.d.ts +31 -0
  117. package/dist/shaders/shadow.wgsl.d.ts.map +1 -0
  118. package/dist/shaders/shadow.wgsl.js +141 -0
  119. package/dist/shaders/shadow.wgsl.js.map +1 -0
  120. package/dist/shadow-light-matrix.d.ts +106 -0
  121. package/dist/shadow-light-matrix.d.ts.map +1 -0
  122. package/dist/shadow-light-matrix.js +185 -0
  123. package/dist/shadow-light-matrix.js.map +1 -0
  124. package/dist/shadow-occluders.d.ts +106 -0
  125. package/dist/shadow-occluders.d.ts.map +1 -0
  126. package/dist/shadow-occluders.js +156 -0
  127. package/dist/shadow-occluders.js.map +1 -0
  128. package/dist/shadow-pass.d.ts +132 -0
  129. package/dist/shadow-pass.d.ts.map +1 -0
  130. package/dist/shadow-pass.js +342 -0
  131. package/dist/shadow-pass.js.map +1 -0
  132. package/dist/snap-detector.d.ts +10 -0
  133. package/dist/snap-detector.d.ts.map +1 -1
  134. package/dist/snap-detector.js.map +1 -1
  135. package/dist/symbolic-overlay-pipelines.d.ts +34 -0
  136. package/dist/symbolic-overlay-pipelines.d.ts.map +1 -1
  137. package/dist/symbolic-overlay-pipelines.js +31 -20
  138. package/dist/symbolic-overlay-pipelines.js.map +1 -1
  139. package/dist/types.d.ts +45 -19
  140. package/dist/types.d.ts.map +1 -1
  141. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export { WebGPUDevice } from './device.js';
8
8
  export { RenderPipeline } from './pipeline.js';
9
9
  export { Camera } from './camera.js';
10
10
  export { pickFitPolicy } from './camera-fit-policy.js';
11
- export { Scene } from './scene.js';
11
+ // `Scene` is NOT exported: an exported class republishes every method `SceneContents` above just froze.
12
12
  export { Picker } from './picker.js';
13
13
  export { MathUtils } from './math.js';
14
14
  // The orthonormal camera basis `MathUtils.lookAt` renders through, exposed so
@@ -19,7 +19,7 @@ export { MathUtils } from './math.js';
19
19
  // is the same situation from outside it).
20
20
  export { viewBasis } from './math.js';
21
21
  export { SectionPlaneRenderer } from './section-plane.js';
22
- export { Section2DOverlayRenderer } from './section-2d-overlay.js';
22
+ // `Section2DOverlayRenderer` is NOT exported: `Renderer.setLineOverlay` and the section cap drive it from inside. Its types below stay published.
23
23
  // IfcAnnotation overlay pipelines (3D world-space). Self-contained — caller
24
24
  // passes a GPUDevice + presentation format and invokes `.render(pass, viewProj)`
25
25
  // from inside an RGBA-blended pass. See packages/renderer/src/symbolic-overlay-pipelines.ts.
@@ -30,14 +30,13 @@ export { SymbolicTextAtlas } from './symbolic-text-atlas.js';
30
30
  // holds the styling primitives shared with the store and UI.
31
31
  export { DEFAULT_CAP_STYLE, HATCH_PATTERN_IDS } from './section-cap-style.js';
32
32
  export { planeBasis, nearestCardinalAxis } from './section-plane-basis.js';
33
+ export { LINE_OVERLAY_CHANNELS } from './section-2d-overlay.js';
33
34
  export { Raycaster } from './raycaster.js';
34
35
  export { SnapDetector, SnapType } from './snap-detector.js';
35
36
  export { BVH } from './bvh.js';
36
37
  export { FederationRegistry, federationRegistry } from './federation-registry.js';
37
38
  export * from './types.js';
38
39
  export { resolveEnvironment, deriveSkyGradient, packEnvironmentUniforms, ENVIRONMENT_UNIFORM_SIZE, } from './environment.js';
39
- // Extracted manager classes
40
- export { PickingManager } from './picking-manager.js';
41
40
  export { resolveContributionThresholdPx, projectedAabbRadiusPx } from './contribution-cull.js';
42
41
  export { chunkCellKey, bucketBaseKeyFor, DEFAULT_CHUNK_CELL_SIZE } from './chunk-grid.js';
43
42
  export { selectEvictions, MIN_EVICTION_AGE_FRAMES } from './residency.js';
@@ -52,6 +51,15 @@ export { isEntityVisible } from './entity-visibility.js';
52
51
  // through its own glTF pipeline rather than this one (#2591).
53
52
  export { DEFAULT_GHOST_ALPHA, OPAQUE_ALPHA_CUTOFF } from './overlay-routing.js';
54
53
  export { VisibilityEpochTracker } from './visibility-epoch.js';
54
+ // Frame/pass GPU timing (issue #2670 perf-verdict gate). Opt-in and NOT wired
55
+ // into `Renderer` by default — a caller constructs `GpuFrameTimingRecorder`
56
+ // itself and attaches its `timestampWrites` to the passes it wants measured;
57
+ // see `frame-timing-gpu.ts`'s module doc for the usage pattern, and
58
+ // `decideTimingMode` for choosing GPU queries vs. the CPU fallback vs. off.
59
+ export { decideTimingMode, passDurationsMs, frameTotalMs, aggregateFrameTimings } from './frame-timing.js';
60
+ export { computeDurationStats, nsToMs, isNegativeDelta } from './frame-timing-stats.js';
61
+ export { GpuFrameTimingRecorder, hasTimestampQueryFeature } from './frame-timing-gpu.js';
62
+ export { createCpuFrameTicker } from './frame-timing-cpu.js';
55
63
  export { RaycastEngine } from './raycast-engine.js';
56
64
  export { PointPicker, decodePickSample } from './point-picker.js';
57
65
  // Point cloud rendering (Phase 0: IFCx inline; Phase 1+: streaming LAS/LAZ)
@@ -62,6 +70,7 @@ import { RenderPipeline } from './pipeline.js';
62
70
  import { Camera } from './camera.js';
63
71
  import { Scene } from './scene.js';
64
72
  import { Picker } from './picker.js';
73
+ import { reportableItemId } from './pick-resolve.js';
65
74
  import { MathUtils, viewBasis } from './math.js';
66
75
  import { FrustumUtils } from '@ifc-lite/spatial';
67
76
  import { VisualEnhancementResolver } from './visual-enhancement.js';
@@ -81,6 +90,9 @@ import { EdlPass } from './edl-pass.js';
81
90
  import { SkyPass } from './sky-pass.js';
82
91
  import { skyShaderSource } from './shaders/sky.wgsl.js';
83
92
  import { resolveEnvironment } from './environment.js';
93
+ import { ShadowPass, resolveShadowMapResolution } from './shadow-pass.js';
94
+ import { fitSunLightMatrix, cameraFrustumFocusCorners } from './shadow-light-matrix.js';
95
+ import { collectShadowOccluders, classifyBatchVisibility, DEFAULT_MIN_CAST_ALPHA } from './shadow-occluders.js';
84
96
  import { shouldRouteMeshTransparent, shouldRouteBatchTransparent, splitVisibleIdsByPromotion, DEFAULT_GHOST_ALPHA } from './overlay-routing.js';
85
97
  import { colorSaltByte, packEntityLane } from './scene-geometry.js';
86
98
  import { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
@@ -181,6 +193,14 @@ export class Renderer {
181
193
  // Procedural sky background — created lazily on the first frame that
182
194
  // enables it (most sessions never do).
183
195
  skyPass = null;
196
+ // Sun shadow-map depth pre-pass (#2670, Phase 2) — created lazily on the
197
+ // first frame that enables `RenderOptions.sunShadows`. Off by default.
198
+ shadowPass = null;
199
+ // Whether the shadow map was wired into the environment bind group last
200
+ // frame — lets a toggle-off write `enabled = 0` and drop the depth view
201
+ // exactly once instead of every frame.
202
+ shadowsWired = false;
203
+ shadowScratch = new Float32Array(24); // lightViewProj(16) + params(4) + params2(4)
184
204
  edlOptions = {
185
205
  enabled: false,
186
206
  strength: 1,
@@ -1107,6 +1127,82 @@ export class Renderer {
1107
1127
  setModelBounds(bounds) {
1108
1128
  this.modelBoundsTracker.set(bounds);
1109
1129
  }
1130
+ /**
1131
+ * The batched occluders the sun shadow pass should cast, filtered to the same
1132
+ * visibility the colour pass draws (#2670, Phase 2b). Without filtering every
1133
+ * batch casts. With hide/isolate active a fully-hidden batch is dropped and a
1134
+ * partially-hidden OPAQUE batch casts only its visible subset via the SAME
1135
+ * cached partial sub-batch the colour pass renders (shared cache key
1136
+ * `${colorKey}:${id}` + `_partialBatchEpoch`), so no extra clone memory and no
1137
+ * phantom shadow from an individually-hidden element in a shared batch.
1138
+ *
1139
+ * Transparent (glass-like) partially-hidden parents are left to the collector's
1140
+ * material-alpha filter — they don't cast at all, so building a visible subset
1141
+ * for them would waste a clone (and diverge from the colour pass's promotion
1142
+ * split keys). Fully-visible transparent batches pass through unchanged and the
1143
+ * collector drops them.
1144
+ */
1145
+ shadowOccluderBatches(options, device, hasVisibilityFiltering) {
1146
+ const all = this.scene.getBatchedMeshes();
1147
+ if (!hasVisibilityFiltering) {
1148
+ for (const batch of all)
1149
+ this.noteShadowOccluderResidency(batch);
1150
+ return all;
1151
+ }
1152
+ const pipeline = this.pipeline;
1153
+ const out = [];
1154
+ for (const batch of all) {
1155
+ const vis = classifyBatchVisibility(batch.expressIds, options.hiddenIds, options.isolatedIds);
1156
+ if (vis.kind === 'none')
1157
+ continue; // fully hidden → does not cast
1158
+ if (vis.kind === 'all') {
1159
+ this.noteShadowOccluderResidency(batch);
1160
+ out.push(batch); // fully visible → its own buffers
1161
+ continue;
1162
+ }
1163
+ // Partially hidden. Transparent parents don't cast (collector's alpha
1164
+ // filter) — skip rather than build a wasted, divergent-key clone.
1165
+ if (batch.color[3] < DEFAULT_MIN_CAST_ALPHA)
1166
+ continue;
1167
+ // The partial sub-batch is built from the PARENT's CPU meshData, so a
1168
+ // cold parent yields nothing until it is restored. Queue that restore
1169
+ // here too: the colour pass only queues it for parents inside its own
1170
+ // frustum, but an up-sun occluder behind the camera still has to cast.
1171
+ this.noteShadowOccluderResidency(batch);
1172
+ // Opaque partial: reuse the colour pass's cached sub-batch. The key is
1173
+ // visibility-content-independent; `_partialBatchEpoch` invalidates it on
1174
+ // any hide/isolate or override change, so the clone is always current.
1175
+ // Without a pipeline the renderer isn't drawing, so skip (the shadow
1176
+ // pass won't run either); casting the whole parent would be wrong.
1177
+ if (!pipeline)
1178
+ continue;
1179
+ const sub = this.scene.getOrCreatePartialBatch(`${batch.colorKey}:${batch.id}`, batch.colorKey, vis.visibleIds, device, pipeline, this._partialBatchEpoch);
1180
+ // A cold parent yields an empty partial (its residency restore is queued
1181
+ // above); skip this frame — the collector drops zero-index draws anyway,
1182
+ // and the subset casts once resident.
1183
+ if (sub && sub.indexCount > 0)
1184
+ out.push(sub);
1185
+ }
1186
+ return out;
1187
+ }
1188
+ /**
1189
+ * Keep a shadow occluder batch resident so it does not thin out silently on
1190
+ * large models under the GPU residency budget (#2670 review). The depth pass
1191
+ * reads these batches' buffers, but that read did not count as usage, so an
1192
+ * up-sun occluder outside the colour frustum aged into an eviction candidate.
1193
+ * Transparent batches never cast (the collector's alpha filter), so their
1194
+ * residency is irrelevant here.
1195
+ */
1196
+ noteShadowOccluderResidency(batch) {
1197
+ if (batch.color[3] < DEFAULT_MIN_CAST_ALPHA)
1198
+ return;
1199
+ if (batch.gpuResident === false) {
1200
+ this.scene.requestBatchResidency(batch);
1201
+ }
1202
+ else {
1203
+ this.scene.recordBatchDrawn(batch);
1204
+ }
1205
+ }
1110
1206
  /**
1111
1207
  * Create a GPU Mesh from MeshData (lazy creation for selection highlighting)
1112
1208
  * This is called on-demand when a mesh is selected, avoiding 2x buffer creation during streaming
@@ -1187,6 +1283,11 @@ export class Renderer {
1187
1283
  this.scene.addMesh({
1188
1284
  expressId: meshData.expressId,
1189
1285
  modelIndex: meshData.modelIndex, // Preserve modelIndex for multi-model selection
1286
+ // Source item, so a pick can report it (#2985) — via the shared rule,
1287
+ // so this and the CPU raycaster cannot answer one click two ways.
1288
+ // In-tree callers pre-split merged pieces (Scene.getMeshDataPieces
1289
+ // drops the field incidentally); this is public, so it owns the rule.
1290
+ geometryItemId: reportableItemId(meshData, meshData.expressId),
1190
1291
  vertexBuffer,
1191
1292
  indexBuffer,
1192
1293
  indexCount: meshData.indices.length,
@@ -1783,6 +1884,137 @@ export class Renderer {
1783
1884
  }
1784
1885
  // Now record draw commands
1785
1886
  const encoder = device.createCommandEncoder();
1887
+ // Sun shadow-map pass (#2670, Phase 2). Off unless the caller opts
1888
+ // in; when off the hot path pays only this check and (once) an
1889
+ // enabled=0 reset on toggle-off. Runs BEFORE the colour pass (its
1890
+ // own complete depth-only sub-pass on the same encoder); the colour
1891
+ // pass then samples the map via the environment bind group. Every
1892
+ // geometry path both casts (collectShadowOccluders) and receives
1893
+ // (the shared main-family shader), so no part of the model silently
1894
+ // stops shadowing.
1895
+ const shadowOpts = options.sunShadows;
1896
+ let shadowsThisFrame = false;
1897
+ if (shadowOpts?.enabled) {
1898
+ const bounds = this.getModelBounds();
1899
+ if (bounds) {
1900
+ // `resolution === 0` (or unset) means Auto: pick from the
1901
+ // device's texture limit. A manual value is clamped to that
1902
+ // limit so a 4096 request can't fail createTexture on a
1903
+ // 2048-max device (#2670 review).
1904
+ const resolution = resolveShadowMapResolution(shadowOpts.resolution, device.limits.maxTextureDimension2D);
1905
+ if (!this.shadowPass) {
1906
+ this.shadowPass = new ShadowPass(device, resolution);
1907
+ }
1908
+ else {
1909
+ this.shadowPass.setResolution(resolution);
1910
+ }
1911
+ const boundsMin = [bounds.min.x, bounds.min.y, bounds.min.z];
1912
+ const boundsMax = [bounds.max.x, bounds.max.y, bounds.max.z];
1913
+ // Lateral shadow fit. AT REST: fit to the camera frustum
1914
+ // clipped to the model (maintainer #1) so a small building on
1915
+ // a large site keeps sharp shadows instead of spending the
1916
+ // map on distant terrain. DURING INTERACTION: fall back to a
1917
+ // whole-bounds fit — it is camera-INDEPENDENT, so orbiting or
1918
+ // scroll-zooming can't make the focus box breathe and drop
1919
+ // receivers off the map edge (which read as chunks of shadow
1920
+ // vanishing, #2670 follow-up). Depth always spans the whole
1921
+ // model so up-sun occluders keep casting.
1922
+ let focusCorners;
1923
+ if (!interacting) {
1924
+ const camEye = this.camera.getPosition();
1925
+ const camBasisFit = viewBasis(camEye, this.camera.getTarget(), this.camera.getUp());
1926
+ focusCorners = cameraFrustumFocusCorners({
1927
+ eye: camEye,
1928
+ forward: camBasisFit.forward,
1929
+ right: camBasisFit.right,
1930
+ up: camBasisFit.up,
1931
+ fovY: this.camera.getFOV(),
1932
+ aspect: this.canvas.height > 0 ? this.canvas.width / this.canvas.height : 1,
1933
+ ortho: this.camera.getProjectionMode() === 'orthographic',
1934
+ orthoHalfHeight: this.camera.getOrthoSize(),
1935
+ boundsMin,
1936
+ boundsMax,
1937
+ }) ?? undefined;
1938
+ }
1939
+ const sun = resolveEnvironment(options.environment).sunDirection;
1940
+ const fit = fitSunLightMatrix({ sunDirection: sun, boundsMin, boundsMax, focusCorners });
1941
+ const occluders = collectShadowOccluders({
1942
+ // Cast from the same visibility the colour pass draws: a
1943
+ // fully-hidden batch is dropped and a partially-hidden one
1944
+ // casts only its visible subset (the same cached partial
1945
+ // sub-batch the colour pass renders), so an
1946
+ // individually-hidden element in a shared batch stops
1947
+ // casting instead of throwing a phantom shadow.
1948
+ batches: this.shadowOccluderBatches(options, device, hasVisibilityFiltering),
1949
+ instanced: this.scene.getInstancedTemplates(),
1950
+ textured: this.scene.getTexturedMeshes(),
1951
+ // Individual meshes cast too (Renderer.addMesh() /
1952
+ // no-batch fallback); the collector skips hydrated
1953
+ // selection copies so batched scenes don't double-cast.
1954
+ meshes: this.scene.getMeshes(),
1955
+ }, { hiddenIds: options.hiddenIds, isolatedIds: options.isolatedIds ?? undefined });
1956
+ // Cast the same cut the colour pass draws: geometry a
1957
+ // section plane / crop box removed from view must stop
1958
+ // casting too, or the sliced-off roof keeps shadowing the
1959
+ // floor it no longer covers. `sectionPlaneData` already
1960
+ // folds in the terrain clip, so that is covered as well.
1961
+ this.shadowPass.render(encoder, fit.lightViewProj, occluders, {
1962
+ section: sectionPlaneData?.enabled
1963
+ ? {
1964
+ normal: sectionPlaneData.normal,
1965
+ distance: sectionPlaneData.distance,
1966
+ flipped: options.sectionPlane?.flipped === true,
1967
+ }
1968
+ : null,
1969
+ box: options.clipBox?.enabled
1970
+ ? { min: options.clipBox.min, max: options.clipBox.max }
1971
+ : null,
1972
+ });
1973
+ // Shadow uniform: light matrix + sampling params. The kernel
1974
+ // width follows the sun's angular size (physical, ~0.53°
1975
+ // like Blender's Sun lamp). Bias scales with the kernel: a
1976
+ // wider penumbra samples farther, so the normal offset must
1977
+ // grow with it or the kernel edge self-shadows (the hardware
1978
+ // slope bias in ShadowPass handles the grazing-angle case).
1979
+ const texelWorld = (2 * fit.orthoHalfWidth) / resolution;
1980
+ const sunAngleDeg = shadowOpts.sunAngleDeg ?? 0.53;
1981
+ const pcfRadius = Math.min(Math.max(sunAngleDeg * 3.0, 0.75), 8.0);
1982
+ const normalBias = texelWorld * (2.0 + pcfRadius);
1983
+ const s = this.shadowScratch;
1984
+ s.set(fit.lightViewProj.m, 0);
1985
+ s[16] = 1 / resolution; // texelSize
1986
+ s[17] = 1; // enabled
1987
+ s[18] = normalBias;
1988
+ s[19] = pcfRadius;
1989
+ s[20] = 0.0006; // depthBias (reverse-Z clip units)
1990
+ s[21] = 0;
1991
+ s[22] = 0;
1992
+ s[23] = 0;
1993
+ this.pipeline.updateShadowUniform(s);
1994
+ this.pipeline.setShadowDepthView(this.shadowPass.getDepthTextureView());
1995
+ this.shadowsWired = true;
1996
+ shadowsThisFrame = true;
1997
+ }
1998
+ }
1999
+ if (!shadowsThisFrame && (this.shadowsWired || this.shadowPass)) {
2000
+ // Toggle-off: disable sampling, release the depth view, and free
2001
+ // the shadow pass itself so its depth texture (16.8 MB at 2048,
2002
+ // 67 MB at 4096) is returned to the driver for the rest of the
2003
+ // session instead of lingering unused. Re-enabling reconstructs
2004
+ // it lazily on the next shadowed frame (see the `!this.shadowPass`
2005
+ // guard above). The `|| this.shadowPass` arm also covers a pass
2006
+ // that was allocated but never wired — an occluder-prep throw
2007
+ // between `new ShadowPass` and `shadowsWired = true` would
2008
+ // otherwise leak its texture until Renderer.destroy() (Greptile
2009
+ // #3053). The uniform-unwire below is a no-op when never wired.
2010
+ // #2670 review.
2011
+ this.shadowScratch[17] = 0;
2012
+ this.pipeline.updateShadowUniform(this.shadowScratch);
2013
+ this.pipeline.setShadowDepthView(null);
2014
+ this.shadowsWired = false;
2015
+ this.shadowPass?.destroy();
2016
+ this.shadowPass = null;
2017
+ }
1786
2018
  // Set up MSAA rendering if enabled
1787
2019
  const msaaView = this.pipeline.getMultisampleTextureView();
1788
2020
  const useMSAA = msaaView !== null && this.pipeline.getSampleCount() > 1;
@@ -2815,18 +3047,6 @@ export class Renderer {
2815
3047
  invalidateBVHCache() {
2816
3048
  this.raycastEngine.invalidateBVHCache();
2817
3049
  }
2818
- /**
2819
- * Get the raycaster instance (for advanced usage)
2820
- */
2821
- getRaycaster() {
2822
- return this.raycastEngine.getRaycaster();
2823
- }
2824
- /**
2825
- * Get the snap detector instance (for advanced usage)
2826
- */
2827
- getSnapDetector() {
2828
- return this.raycastEngine.getSnapDetector();
2829
- }
2830
3050
  /**
2831
3051
  * Clear all caches (call when geometry changes)
2832
3052
  */
@@ -2886,6 +3106,7 @@ export class Renderer {
2886
3106
  getCamera() {
2887
3107
  return this.camera;
2888
3108
  }
3109
+ /** MEASURED external surface, not the 4400-line `Scene`: widening `SceneContents` is a published-API decision, not a detail — see `scene-contents.ts`. */
2889
3110
  getScene() {
2890
3111
  return this.scene;
2891
3112
  }
@@ -2929,63 +3150,32 @@ export class Renderer {
2929
3150
  this.overlays.setOverlayLineColor(color);
2930
3151
  }
2931
3152
  /**
2932
- * Upload pre-lifted 3D line-list vertices for the standalone annotation
2933
- * overlay. Each segment is `[x1, y1, z1, x2, y2, z2]` in world space.
2934
- * The overlay is drawn regardless of whether a section plane is active.
2935
- * Pass an empty Float32Array to clear.
2936
- */
2937
- uploadAnnotationLines3D(vertices) {
2938
- this.overlays.uploadAnnotationLines3D(vertices);
2939
- }
2940
- /**
2941
- * Clear the standalone annotation line overlay.
2942
- */
2943
- clearAnnotationLines3D() {
2944
- this.overlays.clearAnnotationLines3D();
2945
- }
2946
- /**
2947
- * Upload IfcAlignment centerline segments as a flat [x,y,z,x,y,z,...]
2948
- * line-list in world space. Rendered as thin lines (not a ribbon mesh)
2949
- * to match IfcGrid / IfcAnnotation. Pass an empty Float32Array to clear.
2950
- */
2951
- uploadAlignmentLines3D(vertices) {
2952
- this.overlays.uploadAlignmentLines3D(vertices);
2953
- }
2954
- /** Clear the alignment centerline overlay. */
2955
- clearAlignmentLines3D() {
2956
- this.overlays.clearAlignmentLines3D();
2957
- }
2958
- /**
2959
- * Upload structural-grid (IfcGridAxis) segments as a flat [x,y,z,x,y,z,...]
2960
- * line-list in world space (issue #967). Rendered as thin lines, mirroring
2961
- * the alignment overlay. Pass an empty Float32Array to clear.
3153
+ * Set one standalone 3D line overlay, or clear it by passing `null`.
2962
3154
  *
2963
- * Unlike alignment, grids do NOT expand model bounds: they're behind a
2964
- * visibility toggle, so toggling them on must not reframe the camera (and
2965
- * grid axes routinely extend past the model envelope).
2966
- */
2967
- uploadGridLines3D(vertices) {
2968
- this.overlays.uploadGridLines3D(vertices);
2969
- }
2970
- /** Clear the structural-grid overlay. */
2971
- clearGridLines3D() {
2972
- this.overlays.clearGridLines3D();
2973
- }
2974
- /**
2975
- * Upload the DXF reference-layer's line paths as a flat
2976
- * [x,y,z,x,y,z,...] line-list in world space (issue #2043, follow-up to
2977
- * the 2D-only DXF underlay from #1782/#1929). Mirrors
2978
- * `uploadGridLines3D`: a dedicated buffer so 3D DXF visibility is
2979
- * independent of the 2D underlay's own toggle, and does NOT expand
2980
- * model bounds/reframe the camera on upload it's behind its own
2981
- * visibility toggle, like grid axes. Pass an empty Float32Array to clear.
3155
+ * `vertices` is a flat world-space line-list `[x1,y1,z1, x2,y2,z2, …]`,
3156
+ * one segment per six floats. The vertices are already lifted to world
3157
+ * space, so these overlays draw whether or not a section plane is active.
3158
+ * A trailing partial segment is dropped rather than rejecting the array.
3159
+ * An empty array clears too, but `null` skips building the pipelines.
3160
+ *
3161
+ * Every channel is an independent buffer with its own visibility, so
3162
+ * setting one leaves the other three untouched. All four share the colour
3163
+ * set by {@link setOverlayLineColor}; label colour is per-text via
3164
+ * `SymbolicTextInput.color` on `uploadAnnotationTexts3D`.
3165
+ *
3166
+ * The channels differ in exactly one way — whether they grow the scene
3167
+ * bounds. `annotation` (#653) and `alignment` DO: a file holding only them
3168
+ * has no IfcProduct meshes to frame, so Home / fit-to-view would have
3169
+ * nothing to aim at and the near/far range would clip the lines away.
3170
+ * `grid` (IfcGridAxis, #967) and `dxf` (the DXF reference layer, #2043) do
3171
+ * NOT: they are reference layers that routinely extend past the model
3172
+ * envelope, so growing the bounds would reframe the camera whenever one
3173
+ * was ticked on. The rule is "does this content DEFINE the model's
3174
+ * extent", NOT "is it behind a visibility toggle" — annotations sit behind
3175
+ * `ifcAnnotationsVisible` too.
2982
3176
  */
2983
- uploadDxfLines3D(vertices) {
2984
- this.overlays.uploadDxfLines3D(vertices);
2985
- }
2986
- /** Clear the 3D DXF reference-layer overlay. */
2987
- clearDxfLines3D() {
2988
- this.overlays.clearDxfLines3D();
3177
+ setLineOverlay(channel, vertices) {
3178
+ this.overlays.setLineOverlay(channel, vertices);
2989
3179
  }
2990
3180
  /**
2991
3181
  * Show (or clear) the clash-overlap box: the wireframe AABB of a focused
@@ -3039,7 +3229,10 @@ export class Renderer {
3039
3229
  return this.overlays.hasSection2DOverlay();
3040
3230
  }
3041
3231
  /**
3042
- * Get render pipeline (for batching)
3232
+ * Get render pipeline (for batching). DELIBERATELY NOT NARROWED, unlike
3233
+ * `getScene()`: the measurement found ZERO external
3234
+ * `RenderPipeline` members — all 12 call sites pass the handle straight
3235
+ * back into a `SceneContents` upload method typed for the real class.
3043
3236
  */
3044
3237
  getPipeline() {
3045
3238
  return this.pipeline;
@@ -3081,7 +3274,11 @@ export class Renderer {
3081
3274
  *
3082
3275
  * Returning null instead routes into the `if (!device) return` check that
3083
3276
  * every call site already has, so a lost device degrades to "stop
3084
- * uploading" rather than an uncaught throw. See `isDeviceLost()` /
3277
+ * uploading" rather than an uncaught throw.
3278
+ *
3279
+ * DELIBERATELY NOT NARROWED, unlike `getScene()`: the one external
3280
+ * `GPUDevice` member measured is `queue`; every other call site
3281
+ * hands the device back to a `SceneContents` method. See `isDeviceLost()` /
3085
3282
  * `onDeviceLost()` for the recovery contract.
3086
3283
  */
3087
3284
  getGPUDevice() {
@@ -3182,6 +3379,8 @@ export class Renderer {
3182
3379
  this.edlPass = null;
3183
3380
  this.skyPass?.destroy();
3184
3381
  this.skyPass = null;
3382
+ this.shadowPass?.destroy();
3383
+ this.shadowPass = null;
3185
3384
  // Section-plane gizmo, 2D section overlay and the symbolic annotation
3186
3385
  // pipelines — see RendererOverlays.destroy().
3187
3386
  this.overlays.destroy();