@ifc-lite/renderer 1.2.1 → 1.4.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.
- package/dist/camera.d.ts +4 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +6 -0
- package/dist/camera.js.map +1 -1
- package/dist/constants.d.ts +125 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +124 -0
- package/dist/constants.js.map +1 -0
- package/dist/device.d.ts.map +1 -1
- package/dist/device.js +2 -1
- package/dist/device.js.map +1 -1
- package/dist/federation-registry.d.ts +98 -0
- package/dist/federation-registry.d.ts.map +1 -0
- package/dist/federation-registry.js +197 -0
- package/dist/federation-registry.js.map +1 -0
- package/dist/index.d.ts +62 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +271 -98
- package/dist/index.js.map +1 -1
- package/dist/picker.d.ts +3 -2
- package/dist/picker.d.ts.map +1 -1
- package/dist/picker.js +19 -10
- package/dist/picker.js.map +1 -1
- package/dist/scene.d.ts +32 -8
- package/dist/scene.d.ts.map +1 -1
- package/dist/scene.js +167 -16
- package/dist/scene.js.map +1 -1
- package/dist/section-2d-overlay.d.ts +104 -0
- package/dist/section-2d-overlay.d.ts.map +1 -0
- package/dist/section-2d-overlay.js +437 -0
- package/dist/section-2d-overlay.js.map +1 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -11,9 +11,11 @@ export { Scene } from './scene.js';
|
|
|
11
11
|
export { Picker } from './picker.js';
|
|
12
12
|
export { MathUtils } from './math.js';
|
|
13
13
|
export { SectionPlaneRenderer } from './section-plane.js';
|
|
14
|
+
export { Section2DOverlayRenderer } from './section-2d-overlay.js';
|
|
14
15
|
export { Raycaster } from './raycaster.js';
|
|
15
16
|
export { SnapDetector, SnapType } from './snap-detector.js';
|
|
16
17
|
export { BVH } from './bvh.js';
|
|
18
|
+
export { FederationRegistry, federationRegistry } from './federation-registry.js';
|
|
17
19
|
export * from './types.js';
|
|
18
20
|
// Zero-copy GPU upload (new - faster, less memory)
|
|
19
21
|
export { ZeroCopyGpuUploader, createZeroCopyUploader, } from './zero-copy-uploader.js';
|
|
@@ -24,6 +26,7 @@ import { Scene } from './scene.js';
|
|
|
24
26
|
import { Picker } from './picker.js';
|
|
25
27
|
import { FrustumUtils } from '@ifc-lite/spatial';
|
|
26
28
|
import { SectionPlaneRenderer } from './section-plane.js';
|
|
29
|
+
import { Section2DOverlayRenderer } from './section-2d-overlay.js';
|
|
27
30
|
import { deduplicateMeshes } from '@ifc-lite/geometry';
|
|
28
31
|
import { MathUtils } from './math.js';
|
|
29
32
|
import { Raycaster } from './raycaster.js';
|
|
@@ -41,6 +44,7 @@ export class Renderer {
|
|
|
41
44
|
picker = null;
|
|
42
45
|
canvas;
|
|
43
46
|
sectionPlaneRenderer = null;
|
|
47
|
+
section2DOverlayRenderer = null;
|
|
44
48
|
modelBounds = null;
|
|
45
49
|
raycaster;
|
|
46
50
|
snapDetector;
|
|
@@ -49,6 +53,9 @@ export class Renderer {
|
|
|
49
53
|
bvhCache = null;
|
|
50
54
|
// Performance constants
|
|
51
55
|
BVH_THRESHOLD = 100;
|
|
56
|
+
// Error rate limiting (log at most once per second)
|
|
57
|
+
lastRenderErrorTime = 0;
|
|
58
|
+
RENDER_ERROR_THROTTLE_MS = 1000;
|
|
52
59
|
constructor(canvas) {
|
|
53
60
|
this.canvas = canvas;
|
|
54
61
|
this.device = new WebGPUDevice();
|
|
@@ -77,8 +84,96 @@ export class Renderer {
|
|
|
77
84
|
this.instancedPipeline = new InstancedRenderPipeline(this.device, width, height);
|
|
78
85
|
this.picker = new Picker(this.device, width, height);
|
|
79
86
|
this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
|
|
87
|
+
this.section2DOverlayRenderer = new Section2DOverlayRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
|
|
80
88
|
this.camera.setAspect(width / height);
|
|
81
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Load geometry from GeometryResult or MeshData array
|
|
92
|
+
* This is the main entry point for loading IFC geometry into the renderer
|
|
93
|
+
*
|
|
94
|
+
* @param geometry - Either a GeometryResult from geometry.process() or an array of MeshData
|
|
95
|
+
*/
|
|
96
|
+
loadGeometry(geometry) {
|
|
97
|
+
if (!this.device.isInitialized() || !this.pipeline) {
|
|
98
|
+
throw new Error('Renderer not initialized. Call init() first.');
|
|
99
|
+
}
|
|
100
|
+
const meshes = Array.isArray(geometry) ? geometry : geometry.meshes;
|
|
101
|
+
if (meshes.length === 0) {
|
|
102
|
+
console.warn('[Renderer] loadGeometry called with empty mesh array');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Use batched rendering for optimal performance
|
|
106
|
+
const device = this.device.getDevice();
|
|
107
|
+
this.scene.appendToBatches(meshes, device, this.pipeline, false);
|
|
108
|
+
// Calculate and store model bounds for fitToView
|
|
109
|
+
this.updateModelBounds(meshes);
|
|
110
|
+
console.log(`[Renderer] Loaded ${meshes.length} meshes`);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Add multiple meshes to the scene (convenience method for streaming)
|
|
114
|
+
*
|
|
115
|
+
* @param meshes - Array of MeshData to add
|
|
116
|
+
* @param isStreaming - If true, throttles batch rebuilding for better streaming performance
|
|
117
|
+
*/
|
|
118
|
+
addMeshes(meshes, isStreaming = false) {
|
|
119
|
+
if (!this.device.isInitialized() || !this.pipeline) {
|
|
120
|
+
throw new Error('Renderer not initialized. Call init() first.');
|
|
121
|
+
}
|
|
122
|
+
if (meshes.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
const device = this.device.getDevice();
|
|
125
|
+
this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
|
|
126
|
+
// Update model bounds incrementally
|
|
127
|
+
this.updateModelBounds(meshes);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Update model bounds from mesh data
|
|
131
|
+
*/
|
|
132
|
+
updateModelBounds(meshes) {
|
|
133
|
+
if (!this.modelBounds) {
|
|
134
|
+
this.modelBounds = {
|
|
135
|
+
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
136
|
+
max: { x: -Infinity, y: -Infinity, z: -Infinity }
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
for (const mesh of meshes) {
|
|
140
|
+
const positions = mesh.positions;
|
|
141
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
142
|
+
const x = positions[i];
|
|
143
|
+
const y = positions[i + 1];
|
|
144
|
+
const z = positions[i + 2];
|
|
145
|
+
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
|
|
146
|
+
this.modelBounds.min.x = Math.min(this.modelBounds.min.x, x);
|
|
147
|
+
this.modelBounds.min.y = Math.min(this.modelBounds.min.y, y);
|
|
148
|
+
this.modelBounds.min.z = Math.min(this.modelBounds.min.z, z);
|
|
149
|
+
this.modelBounds.max.x = Math.max(this.modelBounds.max.x, x);
|
|
150
|
+
this.modelBounds.max.y = Math.max(this.modelBounds.max.y, y);
|
|
151
|
+
this.modelBounds.max.z = Math.max(this.modelBounds.max.z, z);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Fit camera to view all loaded geometry
|
|
158
|
+
*/
|
|
159
|
+
fitToView() {
|
|
160
|
+
if (!this.modelBounds) {
|
|
161
|
+
console.warn('[Renderer] fitToView called but no geometry loaded');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const { min, max } = this.modelBounds;
|
|
165
|
+
// Calculate center and size
|
|
166
|
+
const center = {
|
|
167
|
+
x: (min.x + max.x) / 2,
|
|
168
|
+
y: (min.y + max.y) / 2,
|
|
169
|
+
z: (min.z + max.z) / 2
|
|
170
|
+
};
|
|
171
|
+
const size = Math.max(max.x - min.x, max.y - min.y, max.z - min.z);
|
|
172
|
+
// Position camera to see entire model
|
|
173
|
+
const distance = size * 1.5;
|
|
174
|
+
this.camera.setPosition(center.x + distance * 0.5, center.y + distance * 0.5, center.z + distance);
|
|
175
|
+
this.camera.setTarget(center.x, center.y, center.z);
|
|
176
|
+
}
|
|
82
177
|
/**
|
|
83
178
|
* Add mesh to scene with per-mesh GPU resources for unique colors
|
|
84
179
|
*/
|
|
@@ -308,6 +403,7 @@ export class Renderer {
|
|
|
308
403
|
// Add to scene with identity transform (positions already in world space)
|
|
309
404
|
this.scene.addMesh({
|
|
310
405
|
expressId: meshData.expressId,
|
|
406
|
+
modelIndex: meshData.modelIndex, // Preserve modelIndex for multi-model selection
|
|
311
407
|
vertexBuffer,
|
|
312
408
|
indexBuffer,
|
|
313
409
|
indexCount: meshData.indices.length,
|
|
@@ -356,11 +452,13 @@ export class Renderer {
|
|
|
356
452
|
if (!this.device.isInitialized() || !this.pipeline)
|
|
357
453
|
return;
|
|
358
454
|
// Validate canvas dimensions
|
|
455
|
+
// Align width to 64 pixels for WebGPU texture row alignment (256 bytes / 4 bytes per pixel)
|
|
359
456
|
const rect = this.canvas.getBoundingClientRect();
|
|
360
|
-
const
|
|
457
|
+
const rawWidth = Math.max(1, Math.floor(rect.width));
|
|
458
|
+
const width = Math.max(64, Math.floor(rawWidth / 64) * 64);
|
|
361
459
|
const height = Math.max(1, Math.floor(rect.height));
|
|
362
460
|
// Skip rendering if canvas is too small
|
|
363
|
-
if (width <
|
|
461
|
+
if (width < 64 || height < 10)
|
|
364
462
|
return;
|
|
365
463
|
// Update canvas pixel dimensions if needed
|
|
366
464
|
const dimensionsChanged = this.canvas.width !== width || this.canvas.height !== height;
|
|
@@ -459,6 +557,7 @@ export class Renderer {
|
|
|
459
557
|
const allMeshes = [...opaqueMeshes, ...transparentMeshes];
|
|
460
558
|
const selectedId = options.selectedId;
|
|
461
559
|
const selectedIds = options.selectedIds;
|
|
560
|
+
const selectedModelIndex = options.selectedModelIndex;
|
|
462
561
|
// Calculate section plane parameters and model bounds
|
|
463
562
|
// Always calculate bounds when sectionPlane is provided (for preview and active mode)
|
|
464
563
|
let sectionPlaneData;
|
|
@@ -526,7 +625,10 @@ export class Renderer {
|
|
|
526
625
|
buffer.set(viewProj, 0);
|
|
527
626
|
buffer.set(mesh.transform.m, 16);
|
|
528
627
|
// Check if mesh is selected (single or multi-selection)
|
|
529
|
-
|
|
628
|
+
// For multi-model support: also check modelIndex if provided
|
|
629
|
+
const expressIdMatch = mesh.expressId === selectedId;
|
|
630
|
+
const modelIndexMatch = selectedModelIndex === undefined || mesh.modelIndex === selectedModelIndex;
|
|
631
|
+
const isSelected = (selectedId !== undefined && selectedId !== null && expressIdMatch && modelIndexMatch)
|
|
530
632
|
|| (selectedIds !== undefined && selectedIds.has(mesh.expressId));
|
|
531
633
|
// Apply selection highlight effect
|
|
532
634
|
if (isSelected) {
|
|
@@ -612,26 +714,33 @@ export class Renderer {
|
|
|
612
714
|
// need individual mesh rendering to show only the visible elements
|
|
613
715
|
const opaqueBatches = [];
|
|
614
716
|
const transparentBatches = [];
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
const
|
|
717
|
+
// PERFORMANCE FIX: Track partially visible batches for sub-batch rendering
|
|
718
|
+
// Instead of creating 10,000+ individual meshes, we create cached sub-batches
|
|
719
|
+
const partiallyVisibleBatches = [];
|
|
618
720
|
for (const batch of allBatchedMeshes) {
|
|
619
721
|
// Check visibility
|
|
620
722
|
if (hasVisibilityFiltering) {
|
|
621
723
|
const vis = batchVisibility.get(batch.colorKey);
|
|
622
724
|
if (!vis || !vis.visible)
|
|
623
725
|
continue; // Skip completely hidden batches
|
|
624
|
-
//
|
|
625
|
-
// will be rendered individually below
|
|
726
|
+
// Handle partially visible batches - create sub-batches instead of individual meshes
|
|
626
727
|
if (!vis.fullyVisible) {
|
|
627
728
|
// Collect the visible expressIds from this batch
|
|
729
|
+
const visibleIds = new Set();
|
|
628
730
|
for (const expressId of batch.expressIds) {
|
|
629
731
|
const isHidden = options.hiddenIds?.has(expressId) ?? false;
|
|
630
732
|
const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
|
|
631
733
|
if (!isHidden && isIsolated) {
|
|
632
|
-
|
|
734
|
+
visibleIds.add(expressId);
|
|
633
735
|
}
|
|
634
736
|
}
|
|
737
|
+
if (visibleIds.size > 0) {
|
|
738
|
+
partiallyVisibleBatches.push({
|
|
739
|
+
colorKey: batch.colorKey,
|
|
740
|
+
visibleIds,
|
|
741
|
+
color: batch.color,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
635
744
|
continue; // Don't add batch to render list
|
|
636
745
|
}
|
|
637
746
|
}
|
|
@@ -694,77 +803,32 @@ export class Renderer {
|
|
|
694
803
|
for (const batch of opaqueBatches) {
|
|
695
804
|
renderBatch(batch);
|
|
696
805
|
}
|
|
697
|
-
// Render partially visible
|
|
698
|
-
// This is the
|
|
806
|
+
// PERFORMANCE FIX: Render partially visible batches as sub-batches (not individual meshes!)
|
|
807
|
+
// This is the key optimization: instead of 10,000+ individual draw calls,
|
|
808
|
+
// we create cached sub-batches with only visible elements and render them as single draw calls
|
|
699
809
|
const allMeshes = this.scene.getMeshes();
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
});
|
|
719
|
-
mesh.bindGroup = device.createBindGroup({
|
|
720
|
-
layout: this.pipeline.getBindGroupLayout(),
|
|
721
|
-
entries: [
|
|
722
|
-
{
|
|
723
|
-
binding: 0,
|
|
724
|
-
resource: { buffer: mesh.uniformBuffer },
|
|
725
|
-
},
|
|
726
|
-
],
|
|
727
|
-
});
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
// Render partially visible meshes (not selected, normal rendering)
|
|
731
|
-
for (const mesh of partialMeshes) {
|
|
732
|
-
if (!mesh.bindGroup || !mesh.uniformBuffer) {
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
const buffer = new Float32Array(48);
|
|
736
|
-
const flagBuffer = new Uint32Array(buffer.buffer, 176, 4);
|
|
737
|
-
buffer.set(viewProj, 0);
|
|
738
|
-
buffer.set(mesh.transform.m, 16);
|
|
739
|
-
buffer.set(mesh.color, 32);
|
|
740
|
-
buffer[36] = mesh.material?.metallic ?? 0.0;
|
|
741
|
-
buffer[37] = mesh.material?.roughness ?? 0.6;
|
|
742
|
-
// Section plane data
|
|
743
|
-
if (sectionPlaneData) {
|
|
744
|
-
buffer[40] = sectionPlaneData.normal[0];
|
|
745
|
-
buffer[41] = sectionPlaneData.normal[1];
|
|
746
|
-
buffer[42] = sectionPlaneData.normal[2];
|
|
747
|
-
buffer[43] = sectionPlaneData.distance;
|
|
748
|
-
}
|
|
749
|
-
// Flags (not selected)
|
|
750
|
-
flagBuffer[0] = 0;
|
|
751
|
-
flagBuffer[1] = sectionPlaneData?.enabled ? 1 : 0;
|
|
752
|
-
flagBuffer[2] = 0;
|
|
753
|
-
flagBuffer[3] = 0;
|
|
754
|
-
device.queue.writeBuffer(mesh.uniformBuffer, 0, buffer);
|
|
755
|
-
// Use opaque or transparent pipeline based on alpha
|
|
756
|
-
const isTransparent = mesh.color[3] < 0.99;
|
|
757
|
-
if (isTransparent) {
|
|
758
|
-
pass.setPipeline(this.pipeline.getTransparentPipeline());
|
|
759
|
-
}
|
|
760
|
-
else {
|
|
761
|
-
pass.setPipeline(this.pipeline.getPipeline());
|
|
810
|
+
// Track existing meshes by (expressId:modelIndex) to handle multi-model expressId collisions
|
|
811
|
+
// E.g., door #535 in model 0 vs beam #535 in model 1 need separate tracking
|
|
812
|
+
const existingMeshKeys = new Set(allMeshes.map(m => `${m.expressId}:${m.modelIndex ?? 'any'}`));
|
|
813
|
+
if (partiallyVisibleBatches.length > 0) {
|
|
814
|
+
for (const { colorKey, visibleIds, color } of partiallyVisibleBatches) {
|
|
815
|
+
// Get or create a cached sub-batch for this visibility state
|
|
816
|
+
const subBatch = this.scene.getOrCreatePartialBatch(colorKey, visibleIds, device, this.pipeline);
|
|
817
|
+
if (subBatch) {
|
|
818
|
+
// Use opaque or transparent pipeline based on alpha
|
|
819
|
+
const isTransparent = color[3] < 0.99;
|
|
820
|
+
if (isTransparent) {
|
|
821
|
+
pass.setPipeline(this.pipeline.getTransparentPipeline());
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
pass.setPipeline(this.pipeline.getPipeline());
|
|
825
|
+
}
|
|
826
|
+
// Render the sub-batch as a single draw call
|
|
827
|
+
renderBatch(subBatch);
|
|
762
828
|
}
|
|
763
|
-
pass.setBindGroup(0, mesh.bindGroup);
|
|
764
|
-
pass.setVertexBuffer(0, mesh.vertexBuffer);
|
|
765
|
-
pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
|
|
766
|
-
pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
|
|
767
829
|
}
|
|
830
|
+
// Reset to opaque pipeline for subsequent rendering
|
|
831
|
+
pass.setPipeline(this.pipeline.getPipeline());
|
|
768
832
|
}
|
|
769
833
|
// Render selected meshes individually for proper highlighting
|
|
770
834
|
// First, check if we have Mesh objects for selected IDs
|
|
@@ -782,15 +846,26 @@ export class Renderer {
|
|
|
782
846
|
visibleSelectedIds.add(selId);
|
|
783
847
|
}
|
|
784
848
|
// Create GPU resources lazily for visible selected meshes that don't have them yet
|
|
849
|
+
// Pass selectedModelIndex to get mesh data from the correct model (for multi-model support)
|
|
850
|
+
// Use composite key to handle expressId collisions between models
|
|
785
851
|
for (const selId of visibleSelectedIds) {
|
|
786
|
-
|
|
787
|
-
|
|
852
|
+
const meshKey = `${selId}:${selectedModelIndex ?? 'any'}`;
|
|
853
|
+
if (!existingMeshKeys.has(meshKey) && this.scene.hasMeshData(selId, selectedModelIndex)) {
|
|
854
|
+
const meshData = this.scene.getMeshData(selId, selectedModelIndex);
|
|
788
855
|
this.createMeshFromData(meshData);
|
|
789
|
-
|
|
856
|
+
existingMeshKeys.add(meshKey);
|
|
790
857
|
}
|
|
791
858
|
}
|
|
792
859
|
// Now get selected meshes (only visible ones)
|
|
793
|
-
|
|
860
|
+
// For multi-model support: also filter by modelIndex if provided
|
|
861
|
+
const selectedMeshes = this.scene.getMeshes().filter(mesh => {
|
|
862
|
+
if (!visibleSelectedIds.has(mesh.expressId))
|
|
863
|
+
return false;
|
|
864
|
+
// If selectedModelIndex is provided, also match modelIndex
|
|
865
|
+
if (selectedModelIndex !== undefined && mesh.modelIndex !== selectedModelIndex)
|
|
866
|
+
return false;
|
|
867
|
+
return true;
|
|
868
|
+
});
|
|
794
869
|
// Ensure selected meshes have uniform buffers and bind groups
|
|
795
870
|
for (const mesh of selectedMeshes) {
|
|
796
871
|
if (!mesh.uniformBuffer && this.pipeline) {
|
|
@@ -945,6 +1020,17 @@ export class Renderer {
|
|
|
945
1020
|
min: options.sectionPlane.min,
|
|
946
1021
|
max: options.sectionPlane.max,
|
|
947
1022
|
});
|
|
1023
|
+
// Draw 2D section overlay on the section plane (when section is active, not preview)
|
|
1024
|
+
if (options.sectionPlane.enabled && this.section2DOverlayRenderer?.hasGeometry()) {
|
|
1025
|
+
this.section2DOverlayRenderer.draw(pass, {
|
|
1026
|
+
axis: options.sectionPlane.axis,
|
|
1027
|
+
position: options.sectionPlane.position,
|
|
1028
|
+
bounds: this.modelBounds,
|
|
1029
|
+
viewProj,
|
|
1030
|
+
min: options.sectionPlane.min,
|
|
1031
|
+
max: options.sectionPlane.max,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
948
1034
|
}
|
|
949
1035
|
pass.end();
|
|
950
1036
|
device.queue.submit([encoder.finish()]);
|
|
@@ -953,8 +1039,10 @@ export class Renderer {
|
|
|
953
1039
|
// Handle WebGPU errors (e.g., device lost, invalid state)
|
|
954
1040
|
// Mark context as invalid so it gets reconfigured next frame
|
|
955
1041
|
this.device.invalidateContext();
|
|
956
|
-
//
|
|
957
|
-
|
|
1042
|
+
// Rate-limit error logging to avoid spam (max once per second)
|
|
1043
|
+
const now = performance.now();
|
|
1044
|
+
if (now - this.lastRenderErrorTime > this.RENDER_ERROR_THROTTLE_MS) {
|
|
1045
|
+
this.lastRenderErrorTime = now;
|
|
958
1046
|
console.warn('Render error (context will be reconfigured):', error);
|
|
959
1047
|
}
|
|
960
1048
|
}
|
|
@@ -962,6 +1050,7 @@ export class Renderer {
|
|
|
962
1050
|
/**
|
|
963
1051
|
* Pick object at screen coordinates
|
|
964
1052
|
* Respects visibility filtering so users can only select visible elements
|
|
1053
|
+
* Returns PickResult with expressId and modelIndex for multi-model support
|
|
965
1054
|
*/
|
|
966
1055
|
async pick(x, y, options) {
|
|
967
1056
|
if (!this.picker) {
|
|
@@ -985,19 +1074,27 @@ export class Renderer {
|
|
|
985
1074
|
expressIds.add(expressId);
|
|
986
1075
|
}
|
|
987
1076
|
}
|
|
988
|
-
// Track existing
|
|
989
|
-
|
|
1077
|
+
// Track existing meshes by (expressId:modelIndex) for multi-model support
|
|
1078
|
+
// This handles expressId collisions (e.g., door #535 in model 0 vs beam #535 in model 1)
|
|
1079
|
+
const existingMeshKeys = new Set(meshes.map(m => `${m.expressId}:${m.modelIndex ?? 'any'}`));
|
|
990
1080
|
// Count how many meshes we'd need to create for full GPU picking
|
|
1081
|
+
// For multi-model, count all pieces with unique (expressId, modelIndex) pairs
|
|
991
1082
|
let toCreate = 0;
|
|
992
1083
|
for (const expressId of expressIds) {
|
|
993
|
-
if (existingExpressIds.has(expressId))
|
|
994
|
-
continue;
|
|
995
1084
|
if (options?.hiddenIds?.has(expressId))
|
|
996
1085
|
continue;
|
|
997
1086
|
if (options?.isolatedIds !== null && options?.isolatedIds !== undefined && !options.isolatedIds.has(expressId))
|
|
998
1087
|
continue;
|
|
999
|
-
|
|
1000
|
-
|
|
1088
|
+
// Get all pieces for this expressId (handles multi-model)
|
|
1089
|
+
const pieces = this.scene.getMeshDataPieces(expressId);
|
|
1090
|
+
if (pieces) {
|
|
1091
|
+
for (const piece of pieces) {
|
|
1092
|
+
const meshKey = `${expressId}:${piece.modelIndex ?? 'any'}`;
|
|
1093
|
+
if (!existingMeshKeys.has(meshKey)) {
|
|
1094
|
+
toCreate++;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1001
1098
|
}
|
|
1002
1099
|
// PERFORMANCE FIX: Use CPU raycasting for large models instead of creating GPU meshes
|
|
1003
1100
|
// GPU picking requires individual mesh buffers; for 60K+ elements this is too slow
|
|
@@ -1007,25 +1104,34 @@ export class Renderer {
|
|
|
1007
1104
|
// Use CPU raycasting fallback - works regardless of how many individual meshes exist
|
|
1008
1105
|
const ray = this.camera.unprojectToRay(x, y, this.canvas.width, this.canvas.height);
|
|
1009
1106
|
const hit = this.scene.raycast(ray.origin, ray.direction, options?.hiddenIds, options?.isolatedIds);
|
|
1010
|
-
|
|
1107
|
+
if (!hit)
|
|
1108
|
+
return null;
|
|
1109
|
+
// CPU raycasting returns expressId and modelIndex
|
|
1110
|
+
return {
|
|
1111
|
+
expressId: hit.expressId,
|
|
1112
|
+
modelIndex: hit.modelIndex,
|
|
1113
|
+
};
|
|
1011
1114
|
}
|
|
1012
1115
|
// For smaller models, create GPU meshes for picking
|
|
1013
1116
|
// Only create meshes for VISIBLE elements (not hidden, and either no isolation or in isolated set)
|
|
1117
|
+
// For multi-model support: create meshes for ALL (expressId, modelIndex) pairs
|
|
1014
1118
|
for (const expressId of expressIds) {
|
|
1015
|
-
// Skip if already exists
|
|
1016
|
-
if (existingExpressIds.has(expressId))
|
|
1017
|
-
continue;
|
|
1018
1119
|
// Skip if hidden
|
|
1019
1120
|
if (options?.hiddenIds?.has(expressId))
|
|
1020
1121
|
continue;
|
|
1021
1122
|
// Skip if isolation is active and this entity is not isolated
|
|
1022
1123
|
if (options?.isolatedIds !== null && options?.isolatedIds !== undefined && !options.isolatedIds.has(expressId))
|
|
1023
1124
|
continue;
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1125
|
+
// Get all pieces for this expressId (handles multi-model)
|
|
1126
|
+
const pieces = this.scene.getMeshDataPieces(expressId);
|
|
1127
|
+
if (pieces) {
|
|
1128
|
+
for (const piece of pieces) {
|
|
1129
|
+
const meshKey = `${piece.expressId}:${piece.modelIndex ?? 'any'}`;
|
|
1130
|
+
// Skip if mesh already exists for this (expressId, modelIndex) pair
|
|
1131
|
+
if (existingMeshKeys.has(meshKey))
|
|
1132
|
+
continue;
|
|
1133
|
+
this.createMeshFromData(piece);
|
|
1134
|
+
existingMeshKeys.add(meshKey);
|
|
1029
1135
|
}
|
|
1030
1136
|
}
|
|
1031
1137
|
}
|
|
@@ -1278,6 +1384,43 @@ export class Renderer {
|
|
|
1278
1384
|
getScene() {
|
|
1279
1385
|
return this.scene;
|
|
1280
1386
|
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Upload 2D section drawing data for 3D overlay rendering
|
|
1389
|
+
* Call this when a 2D drawing is generated to display it on the section plane
|
|
1390
|
+
* Uses same position calculation as section plane: sectionRange min/max if provided, else modelBounds
|
|
1391
|
+
*/
|
|
1392
|
+
uploadSection2DOverlay(polygons, lines, axis, position, // 0-100 percentage
|
|
1393
|
+
sectionRange, // Same storey-based range as section plane
|
|
1394
|
+
flipped = false) {
|
|
1395
|
+
if (!this.section2DOverlayRenderer)
|
|
1396
|
+
return;
|
|
1397
|
+
// Use EXACTLY same calculation as section plane in render() method:
|
|
1398
|
+
// minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
|
|
1399
|
+
// maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
|
|
1400
|
+
const axisIdx = axis === 'side' ? 'x' : axis === 'down' ? 'y' : 'z';
|
|
1401
|
+
// Allow upload if either sectionRange has both values, or modelBounds exists as fallback
|
|
1402
|
+
const hasFullRange = sectionRange?.min !== undefined && sectionRange?.max !== undefined;
|
|
1403
|
+
if (!hasFullRange && !this.modelBounds)
|
|
1404
|
+
return;
|
|
1405
|
+
const minVal = sectionRange?.min ?? this.modelBounds.min[axisIdx];
|
|
1406
|
+
const maxVal = sectionRange?.max ?? this.modelBounds.max[axisIdx];
|
|
1407
|
+
const planePosition = minVal + (position / 100) * (maxVal - minVal);
|
|
1408
|
+
this.section2DOverlayRenderer.uploadDrawing(polygons, lines, axis, planePosition, flipped);
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Clear the 2D section overlay
|
|
1412
|
+
*/
|
|
1413
|
+
clearSection2DOverlay() {
|
|
1414
|
+
if (this.section2DOverlayRenderer) {
|
|
1415
|
+
this.section2DOverlayRenderer.clearGeometry();
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Check if 2D section overlay has geometry to render
|
|
1420
|
+
*/
|
|
1421
|
+
hasSection2DOverlay() {
|
|
1422
|
+
return this.section2DOverlayRenderer?.hasGeometry() ?? false;
|
|
1423
|
+
}
|
|
1281
1424
|
/**
|
|
1282
1425
|
* Get render pipeline (for batching)
|
|
1283
1426
|
*/
|
|
@@ -1299,5 +1442,35 @@ export class Renderer {
|
|
|
1299
1442
|
}
|
|
1300
1443
|
return this.device.getDevice();
|
|
1301
1444
|
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Capture a screenshot of the current view
|
|
1447
|
+
* Waits for GPU work to complete and captures exactly what's displayed
|
|
1448
|
+
* @returns PNG data URL or null if capture failed
|
|
1449
|
+
*/
|
|
1450
|
+
async captureScreenshot() {
|
|
1451
|
+
if (!this.device.isInitialized()) {
|
|
1452
|
+
console.warn('[Renderer] Cannot capture screenshot: not initialized');
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
try {
|
|
1456
|
+
// Wait for any pending GPU work to complete before capturing
|
|
1457
|
+
// This ensures we capture the fully rendered frame
|
|
1458
|
+
const device = this.device.getDevice();
|
|
1459
|
+
await device.queue.onSubmittedWorkDone();
|
|
1460
|
+
// Capture exactly what's displayed on the canvas
|
|
1461
|
+
const dataUrl = this.canvas.toDataURL('image/png');
|
|
1462
|
+
return dataUrl;
|
|
1463
|
+
}
|
|
1464
|
+
catch (error) {
|
|
1465
|
+
console.error('[Renderer] Screenshot capture failed:', error);
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Get the canvas element
|
|
1471
|
+
*/
|
|
1472
|
+
getCanvas() {
|
|
1473
|
+
return this.canvas;
|
|
1474
|
+
}
|
|
1302
1475
|
}
|
|
1303
1476
|
//# sourceMappingURL=index.js.map
|