@ifc-lite/renderer 1.17.0 → 1.20.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 (75) hide show
  1. package/dist/deviation/deviation-pipeline.d.ts +48 -0
  2. package/dist/deviation/deviation-pipeline.d.ts.map +1 -0
  3. package/dist/deviation/deviation-pipeline.js +163 -0
  4. package/dist/deviation/deviation-pipeline.js.map +1 -0
  5. package/dist/deviation/deviation-shader.wgsl.d.ts +23 -0
  6. package/dist/deviation/deviation-shader.wgsl.d.ts.map +1 -0
  7. package/dist/deviation/deviation-shader.wgsl.js +237 -0
  8. package/dist/deviation/deviation-shader.wgsl.js.map +1 -0
  9. package/dist/deviation/triangle-bvh.d.ts +58 -0
  10. package/dist/deviation/triangle-bvh.d.ts.map +1 -0
  11. package/dist/deviation/triangle-bvh.js +255 -0
  12. package/dist/deviation/triangle-bvh.js.map +1 -0
  13. package/dist/device.d.ts +3 -0
  14. package/dist/device.d.ts.map +1 -1
  15. package/dist/device.js +10 -0
  16. package/dist/device.js.map +1 -1
  17. package/dist/edl-pass.d.ts +52 -0
  18. package/dist/edl-pass.d.ts.map +1 -0
  19. package/dist/edl-pass.js +204 -0
  20. package/dist/edl-pass.js.map +1 -0
  21. package/dist/index.d.ts +175 -4
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +676 -110
  24. package/dist/index.js.map +1 -1
  25. package/dist/picker.d.ts +40 -3
  26. package/dist/picker.d.ts.map +1 -1
  27. package/dist/picker.js +211 -59
  28. package/dist/picker.js.map +1 -1
  29. package/dist/picking-manager.d.ts +32 -1
  30. package/dist/picking-manager.d.ts.map +1 -1
  31. package/dist/picking-manager.js +53 -1
  32. package/dist/picking-manager.js.map +1 -1
  33. package/dist/point-picker.d.ts +61 -0
  34. package/dist/point-picker.d.ts.map +1 -0
  35. package/dist/point-picker.js +223 -0
  36. package/dist/point-picker.js.map +1 -0
  37. package/dist/pointcloud/point-cloud-node.d.ts +63 -0
  38. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -0
  39. package/dist/pointcloud/point-cloud-node.js +149 -0
  40. package/dist/pointcloud/point-cloud-node.js.map +1 -0
  41. package/dist/pointcloud/point-cloud-renderer.d.ts +166 -0
  42. package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -0
  43. package/dist/pointcloud/point-cloud-renderer.js +293 -0
  44. package/dist/pointcloud/point-cloud-renderer.js.map +1 -0
  45. package/dist/pointcloud/point-cloud-uniforms.d.ts +36 -0
  46. package/dist/pointcloud/point-cloud-uniforms.d.ts.map +1 -0
  47. package/dist/pointcloud/point-cloud-uniforms.js +89 -0
  48. package/dist/pointcloud/point-cloud-uniforms.js.map +1 -0
  49. package/dist/pointcloud/point-pipeline.d.ts +27 -0
  50. package/dist/pointcloud/point-pipeline.d.ts.map +1 -0
  51. package/dist/pointcloud/point-pipeline.js +126 -0
  52. package/dist/pointcloud/point-pipeline.js.map +1 -0
  53. package/dist/pointcloud/point-shader.wgsl.d.ts +22 -0
  54. package/dist/pointcloud/point-shader.wgsl.d.ts.map +1 -0
  55. package/dist/pointcloud/point-shader.wgsl.js +288 -0
  56. package/dist/pointcloud/point-shader.wgsl.js.map +1 -0
  57. package/dist/scene.d.ts +11 -0
  58. package/dist/scene.d.ts.map +1 -1
  59. package/dist/scene.js +21 -0
  60. package/dist/scene.js.map +1 -1
  61. package/dist/section-2d-overlay.d.ts +24 -5
  62. package/dist/section-2d-overlay.d.ts.map +1 -1
  63. package/dist/section-2d-overlay.js +42 -13
  64. package/dist/section-2d-overlay.js.map +1 -1
  65. package/dist/section-plane-basis.d.ts +64 -0
  66. package/dist/section-plane-basis.d.ts.map +1 -0
  67. package/dist/section-plane-basis.js +86 -0
  68. package/dist/section-plane-basis.js.map +1 -0
  69. package/dist/section-plane.d.ts +18 -0
  70. package/dist/section-plane.d.ts.map +1 -1
  71. package/dist/section-plane.js +89 -6
  72. package/dist/section-plane.js.map +1 -1
  73. package/dist/types.d.ts +35 -4
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export { Section2DOverlayRenderer } from './section-2d-overlay.js';
16
16
  // is now rendered by Section2DOverlayRenderer's fill pass; this module just
17
17
  // holds the styling primitives shared with the store and UI.
18
18
  export { DEFAULT_CAP_STYLE, HATCH_PATTERN_IDS } from './section-cap-style.js';
19
+ export { planeBasis, nearestCardinalAxis } from './section-plane-basis.js';
19
20
  export { Raycaster } from './raycaster.js';
20
21
  export { SnapDetector, SnapType } from './snap-detector.js';
21
22
  export { BVH } from './bvh.js';
@@ -26,6 +27,10 @@ export { ZeroCopyGpuUploader, createZeroCopyUploader, } from './zero-copy-upload
26
27
  // Extracted manager classes
27
28
  export { PickingManager } from './picking-manager.js';
28
29
  export { RaycastEngine } from './raycast-engine.js';
