@ifc-lite/renderer 1.18.0 → 1.20.1

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 (71) 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 +437 -105
  20. package/dist/index.js.map +1 -1
  21. package/dist/overlay-routing.d.ts +63 -0
  22. package/dist/overlay-routing.d.ts.map +1 -0
  23. package/dist/overlay-routing.js +98 -0
  24. package/dist/overlay-routing.js.map +1 -0
  25. package/dist/picker.d.ts +18 -0
  26. package/dist/picker.d.ts.map +1 -1
  27. package/dist/picker.js +182 -70
  28. package/dist/picker.js.map +1 -1
  29. package/dist/picking-manager.d.ts +18 -0
  30. package/dist/picking-manager.d.ts.map +1 -1
  31. package/dist/picking-manager.js +40 -0
  32. package/dist/picking-manager.js.map +1 -1
  33. package/dist/pointcloud/point-cloud-node.d.ts +7 -0
  34. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
  35. package/dist/pointcloud/point-cloud-node.js +38 -1
  36. package/dist/pointcloud/point-cloud-node.js.map +1 -1
  37. package/dist/pointcloud/point-cloud-renderer.d.ts +35 -4
  38. package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -1
  39. package/dist/pointcloud/point-cloud-renderer.js +52 -55
  40. package/dist/pointcloud/point-cloud-renderer.js.map +1 -1
  41. package/dist/pointcloud/point-cloud-uniforms.d.ts +36 -0
  42. package/dist/pointcloud/point-cloud-uniforms.d.ts.map +1 -0
  43. package/dist/pointcloud/point-cloud-uniforms.js +89 -0
  44. package/dist/pointcloud/point-cloud-uniforms.js.map +1 -0
  45. package/dist/pointcloud/point-pipeline.d.ts +4 -2
  46. package/dist/pointcloud/point-pipeline.d.ts.map +1 -1
  47. package/dist/pointcloud/point-pipeline.js +17 -2
  48. package/dist/pointcloud/point-pipeline.js.map +1 -1
  49. package/dist/pointcloud/point-shader.wgsl.d.ts +1 -1
  50. package/dist/pointcloud/point-shader.wgsl.d.ts.map +1 -1
  51. package/dist/pointcloud/point-shader.wgsl.js +78 -2
  52. package/dist/pointcloud/point-shader.wgsl.js.map +1 -1
  53. package/dist/scene.d.ts +26 -0
  54. package/dist/scene.d.ts.map +1 -1
  55. package/dist/scene.js +45 -1
  56. package/dist/scene.js.map +1 -1
  57. package/dist/section-2d-overlay.d.ts +24 -5
  58. package/dist/section-2d-overlay.d.ts.map +1 -1
  59. package/dist/section-2d-overlay.js +42 -13
  60. package/dist/section-2d-overlay.js.map +1 -1
  61. package/dist/section-plane-basis.d.ts +64 -0
  62. package/dist/section-plane-basis.d.ts.map +1 -0
  63. package/dist/section-plane-basis.js +86 -0
  64. package/dist/section-plane-basis.js.map +1 -0
  65. package/dist/section-plane.d.ts +18 -0
  66. package/dist/section-plane.d.ts.map +1 -1
  67. package/dist/section-plane.js +89 -6
  68. package/dist/section-plane.js.map +1 -1
  69. package/dist/types.d.ts +20 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/package.json +4 -4
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';
@@ -45,9 +46,31 @@ import { PickingManager } from './picking-manager.js';
45
46
  import { RaycastEngine } from './raycast-engine.js';
46
47
  import { PostProcessor } from './post-processor.js';
47
48
  import { EdlPass } from './edl-pass.js';
