@ifc-lite/renderer 1.18.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 (67) 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/index.d.ts +98 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +362 -91
  20. package/dist/index.js.map +1 -1
  21. package/dist/picker.d.ts +18 -0
  22. package/dist/picker.d.ts.map +1 -1
  23. package/dist/picker.js +182 -70
  24. package/dist/picker.js.map +1 -1
  25. package/dist/picking-manager.d.ts +18 -0
  26. package/dist/picking-manager.d.ts.map +1 -1
  27. package/dist/picking-manager.js +40 -0
  28. package/dist/picking-manager.js.map +1 -1
  29. package/dist/pointcloud/point-cloud-node.d.ts +7 -0
  30. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
  31. package/dist/pointcloud/point-cloud-node.js +38 -1
  32. package/dist/pointcloud/point-cloud-node.js.map +1 -1
  33. package/dist/pointcloud/point-cloud-renderer.d.ts +35 -4
  34. package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -1
  35. package/dist/pointcloud/point-cloud-renderer.js +52 -55
  36. package/dist/pointcloud/point-cloud-renderer.js.map +1 -1
  37. package/dist/pointcloud/point-cloud-uniforms.d.ts +36 -0
  38. package/dist/pointcloud/point-cloud-uniforms.d.ts.map +1 -0
  39. package/dist/pointcloud/point-cloud-uniforms.js +89 -0
  40. package/dist/pointcloud/point-cloud-uniforms.js.map +1 -0
  41. package/dist/pointcloud/point-pipeline.d.ts +4 -2
  42. package/dist/pointcloud/point-pipeline.d.ts.map +1 -1
  43. package/dist/pointcloud/point-pipeline.js +17 -2
  44. package/dist/pointcloud/point-pipeline.js.map +1 -1
  45. package/dist/pointcloud/point-shader.wgsl.d.ts +1 -1
  46. package/dist/pointcloud/point-shader.wgsl.d.ts.map +1 -1
  47. package/dist/pointcloud/point-shader.wgsl.js +78 -2
  48. package/dist/pointcloud/point-shader.wgsl.js.map +1 -1
  49. package/dist/scene.d.ts +11 -0
  50. package/dist/scene.d.ts.map +1 -1
  51. package/dist/scene.js +21 -0
  52. package/dist/scene.js.map +1 -1
  53. package/dist/section-2d-overlay.d.ts +24 -5
  54. package/dist/section-2d-overlay.d.ts.map +1 -1
  55. package/dist/section-2d-overlay.js +42 -13
  56. package/dist/section-2d-overlay.js.map +1 -1
  57. package/dist/section-plane-basis.d.ts +64 -0
  58. package/dist/section-plane-basis.d.ts.map +1 -0
  59. package/dist/section-plane-basis.js +86 -0
  60. package/dist/section-plane-basis.js.map +1 -0
  61. package/dist/section-plane.d.ts +18 -0
  62. package/dist/section-plane.d.ts.map +1 -1
  63. package/dist/section-plane.js +89 -6
  64. package/dist/section-plane.js.map +1 -1
  65. package/dist/types.d.ts +20 -0
  66. package/dist/types.d.ts.map +1 -1
  67. 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';
@@ -46,8 +47,29 @@ import { RaycastEngine } from './raycast-engine.js';
46
47
  import { PostProcessor } from './post-processor.js';
47
48
  import { EdlPass } from './edl-pass.js';
48
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';
49
52
  const MAX_ENCODED_ENTITY_ID = 0xFFFFFF;
50
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
+ }
51
73
  /**
52
74
  * Main renderer class
53
75
  */
@@ -70,6 +92,15 @@ export class Renderer {
70
92
  highQuality: true,
71
93
  };
