@ifc-lite/renderer 1.16.0 → 1.18.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 (47) hide show
  1. package/README.md +73 -15
  2. package/dist/edl-pass.d.ts +52 -0
  3. package/dist/edl-pass.d.ts.map +1 -0
  4. package/dist/edl-pass.js +204 -0
  5. package/dist/edl-pass.js.map +1 -0
  6. package/dist/index.d.ts +77 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +348 -19
  9. package/dist/index.js.map +1 -1
  10. package/dist/picker.d.ts +22 -3
  11. package/dist/picker.d.ts.map +1 -1
  12. package/dist/picker.js +48 -8
  13. package/dist/picker.js.map +1 -1
  14. package/dist/picking-manager.d.ts +14 -1
  15. package/dist/picking-manager.d.ts.map +1 -1
  16. package/dist/picking-manager.js +13 -1
  17. package/dist/picking-manager.js.map +1 -1
  18. package/dist/pipeline.d.ts.map +1 -1
  19. package/dist/pipeline.js +16 -1
  20. package/dist/pipeline.js.map +1 -1
  21. package/dist/point-picker.d.ts +61 -0
  22. package/dist/point-picker.d.ts.map +1 -0
  23. package/dist/point-picker.js +223 -0
  24. package/dist/point-picker.js.map +1 -0
  25. package/dist/pointcloud/point-cloud-node.d.ts +56 -0
  26. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -0
  27. package/dist/pointcloud/point-cloud-node.js +112 -0
  28. package/dist/pointcloud/point-cloud-node.js.map +1 -0
  29. package/dist/pointcloud/point-cloud-renderer.d.ts +135 -0
  30. package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -0
  31. package/dist/pointcloud/point-cloud-renderer.js +296 -0
  32. package/dist/pointcloud/point-cloud-renderer.js.map +1 -0
  33. package/dist/pointcloud/point-pipeline.d.ts +25 -0
  34. package/dist/pointcloud/point-pipeline.d.ts.map +1 -0
  35. package/dist/pointcloud/point-pipeline.js +111 -0
  36. package/dist/pointcloud/point-pipeline.js.map +1 -0
  37. package/dist/pointcloud/point-shader.wgsl.d.ts +22 -0
  38. package/dist/pointcloud/point-shader.wgsl.d.ts.map +1 -0
  39. package/dist/pointcloud/point-shader.wgsl.js +212 -0
  40. package/dist/pointcloud/point-shader.wgsl.js.map +1 -0
  41. package/dist/shaders/main.wgsl.d.ts +1 -1
  42. package/dist/shaders/main.wgsl.d.ts.map +1 -1
  43. package/dist/shaders/main.wgsl.js +15 -3
  44. package/dist/shaders/main.wgsl.js.map +1 -1
  45. package/dist/types.d.ts +20 -0
  46. package/dist/types.d.ts.map +1 -1
  47. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -26,6 +26,10 @@ export { ZeroCopyGpuUploader, createZeroCopyUploader, } from './zero-copy-upload
26
26
  // Extracted manager classes
27
27
  export { PickingManager } from './picking-manager.js';
28
28
  export { RaycastEngine } from './raycast-engine.js';