30
+ export { PointPicker, decodePickSample } from './point-picker.js';
31
+ // Point cloud rendering (Phase 0: IFCx inline; Phase 1+: streaming LAS/LAZ)
32
+ export { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
33
+ export { PointRenderPipeline } from './pointcloud/point-pipeline.js';
29
34
  import { WebGPUDevice } from './device.js';
30
35
  import { RenderPipeline, InstancedRenderPipeline } from './pipeline.js';
31
36
  import { Camera } from './camera.js';
@@ -40,8 +45,31 @@ import { DEFAULT_CAP_STYLE, HATCH_PATTERN_IDS } from './section-cap-style.js';
40
45
  import { PickingManager } from './picking-manager.js';
41
46
  import { RaycastEngine } from './raycast-engine.js';
42
47
  import { PostProcessor } from './post-processor.js';
48
+ import { EdlPass } from './edl-pass.js';
49
+ import { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
50
+ import { DeviationPipeline } from './deviation/deviation-pipeline.js';
51
+ import { buildTriangleBVH } from './deviation/triangle-bvh.js';
43
52
  const MAX_ENCODED_ENTITY_ID = 0xFFFFFF;
44
53
  let warnedEntityIdRange = false;
54
+ /**
55
+ * Build a deterministic fingerprint of the BVH input mesh set so
56
+ * `Renderer.computeDeviations` can skip the rebuild when the source
57
+ * geometry hasn't changed. Folds in expressId / modelIndex / position
58
+ * + index lengths per mesh so two distinct mesh sets that happen to
59
+ * share the same aggregate position-length total can't collide on the
60
+ * same fingerprint and reuse a stale BVH.
61
+ */
62
+ function computeBvhFingerprint(meshes) {
63
+ const parts = [String(meshes.length)];
64
+ for (const m of meshes) {
65
+ const id = m.expressId ?? -1;
66
+ const mi = m.modelIndex ?? -1;
67
+ const posLen = m.positions?.length ?? 0;
68
+ const idxLen = m.indices?.length ?? 0;
69
+ parts.push(`${id}:${mi}:${posLen}:${idxLen}`);
70
+ }
71
+ return parts.join('|');
72
+ }
45
73
  /**
46
74
  * Main renderer class
47
75
  */
@@ -56,6 +84,23 @@ export class Renderer {
56
84
  sectionPlaneRenderer = null;
57
85
  section2DOverlayRenderer = null;
58
86
  postProcessor = null;
87
+ edlPass = null;
88
+ edlOptions = {
89
+ enabled: false,
90
+ strength: 1,
91
+ radiusPx: 1,
92
+ highQuality: true,
93
+ };
94
+ pointCloudRenderer = null;
95
+ deviationPipeline = null;
96
+ /**
97
+ * Cache of which mesh-set the BVH was built from. We rebuild on
98
+ * `computeDeviations` only when the cached "fingerprint" misses,
99
+ * so re-running deviation against the same model is a fast
100
+ * dispatch — the BVH is multi-second on big BIMs and we don't
101
+ * want to pay that on every slider drag.
102
+ */
103
+ deviationBvhFingerprint = null;
59
104
  visualEnhancementState = {
60
105
  enabled: true,
61
106
  edgeContrast: { enabled: true, intensity: 1.0 },
@@ -70,6 +115,11 @@ export class Renderer {
70
115
  // Error rate limiting (log at most once per second)
71
116
  lastRenderErrorTime = 0;
72
117
  RENDER_ERROR_THROTTLE_MS = 1000;
118
+ // Diagnostic counters for mobile debugging
119
+ _renderCallCount = 0;
120
+ _renderSkipCount = 0;
121
+ _renderErrorCount = 0;
122
+ _lastRenderError = '';
73
123
  // Dirty flag: set by requestRender(), consumed by the animation loop.
74
124
  // Centralises all render scheduling — callers never call render() directly.
75
125
  _renderRequested = false;
@@ -108,14 +158,335 @@ export class Renderer {
108
158
  this.picker = new Picker(this.device, width, height);
109
159
  this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
110
160
  this.section2DOverlayRenderer = new Section2DOverlayRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
111
- this.postProcessor = new PostProcessor(this.device, {
112
- enableContactShading: true,
113
- contactRadius: 1.0,
114
- contactIntensity: 0.3,
115
- }, this.pipeline.getSampleCount());
161
+ // PostProcessor is optional — if it fails (e.g. mobile GPU lacking
162
+ // depth TEXTURE_BINDING), rendering still works without post-processing.
163
+ try {
164
+ this.postProcessor = new PostProcessor(this.device, {
165
+ enableContactShading: true,
166
+ contactRadius: 1.0,
167
+ contactIntensity: 0.3,
168
+ }, this.pipeline.getSampleCount());
169
+ }
170
+ catch (e) {
171
+ console.warn('[Renderer] PostProcessor init failed (post-processing disabled):', e);
172
+ this.postProcessor = null;
173
+ }
174
+ this.pointCloudRenderer = new PointCloudRenderer(this.device.getDevice(), this.device.getFormat(), 'depth24plus-stencil8', this.pipeline.getSampleCount());
175
+ // Compute pipeline for the BIM↔scan deviation heatmap. Lazily
176
+ // owns the per-triangle BVH GPU buffers; idle until the first
177
+ // `computeDeviations` call.
178
+ this.deviationPipeline = new DeviationPipeline(this.device.getDevice());
179
+ this.edlPass = new EdlPass(this.device, this.pipeline.getSampleCount());
116
180
  this.camera.setAspect(width / height);
117
181
  // Update picking manager with initialized picker
118
182
  this.pickingManager.setPicker(this.picker);
183
+ // Provide a snapshot of pickable point nodes per pick. The
184
+ // sizing must mirror the live splat shader so click hit-testing
185
+ // matches what the user actually sees on screen.
186
+ this.pickingManager.setPointPickProvider(() => {
187
+ const pcr = this.pointCloudRenderer;
188
+ if (!pcr || !pcr.hasAssets())
189
+ return null;
190
+ const opts = pcr.getOptions();
191
+ const sizeMode = opts.sizeMode === 'fixed-px' ? 0 : opts.sizeMode === 'adaptive-world' ? 1 : 2;
192
+ return {
193
+ nodes: pcr.getPickNodes(),
194
+ sizing: {
195
+ sizeMode: sizeMode,
196
+ worldRadius: opts.worldRadius,
197
+ pointSizePx: opts.pointSize,
198
+ clickTolerancePx: 2,
199
+ },
200
+ };
201
+ });
202
+ }
203
+ /**
204
+ * Replace all loaded point clouds with `assets`.
205
+ *
206
+ * Phase 0 entry point — single-chunk inline assets from IFCx
207
+ * (`pcd::base64`, `points::array`, `points::base64`). Future phases
208
+ * accept streaming sources via a different overload.
209
+ */
210
+ setPointClouds(assets) {
211
+ if (!this.pointCloudRenderer) {
212
+ throw new Error('Renderer not initialized. Call init() first.');
213
+ }
214
+ this.pointCloudRenderer.setAssets(assets);
215
+ // Replace, not append — bounds may have shrunk (e.g. an IFCx
216
+ // reload with a smaller scan). `expandModelBoundsForPointClouds`
217
+ // alone only grows; recompute from scratch to keep
218
+ // fit-to-view + section-plane sliders accurate.
219
+ this.recomputeModelBounds();
220
+ this.camera.setSceneBounds(this.modelBounds);
221
+ this.requestRender();
222
+ }
223
+ /** Append additional point clouds without clearing existing ones. */
224
+ addPointClouds(assets) {
225
+ if (!this.pointCloudRenderer) {
226
+ throw new Error('Renderer not initialized. Call init() first.');
227
+ }
228
+ for (const asset of assets) {
229
+ this.pointCloudRenderer.addAsset(asset);
230
+ }
231
+ this.expandModelBoundsForPointClouds();
232
+ this.camera.setSceneBounds(this.modelBounds);
233
+ this.requestRender();
234
+ }
235
+ /** Total number of point cloud assets currently uploaded. */
236
+ getPointCloudAssetCount() {
237
+ return this.pointCloudRenderer?.getNodeCount() ?? 0;
238
+ }
239
+ /** Total number of points across all point cloud assets. */
240
+ getPointCloudPointCount() {
241
+ return this.pointCloudRenderer?.getPointCount() ?? 0;
242
+ }
243
+ /** Drop all point cloud GPU resources. */
244
+ clearPointClouds() {
245
+ this.pointCloudRenderer?.clear();
246
+ this.recomputeModelBounds();
247
+ this.camera.setSceneBounds(this.modelBounds);
248
+ this.requestRender();
249
+ }
250
+ /**
251
+ * Streaming entry: open an empty asset that will receive chunks via
252
+ * `appendPointCloudChunk`. Call `endPointCloudStream` when no more
253
+ * chunks will arrive (currently a no-op but kept for symmetry).
254
+ */
255
+ beginPointCloudStream(meta) {
256
+ if (!this.pointCloudRenderer) {
257
+ throw new Error('Renderer not initialized. Call init() first.');
258
+ }
259
+ return this.pointCloudRenderer.beginAsset(meta);
260
+ }
261
+ appendPointCloudChunk(handle, chunk) {
262
+ if (!this.pointCloudRenderer)
263
+ return;
264
+ this.pointCloudRenderer.appendChunk(handle, chunk);
265
+ this.expandModelBoundsForPointClouds();
266
+ this.camera.setSceneBounds(this.modelBounds);
267
+ this.requestRender();
268
+ }
269
+ endPointCloudStream(handle) {
270
+ this.pointCloudRenderer?.endAsset(handle);
271
+ this.requestRender();
272
+ }
273
+ removePointCloudAsset(handle) {
274
+ this.pointCloudRenderer?.removeAsset(handle);
275
+ // Bounds may have shrunk — recompute from scratch so fit-to-view
276
+ // and section-plane sliders see fresh extents.
277
+ this.recomputeModelBounds();
278
+ this.camera.setSceneBounds(this.modelBounds);
279
+ this.requestRender();
280
+ }
281
+ /**
282
+ * Reassign a streamed point-cloud's expressId after upload. Use
283
+ * this when the federation registry assigns a new model offset and
284
+ * the renderer needs to emit the post-offset globalId in picking
285
+ * outputs. The change takes effect on the next render — no GPU
286
+ * buffer rewrite needed.
287
+ */
288
+ relabelPointCloudAsset(handle, newExpressId) {
289
+ this.pointCloudRenderer?.relabelAsset(handle, newExpressId);
290
+ this.requestRender();
291
+ }
292
+ /**
293
+ * Compute model bounds from triangle meshes + remaining point clouds.
294
+ * Called from removeAsset / clear paths so bounds shrink correctly.
295
+ * Triangle meshes still drive the bounds when present (existing
296
+ * Scene-driven path), so this only re-folds in the point cloud
297
+ * extents over whatever the mesh path left.
298
+ */
299
+ recomputeModelBounds() {
300
+ // Always recompute from scratch: take mesh bounds as the
301
+ // baseline, then fold in the CURRENT point-cloud bounds on
302
+ // top. Folding only-up via expandModelBoundsForPointClouds()
303
+ // is correct when pc bounds grow but never shrinks them when
304
+ // an asset is removed, leaving stale oversized extents until
305
+ // every point cloud is gone.
306
+ const meshBounds = this.computeMeshBounds();
307
+ const pcBounds = this.pointCloudRenderer?.getBounds() ?? null;
308
+ if (!meshBounds && !pcBounds) {
309
+ this.modelBounds = null;
310
+ return;
311
+ }
312
+ this.modelBounds = meshBounds ?? {
313
+ min: { x: pcBounds.min[0], y: pcBounds.min[1], z: pcBounds.min[2] },
314
+ max: { x: pcBounds.max[0], y: pcBounds.max[1], z: pcBounds.max[2] },
315
+ };
316
+ if (meshBounds && pcBounds) {
317
+ this.expandModelBoundsForPointClouds();
318
+ }
319
+ }
320
+ /** Aggregate bounds across all batched + individual meshes. Returns
321
+ * null if the scene has no mesh geometry. */
322
+ computeMeshBounds() {
323
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
324
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
325
+ let any = false;
326
+ for (const batch of this.scene.getBatchedMeshes()) {
327
+ if (!batch.bounds)
328
+ continue;
329
+ any = true;
330
+ if (batch.bounds.min[0] < minX)
331
+ minX = batch.bounds.min[0];
332
+ if (batch.bounds.min[1] < minY)
333
+ minY = batch.bounds.min[1];
334
+ if (batch.bounds.min[2] < minZ)
335
+ minZ = batch.bounds.min[2];
336
+ if (batch.bounds.max[0] > maxX)
337
+ maxX = batch.bounds.max[0];
338
+ if (batch.bounds.max[1] > maxY)
339
+ maxY = batch.bounds.max[1];
340
+ if (batch.bounds.max[2] > maxZ)
341
+ maxZ = batch.bounds.max[2];
342
+ }
343
+ if (!any)
344
+ return null;
345
+ return { min: { x: minX, y: minY, z: minZ }, max: { x: maxX, y: maxY, z: maxZ } };
346
+ }
347
+ /** Apply rendering options (color mode, fixed override, point size). */
348
+ setPointCloudOptions(opts) {
349
+ this.pointCloudRenderer?.setOptions(opts);
350
+ this.requestRender();
351
+ }
352
+ /**
353
+ * Compute BIM ↔ scan deviation for every loaded point cloud asset.
354
+ *
355
+ * Walks every triangle in the scene (individual + batched meshes,
356
+ * regardless of which IFC ingest path produced them — STEP, IFCx,
357
+ * GLB, or federated combinations), builds a per-triangle BVH on
358
+ * the GPU, then runs a closest-point compute pass per chunk that
359
+ * writes signed distance into each chunk's deviation buffer.
360
+ *
361
+ * Returns metadata so the UI can populate a histogram + auto-range:
362
+ * the per-asset point count, the suggested ±range from the 95th
363
+ * percentile, and the bbox the BVH was built from.
364
+ *
365
+ * Idempotent: re-running with the same mesh set reuses the GPU
366
+ * BVH (the BVH build dominates wall time on big BIMs). Pass
367
+ * `forceRebuild: true` to invalidate.
368
+ */
369
+ async computeDeviations(opts = {}) {
370
+ if (!this.deviationPipeline || !this.pointCloudRenderer) {
371
+ throw new Error('Renderer not initialised — call init() first.');
372
+ }
373
+ const meshes = this.collectAllSceneMeshes();
374
+ // Fingerprint folds in per-mesh expressId / modelIndex /
375
+ // positions length / triangle count, so two distinct meshes
376
+ // that happen to share an aggregate position-length total
377
+ // can't alias each other. A federation reload that swaps one
378
+ // model for another with the same total triangle count would
379
+ // otherwise reuse the previous BVH and report wrong distances.
380
+ const fingerprint = computeBvhFingerprint(meshes);
381
+ if (opts.forceRebuild || fingerprint !== this.deviationBvhFingerprint) {
382
+ const bvh = buildTriangleBVH(meshes);
383
+ this.deviationPipeline.uploadBvh(bvh);
384
+ this.deviationBvhFingerprint = fingerprint;
385
+ }
386
+ const stats = this.deviationPipeline.getBvhStats();
387
+ const maxRange = opts.maxRange ?? 1.0;
388
+ // Encode every chunk into a single command submit so the GPU
389
+ // can pipeline the dispatches without a CPU round-trip per
390
+ // chunk. Histogram readback is a follow-up — for v1 we emit
391
+ // the deviation buffers and let the splat shader visualise.
392
+ const encoder = this.device.getDevice().createCommandEncoder({ label: 'pointcloud-deviation' });
393
+ let chunksProcessed = 0;
394
+ let pointsProcessed = 0;
395
+ const nodes = this.pointCloudRenderer.getInternalNodes();
396
+ for (const node of nodes) {
397
+ for (const chunk of node.chunks) {
398
+ const ok = this.deviationPipeline.dispatch(encoder, {
399
+ positionsBuffer: chunk.vertexBuffer,
400
+ deviationsBuffer: chunk.deviationBuffer,
401
+ pointCount: chunk.pointCount,
402
+ maxRange,
403
+ });
404
+ if (ok) {
405
+ chunksProcessed++;
406
+ pointsProcessed += chunk.pointCount;
407
+ }
408
+ }
409
+ }
410
+ this.device.getDevice().queue.submit([encoder.finish()]);
411
+ // Wait until the GPU finishes the dispatches before resolving.
412
+ // Otherwise the caller's "compute done" callback fires before
413
+ // the deviation buffers are actually populated.
414
+ await this.device.getDevice().queue.onSubmittedWorkDone();
415
+ this.requestRender();
416
+ // Suggest a default half-range = max(0.01m, max-extent / 1000).
417
+ // Tighter than the maxRange clip; gives the user a reasonable
418
+ // starting slider position without a histogram readback.
419
+ const bb = stats.bounds;
420
+ const suggestedHalfRange = bb
421
+ ? Math.max(0.01, Math.max(bb.max[0] - bb.min[0], bb.max[1] - bb.min[1], bb.max[2] - bb.min[2]) / 1000)
422
+ : 0.05;
423
+ return {
424
+ bvhTriangles: stats.triangleCount,
425
+ bvhNodes: stats.nodeCount,
426
+ chunksProcessed,
427
+ pointsProcessed,
428
+ bounds: stats.bounds,
429
+ suggestedHalfRange,
430
+ };
431
+ }
432
+ /**
433
+ * Aggregate every triangle source the scene exposes — individual
434
+ * meshes (created on demand by picking / highlights) AND batched
435
+ * meshes (the streaming geometry path's compact GPU buffers).
436
+ * Both formats arrive as `MeshData`; the BVH builder doesn't care
437
+ * which source they came from.
438
+ */
439
+ collectAllSceneMeshes() {
440
+ // The Scene keeps every CPU-side MeshData regardless of which
441
+ // ingest path produced it (STEP / IFCx / GLB). One iteration
442
+ // covers individual + batched + multi-piece + multi-model.
443
+ // `forEachMeshData` deduplicates by identity so a colour-merged
444
+ // batch is only added once even if it's indexed under multiple
445
+ // contributor expressIds.
446
+ const out = [];
447
+ this.scene.forEachMeshData((md) => {
448
+ if (md.positions && md.positions.length > 0)
449
+ out.push(md);
450
+ });
451
+ return out;
452
+ }
453
+ /**
454
+ * Toggle Eye-Dome Lighting and tune its strength.
455
+ *
456
+ * EDL adds depth perception to point clouds (and meshes) via screen-
457
+ * space depth gradient — silhouette pixels get a soft black halo.
458
+ * Cheap: ~9 texture taps per pixel. Only runs when point clouds are
459
+ * loaded.
460
+ */
461
+ setEdlOptions(opts) {
462
+ if (opts.enabled !== undefined)
463
+ this.edlOptions.enabled = opts.enabled;
464
+ if (opts.strength !== undefined)
465
+ this.edlOptions.strength = Math.max(0, Math.min(3, opts.strength));
466
+ if (opts.radiusPx !== undefined)
467
+ this.edlOptions.radiusPx = Math.max(1, Math.min(4, opts.radiusPx));
468
+ if (opts.highQuality !== undefined)
469
+ this.edlOptions.highQuality = opts.highQuality;
470
+ this.requestRender();
471
+ }
472
+ expandModelBoundsForPointClouds() {
473
+ const pcBounds = this.pointCloudRenderer?.getBounds();
474
+ if (!pcBounds)
475
+ return;
476
+ if (!this.modelBounds) {
477
+ this.modelBounds = {
478
+ min: { x: pcBounds.min[0], y: pcBounds.min[1], z: pcBounds.min[2] },
479
+ max: { x: pcBounds.max[0], y: pcBounds.max[1], z: pcBounds.max[2] },
480
+ };
481
+ return;
482
+ }
483
+ const m = this.modelBounds;
484
+ m.min.x = Math.min(m.min.x, pcBounds.min[0]);
485
+ m.min.y = Math.min(m.min.y, pcBounds.min[1]);
486
+ m.min.z = Math.min(m.min.z, pcBounds.min[2]);
487
+ m.max.x = Math.max(m.max.x, pcBounds.max[0]);
488
+ m.max.y = Math.max(m.max.y, pcBounds.max[1]);
489
+ m.max.z = Math.max(m.max.z, pcBounds.max[2]);
119
490
  }
120
491
  /**
121
492
  * Load geometry from GeometryResult or MeshData array
@@ -500,9 +871,32 @@ export class Renderer {
500
871
  /**
501
872
  * Render frame
502
873
  */
874
+ /** Get diagnostic info for mobile debugging */
875
+ getDiagnostics() {
876
+ const pos = this.camera.getPosition();
877
+ const tgt = this.camera.getTarget();
878
+ const b = this.modelBounds;
879
+ return {
880
+ calls: this._renderCallCount,
881
+ skips: this._renderSkipCount,
882
+ errors: this._renderErrorCount,
883
+ lastError: this._lastRenderError,
884
+ batches: this.scene.getBatchedMeshes().length,
885
+ meshes: this.scene.getMeshes().length,
886
+ contextOk: this.device.isInitialized(),
887
+ gpuErrors: this.device._uncapturedErrorCount,
888
+ lastGpuError: this.device._lastUncapturedError ?? '',
889
+ camPos: `${pos.x.toFixed(1)},${pos.y.toFixed(1)},${pos.z.toFixed(1)}`,
890
+ camTgt: `${tgt.x.toFixed(1)},${tgt.y.toFixed(1)},${tgt.z.toFixed(1)}`,
891
+ bounds: b ? `${b.min.x.toFixed(0)}..${b.max.x.toFixed(0)} ${b.min.y.toFixed(0)}..${b.max.y.toFixed(0)} ${b.min.z.toFixed(0)}..${b.max.z.toFixed(0)}` : 'none',
892
+ };
893
+ }
503
894
  render(options = {}) {
504
- if (!this.device.isInitialized() || !this.pipeline)
895
+ this._renderCallCount++;
896
+ if (!this.device.isInitialized() || !this.pipeline) {
897
+ this._renderSkipCount++;
505
898
  return;
899
+ }
506
900
  // Validate canvas dimensions
507
901
  // Align width to 64 pixels for WebGPU texture row alignment (256 bytes / 4 bytes per pixel)
508
902
  const rect = this.canvas.getBoundingClientRect();
@@ -510,8 +904,10 @@ export class Renderer {
510
904
  const width = Math.max(64, Math.floor(rawWidth / 64) * 64);
511
905
  const height = Math.max(1, Math.floor(rect.height));
512
906
  // Skip rendering if canvas is too small
513
- if (width < 64 || height < 10)
907
+ if (width < 64 || height < 10) {
908
+ this._renderSkipCount++;
514
909
  return;
910
+ }
515
911
  // Update canvas pixel dimensions if needed
516
912
  const dimensionsChanged = this.canvas.width !== width || this.canvas.height !== height;
517
913
  if (dimensionsChanged) {
@@ -527,10 +923,13 @@ export class Renderer {
527
923
  }
528
924
  }
529
925
  // Skip rendering if canvas is invalid
530
- if (this.canvas.width === 0 || this.canvas.height === 0)
926
+ if (this.canvas.width === 0 || this.canvas.height === 0) {
927
+ this._renderSkipCount++;
531
928
  return;
929
+ }
532
930
  // Ensure context is valid before rendering (handles HMR, focus changes, etc.)
533
931
  if (!this.device.ensureContext()) {
932
+ this._renderSkipCount++;
534
933
  return; // Skip this frame, context will be ready next frame
535
934
  }
536
935
  const device = this.device.getDevice();
@@ -553,29 +952,66 @@ export class Renderer {
553
952
  const hasHiddenFilter = options.hiddenIds && options.hiddenIds.size > 0;
554
953
  const hasIsolatedFilter = options.isolatedIds !== null && options.isolatedIds !== undefined;
555
954
  const hasVisibilityFiltering = hasHiddenFilter || hasIsolatedFilter;
955
+ // Build the selected-id set once per frame so the X-Ray override paths
956
+ // can keep highlighted entities at full alpha without per-site checks.
957
+ const selectedId = options.selectedId;
958
+ const selectedIds = options.selectedIds;
959
+ const selectedModelIndex = options.selectedModelIndex;
960
+ const selectedExpressIds = new Set();
961
+ if (selectedId !== undefined && selectedId !== null) {
962
+ selectedExpressIds.add(selectedId);
963
+ }
964
+ if (selectedIds) {
965
+ for (const id of selectedIds) {
966
+ selectedExpressIds.add(id);
967
+ }
968
+ }
969
+ const hasSelected = selectedExpressIds.size > 0;
556
970
  // Per-frame alpha overrides for X-Ray mode. See RenderOptions.transparencyOverrides.
557
- // Callers supply a fresh Map when contents change (same convention as hiddenIds/isolatedIds).
558
- const txOverrides = options.transparencyOverrides;
559
- const hasTxOverrides = txOverrides != null && txOverrides.size > 0;
971
+ // Snapshot the caller's map so mid-frame mutation can't desync classification
972
+ // and uniform-write decisions for the same batch/mesh.
973
+ const txOverridesSrc = options.transparencyOverrides;
974
+ const hasTxOverrides = txOverridesSrc != null && txOverridesSrc.size > 0;
975
+ const txOverrides = hasTxOverrides ? new Map(txOverridesSrc) : null;
560
976
  const alphaForMesh = (expressId, fallback) => {
561
977
  if (!hasTxOverrides)
562
978
  return fallback;
979
+ // Selected meshes are exempt — the highlight pass renders them last,
980
+ // but exempting here also keeps mesh classification + uniform writes
981
+ // consistent so a selected mesh never enters the transparent pipeline
982
+ // because of its own override entry.
983
+ if (hasSelected && selectedExpressIds.has(expressId))
984
+ return fallback;
563
985
  const a = txOverrides.get(expressId);
564
986
  return a !== undefined ? a : fallback;
565
987
  };
566
- // alphaForBatch walks batch.expressIds per frame. For typical batch sizes
567
- // the cost is well below noise vs. the GPU work, and avoiding a cache
568
- // removes the stale-on-mutation bug class entirely.
988
+ // Cache resolved batch alpha for the frame: classification needs it
989
+ // (opaque vs transparent routing) and renderBatch needs it for the
990
+ // uniform write. Without the cache we'd walk batch.expressIds twice
991
+ // per batch per frame, which becomes the dominant JS cost in X-Ray.
992
+ const batchAlphaCache = hasTxOverrides
993
+ ? new WeakMap()
994
+ : null;
569
995
  const alphaForBatch = (batch, fallback) => {
570
996
  if (!hasTxOverrides)
571
997
  return fallback;
998
+ const cached = batchAlphaCache.get(batch);
999
+ if (cached !== undefined)
1000
+ return cached;
572
1001
  let minAlpha = Infinity;
573
1002
  for (const eid of batch.expressIds) {
1003
+ // Selected ids never drag down a batch's alpha — the highlight
1004
+ // pass redraws them on top, but excluding here also means a
1005
+ // batch made entirely of selected entities stays opaque.
1006
+ if (hasSelected && selectedExpressIds.has(eid))
1007
+ continue;
574
1008
  const a = txOverrides.get(eid);
575
1009
  if (a !== undefined && a < minAlpha)
576
1010
  minAlpha = a;
577
1011
  }
578
- return minAlpha === Infinity ? fallback : minAlpha;
1012
+ const resolved = minAlpha === Infinity ? fallback : minAlpha;
1013
+ batchAlphaCache.set(batch, resolved);
1014
+ return resolved;
579
1015
  };
580
1016
  // PERFORMANCE FIX: Use batch-level visibility filtering instead of creating individual meshes
581
1017
  // Only create individual meshes for selected elements (for highlighting)
@@ -609,6 +1045,12 @@ export class Renderer {
609
1045
  if (this.instancedPipeline?.needsResize(this.canvas.width, this.canvas.height)) {
610
1046
  this.instancedPipeline.resize(this.canvas.width, this.canvas.height);
611
1047
  }
1048
+ // Push a validation error scope to capture the EXACT error (for mobile debugging)
1049
+ // Only do this for the first few renders to avoid performance overhead
1050
+ const captureGpuError = this._renderCallCount <= 5;
1051
+ if (captureGpuError) {
1052
+ device.pushErrorScope('validation');
1053
+ }
612
1054
  // Get current texture safely - may return null if context needs reconfiguration
613
1055
  const currentTexture = this.device.getCurrentTexture();
614
1056
  if (!currentTexture) {
@@ -645,9 +1087,6 @@ export class Renderer {
645
1087
  // Write uniform data to each mesh's buffer BEFORE recording commands
646
1088
  // This ensures each mesh has its own color data
647
1089
  const allMeshes = [...opaqueMeshes, ...transparentMeshes];
648
- const selectedId = options.selectedId;
649
- const selectedIds = options.selectedIds;
650
- const selectedModelIndex = options.selectedModelIndex;
651
1090
  // Calculate section plane parameters and model bounds
652
1091
  // Always calculate bounds when sectionPlane is provided (for preview and active mode)
653
1092
  let sectionPlaneData;
@@ -682,6 +1121,21 @@ export class Renderer {
682
1121
  boundsMax.z = Math.max(boundsMax.z, batch.bounds.max[2]);
683
1122
  }
684
1123
  }
1124
+ // Fold in point-cloud bounds too — without this, a
1125
+ // pure point-cloud scene falls through to the default
1126
+ // [-100,100], and a mixed scene clips against a
1127
+ // smaller mesh-only range while the point pipeline
1128
+ // (which honours the same sectionPlaneData) keeps
1129
+ // drawing points outside the slider's reach.
1130
+ const pcBoundsForSection = this.pointCloudRenderer?.getBounds();
1131
+ if (pcBoundsForSection) {
1132
+ boundsMin.x = Math.min(boundsMin.x, pcBoundsForSection.min[0]);
1133
+ boundsMin.y = Math.min(boundsMin.y, pcBoundsForSection.min[1]);
1134
+ boundsMin.z = Math.min(boundsMin.z, pcBoundsForSection.min[2]);
1135
+ boundsMax.x = Math.max(boundsMax.x, pcBoundsForSection.max[0]);
1136
+ boundsMax.y = Math.max(boundsMax.y, pcBoundsForSection.max[1]);
1137
+ boundsMax.z = Math.max(boundsMax.z, pcBoundsForSection.max[2]);
1138
+ }
685
1139
  // If no batched meshes have bounds yet (streaming, degenerate
686
1140
  // models), fall back to individual meshes so at least the
687
1141
  // slider has a workable range.
@@ -717,82 +1171,113 @@ export class Renderer {
717
1171
  };
718
1172
  }
719
1173
  if (options.sectionPlane.enabled) {
720
- // Calculate plane normal based on semantic axis
721
- // down = Y axis (horizontal cut), front = Z axis, side = X axis
722
- let normal = [0, 0, 0];
723
- if (options.sectionPlane.axis === 'side')
724
- normal[0] = 1; // X axis
725
- else if (options.sectionPlane.axis === 'down')
726
- normal[1] = 1; // Y axis (horizontal)
727
- else
728
- normal[2] = 1; // Z axis (front)
729
- // Apply building rotation if present (rotate normal around Y axis)
730
- // Building rotation is in X-Y plane (Z is up in IFC, Y is up in WebGL)
731
- if (options.buildingRotation !== undefined && options.buildingRotation !== 0) {
732
- const cosR = Math.cos(options.buildingRotation);
733
- const sinR = Math.sin(options.buildingRotation);
734
- // Rotate normal vector around Y axis (vertical)
735
- // For X-Z plane rotation: x' = x*cos - z*sin, z' = x*sin + z*cos, y' = y
736
- const x = normal[0];
737
- const z = normal[2];
738
- normal[0] = x * cosR - z * sinR;
739
- normal[2] = x * sinR + z * cosR;
740
- // Normalize to maintain unit length
741
- const len = Math.sqrt(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]);
742
- if (len > 0.0001) {
743
- normal[0] /= len;
744
- normal[1] /= len;
745
- normal[2] /= len;
1174
+ // Explicit normal + distance override (face-pick / arbitrary
1175
+ // plane, issue #243). Used verbatim: no axis mapping, no
1176
+ // position slider, no building rotation — the caller already
1177
+ // has the plane in world space.
1178
+ const explicitNormal = options.sectionPlane.normal;
1179
+ const explicitDistance = options.sectionPlane.distance;
1180
+ const hasExplicitPlane = explicitNormal !== undefined &&
1181
+ explicitDistance !== undefined &&
1182
+ Number.isFinite(explicitDistance);
1183
+ let normal;
1184
+ let distance;
1185
+ if (hasExplicitPlane) {
1186
+ // Defensive renormalisation in case the caller passed a
1187
+ // non-unit vector (e.g. mesh face normals quantised by
1188
+ // the geometry pipeline).
1189
+ const nx = explicitNormal[0];
1190
+ const ny = explicitNormal[1];
1191
+ const nz = explicitNormal[2];
1192
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
1193
+ if (len > 1e-6) {
1194
+ normal = [nx / len, ny / len, nz / len];
1195
+ distance = explicitDistance / len;
1196
+ }
1197
+ else {
1198
+ normal = [0, 1, 0];
1199
+ distance = explicitDistance;
746
1200
  }
747
1201
  }
748
- // Get axis-specific range. The renderer's own `boundsMin/Max`
749
- // are computed from the GPU vertex buffers this frame, so
750
- // they are guaranteed to be in the same Y-up world space as
751
- // `input.worldPos` in the shader. `options.sectionPlane.min/max`
752
- // comes from the UI via `coordinateInfo.shiftedBounds` and can
753
- // be stale during streaming or outright wrong during model
754
- // load (initialised to {0,0,0} before the first bounds update)
755
- // using those directly was the cause of the "slider moves
756
- // 1% and the whole model disappears" bug.
757
- //
758
- // Policy: always use the renderer's own bounds for the Y-up
759
- // range. Only honour the UI override when it is a valid,
760
- // non-degenerate range that lies INSIDE the actual mesh
761
- // bounds (e.g. storey filtering from the level picker).
762
- const axisIdx = options.sectionPlane.axis === 'side' ? 'x' : options.sectionPlane.axis === 'down' ? 'y' : 'z';
763
- let minVal = boundsMin[axisIdx];
764
- let maxVal = boundsMax[axisIdx];
765
- const uiMin = options.sectionPlane.min;
766
- const uiMax = options.sectionPlane.max;
767
- if (Number.isFinite(uiMin) &&
768
- Number.isFinite(uiMax) &&
769
- uiMax - uiMin > 1e-6 &&
770
- uiMin >= minVal - 1e-3 &&
771
- uiMax <= maxVal + 1e-3) {
772
- minVal = uiMin;
773
- maxVal = uiMax;
1202
+ else {
1203
+ // Cardinal-axis preset path (unchanged behaviour).
1204
+ // down = Y axis (horizontal cut), front = Z axis, side = X axis
1205
+ normal = [0, 0, 0];
1206
+ if (options.sectionPlane.axis === 'side')
1207
+ normal[0] = 1; // X axis
1208
+ else if (options.sectionPlane.axis === 'down')
1209
+ normal[1] = 1; // Y axis (horizontal)
1210
+ else
1211
+ normal[2] = 1; // Z axis (front)
1212
+ // Apply building rotation if present (rotate normal around Y axis)
1213
+ // Building rotation is in X-Y plane (Z is up in IFC, Y is up in WebGL)
1214
+ if (options.buildingRotation !== undefined && options.buildingRotation !== 0) {
1215
+ const cosR = Math.cos(options.buildingRotation);
1216
+ const sinR = Math.sin(options.buildingRotation);
1217
+ // Rotate normal vector around Y axis (vertical)
1218
+ // For X-Z plane rotation: x' = x*cos - z*sin, z' = x*sin + z*cos, y' = y
1219
+ const x = normal[0];
1220
+ const z = normal[2];
1221
+ normal[0] = x * cosR - z * sinR;
1222
+ normal[2] = x * sinR + z * cosR;
1223
+ // Normalize to maintain unit length
1224
+ const rlen = Math.sqrt(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]);
1225
+ if (rlen > 0.0001) {
1226
+ normal[0] /= rlen;
1227
+ normal[1] /= rlen;
1228
+ normal[2] /= rlen;
1229
+ }
1230
+ }
1231
+ // Get axis-specific range. The renderer's own `boundsMin/Max`
1232
+ // are computed from the GPU vertex buffers this frame, so
1233
+ // they are guaranteed to be in the same Y-up world space as
1234
+ // `input.worldPos` in the shader. `options.sectionPlane.min/max`
1235
+ // comes from the UI via `coordinateInfo.shiftedBounds` and can
1236
+ // be stale during streaming or outright wrong during model
1237
+ // load (initialised to {0,0,0} before the first bounds update)
1238
+ // — using those directly was the cause of the "slider moves
1239
+ // 1% and the whole model disappears" bug.
1240
+ //
1241
+ // Policy: always use the renderer's own bounds for the Y-up
1242
+ // range. Only honour the UI override when it is a valid,
1243
+ // non-degenerate range that lies INSIDE the actual mesh
1244
+ // bounds (e.g. storey filtering from the level picker).
1245
+ const axisIdx = options.sectionPlane.axis === 'side' ? 'x' : options.sectionPlane.axis === 'down' ? 'y' : 'z';
1246
+ let minVal = boundsMin[axisIdx];
1247
+ let maxVal = boundsMax[axisIdx];
1248
+ const uiMin = options.sectionPlane.min;
1249
+ const uiMax = options.sectionPlane.max;
1250
+ if (Number.isFinite(uiMin) &&
1251
+ Number.isFinite(uiMax) &&
1252
+ uiMax - uiMin > 1e-6 &&
1253
+ uiMin >= minVal - 1e-3 &&
1254
+ uiMax <= maxVal + 1e-3) {
1255
+ minVal = uiMin;
1256
+ maxVal = uiMax;
1257
+ }
1258
+ // Calculate plane distance from position percentage
1259
+ const range = maxVal - minVal;
1260
+ distance = minVal + (options.sectionPlane.position / 100) * range;
774
1261
  }
775
- // Calculate plane distance from position percentage
776
- const range = maxVal - minVal;
777
- const distance = minVal + (options.sectionPlane.position / 100) * range;
778
1262
  sectionPlaneData = { normal, distance, enabled: true };
779
1263
  // One-shot diagnostic: when section first becomes active,
780
- // log the exact bounds + distance the shader will use.
781
- // This is the fastest way to confirm "bounds mismatch" bugs
782
- // without asking the user to run a debugger.
1264
+ // log the exact bounds + plane the shader will use. This
1265
+ // is the fastest way to confirm "bounds mismatch" / "plane
1266
+ // off-screen" bugs without asking the user to run a
1267
+ // debugger. The custom-plane branch logs `mode: 'explicit'`
1268
+ // so reports against tilted planes are easy to spot.
783
1269
  if (!this._loggedSectionBounds) {
784
1270
  this._loggedSectionBounds = true;
785
1271
  console.info('[Section] Y-up bounds used for clip:', {
1272
+ mode: hasExplicitPlane ? 'explicit' : 'axis-aligned',
786
1273
  axis: options.sectionPlane.axis,
787
- axisIdx,
788
1274
  bounds: {
789
1275
  min: { x: boundsMin.x, y: boundsMin.y, z: boundsMin.z },
790
1276
  max: { x: boundsMax.x, y: boundsMax.y, z: boundsMax.z },
791
1277
  },
792
- uiOverride: { min: uiMin, max: uiMax },
793
- used: { min: minVal, max: maxVal },
794
- position: options.sectionPlane.position,
1278
+ normal,
795
1279
  distance,
1280
+ position: options.sectionPlane.position,
796
1281
  batchedMeshCount: this.scene.getBatchedMeshes().length,
797
1282
  });
798
1283
  }
@@ -849,24 +1334,26 @@ export class Renderer {
849
1334
  // Set up MSAA rendering if enabled
850
1335
  const msaaView = this.pipeline.getMultisampleTextureView();
851
1336
  const useMSAA = msaaView !== null && this.pipeline.getSampleCount() > 1;
1337
+ // Build color attachments — skip objectId in single-target mode
1338
+ const colorAttachments = [
1339
+ {
1340
+ // If MSAA enabled: render to multisample texture, resolve to swap chain
1341
+ // If MSAA disabled: render directly to swap chain
1342
+ view: useMSAA ? msaaView : textureView,
1343
+ resolveTarget: useMSAA ? textureView : undefined,
1344
+ loadOp: 'clear',
1345
+ clearValue: clearColor,
1346
+ storeOp: (useMSAA ? 'discard' : 'store'),
1347
+ },
1348
+ ];
1349
+ colorAttachments.push({
1350
+ view: objectIdView,
1351
+ loadOp: 'clear',
1352
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1353
+ storeOp: (needsObjectIdPass ? 'store' : 'discard'),
1354
+ });
852
1355
  const pass = encoder.beginRenderPass({
853
- colorAttachments: [
854
- {
855
- // If MSAA enabled: render to multisample texture, resolve to swap chain
856
- // If MSAA disabled: render directly to swap chain
857
- view: useMSAA ? msaaView : textureView,
858
- resolveTarget: useMSAA ? textureView : undefined,
859
- loadOp: 'clear',
860
- clearValue: clearColor,
861
- storeOp: useMSAA ? 'discard' : 'store', // Discard MSAA buffer after resolve
862
- },
863
- {
864
- view: objectIdView,
865
- loadOp: 'clear',
866
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
867
- storeOp: needsObjectIdPass ? 'store' : 'discard',
868
- },
869
- ],
1356
+ colorAttachments,
870
1357
  depthStencilAttachment: {
871
1358
  view: this.pipeline.getDepthTextureView(),
872
1359
  depthClearValue: 0.0, // Reverse-Z: clear to 0.0 (far plane)
@@ -961,15 +1448,6 @@ export class Renderer {
961
1448
  opaqueBatches.push(batch);
962
1449
  }
963
1450
  }
964
- const selectedExpressIds = new Set();
965
- if (selectedId !== undefined && selectedId !== null) {
966
- selectedExpressIds.add(selectedId);
967
- }
968
- if (selectedIds) {
969
- for (const id of selectedIds) {
970
- selectedExpressIds.add(id);
971
- }
972
- }
973
1451
  // Build a uniform template ONCE per frame — shared across all batches.
974
1452
  // Only the 4-float color (offset 32) differs per batch; everything else
975
1453
  // (viewProj, identity model, material, section plane, flags) is identical.
@@ -1308,6 +1786,19 @@ export class Renderer {
1308
1786
  }
1309
1787
  }
1310
1788
  }
1789
+ // Draw point clouds (IFCx inline + streamed LAS/LAZ).
1790
+ // Shares the depth buffer + section plane state with the mesh pipeline so
1791
+ // points occlude triangles and vice versa. The splat shader needs the
1792
+ // viewport size to convert pixel sizes into clip-space offsets.
1793
+ if (this.pointCloudRenderer && this.pointCloudRenderer.hasAssets()) {
1794
+ this.pointCloudRenderer.draw(pass, {
1795
+ viewProj,
1796
+ sectionPlane: sectionPlaneData
1797
+ ? { ...sectionPlaneData, flipped: options.sectionPlane?.flipped === true }
1798
+ : null,
1799
+ viewport: { width: this.canvas.width, height: this.canvas.height },
1800
+ });
1801
+ }
1311
1802
  // Draw section plane visual BEFORE pass.end() (within same MSAA render pass)
1312
1803
  // Always show plane when sectionPlane options are provided (as preview or active)
1313
1804
  const modelBounds = this.getModelBounds();
@@ -1320,6 +1811,11 @@ export class Renderer {
1320
1811
  isPreview: !options.sectionPlane.enabled, // Preview mode when not enabled
1321
1812
  min: options.sectionPlane.min,
1322
1813
  max: options.sectionPlane.max,
1814
+ // Custom-plane gizmo override (issue #243). When both
1815
+ // are set the gizmo bypasses the cardinal path; see
1816
+ // SectionPlaneRenderer.calculatePlaneVerticesFromNormal.
1817
+ normal: options.sectionPlane.normal,
1818
+ distance: options.sectionPlane.distance,
1323
1819
  });
1324
1820
  // Draw 2D section overlay on the section plane (when section is
1325
1821
  // active, not preview). The overlay is also the 3D SECTION CAP:
@@ -1380,9 +1876,37 @@ export class Renderer {
1380
1876
  enableSeparationLines: separationEnabled,
1381
1877
  });
1382
1878
  }
1879
+ // Eye-Dome Lighting — runs AFTER contact/separation so it darkens
1880
+ // every layer uniformly. Cheap (~9 depth taps), only active when
1881
+ // there are point clouds in the scene and the user has enabled it.
1882
+ if (this.edlPass
1883
+ && this.edlOptions.enabled
1884
+ && this.pointCloudRenderer?.hasAssets()) {
1885
+ this.edlPass.apply(encoder, {
1886
+ targetView: textureView,
1887
+ depthView: this.pipeline.getDepthOnlyTextureView(),
1888
+ }, {
1889
+ strength: this.edlOptions.strength,
1890
+ radiusPx: this.edlOptions.radiusPx,
1891
+ highQuality: this.edlOptions.highQuality,
1892
+ });
1893
+ }
1383
1894
  device.queue.submit([encoder.finish()]);
1895
+ // Pop validation error scope and capture the exact error
1896
+ if (captureGpuError) {
1897
+ device.popErrorScope().then((error) => {
1898
+ if (error) {
1899
+ const msg = error.message || String(error);
1900
+ console.error('[WebGPU] Validation error in render pass:', msg);
1901
+ this.device._lastUncapturedError = `VALIDATION: ${msg}`;
1902
+ this.device._uncapturedErrorCount++;
1903
+ }
1904
+ });
1905
+ }
1384
1906
  }
1385
1907
  catch (error) {
1908
+ this._renderErrorCount++;
1909
+ this._lastRenderError = error instanceof Error ? error.message : String(error);
1386
1910
  // Handle WebGPU errors (e.g., device lost, invalid state)
1387
1911
  // Mark context as invalid so it gets reconfigured next frame
1388
1912
  this.device.invalidateContext();
@@ -1405,6 +1929,18 @@ export class Renderer {
1405
1929
  async pick(x, y, options) {
1406
1930
  return this.pickingManager.pick(x, y, options);
1407
1931
  }
1932
+ /**
1933
+ * GPU-based rectangle pick. Drag-select returns the set of
1934
+ * `expressId`s touched by any pixel inside `[x0,y0]..[x1,y1]`
1935
+ * (CSS pixels, canvas-relative). Both meshes and point clouds
1936
+ * participate.
1937
+ *
1938
+ * See `PickingManager.pickRect` for the visibility-filter +
1939
+ * limitation notes.
1940
+ */
1941
+ async pickRect(x0, y0, x1, y1, options) {
1942
+ return this.pickingManager.pickRect(x0, y0, x1, y1, options);
1943
+ }
1408
1944
  /**
1409
1945
  * Raycast into the scene to get precise 3D intersection point
1410
1946
  * This is more accurate than pick() as it returns the exact surface point
@@ -1495,15 +2031,34 @@ export class Renderer {
1495
2031
  return this.scene;
1496
2032
  }
1497
2033
  /**
1498
- * Upload 2D section drawing data for 3D overlay rendering
1499
- * Call this when a 2D drawing is generated to display it on the section plane
1500
- * Uses same position calculation as section plane: sectionRange min/max if provided, else modelBounds
2034
+ * Upload 2D section drawing data for 3D overlay rendering.
2035
+ *
2036
+ * Cardinal-axis call site: pass `axis` + `position` percentage and the
2037
+ * upload computes the plane offset along the cardinal axis using the
2038
+ * model bounds (or `sectionRange` override). 2D points are then lifted
2039
+ * to 3D via the cardinal-axis swap.
2040
+ *
2041
+ * Custom-plane call site (issue #243): pass `customPlane = { origin,
2042
+ * tangent, bitangent }`. The 2D points are lifted via the explicit
2043
+ * basis, exactly inverting the projection `SectionCutter` applied when
2044
+ * generating the polygons. Without this the cap silhouette lands off
2045
+ * the actual cutting plane (the bug PR #581 hid by suppressing the
2046
+ * cap entirely for non-cardinal planes).
1501
2047
  */
1502
2048
  uploadSection2DOverlay(polygons, lines, axis, position, // 0-100 percentage
1503
2049
  sectionRange, // Same storey-based range as section plane
1504
- flipped = false) {
2050
+ flipped = false, customPlane) {
1505
2051
  if (!this.section2DOverlayRenderer)
1506
2052
  return;
2053
+ if (customPlane) {
2054
+ // Custom-plane path: planePosition / axis are unused — the
2055
+ // basis the cap shader needs travels in `customPlane`. We pass
2056
+ // 0 for `planePosition` and the existing `axis` so the cardinal
2057
+ // shader code path that callers depend on (e.g. legacy SVG
2058
+ // export) keeps working when customPlane is omitted.
2059
+ this.section2DOverlayRenderer.uploadDrawing(polygons, lines, axis, 0, flipped, customPlane);
2060
+ return;
2061
+ }
1507
2062
  // Use EXACTLY same calculation as section plane in render() method:
1508
2063
  // minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
1509
2064
  // maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
@@ -1601,11 +2156,22 @@ export class Renderer {
1601
2156
  // Post-processor uniform buffer
1602
2157
  this.postProcessor?.destroy();
1603
2158
  this.postProcessor = null;
2159
+ this.edlPass?.destroy();
2160
+ this.edlPass = null;
1604
2161
  // Section-plane renderers
1605
2162
  this.sectionPlaneRenderer?.destroy();
1606
2163
  this.sectionPlaneRenderer = null;
1607
2164
  this.section2DOverlayRenderer?.dispose();
1608
2165
  this.section2DOverlayRenderer = null;
2166
+ // Point cloud GPU resources
2167
+ this.pointCloudRenderer?.clear();
2168
+ this.pointCloudRenderer = null;
2169
+ // BIM ↔ scan deviation pipeline + cached BVH GPU buffers.
2170
+ // Done before queue.destroy() so the GPU calls inside
2171
+ // `destroy()` still have a valid device.
2172
+ this.deviationPipeline?.destroy();
2173
+ this.deviationPipeline = null;
2174
+ this.deviationBvhFingerprint = null;
1609
2175
  // Snap detector geometry cache
1610
2176
  this.raycastEngine.clearCaches();
1611
2177
  }