72
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;
73
104
  visualEnhancementState = {
74
105
  enabled: true,
75
106
  edgeContrast: { enabled: true, intensity: 1.0 },
@@ -84,6 +115,11 @@ export class Renderer {
84
115
  // Error rate limiting (log at most once per second)
85
116
  lastRenderErrorTime = 0;
86
117
  RENDER_ERROR_THROTTLE_MS = 1000;
118
+ // Diagnostic counters for mobile debugging
119
+ _renderCallCount = 0;
120
+ _renderSkipCount = 0;
121
+ _renderErrorCount = 0;
122
+ _lastRenderError = '';
87
123
  // Dirty flag: set by requestRender(), consumed by the animation loop.
88
124
  // Centralises all render scheduling — callers never call render() directly.
89
125
  _renderRequested = false;
@@ -122,12 +158,24 @@ export class Renderer {
122
158
  this.picker = new Picker(this.device, width, height);
123
159
  this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
124
160
  this.section2DOverlayRenderer = new Section2DOverlayRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
125
- this.postProcessor = new PostProcessor(this.device, {
126
- enableContactShading: true,
127
- contactRadius: 1.0,
128
- contactIntensity: 0.3,
129
- }, 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
+ }
130
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());
131
179
  this.edlPass = new EdlPass(this.device, this.pipeline.getSampleCount());
132
180
  this.camera.setAspect(width / height);
133
181
  // Update picking manager with initialized picker
@@ -301,6 +349,107 @@ export class Renderer {
301
349
  this.pointCloudRenderer?.setOptions(opts);
302
350
  this.requestRender();
303
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
+ }
304
453
  /**
305
454
  * Toggle Eye-Dome Lighting and tune its strength.
306
455
  *
@@ -722,9 +871,32 @@ export class Renderer {
722
871
  /**
723
872
  * Render frame
724
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
+ }
725
894
  render(options = {}) {
726
- if (!this.device.isInitialized() || !this.pipeline)
895
+ this._renderCallCount++;
896
+ if (!this.device.isInitialized() || !this.pipeline) {
897
+ this._renderSkipCount++;
727
898
  return;
899
+ }
728
900
  // Validate canvas dimensions
729
901
  // Align width to 64 pixels for WebGPU texture row alignment (256 bytes / 4 bytes per pixel)
730
902
  const rect = this.canvas.getBoundingClientRect();
@@ -732,8 +904,10 @@ export class Renderer {
732
904
  const width = Math.max(64, Math.floor(rawWidth / 64) * 64);
733
905
  const height = Math.max(1, Math.floor(rect.height));
734
906
  // Skip rendering if canvas is too small
735
- if (width < 64 || height < 10)
907
+ if (width < 64 || height < 10) {
908
+ this._renderSkipCount++;
736
909
  return;
910
+ }
737
911
  // Update canvas pixel dimensions if needed
738
912
  const dimensionsChanged = this.canvas.width !== width || this.canvas.height !== height;
739
913
  if (dimensionsChanged) {
@@ -749,10 +923,13 @@ export class Renderer {
749
923
  }
750
924
  }
751
925
  // Skip rendering if canvas is invalid
752
- if (this.canvas.width === 0 || this.canvas.height === 0)
926
+ if (this.canvas.width === 0 || this.canvas.height === 0) {
927
+ this._renderSkipCount++;
753
928
  return;
929
+ }
754
930
  // Ensure context is valid before rendering (handles HMR, focus changes, etc.)
755
931
  if (!this.device.ensureContext()) {
932
+ this._renderSkipCount++;
756
933
  return; // Skip this frame, context will be ready next frame
757
934
  }
758
935
  const device = this.device.getDevice();
@@ -868,6 +1045,12 @@ export class Renderer {
868
1045
  if (this.instancedPipeline?.needsResize(this.canvas.width, this.canvas.height)) {
869
1046
  this.instancedPipeline.resize(this.canvas.width, this.canvas.height);
870
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
+ }
871
1054
  // Get current texture safely - may return null if context needs reconfiguration
872
1055
  const currentTexture = this.device.getCurrentTexture();
873
1056
  if (!currentTexture) {
@@ -988,82 +1171,113 @@ export class Renderer {
988
1171
  };
989
1172
  }
990
1173
  if (options.sectionPlane.enabled) {
991
- // Calculate plane normal based on semantic axis
992
- // down = Y axis (horizontal cut), front = Z axis, side = X axis
993
- let normal = [0, 0, 0];
994
- if (options.sectionPlane.axis === 'side')
995
- normal[0] = 1; // X axis
996
- else if (options.sectionPlane.axis === 'down')
997
- normal[1] = 1; // Y axis (horizontal)
998
- else
999
- normal[2] = 1; // Z axis (front)
1000
- // Apply building rotation if present (rotate normal around Y axis)
1001
- // Building rotation is in X-Y plane (Z is up in IFC, Y is up in WebGL)
1002
- if (options.buildingRotation !== undefined && options.buildingRotation !== 0) {
1003
- const cosR = Math.cos(options.buildingRotation);
1004
- const sinR = Math.sin(options.buildingRotation);
1005
- // Rotate normal vector around Y axis (vertical)
1006
- // For X-Z plane rotation: x' = x*cos - z*sin, z' = x*sin + z*cos, y' = y
1007
- const x = normal[0];
1008
- const z = normal[2];
1009
- normal[0] = x * cosR - z * sinR;
1010
- normal[2] = x * sinR + z * cosR;
1011
- // Normalize to maintain unit length
1012
- const len = Math.sqrt(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]);
1013
- if (len > 0.0001) {
1014
- normal[0] /= len;
1015
- normal[1] /= len;
1016
- 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;
1017
1200
  }
1018
1201
  }
1019
- // Get axis-specific range. The renderer's own `boundsMin/Max`
1020
- // are computed from the GPU vertex buffers this frame, so
1021
- // they are guaranteed to be in the same Y-up world space as
1022
- // `input.worldPos` in the shader. `options.sectionPlane.min/max`
1023
- // comes from the UI via `coordinateInfo.shiftedBounds` and can
1024
- // be stale during streaming or outright wrong during model
1025
- // load (initialised to {0,0,0} before the first bounds update)
1026
- // using those directly was the cause of the "slider moves
1027
- // 1% and the whole model disappears" bug.
1028
- //
1029
- // Policy: always use the renderer's own bounds for the Y-up
1030
- // range. Only honour the UI override when it is a valid,
1031
- // non-degenerate range that lies INSIDE the actual mesh
1032
- // bounds (e.g. storey filtering from the level picker).
1033
- const axisIdx = options.sectionPlane.axis === 'side' ? 'x' : options.sectionPlane.axis === 'down' ? 'y' : 'z';
1034
- let minVal = boundsMin[axisIdx];
1035
- let maxVal = boundsMax[axisIdx];
1036
- const uiMin = options.sectionPlane.min;
1037
- const uiMax = options.sectionPlane.max;
1038
- if (Number.isFinite(uiMin) &&
1039
- Number.isFinite(uiMax) &&
1040
- uiMax - uiMin > 1e-6 &&
1041
- uiMin >= minVal - 1e-3 &&
1042
- uiMax <= maxVal + 1e-3) {
1043
- minVal = uiMin;
1044
- 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;
1045
1261
  }
1046
- // Calculate plane distance from position percentage
1047
- const range = maxVal - minVal;
1048
- const distance = minVal + (options.sectionPlane.position / 100) * range;
1049
1262
  sectionPlaneData = { normal, distance, enabled: true };
1050
1263
  // One-shot diagnostic: when section first becomes active,
1051
- // log the exact bounds + distance the shader will use.
1052
- // This is the fastest way to confirm "bounds mismatch" bugs
1053
- // 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.
1054
1269
  if (!this._loggedSectionBounds) {
1055
1270
  this._loggedSectionBounds = true;
1056
1271
  console.info('[Section] Y-up bounds used for clip:', {
1272
+ mode: hasExplicitPlane ? 'explicit' : 'axis-aligned',
1057
1273
  axis: options.sectionPlane.axis,
1058
- axisIdx,
1059
1274
  bounds: {
1060
1275
  min: { x: boundsMin.x, y: boundsMin.y, z: boundsMin.z },
1061
1276
  max: { x: boundsMax.x, y: boundsMax.y, z: boundsMax.z },
1062
1277
  },
1063
- uiOverride: { min: uiMin, max: uiMax },
1064
- used: { min: minVal, max: maxVal },
1065
- position: options.sectionPlane.position,
1278
+ normal,
1066
1279
  distance,
1280
+ position: options.sectionPlane.position,
1067
1281
  batchedMeshCount: this.scene.getBatchedMeshes().length,
1068
1282
  });
1069
1283
  }
@@ -1120,24 +1334,26 @@ export class Renderer {
1120
1334
  // Set up MSAA rendering if enabled
1121
1335
  const msaaView = this.pipeline.getMultisampleTextureView();
1122
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
+ });
1123
1355
  const pass = encoder.beginRenderPass({
1124
- colorAttachments: [
1125
- {
1126
- // If MSAA enabled: render to multisample texture, resolve to swap chain
1127
- // If MSAA disabled: render directly to swap chain
1128
- view: useMSAA ? msaaView : textureView,
1129
- resolveTarget: useMSAA ? textureView : undefined,
1130
- loadOp: 'clear',
1131
- clearValue: clearColor,
1132
- storeOp: useMSAA ? 'discard' : 'store', // Discard MSAA buffer after resolve
1133
- },
1134
- {
1135
- view: objectIdView,
1136
- loadOp: 'clear',
1137
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
1138
- storeOp: needsObjectIdPass ? 'store' : 'discard',
1139
- },
1140
- ],
1356
+ colorAttachments,
1141
1357
  depthStencilAttachment: {
1142
1358
  view: this.pipeline.getDepthTextureView(),
1143
1359
  depthClearValue: 0.0, // Reverse-Z: clear to 0.0 (far plane)
@@ -1595,6 +1811,11 @@ export class Renderer {
1595
1811
  isPreview: !options.sectionPlane.enabled, // Preview mode when not enabled
1596
1812
  min: options.sectionPlane.min,
1597
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,
1598
1819
  });
1599
1820
  // Draw 2D section overlay on the section plane (when section is
1600
1821
  // active, not preview). The overlay is also the 3D SECTION CAP:
@@ -1671,8 +1892,21 @@ export class Renderer {
1671
1892
  });
1672
1893
  }
1673
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
+ }
1674
1906
  }
1675
1907
  catch (error) {
1908
+ this._renderErrorCount++;
1909
+ this._lastRenderError = error instanceof Error ? error.message : String(error);
1676
1910
  // Handle WebGPU errors (e.g., device lost, invalid state)
1677
1911
  // Mark context as invalid so it gets reconfigured next frame
1678
1912
  this.device.invalidateContext();
@@ -1695,6 +1929,18 @@ export class Renderer {
1695
1929
  async pick(x, y, options) {
1696
1930
  return this.pickingManager.pick(x, y, options);
1697
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
+ }
1698
1944
  /**
1699
1945
  * Raycast into the scene to get precise 3D intersection point
1700
1946
  * This is more accurate than pick() as it returns the exact surface point
@@ -1785,15 +2031,34 @@ export class Renderer {
1785
2031
  return this.scene;
1786
2032
  }
1787
2033
  /**
1788
- * Upload 2D section drawing data for 3D overlay rendering
1789
- * Call this when a 2D drawing is generated to display it on the section plane
1790
- * 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).
1791
2047
  */
1792
2048
  uploadSection2DOverlay(polygons, lines, axis, position, // 0-100 percentage
1793
2049
  sectionRange, // Same storey-based range as section plane
1794
- flipped = false) {
2050
+ flipped = false, customPlane) {
1795
2051
  if (!this.section2DOverlayRenderer)
1796
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
+ }
1797
2062
  // Use EXACTLY same calculation as section plane in render() method:
1798
2063
  // minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
1799
2064
  // maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
@@ -1901,6 +2166,12 @@ export class Renderer {
1901
2166
  // Point cloud GPU resources
1902
2167
  this.pointCloudRenderer?.clear();
1903
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;
1904
2175
  // Snap detector geometry cache
1905
2176
  this.raycastEngine.clearCaches();
1906
2177
  }