@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.
- package/dist/deviation/deviation-pipeline.d.ts +48 -0
- package/dist/deviation/deviation-pipeline.d.ts.map +1 -0
- package/dist/deviation/deviation-pipeline.js +163 -0
- package/dist/deviation/deviation-pipeline.js.map +1 -0
- package/dist/deviation/deviation-shader.wgsl.d.ts +23 -0
- package/dist/deviation/deviation-shader.wgsl.d.ts.map +1 -0
- package/dist/deviation/deviation-shader.wgsl.js +237 -0
- package/dist/deviation/deviation-shader.wgsl.js.map +1 -0
- package/dist/deviation/triangle-bvh.d.ts +58 -0
- package/dist/deviation/triangle-bvh.d.ts.map +1 -0
- package/dist/deviation/triangle-bvh.js +255 -0
- package/dist/deviation/triangle-bvh.js.map +1 -0
- package/dist/device.d.ts +3 -0
- package/dist/device.d.ts.map +1 -1
- package/dist/device.js +10 -0
- package/dist/device.js.map +1 -1
- package/dist/index.d.ts +98 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +437 -105
- package/dist/index.js.map +1 -1
- package/dist/overlay-routing.d.ts +63 -0
- package/dist/overlay-routing.d.ts.map +1 -0
- package/dist/overlay-routing.js +98 -0
- package/dist/overlay-routing.js.map +1 -0
- package/dist/picker.d.ts +18 -0
- package/dist/picker.d.ts.map +1 -1
- package/dist/picker.js +182 -70
- package/dist/picker.js.map +1 -1
- package/dist/picking-manager.d.ts +18 -0
- package/dist/picking-manager.d.ts.map +1 -1
- package/dist/picking-manager.js +40 -0
- package/dist/picking-manager.js.map +1 -1
- package/dist/pointcloud/point-cloud-node.d.ts +7 -0
- package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
- package/dist/pointcloud/point-cloud-node.js +38 -1
- package/dist/pointcloud/point-cloud-node.js.map +1 -1
- package/dist/pointcloud/point-cloud-renderer.d.ts +35 -4
- package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -1
- package/dist/pointcloud/point-cloud-renderer.js +52 -55
- package/dist/pointcloud/point-cloud-renderer.js.map +1 -1
- package/dist/pointcloud/point-cloud-uniforms.d.ts +36 -0
- package/dist/pointcloud/point-cloud-uniforms.d.ts.map +1 -0
- package/dist/pointcloud/point-cloud-uniforms.js +89 -0
- package/dist/pointcloud/point-cloud-uniforms.js.map +1 -0
- package/dist/pointcloud/point-pipeline.d.ts +4 -2
- package/dist/pointcloud/point-pipeline.d.ts.map +1 -1
- package/dist/pointcloud/point-pipeline.js +17 -2
- package/dist/pointcloud/point-pipeline.js.map +1 -1
- package/dist/pointcloud/point-shader.wgsl.d.ts +1 -1
- package/dist/pointcloud/point-shader.wgsl.d.ts.map +1 -1
- package/dist/pointcloud/point-shader.wgsl.js +78 -2
- package/dist/pointcloud/point-shader.wgsl.js.map +1 -1
- package/dist/scene.d.ts +26 -0
- package/dist/scene.d.ts.map +1 -1
- package/dist/scene.js +45 -1
- package/dist/scene.js.map +1 -1
- package/dist/section-2d-overlay.d.ts +24 -5
- package/dist/section-2d-overlay.d.ts.map +1 -1
- package/dist/section-2d-overlay.js +42 -13
- package/dist/section-2d-overlay.js.map +1 -1
- package/dist/section-plane-basis.d.ts +64 -0
- package/dist/section-plane-basis.d.ts.map +1 -0
- package/dist/section-plane-basis.js +86 -0
- package/dist/section-plane-basis.js.map +1 -0
- package/dist/section-plane.d.ts +18 -0
- package/dist/section-plane.d.ts.map +1 -1
- package/dist/section-plane.js +89 -6
- package/dist/section-plane.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- 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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
992
|
-
//
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
if (
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
//
|
|
1006
|
-
|
|
1007
|
-
const
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
normal[1]
|
|
1016
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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 +
|
|
1052
|
-
//
|
|
1053
|
-
// without asking the user to run a
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1228
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1790
|
-
*
|
|
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
|
}
|