29
+ export { PointPicker, decodePickSample } from './point-picker.js';
30
+ // Point cloud rendering (Phase 0: IFCx inline; Phase 1+: streaming LAS/LAZ)
31
+ export { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
32
+ export { PointRenderPipeline } from './pointcloud/point-pipeline.js';
29
33
  import { WebGPUDevice } from './device.js';
30
34
  import { RenderPipeline, InstancedRenderPipeline } from './pipeline.js';
31
35
  import { Camera } from './camera.js';
@@ -40,6 +44,8 @@ import { DEFAULT_CAP_STYLE, HATCH_PATTERN_IDS } from './section-cap-style.js';
40
44
  import { PickingManager } from './picking-manager.js';
41
45
  import { RaycastEngine } from './raycast-engine.js';
42
46
  import { PostProcessor } from './post-processor.js';
47
+ import { EdlPass } from './edl-pass.js';
48
+ import { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
43
49
  const MAX_ENCODED_ENTITY_ID = 0xFFFFFF;
44
50
  let warnedEntityIdRange = false;
45
51
  /**
@@ -56,6 +62,14 @@ export class Renderer {
56
62
  sectionPlaneRenderer = null;
57
63
  section2DOverlayRenderer = null;
58
64
  postProcessor = null;
65
+ edlPass = null;
66
+ edlOptions = {
67
+ enabled: false,
68
+ strength: 1,
69
+ radiusPx: 1,
70
+ highQuality: true,
71
+ };
72
+ pointCloudRenderer = null;
59
73
  visualEnhancementState = {
60
74
  enabled: true,
61
75
  edgeContrast: { enabled: true, intensity: 1.0 },
@@ -113,9 +127,217 @@ export class Renderer {
113
127
  contactRadius: 1.0,
114
128
  contactIntensity: 0.3,
115
129
  }, this.pipeline.getSampleCount());
130
+ this.pointCloudRenderer = new PointCloudRenderer(this.device.getDevice(), this.device.getFormat(), 'depth24plus-stencil8', this.pipeline.getSampleCount());
131
+ this.edlPass = new EdlPass(this.device, this.pipeline.getSampleCount());
116
132
  this.camera.setAspect(width / height);
117
133
  // Update picking manager with initialized picker
118
134
  this.pickingManager.setPicker(this.picker);
135
+ // Provide a snapshot of pickable point nodes per pick. The
136
+ // sizing must mirror the live splat shader so click hit-testing
137
+ // matches what the user actually sees on screen.
138
+ this.pickingManager.setPointPickProvider(() => {
139
+ const pcr = this.pointCloudRenderer;
140
+ if (!pcr || !pcr.hasAssets())
141
+ return null;
142
+ const opts = pcr.getOptions();
143
+ const sizeMode = opts.sizeMode === 'fixed-px' ? 0 : opts.sizeMode === 'adaptive-world' ? 1 : 2;
144
+ return {
145
+ nodes: pcr.getPickNodes(),
146
+ sizing: {
147
+ sizeMode: sizeMode,
148
+ worldRadius: opts.worldRadius,
149
+ pointSizePx: opts.pointSize,
150
+ clickTolerancePx: 2,
151
+ },
152
+ };
153
+ });
154
+ }
155
+ /**
156
+ * Replace all loaded point clouds with `assets`.
157
+ *
158
+ * Phase 0 entry point — single-chunk inline assets from IFCx
159
+ * (`pcd::base64`, `points::array`, `points::base64`). Future phases
160
+ * accept streaming sources via a different overload.
161
+ */
162
+ setPointClouds(assets) {
163
+ if (!this.pointCloudRenderer) {
164
+ throw new Error('Renderer not initialized. Call init() first.');
165
+ }
166
+ this.pointCloudRenderer.setAssets(assets);
167
+ // Replace, not append — bounds may have shrunk (e.g. an IFCx
168
+ // reload with a smaller scan). `expandModelBoundsForPointClouds`
169
+ // alone only grows; recompute from scratch to keep
170
+ // fit-to-view + section-plane sliders accurate.
171
+ this.recomputeModelBounds();
172
+ this.camera.setSceneBounds(this.modelBounds);
173
+ this.requestRender();
174
+ }
175
+ /** Append additional point clouds without clearing existing ones. */
176
+ addPointClouds(assets) {
177
+ if (!this.pointCloudRenderer) {
178
+ throw new Error('Renderer not initialized. Call init() first.');
179
+ }
180
+ for (const asset of assets) {
181
+ this.pointCloudRenderer.addAsset(asset);
182
+ }
183
+ this.expandModelBoundsForPointClouds();
184
+ this.camera.setSceneBounds(this.modelBounds);
185
+ this.requestRender();
186
+ }
187
+ /** Total number of point cloud assets currently uploaded. */
188
+ getPointCloudAssetCount() {
189
+ return this.pointCloudRenderer?.getNodeCount() ?? 0;
190
+ }
191
+ /** Total number of points across all point cloud assets. */
192
+ getPointCloudPointCount() {
193
+ return this.pointCloudRenderer?.getPointCount() ?? 0;
194
+ }
195
+ /** Drop all point cloud GPU resources. */
196
+ clearPointClouds() {
197
+ this.pointCloudRenderer?.clear();
198
+ this.recomputeModelBounds();
199
+ this.camera.setSceneBounds(this.modelBounds);
200
+ this.requestRender();
201
+ }
202
+ /**
203
+ * Streaming entry: open an empty asset that will receive chunks via
204
+ * `appendPointCloudChunk`. Call `endPointCloudStream` when no more
205
+ * chunks will arrive (currently a no-op but kept for symmetry).
206
+ */
207
+ beginPointCloudStream(meta) {
208
+ if (!this.pointCloudRenderer) {
209
+ throw new Error('Renderer not initialized. Call init() first.');
210
+ }
211
+ return this.pointCloudRenderer.beginAsset(meta);
212
+ }
213
+ appendPointCloudChunk(handle, chunk) {
214
+ if (!this.pointCloudRenderer)
215
+ return;
216
+ this.pointCloudRenderer.appendChunk(handle, chunk);
217
+ this.expandModelBoundsForPointClouds();
218
+ this.camera.setSceneBounds(this.modelBounds);
219
+ this.requestRender();
220
+ }
221
+ endPointCloudStream(handle) {
222
+ this.pointCloudRenderer?.endAsset(handle);
223
+ this.requestRender();
224
+ }
225
+ removePointCloudAsset(handle) {
226
+ this.pointCloudRenderer?.removeAsset(handle);
227
+ // Bounds may have shrunk — recompute from scratch so fit-to-view
228
+ // and section-plane sliders see fresh extents.
229
+ this.recomputeModelBounds();
230
+ this.camera.setSceneBounds(this.modelBounds);
231
+ this.requestRender();
232
+ }
233
+ /**
234
+ * Reassign a streamed point-cloud's expressId after upload. Use
235
+ * this when the federation registry assigns a new model offset and
236
+ * the renderer needs to emit the post-offset globalId in picking
237
+ * outputs. The change takes effect on the next render — no GPU
238
+ * buffer rewrite needed.
239
+ */
240
+ relabelPointCloudAsset(handle, newExpressId) {
241
+ this.pointCloudRenderer?.relabelAsset(handle, newExpressId);
242
+ this.requestRender();
243
+ }
244
+ /**
245
+ * Compute model bounds from triangle meshes + remaining point clouds.
246
+ * Called from removeAsset / clear paths so bounds shrink correctly.
247
+ * Triangle meshes still drive the bounds when present (existing
248
+ * Scene-driven path), so this only re-folds in the point cloud
249
+ * extents over whatever the mesh path left.
250
+ */
251
+ recomputeModelBounds() {
252
+ // Always recompute from scratch: take mesh bounds as the
253
+ // baseline, then fold in the CURRENT point-cloud bounds on
254
+ // top. Folding only-up via expandModelBoundsForPointClouds()
255
+ // is correct when pc bounds grow but never shrinks them when
256
+ // an asset is removed, leaving stale oversized extents until
257
+ // every point cloud is gone.
258
+ const meshBounds = this.computeMeshBounds();
259
+ const pcBounds = this.pointCloudRenderer?.getBounds() ?? null;
260
+ if (!meshBounds && !pcBounds) {
261
+ this.modelBounds = null;
262
+ return;
263
+ }
264
+ this.modelBounds = meshBounds ?? {
265
+ min: { x: pcBounds.min[0], y: pcBounds.min[1], z: pcBounds.min[2] },
266
+ max: { x: pcBounds.max[0], y: pcBounds.max[1], z: pcBounds.max[2] },
267
+ };
268
+ if (meshBounds && pcBounds) {
269
+ this.expandModelBoundsForPointClouds();
270
+ }
271
+ }
272
+ /** Aggregate bounds across all batched + individual meshes. Returns
273
+ * null if the scene has no mesh geometry. */
274
+ computeMeshBounds() {
275
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
276
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
277
+ let any = false;
278
+ for (const batch of this.scene.getBatchedMeshes()) {
279
+ if (!batch.bounds)
280
+ continue;
281
+ any = true;
282
+ if (batch.bounds.min[0] < minX)
283
+ minX = batch.bounds.min[0];
284
+ if (batch.bounds.min[1] < minY)
285
+ minY = batch.bounds.min[1];
286
+ if (batch.bounds.min[2] < minZ)
287
+ minZ = batch.bounds.min[2];
288
+ if (batch.bounds.max[0] > maxX)
289
+ maxX = batch.bounds.max[0];
290
+ if (batch.bounds.max[1] > maxY)
291
+ maxY = batch.bounds.max[1];
292
+ if (batch.bounds.max[2] > maxZ)
293
+ maxZ = batch.bounds.max[2];
294
+ }
295
+ if (!any)
296
+ return null;
297
+ return { min: { x: minX, y: minY, z: minZ }, max: { x: maxX, y: maxY, z: maxZ } };
298
+ }
299
+ /** Apply rendering options (color mode, fixed override, point size). */
300
+ setPointCloudOptions(opts) {
301
+ this.pointCloudRenderer?.setOptions(opts);
302
+ this.requestRender();
303
+ }
304
+ /**
305
+ * Toggle Eye-Dome Lighting and tune its strength.
306
+ *
307
+ * EDL adds depth perception to point clouds (and meshes) via screen-
308
+ * space depth gradient — silhouette pixels get a soft black halo.
309
+ * Cheap: ~9 texture taps per pixel. Only runs when point clouds are
310
+ * loaded.
311
+ */
312
+ setEdlOptions(opts) {
313
+ if (opts.enabled !== undefined)
314
+ this.edlOptions.enabled = opts.enabled;
315
+ if (opts.strength !== undefined)
316
+ this.edlOptions.strength = Math.max(0, Math.min(3, opts.strength));
317
+ if (opts.radiusPx !== undefined)
318
+ this.edlOptions.radiusPx = Math.max(1, Math.min(4, opts.radiusPx));
319
+ if (opts.highQuality !== undefined)
320
+ this.edlOptions.highQuality = opts.highQuality;
321
+ this.requestRender();
322
+ }
323
+ expandModelBoundsForPointClouds() {
324
+ const pcBounds = this.pointCloudRenderer?.getBounds();
325
+ if (!pcBounds)
326
+ return;
327
+ if (!this.modelBounds) {
328
+ this.modelBounds = {
329
+ min: { x: pcBounds.min[0], y: pcBounds.min[1], z: pcBounds.min[2] },
330
+ max: { x: pcBounds.max[0], y: pcBounds.max[1], z: pcBounds.max[2] },
331
+ };
332
+ return;
333
+ }
334
+ const m = this.modelBounds;
335
+ m.min.x = Math.min(m.min.x, pcBounds.min[0]);
336
+ m.min.y = Math.min(m.min.y, pcBounds.min[1]);
337
+ m.min.z = Math.min(m.min.z, pcBounds.min[2]);
338
+ m.max.x = Math.max(m.max.x, pcBounds.max[0]);
339
+ m.max.y = Math.max(m.max.y, pcBounds.max[1]);
340
+ m.max.z = Math.max(m.max.z, pcBounds.max[2]);
119
341
  }
120
342
  /**
121
343
  * Load geometry from GeometryResult or MeshData array
@@ -553,6 +775,67 @@ export class Renderer {
553
775
  const hasHiddenFilter = options.hiddenIds && options.hiddenIds.size > 0;
554
776
  const hasIsolatedFilter = options.isolatedIds !== null && options.isolatedIds !== undefined;
555
777
  const hasVisibilityFiltering = hasHiddenFilter || hasIsolatedFilter;
778
+ // Build the selected-id set once per frame so the X-Ray override paths
779
+ // can keep highlighted entities at full alpha without per-site checks.
780
+ const selectedId = options.selectedId;
781
+ const selectedIds = options.selectedIds;
782
+ const selectedModelIndex = options.selectedModelIndex;
783
+ const selectedExpressIds = new Set();
784
+ if (selectedId !== undefined && selectedId !== null) {
785
+ selectedExpressIds.add(selectedId);
786
+ }
787
+ if (selectedIds) {
788
+ for (const id of selectedIds) {
789
+ selectedExpressIds.add(id);
790
+ }
791
+ }
792
+ const hasSelected = selectedExpressIds.size > 0;
793
+ // Per-frame alpha overrides for X-Ray mode. See RenderOptions.transparencyOverrides.
794
+ // Snapshot the caller's map so mid-frame mutation can't desync classification
795
+ // and uniform-write decisions for the same batch/mesh.
796
+ const txOverridesSrc = options.transparencyOverrides;
797
+ const hasTxOverrides = txOverridesSrc != null && txOverridesSrc.size > 0;
798
+ const txOverrides = hasTxOverrides ? new Map(txOverridesSrc) : null;
799
+ const alphaForMesh = (expressId, fallback) => {
800
+ if (!hasTxOverrides)
801
+ return fallback;
802
+ // Selected meshes are exempt — the highlight pass renders them last,
803
+ // but exempting here also keeps mesh classification + uniform writes
804
+ // consistent so a selected mesh never enters the transparent pipeline
805
+ // because of its own override entry.
806
+ if (hasSelected && selectedExpressIds.has(expressId))
807
+ return fallback;
808
+ const a = txOverrides.get(expressId);
809
+ return a !== undefined ? a : fallback;
810
+ };
811
+ // Cache resolved batch alpha for the frame: classification needs it
812
+ // (opaque vs transparent routing) and renderBatch needs it for the
813
+ // uniform write. Without the cache we'd walk batch.expressIds twice
814
+ // per batch per frame, which becomes the dominant JS cost in X-Ray.
815
+ const batchAlphaCache = hasTxOverrides
816
+ ? new WeakMap()
817
+ : null;
818
+ const alphaForBatch = (batch, fallback) => {
819
+ if (!hasTxOverrides)
820
+ return fallback;
821
+ const cached = batchAlphaCache.get(batch);
822
+ if (cached !== undefined)
823
+ return cached;
824
+ let minAlpha = Infinity;
825
+ for (const eid of batch.expressIds) {
826
+ // Selected ids never drag down a batch's alpha — the highlight
827
+ // pass redraws them on top, but excluding here also means a
828
+ // batch made entirely of selected entities stays opaque.
829
+ if (hasSelected && selectedExpressIds.has(eid))
830
+ continue;
831
+ const a = txOverrides.get(eid);
832
+ if (a !== undefined && a < minAlpha)
833
+ minAlpha = a;
834
+ }
835
+ const resolved = minAlpha === Infinity ? fallback : minAlpha;
836
+ batchAlphaCache.set(batch, resolved);
837
+ return resolved;
838
+ };
556
839
  // PERFORMANCE FIX: Use batch-level visibility filtering instead of creating individual meshes
557
840
  // Only create individual meshes for selected elements (for highlighting)
558
841
  // Batches are filtered at render time - fully visible batches render normally,
@@ -602,7 +885,7 @@ export class Renderer {
602
885
  const opaqueMeshes = [];
603
886
  const transparentMeshes = [];
604
887
  for (const mesh of meshes) {
605
- const alpha = mesh.color[3];
888
+ const alpha = alphaForMesh(mesh.expressId, mesh.color[3]);
606
889
  const transparency = mesh.material?.transparency ?? 0.0;
607
890
  const isTransparent = alpha < 0.99 || transparency > 0.01;
608
891
  if (isTransparent) {
@@ -621,9 +904,6 @@ export class Renderer {
621
904
  // Write uniform data to each mesh's buffer BEFORE recording commands
622
905
  // This ensures each mesh has its own color data
623
906
  const allMeshes = [...opaqueMeshes, ...transparentMeshes];
624
- const selectedId = options.selectedId;
625
- const selectedIds = options.selectedIds;
626
- const selectedModelIndex = options.selectedModelIndex;
627
907
  // Calculate section plane parameters and model bounds
628
908
  // Always calculate bounds when sectionPlane is provided (for preview and active mode)
629
909
  let sectionPlaneData;
@@ -658,6 +938,21 @@ export class Renderer {
658
938
  boundsMax.z = Math.max(boundsMax.z, batch.bounds.max[2]);
659
939
  }
660
940
  }
941
+ // Fold in point-cloud bounds too — without this, a
942
+ // pure point-cloud scene falls through to the default
943
+ // [-100,100], and a mixed scene clips against a
944
+ // smaller mesh-only range while the point pipeline
945
+ // (which honours the same sectionPlaneData) keeps
946
+ // drawing points outside the slider's reach.
947
+ const pcBoundsForSection = this.pointCloudRenderer?.getBounds();
948
+ if (pcBoundsForSection) {
949
+ boundsMin.x = Math.min(boundsMin.x, pcBoundsForSection.min[0]);
950
+ boundsMin.y = Math.min(boundsMin.y, pcBoundsForSection.min[1]);
951
+ boundsMin.z = Math.min(boundsMin.z, pcBoundsForSection.min[2]);
952
+ boundsMax.x = Math.max(boundsMax.x, pcBoundsForSection.max[0]);
953
+ boundsMax.y = Math.max(boundsMax.y, pcBoundsForSection.max[1]);
954
+ boundsMax.z = Math.max(boundsMax.z, pcBoundsForSection.max[2]);
955
+ }
661
956
  // If no batched meshes have bounds yet (streaming, degenerate
662
957
  // models), fall back to individual meshes so at least the
663
958
  // slider has a workable range.
@@ -790,7 +1085,8 @@ export class Renderer {
790
1085
  meshBuf[32] = mesh.color[0];
791
1086
  meshBuf[33] = mesh.color[1];
792
1087
  meshBuf[34] = mesh.color[2];
793
- meshBuf[35] = mesh.color[3];
1088
+ // Selected meshes always keep their own alpha so highlights stay opaque
1089
+ meshBuf[35] = isSelected ? mesh.color[3] : alphaForMesh(mesh.expressId, mesh.color[3]);
794
1090
  meshBuf[36] = mesh.material?.metallic ?? 0.0;
795
1091
  meshBuf[37] = mesh.material?.roughness ?? 0.6;
796
1092
  meshBuf[38] = 0;
@@ -928,7 +1224,7 @@ export class Renderer {
928
1224
  continue; // Don't add batch to render list
929
1225
  }
930
1226
  }
931
- const alpha = batch.color[3];
1227
+ const alpha = alphaForBatch(batch, batch.color[3]);
932
1228
  if (alpha < 0.99) {
933
1229
  transparentBatches.push(batch);
934
1230
  }
@@ -936,15 +1232,6 @@ export class Renderer {
936
1232
  opaqueBatches.push(batch);
937
1233
  }
938
1234
  }
939
- const selectedExpressIds = new Set();
940
- if (selectedId !== undefined && selectedId !== null) {
941
- selectedExpressIds.add(selectedId);
942
- }
943
- if (selectedIds) {
944
- for (const id of selectedIds) {
945
- selectedExpressIds.add(id);
946
- }
947
- }
948
1235
  // Build a uniform template ONCE per frame — shared across all batches.
949
1236
  // Only the 4-float color (offset 32) differs per batch; everything else
950
1237
  // (viewProj, identity model, material, section plane, flags) is identical.
@@ -1006,7 +1293,7 @@ export class Renderer {
1006
1293
  tpl[32] = batch.color[0];
1007
1294
  tpl[33] = batch.color[1];
1008
1295
  tpl[34] = batch.color[2];
1009
- tpl[35] = batch.color[3];
1296
+ tpl[35] = alphaForBatch(batch, batch.color[3]);
1010
1297
  device.queue.writeBuffer(batch.uniformBuffer, 0, tpl);
1011
1298
  // Single draw call for entire batch!
1012
1299
  pass.setBindGroup(0, batch.bindGroup);
@@ -1031,8 +1318,9 @@ export class Renderer {
1031
1318
  // Get or create a cached sub-batch for this visibility state
1032
1319
  const subBatch = this.scene.getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, this.pipeline);
1033
1320
  if (subBatch) {
1034
- // Use opaque or transparent pipeline based on alpha
1035
- const isTransparent = color[3] < 0.99;
1321
+ // Use opaque or transparent pipeline based on resolved alpha
1322
+ // (not the parent batch's color[3] that ignores transparencyOverrides)
1323
+ const isTransparent = alphaForBatch(subBatch, color[3]) < 0.99;
1036
1324
  if (isTransparent) {
1037
1325
  pass.setPipeline(this.pipeline.getTransparentPipeline());
1038
1326
  }
@@ -1051,12 +1339,20 @@ export class Renderer {
1051
1339
  // Placed AFTER partial batches so depth buffer is complete for both full
1052
1340
  // and partial batches. Uses 'equal' depth compare — only paints where
1053
1341
  // original geometry wrote depth, so hidden entities never leak through.
1342
+ //
1343
+ // flags.x bit 1 = overlay: tells the shader to preserve baseColor.a
1344
+ // (the overlay pipeline now has src-alpha blending so low-alpha ghost
1345
+ // tints composite correctly against the opaque pass) AND skip the
1346
+ // glass-fresnel branch (which is meant for real glass materials and
1347
+ // would whiten low-alpha colour overrides at grazing angles).
1054
1348
  const overrideBatches = this.scene.getOverrideBatches();
1055
1349
  if (overrideBatches.length > 0) {
1056
1350
  pass.setPipeline(this.pipeline.getOverlayPipeline());
1351
+ tplFlags[0] = 2; // set overlay bit for the duration of these draws
1057
1352
  for (const batch of overrideBatches) {
1058
1353
  renderBatch(batch);
1059
1354
  }
1355
+ tplFlags[0] = 0; // restore for any downstream use of the template
1060
1356
  pass.setPipeline(this.pipeline.getPipeline());
1061
1357
  }
1062
1358
  // Filled, hatched 3D cut surfaces are now rendered by
@@ -1132,7 +1428,7 @@ export class Renderer {
1132
1428
  tpl[32] = mesh.color[0];
1133
1429
  tpl[33] = mesh.color[1];
1134
1430
  tpl[34] = mesh.color[2];
1135
- tpl[35] = mesh.color[3];
1431
+ tpl[35] = alphaForMesh(mesh.expressId, mesh.color[3]);
1136
1432
  tpl[36] = mesh.material?.metallic ?? 0.0;
1137
1433
  tpl[37] = mesh.material?.roughness ?? 0.6;
1138
1434
  tpl[38] = 0;
@@ -1274,6 +1570,19 @@ export class Renderer {
1274
1570
  }
1275
1571
  }
1276
1572
  }
1573
+ // Draw point clouds (IFCx inline + streamed LAS/LAZ).
1574
+ // Shares the depth buffer + section plane state with the mesh pipeline so
1575
+ // points occlude triangles and vice versa. The splat shader needs the
1576
+ // viewport size to convert pixel sizes into clip-space offsets.
1577
+ if (this.pointCloudRenderer && this.pointCloudRenderer.hasAssets()) {
1578
+ this.pointCloudRenderer.draw(pass, {
1579
+ viewProj,
1580
+ sectionPlane: sectionPlaneData
1581
+ ? { ...sectionPlaneData, flipped: options.sectionPlane?.flipped === true }
1582
+ : null,
1583
+ viewport: { width: this.canvas.width, height: this.canvas.height },
1584
+ });
1585
+ }
1277
1586
  // Draw section plane visual BEFORE pass.end() (within same MSAA render pass)
1278
1587
  // Always show plane when sectionPlane options are provided (as preview or active)
1279
1588
  const modelBounds = this.getModelBounds();
@@ -1346,6 +1655,21 @@ export class Renderer {
1346
1655
  enableSeparationLines: separationEnabled,
1347
1656
  });
1348
1657
  }
1658
+ // Eye-Dome Lighting — runs AFTER contact/separation so it darkens
1659
+ // every layer uniformly. Cheap (~9 depth taps), only active when
1660
+ // there are point clouds in the scene and the user has enabled it.
1661
+ if (this.edlPass
1662
+ && this.edlOptions.enabled
1663
+ && this.pointCloudRenderer?.hasAssets()) {
1664
+ this.edlPass.apply(encoder, {
1665
+ targetView: textureView,
1666
+ depthView: this.pipeline.getDepthOnlyTextureView(),
1667
+ }, {
1668
+ strength: this.edlOptions.strength,
1669
+ radiusPx: this.edlOptions.radiusPx,
1670
+ highQuality: this.edlOptions.highQuality,
1671
+ });
1672
+ }
1349
1673
  device.queue.submit([encoder.finish()]);
1350
1674
  }
1351
1675
  catch (error) {
@@ -1567,11 +1891,16 @@ export class Renderer {
1567
1891
  // Post-processor uniform buffer
1568
1892
  this.postProcessor?.destroy();
1569
1893
  this.postProcessor = null;
1894
+ this.edlPass?.destroy();
1895
+ this.edlPass = null;
1570
1896
  // Section-plane renderers
1571
1897
  this.sectionPlaneRenderer?.destroy();
1572
1898
  this.sectionPlaneRenderer = null;
1573
1899
  this.section2DOverlayRenderer?.dispose();
1574
1900
  this.section2DOverlayRenderer = null;
1901
+ // Point cloud GPU resources
1902
+ this.pointCloudRenderer?.clear();
1903
+ this.pointCloudRenderer = null;
1575
1904
  // Snap detector geometry cache
1576
1905
  this.raycastEngine.clearCaches();
1577
1906
  }