49
+ import { shouldRouteMeshTransparent, shouldRouteBatchTransparent, splitVisibleIdsByPromotion } from './overlay-routing.js';
48
50
  import { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
51
+ import { DeviationPipeline } from './deviation/deviation-pipeline.js';
52
+ import { buildTriangleBVH } from './deviation/triangle-bvh.js';
49
53
  const MAX_ENCODED_ENTITY_ID = 0xFFFFFF;
50
54
  let warnedEntityIdRange = false;
55
+ /**
56
+ * Build a deterministic fingerprint of the BVH input mesh set so
57
+ * `Renderer.computeDeviations` can skip the rebuild when the source
58
+ * geometry hasn't changed. Folds in expressId / modelIndex / position
59
+ * + index lengths per mesh so two distinct mesh sets that happen to
60
+ * share the same aggregate position-length total can't collide on the
61
+ * same fingerprint and reuse a stale BVH.
62
+ */
63
+ function computeBvhFingerprint(meshes) {
64
+ const parts = [String(meshes.length)];
65
+ for (const m of meshes) {
66
+ const id = m.expressId ?? -1;
67
+ const mi = m.modelIndex ?? -1;
68
+ const posLen = m.positions?.length ?? 0;
69
+ const idxLen = m.indices?.length ?? 0;
70
+ parts.push(`${id}:${mi}:${posLen}:${idxLen}`);
71
+ }
72
+ return parts.join('|');
73
+ }
51
74
  /**
52
75
  * Main renderer class
53
76
  */
@@ -70,6 +93,15 @@ export class Renderer {
70
93
  highQuality: true,
71
94
  };
72
95
  pointCloudRenderer = null;
96
+ deviationPipeline = null;
97
+ /**
98
+ * Cache of which mesh-set the BVH was built from. We rebuild on
99
+ * `computeDeviations` only when the cached "fingerprint" misses,
100
+ * so re-running deviation against the same model is a fast
101
+ * dispatch — the BVH is multi-second on big BIMs and we don't
102
+ * want to pay that on every slider drag.
103
+ */
104
+ deviationBvhFingerprint = null;
73
105
  visualEnhancementState = {
74
106
  enabled: true,
75
107
  edgeContrast: { enabled: true, intensity: 1.0 },
@@ -84,6 +116,11 @@ export class Renderer {
84
116
  // Error rate limiting (log at most once per second)
85
117
  lastRenderErrorTime = 0;
86
118
  RENDER_ERROR_THROTTLE_MS = 1000;
119
+ // Diagnostic counters for mobile debugging
120
+ _renderCallCount = 0;
121
+ _renderSkipCount = 0;
122
+ _renderErrorCount = 0;
123
+ _lastRenderError = '';
87
124
  // Dirty flag: set by requestRender(), consumed by the animation loop.
88
125
  // Centralises all render scheduling — callers never call render() directly.
89
126
  _renderRequested = false;
@@ -122,12 +159,24 @@ export class Renderer {
122
159
  this.picker = new Picker(this.device, width, height);
123
160
  this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
124
161
  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());
162
+ // PostProcessor is optional — if it fails (e.g. mobile GPU lacking
163
+ // depth TEXTURE_BINDING), rendering still works without post-processing.
164
+ try {
165
+ this.postProcessor = new PostProcessor(this.device, {
166
+ enableContactShading: true,
167
+ contactRadius: 1.0,
168
+ contactIntensity: 0.3,
169
+ }, this.pipeline.getSampleCount());
170
+ }
171
+ catch (e) {
172
+ console.warn('[Renderer] PostProcessor init failed (post-processing disabled):', e);
173
+ this.postProcessor = null;
174
+ }
130
175
  this.pointCloudRenderer = new PointCloudRenderer(this.device.getDevice(), this.device.getFormat(), 'depth24plus-stencil8', this.pipeline.getSampleCount());
176
+ // Compute pipeline for the BIM↔scan deviation heatmap. Lazily
177
+ // owns the per-triangle BVH GPU buffers; idle until the first
178
+ // `computeDeviations` call.
179
+ this.deviationPipeline = new DeviationPipeline(this.device.getDevice());
131
180
  this.edlPass = new EdlPass(this.device, this.pipeline.getSampleCount());
132
181
  this.camera.setAspect(width / height);
133
182
  // Update picking manager with initialized picker
@@ -301,6 +350,107 @@ export class Renderer {
301
350
  this.pointCloudRenderer?.setOptions(opts);
302
351
  this.requestRender();
303
352
  }
353
+ /**
354
+ * Compute BIM ↔ scan deviation for every loaded point cloud asset.
355
+ *
356
+ * Walks every triangle in the scene (individual + batched meshes,
357
+ * regardless of which IFC ingest path produced them — STEP, IFCx,
358
+ * GLB, or federated combinations), builds a per-triangle BVH on
359
+ * the GPU, then runs a closest-point compute pass per chunk that
360
+ * writes signed distance into each chunk's deviation buffer.
361
+ *
362
+ * Returns metadata so the UI can populate a histogram + auto-range:
363
+ * the per-asset point count, the suggested ±range from the 95th
364
+ * percentile, and the bbox the BVH was built from.
365
+ *
366
+ * Idempotent: re-running with the same mesh set reuses the GPU
367
+ * BVH (the BVH build dominates wall time on big BIMs). Pass
368
+ * `forceRebuild: true` to invalidate.
369
+ */
370
+ async computeDeviations(opts = {}) {
371
+ if (!this.deviationPipeline || !this.pointCloudRenderer) {
372
+ throw new Error('Renderer not initialised — call init() first.');
373
+ }
374
+ const meshes = this.collectAllSceneMeshes();
375
+ // Fingerprint folds in per-mesh expressId / modelIndex /
376
+ // positions length / triangle count, so two distinct meshes
377
+ // that happen to share an aggregate position-length total
378
+ // can't alias each other. A federation reload that swaps one
379
+ // model for another with the same total triangle count would
380
+ // otherwise reuse the previous BVH and report wrong distances.
381
+ const fingerprint = computeBvhFingerprint(meshes);
382
+ if (opts.forceRebuild || fingerprint !== this.deviationBvhFingerprint) {
383
+ const bvh = buildTriangleBVH(meshes);
384
+ this.deviationPipeline.uploadBvh(bvh);
385
+ this.deviationBvhFingerprint = fingerprint;
386
+ }
387
+ const stats = this.deviationPipeline.getBvhStats();
388
+ const maxRange = opts.maxRange ?? 1.0;
389
+ // Encode every chunk into a single command submit so the GPU
390
+ // can pipeline the dispatches without a CPU round-trip per
391
+ // chunk. Histogram readback is a follow-up — for v1 we emit
392
+ // the deviation buffers and let the splat shader visualise.
393
+ const encoder = this.device.getDevice().createCommandEncoder({ label: 'pointcloud-deviation' });
394
+ let chunksProcessed = 0;
395
+ let pointsProcessed = 0;
396
+ const nodes = this.pointCloudRenderer.getInternalNodes();
397
+ for (const node of nodes) {
398
+ for (const chunk of node.chunks) {
399
+ const ok = this.deviationPipeline.dispatch(encoder, {
400
+ positionsBuffer: chunk.vertexBuffer,
401
+ deviationsBuffer: chunk.deviationBuffer,
402
+ pointCount: chunk.pointCount,
403
+ maxRange,
404
+ });
405
+ if (ok) {
406
+ chunksProcessed++;
407
+ pointsProcessed += chunk.pointCount;
408
+ }
409
+ }
410
+ }
411
+ this.device.getDevice().queue.submit([encoder.finish()]);
412
+ // Wait until the GPU finishes the dispatches before resolving.
413
+ // Otherwise the caller's "compute done" callback fires before
414
+ // the deviation buffers are actually populated.
415
+ await this.device.getDevice().queue.onSubmittedWorkDone();
416
+ this.requestRender();
417
+ // Suggest a default half-range = max(0.01m, max-extent / 1000).
418
+ // Tighter than the maxRange clip; gives the user a reasonable
419
+ // starting slider position without a histogram readback.
420
+ const bb = stats.bounds;
421
+ const suggestedHalfRange = bb
422
+ ? 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)
423
+ : 0.05;
424
+ return {
425
+ bvhTriangles: stats.triangleCount,
426
+ bvhNodes: stats.nodeCount,
427
+ chunksProcessed,
428
+ pointsProcessed,
429
+ bounds: stats.bounds,
430
+ suggestedHalfRange,
431
+ };
432
+ }
433
+ /**
434
+ * Aggregate every triangle source the scene exposes — individual
435
+ * meshes (created on demand by picking / highlights) AND batched
436
+ * meshes (the streaming geometry path's compact GPU buffers).
437
+ * Both formats arrive as `MeshData`; the BVH builder doesn't care
438
+ * which source they came from.
439
+ */
440
+ collectAllSceneMeshes() {
441
+ // The Scene keeps every CPU-side MeshData regardless of which
442
+ // ingest path produced it (STEP / IFCx / GLB). One iteration
443
+ // covers individual + batched + multi-piece + multi-model.
444
+ // `forEachMeshData` deduplicates by identity so a colour-merged
445
+ // batch is only added once even if it's indexed under multiple
446
+ // contributor expressIds.
447
+ const out = [];
448
+ this.scene.forEachMeshData((md) => {
449
+ if (md.positions && md.positions.length > 0)
450
+ out.push(md);
451
+ });
452
+ return out;
453
+ }
304
454
  /**
305
455
  * Toggle Eye-Dome Lighting and tune its strength.
306
456
  *
@@ -722,9 +872,32 @@ export class Renderer {
722
872
  /**
723
873
  * Render frame
724
874
  */
875
+ /** Get diagnostic info for mobile debugging */
876
+ getDiagnostics() {
877
+ const pos = this.camera.getPosition();
878
+ const tgt = this.camera.getTarget();
879
+ const b = this.modelBounds;
880
+ return {
881
+ calls: this._renderCallCount,
882
+ skips: this._renderSkipCount,
883
+ errors: this._renderErrorCount,
884
+ lastError: this._lastRenderError,
885
+ batches: this.scene.getBatchedMeshes().length,
886
+ meshes: this.scene.getMeshes().length,
887
+ contextOk: this.device.isInitialized(),
888
+ gpuErrors: this.device._uncapturedErrorCount,
889
+ lastGpuError: this.device._lastUncapturedError ?? '',
890
+ camPos: `${pos.x.toFixed(1)},${pos.y.toFixed(1)},${pos.z.toFixed(1)}`,
891
+ camTgt: `${tgt.x.toFixed(1)},${tgt.y.toFixed(1)},${tgt.z.toFixed(1)}`,
892
+ 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',
893
+ };
894
+ }
725
895
  render(options = {}) {
726
- if (!this.device.isInitialized() || !this.pipeline)
896
+ this._renderCallCount++;
897
+ if (!this.device.isInitialized() || !this.pipeline) {
898
+ this._renderSkipCount++;
727
899
  return;
900
+ }
728
901
  // Validate canvas dimensions
729
902
  // Align width to 64 pixels for WebGPU texture row alignment (256 bytes / 4 bytes per pixel)
730
903
  const rect = this.canvas.getBoundingClientRect();
@@ -732,8 +905,10 @@ export class Renderer {
732
905
  const width = Math.max(64, Math.floor(rawWidth / 64) * 64);
733
906
  const height = Math.max(1, Math.floor(rect.height));
734
907
  // Skip rendering if canvas is too small
735
- if (width < 64 || height < 10)
908
+ if (width < 64 || height < 10) {
909
+ this._renderSkipCount++;
736
910
  return;
911
+ }
737
912
  // Update canvas pixel dimensions if needed
738
913
  const dimensionsChanged = this.canvas.width !== width || this.canvas.height !== height;
739
914
  if (dimensionsChanged) {
@@ -749,10 +924,13 @@ export class Renderer {
749
924
  }
750
925
  }
751
926
  // Skip rendering if canvas is invalid
752
- if (this.canvas.width === 0 || this.canvas.height === 0)
927
+ if (this.canvas.width === 0 || this.canvas.height === 0) {
928
+ this._renderSkipCount++;
753
929
  return;
930
+ }
754
931
  // Ensure context is valid before rendering (handles HMR, focus changes, etc.)
755
932
  if (!this.device.ensureContext()) {
933
+ this._renderSkipCount++;
756
934
  return; // Skip this frame, context will be ready next frame
757
935
  }
758
936
  const device = this.device.getDevice();
@@ -836,6 +1014,14 @@ export class Renderer {
836
1014
  batchAlphaCache.set(batch, resolved);
837
1015
  return resolved;
838
1016
  };
1017
+ // Lens / Pset color overrides: when an entity has an override, force
1018
+ // its base draw through the opaque pipeline so it writes depth. The
1019
+ // overlay paint pass uses depthCompare 'equal' and otherwise silently
1020
+ // drops fragments belonging to entities whose default pipeline is
1021
+ // transparent (IfcSpace, IfcOpeningElement, glass, …). See issue #677.
1022
+ // Pure routing decision lives in overlay-routing.ts and is unit-tested
1023
+ // there.
1024
+ const colorOverrides = this.scene.getColorOverrides();
839
1025
  // PERFORMANCE FIX: Use batch-level visibility filtering instead of creating individual meshes
840
1026
  // Only create individual meshes for selected elements (for highlighting)
841
1027
  // Batches are filtered at render time - fully visible batches render normally,
@@ -868,6 +1054,12 @@ export class Renderer {
868
1054
  if (this.instancedPipeline?.needsResize(this.canvas.width, this.canvas.height)) {
869
1055
  this.instancedPipeline.resize(this.canvas.width, this.canvas.height);
870
1056
  }
1057
+ // Push a validation error scope to capture the EXACT error (for mobile debugging)
1058
+ // Only do this for the first few renders to avoid performance overhead
1059
+ const captureGpuError = this._renderCallCount <= 5;
1060
+ if (captureGpuError) {
1061
+ device.pushErrorScope('validation');
1062
+ }
871
1063
  // Get current texture safely - may return null if context needs reconfiguration
872
1064
  const currentTexture = this.device.getCurrentTexture();
873
1065
  if (!currentTexture) {
@@ -887,7 +1079,7 @@ export class Renderer {
887
1079
  for (const mesh of meshes) {
888
1080
  const alpha = alphaForMesh(mesh.expressId, mesh.color[3]);
889
1081
  const transparency = mesh.material?.transparency ?? 0.0;
890
- const isTransparent = alpha < 0.99 || transparency > 0.01;
1082
+ const isTransparent = shouldRouteMeshTransparent(alpha, transparency, mesh.expressId, colorOverrides);
891
1083
  if (isTransparent) {
892
1084
  transparentMeshes.push(mesh);
893
1085
  }
@@ -988,82 +1180,113 @@ export class Renderer {
988
1180
  };
989
1181
  }
990
1182
  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;
1183
+ // Explicit normal + distance override (face-pick / arbitrary
1184
+ // plane, issue #243). Used verbatim: no axis mapping, no
1185
+ // position slider, no building rotation — the caller already
1186
+ // has the plane in world space.
1187
+ const explicitNormal = options.sectionPlane.normal;
1188
+ const explicitDistance = options.sectionPlane.distance;
1189
+ const hasExplicitPlane = explicitNormal !== undefined &&
1190
+ explicitDistance !== undefined &&
1191
+ Number.isFinite(explicitDistance);
1192
+ let normal;
1193
+ let distance;
1194
+ if (hasExplicitPlane) {
1195
+ // Defensive renormalisation in case the caller passed a
1196
+ // non-unit vector (e.g. mesh face normals quantised by
1197
+ // the geometry pipeline).
1198
+ const nx = explicitNormal[0];
1199
+ const ny = explicitNormal[1];
1200
+ const nz = explicitNormal[2];
1201
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
1202
+ if (len > 1e-6) {
1203
+ normal = [nx / len, ny / len, nz / len];
1204
+ distance = explicitDistance / len;
1205
+ }
1206
+ else {
1207
+ normal = [0, 1, 0];
1208
+ distance = explicitDistance;
1017
1209
  }
1018
1210
  }
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;
1211
+ else {
1212
+ // Cardinal-axis preset path (unchanged behaviour).
1213
+ // down = Y axis (horizontal cut), front = Z axis, side = X axis
1214
+ normal = [0, 0, 0];
1215
+ if (options.sectionPlane.axis === 'side')
1216
+ normal[0] = 1; // X axis
1217
+ else if (options.sectionPlane.axis === 'down')
1218
+ normal[1] = 1; // Y axis (horizontal)
1219
+ else
1220
+ normal[2] = 1; // Z axis (front)
1221
+ // Apply building rotation if present (rotate normal around Y axis)
1222
+ // Building rotation is in X-Y plane (Z is up in IFC, Y is up in WebGL)
1223
+ if (options.buildingRotation !== undefined && options.buildingRotation !== 0) {
1224
+ const cosR = Math.cos(options.buildingRotation);
1225
+ const sinR = Math.sin(options.buildingRotation);
1226
+ // Rotate normal vector around Y axis (vertical)
1227
+ // For X-Z plane rotation: x' = x*cos - z*sin, z' = x*sin + z*cos, y' = y
1228
+ const x = normal[0];
1229
+ const z = normal[2];
1230
+ normal[0] = x * cosR - z * sinR;
1231
+ normal[2] = x * sinR + z * cosR;
1232
+ // Normalize to maintain unit length
1233
+ const rlen = Math.sqrt(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]);
1234
+ if (rlen > 0.0001) {
1235
+ normal[0] /= rlen;
1236
+ normal[1] /= rlen;
1237
+ normal[2] /= rlen;
1238
+ }
1239
+ }
1240
+ // Get axis-specific range. The renderer's own `boundsMin/Max`
1241
+ // are computed from the GPU vertex buffers this frame, so
1242
+ // they are guaranteed to be in the same Y-up world space as
1243
+ // `input.worldPos` in the shader. `options.sectionPlane.min/max`
1244
+ // comes from the UI via `coordinateInfo.shiftedBounds` and can
1245
+ // be stale during streaming or outright wrong during model
1246
+ // load (initialised to {0,0,0} before the first bounds update)
1247
+ // — using those directly was the cause of the "slider moves
1248
+ // 1% and the whole model disappears" bug.
1249
+ //
1250
+ // Policy: always use the renderer's own bounds for the Y-up
1251
+ // range. Only honour the UI override when it is a valid,
1252
+ // non-degenerate range that lies INSIDE the actual mesh
1253
+ // bounds (e.g. storey filtering from the level picker).
1254
+ const axisIdx = options.sectionPlane.axis === 'side' ? 'x' : options.sectionPlane.axis === 'down' ? 'y' : 'z';
1255
+ let minVal = boundsMin[axisIdx];
1256
+ let maxVal = boundsMax[axisIdx];
1257
+ const uiMin = options.sectionPlane.min;
1258
+ const uiMax = options.sectionPlane.max;
1259
+ if (Number.isFinite(uiMin) &&
1260
+ Number.isFinite(uiMax) &&
1261
+ uiMax - uiMin > 1e-6 &&
1262
+ uiMin >= minVal - 1e-3 &&
1263
+ uiMax <= maxVal + 1e-3) {
1264
+ minVal = uiMin;
1265
+ maxVal = uiMax;
1266
+ }
1267
+ // Calculate plane distance from position percentage
1268
+ const range = maxVal - minVal;
1269
+ distance = minVal + (options.sectionPlane.position / 100) * range;
1045
1270
  }
1046
- // Calculate plane distance from position percentage
1047
- const range = maxVal - minVal;
1048
- const distance = minVal + (options.sectionPlane.position / 100) * range;
1049
1271
  sectionPlaneData = { normal, distance, enabled: true };
1050
1272
  // 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.
1273
+ // log the exact bounds + plane the shader will use. This
1274
+ // is the fastest way to confirm "bounds mismatch" / "plane
1275
+ // off-screen" bugs without asking the user to run a
1276
+ // debugger. The custom-plane branch logs `mode: 'explicit'`
1277
+ // so reports against tilted planes are easy to spot.
1054
1278
  if (!this._loggedSectionBounds) {
1055
1279
  this._loggedSectionBounds = true;
1056
1280
  console.info('[Section] Y-up bounds used for clip:', {
1281
+ mode: hasExplicitPlane ? 'explicit' : 'axis-aligned',
1057
1282
  axis: options.sectionPlane.axis,
1058
- axisIdx,
1059
1283
  bounds: {
1060
1284
  min: { x: boundsMin.x, y: boundsMin.y, z: boundsMin.z },
1061
1285
  max: { x: boundsMax.x, y: boundsMax.y, z: boundsMax.z },
1062
1286
  },
1063
- uiOverride: { min: uiMin, max: uiMax },
1064
- used: { min: minVal, max: maxVal },
1065
- position: options.sectionPlane.position,
1287
+ normal,
1066
1288
  distance,
1289
+ position: options.sectionPlane.position,
1067
1290
  batchedMeshCount: this.scene.getBatchedMeshes().length,
1068
1291
  });
1069
1292
  }
@@ -1120,24 +1343,26 @@ export class Renderer {
1120
1343
  // Set up MSAA rendering if enabled
1121
1344
  const msaaView = this.pipeline.getMultisampleTextureView();
1122
1345
  const useMSAA = msaaView !== null && this.pipeline.getSampleCount() > 1;
1346
+ // Build color attachments — skip objectId in single-target mode
1347
+ const colorAttachments = [
1348
+ {
1349
+ // If MSAA enabled: render to multisample texture, resolve to swap chain
1350
+ // If MSAA disabled: render directly to swap chain
1351
+ view: useMSAA ? msaaView : textureView,
1352
+ resolveTarget: useMSAA ? textureView : undefined,
1353
+ loadOp: 'clear',
1354
+ clearValue: clearColor,
1355
+ storeOp: (useMSAA ? 'discard' : 'store'),
1356
+ },
1357
+ ];
1358
+ colorAttachments.push({
1359
+ view: objectIdView,
1360
+ loadOp: 'clear',
1361
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1362
+ storeOp: (needsObjectIdPass ? 'store' : 'discard'),
1363
+ });
1123
1364
  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
- ],
1365
+ colorAttachments,
1141
1366
  depthStencilAttachment: {
1142
1367
  view: this.pipeline.getDepthTextureView(),
1143
1368
  depthClearValue: 0.0, // Reverse-Z: clear to 0.0 (far plane)
@@ -1189,6 +1414,50 @@ export class Renderer {
1189
1414
  // PERFORMANCE FIX: Track partially visible batches for sub-batch rendering
1190
1415
  // Instead of creating 10,000+ individual meshes, we create cached sub-batches
1191
1416
  const partiallyVisibleBatches = [];
1417
+ // Push a partial sub-batch entry, splitting by promotion when needed.
1418
+ // For transparent parent batches with mixed override membership, this
1419
+ // emits two entries (`:promoted` and `:remaining`) so non-overridden
1420
+ // batchmates keep their native transparent routing instead of getting
1421
+ // dragged opaque alongside the overridden ones.
1422
+ const pushVisibleAsPartial = (sourceBatch, visibleIds, isTransparent) => {
1423
+ const baseKey = `${sourceBatch.colorKey}:${sourceBatch.id}`;
1424
+ if (!isTransparent) {
1425
+ partiallyVisibleBatches.push({
1426
+ sourceBatchKey: baseKey,
1427
+ colorKey: sourceBatch.colorKey,
1428
+ visibleIds,
1429
+ color: sourceBatch.color,
1430
+ });
1431
+ return;
1432
+ }
1433
+ const split = splitVisibleIdsByPromotion(visibleIds, colorOverrides);
1434
+ // No promotion or every visible id promoted → single sub-batch,
1435
+ // classifier downstream routes via shouldRouteBatchTransparent.
1436
+ if (split == null || split.remaining.size === 0) {
1437
+ partiallyVisibleBatches.push({
1438
+ sourceBatchKey: baseKey,
1439
+ colorKey: sourceBatch.colorKey,
1440
+ visibleIds,
1441
+ color: sourceBatch.color,
1442
+ });
1443
+ return;
1444
+ }
1445
+ // Mixed — emit one promoted (opaque-routed) and one remaining
1446
+ // (transparent-routed) sub-batch. Distinct sourceBatchKeys so the
1447
+ // partial-batch cache can hold both simultaneously.
1448
+ partiallyVisibleBatches.push({
1449
+ sourceBatchKey: `${baseKey}:promoted`,
1450
+ colorKey: sourceBatch.colorKey,
1451
+ visibleIds: split.promoted,
1452
+ color: sourceBatch.color,
1453
+ });
1454
+ partiallyVisibleBatches.push({
1455
+ sourceBatchKey: `${baseKey}:remaining`,
1456
+ colorKey: sourceBatch.colorKey,
1457
+ visibleIds: split.remaining,
1458
+ color: sourceBatch.color,
1459
+ });
1460
+ };
1192
1461
  for (const batch of allBatchedMeshes) {
1193
1462
  // Frustum culling: skip batches entirely outside the camera view
1194
1463
  if (batch.bounds) {
@@ -1197,6 +1466,8 @@ export class Renderer {
1197
1466
  continue; // Entire batch is off-screen
1198
1467
  }
1199
1468
  }
1469
+ const alpha = alphaForBatch(batch, batch.color[3]);
1470
+ const nativelyTransparent = alpha < 0.99;
1200
1471
  // Check visibility
1201
1472
  if (hasVisibilityFiltering) {
1202
1473
  const vis = batchVisibility.get(batch);
@@ -1204,28 +1475,32 @@ export class Renderer {
1204
1475
  continue; // Skip completely hidden batches
1205
1476
  // Handle partially visible batches - create sub-batches instead of individual meshes
1206
1477
  if (!vis.fullyVisible) {
1207
- // Collect the visible expressIds from this batch
1208
1478
  const visibleIds = new Set();
1209
1479
  for (const expressId of batch.expressIds) {
1210
1480
  const isHidden = options.hiddenIds?.has(expressId) ?? false;
1211
1481
  const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
1212
- if (!isHidden && isIsolated) {
1482
+ if (!isHidden && isIsolated)
1213
1483
  visibleIds.add(expressId);
1214
- }
1215
1484
  }
1216
1485
  if (visibleIds.size > 0) {
1217
- partiallyVisibleBatches.push({
1218
- sourceBatchKey: `${batch.colorKey}:${batch.id}`,
1219
- colorKey: batch.colorKey,
1220
- visibleIds,
1221
- color: batch.color,
1222
- });
1486
+ pushVisibleAsPartial(batch, visibleIds, nativelyTransparent);
1223
1487
  }
1224
1488
  continue; // Don't add batch to render list
1225
1489
  }
1226
1490
  }
1227
- const alpha = alphaForBatch(batch, batch.color[3]);
1228
- if (alpha < 0.99) {
1491
+ // Fully visible (or no filtering). Transparent batches with mixed
1492
+ // override membership must be split so non-overridden batchmates
1493
+ // stay transparent — see splitVisibleIdsByPromotion / issue #677.
1494
+ if (nativelyTransparent) {
1495
+ const split = splitVisibleIdsByPromotion(batch.expressIds, colorOverrides);
1496
+ if (split != null && split.remaining.size > 0) {
1497
+ // Mixed promotion — re-route through the partial-batch path
1498
+ // with distinct sourceBatchKeys for each subset.
1499
+ pushVisibleAsPartial(batch, new Set(batch.expressIds), true);
1500
+ continue;
1501
+ }
1502
+ }
1503
+ if (shouldRouteBatchTransparent(alpha, batch.expressIds, colorOverrides)) {
1229
1504
  transparentBatches.push(batch);
1230
1505
  }
1231
1506
  else {
@@ -1319,8 +1594,10 @@ export class Renderer {
1319
1594
  const subBatch = this.scene.getOrCreatePartialBatch(sourceBatchKey, colorKey, visibleIds, device, this.pipeline);
1320
1595
  if (subBatch) {
1321
1596
  // 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;
1597
+ // (not the parent batch's color[3] — that ignores transparencyOverrides).
1598
+ // Promote to opaque if any expressId in the sub-batch carries a
1599
+ // lens/Pset colour override, so the overlay paint pass finds depth.
1600
+ const isTransparent = shouldRouteBatchTransparent(alphaForBatch(subBatch, color[3]), subBatch.expressIds, colorOverrides);
1324
1601
  if (isTransparent) {
1325
1602
  pass.setPipeline(this.pipeline.getTransparentPipeline());
1326
1603
  }
@@ -1595,6 +1872,11 @@ export class Renderer {
1595
1872
  isPreview: !options.sectionPlane.enabled, // Preview mode when not enabled
1596
1873
  min: options.sectionPlane.min,
1597
1874
  max: options.sectionPlane.max,
1875
+ // Custom-plane gizmo override (issue #243). When both
1876
+ // are set the gizmo bypasses the cardinal path; see
1877
+ // SectionPlaneRenderer.calculatePlaneVerticesFromNormal.
1878
+ normal: options.sectionPlane.normal,
1879
+ distance: options.sectionPlane.distance,
1598
1880
  });
1599
1881
  // Draw 2D section overlay on the section plane (when section is
1600
1882
  // active, not preview). The overlay is also the 3D SECTION CAP:
@@ -1671,8 +1953,21 @@ export class Renderer {
1671
1953
  });
1672
1954
  }
1673
1955
  device.queue.submit([encoder.finish()]);
1956
+ // Pop validation error scope and capture the exact error
1957
+ if (captureGpuError) {
1958
+ device.popErrorScope().then((error) => {
1959
+ if (error) {
1960
+ const msg = error.message || String(error);
1961
+ console.error('[WebGPU] Validation error in render pass:', msg);
1962
+ this.device._lastUncapturedError = `VALIDATION: ${msg}`;
1963
+ this.device._uncapturedErrorCount++;
1964
+ }
1965
+ });
1966
+ }
1674
1967
  }
1675
1968
  catch (error) {
1969
+ this._renderErrorCount++;
1970
+ this._lastRenderError = error instanceof Error ? error.message : String(error);
1676
1971
  // Handle WebGPU errors (e.g., device lost, invalid state)
1677
1972
  // Mark context as invalid so it gets reconfigured next frame
1678
1973
  this.device.invalidateContext();
@@ -1695,6 +1990,18 @@ export class Renderer {
1695
1990
  async pick(x, y, options) {
1696
1991
  return this.pickingManager.pick(x, y, options);
1697
1992
  }
1993
+ /**
1994
+ * GPU-based rectangle pick. Drag-select returns the set of
1995
+ * `expressId`s touched by any pixel inside `[x0,y0]..[x1,y1]`
1996
+ * (CSS pixels, canvas-relative). Both meshes and point clouds
1997
+ * participate.
1998
+ *
1999
+ * See `PickingManager.pickRect` for the visibility-filter +
2000
+ * limitation notes.
2001
+ */
2002
+ async pickRect(x0, y0, x1, y1, options) {
2003
+ return this.pickingManager.pickRect(x0, y0, x1, y1, options);
2004
+ }
1698
2005
  /**
1699
2006
  * Raycast into the scene to get precise 3D intersection point
1700
2007
  * This is more accurate than pick() as it returns the exact surface point
@@ -1785,15 +2092,34 @@ export class Renderer {
1785
2092
  return this.scene;
1786
2093
  }
1787
2094
  /**
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
2095
+ * Upload 2D section drawing data for 3D overlay rendering.
2096
+ *
2097
+ * Cardinal-axis call site: pass `axis` + `position` percentage and the
2098
+ * upload computes the plane offset along the cardinal axis using the
2099
+ * model bounds (or `sectionRange` override). 2D points are then lifted
2100
+ * to 3D via the cardinal-axis swap.
2101
+ *
2102
+ * Custom-plane call site (issue #243): pass `customPlane = { origin,
2103
+ * tangent, bitangent }`. The 2D points are lifted via the explicit
2104
+ * basis, exactly inverting the projection `SectionCutter` applied when
2105
+ * generating the polygons. Without this the cap silhouette lands off
2106
+ * the actual cutting plane (the bug PR #581 hid by suppressing the
2107
+ * cap entirely for non-cardinal planes).
1791
2108
  */
1792
2109
  uploadSection2DOverlay(polygons, lines, axis, position, // 0-100 percentage
1793
2110
  sectionRange, // Same storey-based range as section plane
1794
- flipped = false) {
2111
+ flipped = false, customPlane) {
1795
2112
  if (!this.section2DOverlayRenderer)
1796
2113
  return;
2114
+ if (customPlane) {
2115
+ // Custom-plane path: planePosition / axis are unused — the
2116
+ // basis the cap shader needs travels in `customPlane`. We pass
2117
+ // 0 for `planePosition` and the existing `axis` so the cardinal
2118
+ // shader code path that callers depend on (e.g. legacy SVG
2119
+ // export) keeps working when customPlane is omitted.
2120
+ this.section2DOverlayRenderer.uploadDrawing(polygons, lines, axis, 0, flipped, customPlane);
2121
+ return;
2122
+ }
1797
2123
  // Use EXACTLY same calculation as section plane in render() method:
1798
2124
  // minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
1799
2125
  // maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
@@ -1901,6 +2227,12 @@ export class Renderer {
1901
2227
  // Point cloud GPU resources
1902
2228
  this.pointCloudRenderer?.clear();
1903
2229
  this.pointCloudRenderer = null;
2230
+ // BIM ↔ scan deviation pipeline + cached BVH GPU buffers.
2231
+ // Done before queue.destroy() so the GPU calls inside
2232
+ // `destroy()` still have a valid device.
2233
+ this.deviationPipeline?.destroy();
2234
+ this.deviationPipeline = null;
2235
+ this.deviationBvhFingerprint = null;
1904
2236
  // Snap detector geometry cache
1905
2237
  this.raycastEngine.clearCaches();
1906
2238
  }