@ifc-lite/viewer 1.6.0 → 1.7.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/CHANGELOG.md +78 -0
- package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
- package/dist/assets/index-yTqs8kgX.css +1 -0
- package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
- package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -15
- package/src/components/viewer/BCFPanel.tsx +7 -789
- package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
- package/src/components/viewer/HierarchyPanel.tsx +110 -842
- package/src/components/viewer/IDSExportDialog.tsx +281 -0
- package/src/components/viewer/IDSPanel.tsx +126 -17
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
- package/src/components/viewer/LensPanel.tsx +603 -0
- package/src/components/viewer/MainToolbar.tsx +188 -21
- package/src/components/viewer/PropertiesPanel.tsx +171 -663
- package/src/components/viewer/PropertyEditor.tsx +866 -77
- package/src/components/viewer/Section2DPanel.tsx +76 -2648
- package/src/components/viewer/ToolOverlays.tsx +3 -1097
- package/src/components/viewer/ViewerLayout.tsx +132 -45
- package/src/components/viewer/Viewport.tsx +237 -1659
- package/src/components/viewer/ViewportContainer.tsx +11 -3
- package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
- package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
- package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
- package/src/components/viewer/hierarchy/types.ts +54 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
- package/src/components/viewer/lists/ListBuilder.tsx +486 -0
- package/src/components/viewer/lists/ListPanel.tsx +540 -0
- package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
- package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
- package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
- package/src/components/viewer/properties/DocumentCard.tsx +89 -0
- package/src/components/viewer/properties/MaterialCard.tsx +201 -0
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
- package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
- package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
- package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
- package/src/components/viewer/properties/encodingUtils.ts +29 -0
- package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
- package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
- package/src/components/viewer/tools/SectionPanel.tsx +183 -0
- package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
- package/src/components/viewer/tools/formatDistance.ts +18 -0
- package/src/components/viewer/tools/sectionConstants.ts +14 -0
- package/src/components/viewer/useAnimationLoop.ts +166 -0
- package/src/components/viewer/useGeometryStreaming.ts +398 -0
- package/src/components/viewer/useKeyboardControls.ts +221 -0
- package/src/components/viewer/useMouseControls.ts +1009 -0
- package/src/components/viewer/useRenderUpdates.ts +165 -0
- package/src/components/viewer/useTouchControls.ts +245 -0
- package/src/hooks/ids/idsColorSystem.ts +125 -0
- package/src/hooks/ids/idsDataAccessor.ts +237 -0
- package/src/hooks/ids/idsExportService.ts +444 -0
- package/src/hooks/useBCF.ts +7 -0
- package/src/hooks/useDrawingExport.ts +627 -0
- package/src/hooks/useDrawingGeneration.ts +627 -0
- package/src/hooks/useFloorplanView.ts +108 -0
- package/src/hooks/useIDS.ts +270 -463
- package/src/hooks/useIfc.ts +26 -1628
- package/src/hooks/useIfcFederation.ts +803 -0
- package/src/hooks/useIfcLoader.ts +508 -0
- package/src/hooks/useIfcServer.ts +465 -0
- package/src/hooks/useKeyboardShortcuts.ts +1 -1
- package/src/hooks/useLens.ts +129 -0
- package/src/hooks/useMeasure2D.ts +365 -0
- package/src/hooks/useViewControls.ts +218 -0
- package/src/lib/ifc4-pset-definitions.test.ts +161 -0
- package/src/lib/ifc4-pset-definitions.ts +621 -0
- package/src/lib/ifc4-qto-definitions.ts +315 -0
- package/src/lib/lens/adapter.ts +138 -0
- package/src/lib/lens/index.ts +5 -0
- package/src/lib/lists/adapter.ts +69 -0
- package/src/lib/lists/index.ts +28 -0
- package/src/lib/lists/persistence.ts +64 -0
- package/src/services/fs-cache.ts +1 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/index.ts +38 -2
- package/src/store/slices/cameraSlice.ts +14 -1
- package/src/store/slices/dataSlice.ts +14 -1
- package/src/store/slices/lensSlice.ts +184 -0
- package/src/store/slices/listSlice.ts +74 -0
- package/src/store/slices/pinboardSlice.ts +114 -0
- package/src/store/types.ts +5 -0
- package/src/utils/ifcConfig.ts +16 -3
- package/src/utils/serverDataModel.ts +64 -101
- package/src/vite-env.d.ts +3 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-v3mcCUPN.css +0 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Geometry streaming hook for the 3D viewport
|
|
7
|
+
* Handles mesh batching, incremental loading, dedup, camera fitting
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, type MutableRefObject } from 'react';
|
|
11
|
+
import { Renderer, MathUtils, type Scene, type RenderPipeline } from '@ifc-lite/renderer';
|
|
12
|
+
import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
|
|
13
|
+
|
|
14
|
+
export interface UseGeometryStreamingParams {
|
|
15
|
+
rendererRef: MutableRefObject<Renderer | null>;
|
|
16
|
+
isInitialized: boolean;
|
|
17
|
+
geometry: MeshData[] | null;
|
|
18
|
+
coordinateInfo?: CoordinateInfo;
|
|
19
|
+
isStreaming: boolean;
|
|
20
|
+
geometryBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>;
|
|
21
|
+
pendingColorUpdates: Map<number, [number, number, number, number]> | null;
|
|
22
|
+
clearPendingColorUpdates: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
|
|
26
|
+
const {
|
|
27
|
+
rendererRef,
|
|
28
|
+
isInitialized,
|
|
29
|
+
geometry,
|
|
30
|
+
coordinateInfo,
|
|
31
|
+
isStreaming,
|
|
32
|
+
geometryBoundsRef,
|
|
33
|
+
pendingColorUpdates,
|
|
34
|
+
clearPendingColorUpdates,
|
|
35
|
+
} = params;
|
|
36
|
+
|
|
37
|
+
// Track processed meshes for incremental updates
|
|
38
|
+
// Uses string keys to support compound keys (expressId:color) for submeshes
|
|
39
|
+
const processedMeshIdsRef = useRef<Set<string>>(new Set());
|
|
40
|
+
const lastGeometryLengthRef = useRef<number>(0);
|
|
41
|
+
const lastGeometryRef = useRef<MeshData[] | null>(null);
|
|
42
|
+
const cameraFittedRef = useRef<boolean>(false);
|
|
43
|
+
const finalBoundsRefittedRef = useRef<boolean>(false); // Track if we've refitted after streaming
|
|
44
|
+
|
|
45
|
+
// Render throttling during streaming
|
|
46
|
+
const lastStreamRenderTimeRef = useRef<number>(0);
|
|
47
|
+
const STREAM_RENDER_THROTTLE_MS = 200; // Render at most every 200ms during streaming
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const renderer = rendererRef.current;
|
|
51
|
+
|
|
52
|
+
// Handle geometry cleared/null - reset refs so next load is treated as new file
|
|
53
|
+
if (!geometry) {
|
|
54
|
+
if (lastGeometryLengthRef.current > 0 || lastGeometryRef.current !== null) {
|
|
55
|
+
// Geometry was cleared - reset tracking refs
|
|
56
|
+
lastGeometryLengthRef.current = 0;
|
|
57
|
+
lastGeometryRef.current = null;
|
|
58
|
+
processedMeshIdsRef.current.clear();
|
|
59
|
+
cameraFittedRef.current = false;
|
|
60
|
+
finalBoundsRefittedRef.current = false;
|
|
61
|
+
// Clear scene if renderer is ready
|
|
62
|
+
if (renderer && isInitialized) {
|
|
63
|
+
renderer.getScene().clear();
|
|
64
|
+
renderer.getCamera().reset();
|
|
65
|
+
geometryBoundsRef.current = {
|
|
66
|
+
min: { x: -100, y: -100, z: -100 },
|
|
67
|
+
max: { x: 100, y: 100, z: 100 },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!renderer || !isInitialized) return;
|
|
75
|
+
|
|
76
|
+
const device = renderer.getGPUDevice();
|
|
77
|
+
if (!device) return;
|
|
78
|
+
|
|
79
|
+
const scene = renderer.getScene();
|
|
80
|
+
const currentLength = geometry.length;
|
|
81
|
+
const lastLength = lastGeometryLengthRef.current;
|
|
82
|
+
|
|
83
|
+
// Use length-based detection instead of reference comparison
|
|
84
|
+
// React creates new array references on every appendGeometryBatch call,
|
|
85
|
+
// so reference comparison would always trigger scene.clear()
|
|
86
|
+
const isIncremental = currentLength > lastLength && lastLength > 0;
|
|
87
|
+
const isNewFile = currentLength > 0 && lastLength === 0;
|
|
88
|
+
const isCleared = currentLength === 0;
|
|
89
|
+
|
|
90
|
+
if (isCleared) {
|
|
91
|
+
// Geometry cleared (could be visibility change or file unload)
|
|
92
|
+
// Clear scene but DON'T reset camera - user may just be hiding models
|
|
93
|
+
scene.clear();
|
|
94
|
+
processedMeshIdsRef.current.clear();
|
|
95
|
+
// Keep cameraFittedRef to preserve camera position when models are shown again
|
|
96
|
+
lastGeometryLengthRef.current = 0;
|
|
97
|
+
lastGeometryRef.current = null;
|
|
98
|
+
// Note: Don't reset camera or bounds - preserve user's view
|
|
99
|
+
return;
|
|
100
|
+
} else if (isNewFile) {
|
|
101
|
+
// New file loaded - reset camera and bounds
|
|
102
|
+
scene.clear();
|
|
103
|
+
processedMeshIdsRef.current.clear();
|
|
104
|
+
cameraFittedRef.current = false;
|
|
105
|
+
finalBoundsRefittedRef.current = false;
|
|
106
|
+
lastGeometryLengthRef.current = 0;
|
|
107
|
+
lastGeometryRef.current = geometry;
|
|
108
|
+
// Reset camera state (clear orbit pivot, stop inertia, cancel animations)
|
|
109
|
+
renderer.getCamera().reset();
|
|
110
|
+
// Reset geometry bounds to default
|
|
111
|
+
geometryBoundsRef.current = {
|
|
112
|
+
min: { x: -100, y: -100, z: -100 },
|
|
113
|
+
max: { x: 100, y: 100, z: 100 },
|
|
114
|
+
};
|
|
115
|
+
} else if (!isIncremental && currentLength !== lastLength) {
|
|
116
|
+
// Length changed but not incremental - could be:
|
|
117
|
+
// 1. Length decreased (model hidden) - DON'T reset camera
|
|
118
|
+
// 2. Length increased but lastLength > 0 (new file loaded while another was open) - DO reset
|
|
119
|
+
const isLengthDecrease = currentLength < lastLength;
|
|
120
|
+
|
|
121
|
+
if (isLengthDecrease) {
|
|
122
|
+
// Model visibility changed (hidden) - rebuild scene but keep camera
|
|
123
|
+
scene.clear();
|
|
124
|
+
processedMeshIdsRef.current.clear();
|
|
125
|
+
// Don't reset cameraFittedRef - keep current camera position
|
|
126
|
+
lastGeometryLengthRef.current = 0; // Reset so meshes get re-added
|
|
127
|
+
lastGeometryRef.current = geometry;
|
|
128
|
+
// Note: Don't reset camera or bounds - user wants to keep their view
|
|
129
|
+
} else {
|
|
130
|
+
// New file loaded while another was open - full reset
|
|
131
|
+
scene.clear();
|
|
132
|
+
processedMeshIdsRef.current.clear();
|
|
133
|
+
cameraFittedRef.current = false;
|
|
134
|
+
finalBoundsRefittedRef.current = false;
|
|
135
|
+
lastGeometryLengthRef.current = 0;
|
|
136
|
+
lastGeometryRef.current = geometry;
|
|
137
|
+
// Reset camera state
|
|
138
|
+
renderer.getCamera().reset();
|
|
139
|
+
// Reset geometry bounds to default
|
|
140
|
+
geometryBoundsRef.current = {
|
|
141
|
+
min: { x: -100, y: -100, z: -100 },
|
|
142
|
+
max: { x: 100, y: 100, z: 100 },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
} else if (currentLength === lastLength) {
|
|
146
|
+
// No geometry change - but check if we need to update bounds when streaming completes
|
|
147
|
+
if (cameraFittedRef.current && !isStreaming && !finalBoundsRefittedRef.current && coordinateInfo?.shiftedBounds) {
|
|
148
|
+
const shiftedBounds = coordinateInfo.shiftedBounds;
|
|
149
|
+
const newMaxSize = Math.max(
|
|
150
|
+
shiftedBounds.max.x - shiftedBounds.min.x,
|
|
151
|
+
shiftedBounds.max.y - shiftedBounds.min.y,
|
|
152
|
+
shiftedBounds.max.z - shiftedBounds.min.z
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (newMaxSize > 0 && Number.isFinite(newMaxSize)) {
|
|
156
|
+
// Only refit camera for LARGE models (>1000 meshes) where geometry streamed in multiple batches
|
|
157
|
+
// Small models complete in one batch, so their initial camera fit is already correct
|
|
158
|
+
const isLargeModel = geometry.length > 1000;
|
|
159
|
+
|
|
160
|
+
if (isLargeModel) {
|
|
161
|
+
const oldBounds = geometryBoundsRef.current;
|
|
162
|
+
const oldMaxSize = Math.max(
|
|
163
|
+
oldBounds.max.x - oldBounds.min.x,
|
|
164
|
+
oldBounds.max.y - oldBounds.min.y,
|
|
165
|
+
oldBounds.max.z - oldBounds.min.z
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Refit camera if bounds expanded significantly (>10% larger)
|
|
169
|
+
// This handles skyscrapers where upper floors arrive in later batches
|
|
170
|
+
const boundsExpanded = newMaxSize > oldMaxSize * 1.1;
|
|
171
|
+
|
|
172
|
+
if (boundsExpanded) {
|
|
173
|
+
renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Always update bounds for accurate zoom-to-fit, home view, etc.
|
|
178
|
+
geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
|
|
179
|
+
finalBoundsRefittedRef.current = true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// For incremental batches: update reference and continue to add new meshes
|
|
186
|
+
if (isIncremental) {
|
|
187
|
+
lastGeometryRef.current = geometry;
|
|
188
|
+
} else if (lastGeometryRef.current === null) {
|
|
189
|
+
lastGeometryRef.current = geometry;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// FIX: When not streaming (type visibility toggle), new meshes can be ANYWHERE in the array,
|
|
193
|
+
// not just at the end. During streaming, new meshes ARE appended, so slice is safe.
|
|
194
|
+
// After streaming completes, filter changes can insert meshes at any position.
|
|
195
|
+
const meshesToAdd = isStreaming
|
|
196
|
+
? geometry.slice(lastGeometryLengthRef.current) // Streaming: new meshes at end
|
|
197
|
+
: geometry; // Post-streaming: scan entire array for unprocessed meshes
|
|
198
|
+
|
|
199
|
+
// Filter out already processed meshes
|
|
200
|
+
// NOTE: Multiple meshes can share the same expressId AND same color (e.g., door inner framing pieces),
|
|
201
|
+
// so we use expressId + array index as a compound key to ensure all submeshes are processed.
|
|
202
|
+
const newMeshes: MeshData[] = [];
|
|
203
|
+
const startIndex = isStreaming ? lastGeometryLengthRef.current : 0;
|
|
204
|
+
for (let i = 0; i < meshesToAdd.length; i++) {
|
|
205
|
+
const meshData = meshesToAdd[i];
|
|
206
|
+
// Use expressId + global array index as key to ensure each mesh is unique
|
|
207
|
+
// (same expressId can have multiple submeshes with same color, e.g., door framing)
|
|
208
|
+
const globalIndex = startIndex + i;
|
|
209
|
+
const compoundKey = `${meshData.expressId}:${globalIndex}`;
|
|
210
|
+
|
|
211
|
+
if (!processedMeshIdsRef.current.has(compoundKey)) {
|
|
212
|
+
newMeshes.push(meshData);
|
|
213
|
+
processedMeshIdsRef.current.add(compoundKey);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (newMeshes.length > 0) {
|
|
218
|
+
// Batch meshes by color for efficient rendering (reduces draw calls from N to ~100-500)
|
|
219
|
+
// This dramatically improves performance for large models (50K+ meshes)
|
|
220
|
+
const pipeline = renderer.getPipeline();
|
|
221
|
+
if (pipeline) {
|
|
222
|
+
// Use batched rendering - groups meshes by color into single draw calls
|
|
223
|
+
// Pass isStreaming flag to enable throttled batch rebuilding (reduces O(N^2) cost)
|
|
224
|
+
scene.appendToBatches(newMeshes, device, pipeline, isStreaming);
|
|
225
|
+
|
|
226
|
+
// Note: addMeshData is now called inside appendToBatches, no need to duplicate
|
|
227
|
+
} else {
|
|
228
|
+
// Fallback: add individual meshes if pipeline not ready
|
|
229
|
+
for (const meshData of newMeshes) {
|
|
230
|
+
const vertexCount = meshData.positions.length / 3;
|
|
231
|
+
const interleaved = new Float32Array(vertexCount * 6);
|
|
232
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
233
|
+
const base = i * 6;
|
|
234
|
+
const posBase = i * 3;
|
|
235
|
+
interleaved[base] = meshData.positions[posBase];
|
|
236
|
+
interleaved[base + 1] = meshData.positions[posBase + 1];
|
|
237
|
+
interleaved[base + 2] = meshData.positions[posBase + 2];
|
|
238
|
+
interleaved[base + 3] = meshData.normals[posBase];
|
|
239
|
+
interleaved[base + 4] = meshData.normals[posBase + 1];
|
|
240
|
+
interleaved[base + 5] = meshData.normals[posBase + 2];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const vertexBuffer = device.createBuffer({
|
|
244
|
+
size: interleaved.byteLength,
|
|
245
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
246
|
+
});
|
|
247
|
+
device.queue.writeBuffer(vertexBuffer, 0, interleaved);
|
|
248
|
+
|
|
249
|
+
const indexBuffer = device.createBuffer({
|
|
250
|
+
size: meshData.indices.byteLength,
|
|
251
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
252
|
+
});
|
|
253
|
+
device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
|
|
254
|
+
|
|
255
|
+
scene.addMesh({
|
|
256
|
+
expressId: meshData.expressId,
|
|
257
|
+
vertexBuffer,
|
|
258
|
+
indexBuffer,
|
|
259
|
+
indexCount: meshData.indices.length,
|
|
260
|
+
transform: MathUtils.identity(),
|
|
261
|
+
color: meshData.color,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Invalidate caches when new geometry is added
|
|
267
|
+
renderer.clearCaches();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
lastGeometryLengthRef.current = currentLength;
|
|
271
|
+
|
|
272
|
+
// Fit camera and store bounds
|
|
273
|
+
// IMPORTANT: Fit camera immediately when we have valid bounds to avoid starting inside model
|
|
274
|
+
// The default camera position (50, 50, 100) is inside most models that are shifted to origin
|
|
275
|
+
if (!cameraFittedRef.current && coordinateInfo?.shiftedBounds) {
|
|
276
|
+
const shiftedBounds = coordinateInfo.shiftedBounds;
|
|
277
|
+
const maxSize = Math.max(
|
|
278
|
+
shiftedBounds.max.x - shiftedBounds.min.x,
|
|
279
|
+
shiftedBounds.max.y - shiftedBounds.min.y,
|
|
280
|
+
shiftedBounds.max.z - shiftedBounds.min.z
|
|
281
|
+
);
|
|
282
|
+
// Fit camera immediately when we have valid bounds
|
|
283
|
+
// For streaming: the first batch already has complete bounds from coordinate handler
|
|
284
|
+
// (bounds are calculated from ALL geometry before streaming starts)
|
|
285
|
+
// Waiting for streaming to complete causes the camera to start inside the model
|
|
286
|
+
if (maxSize > 0 && Number.isFinite(maxSize)) {
|
|
287
|
+
renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
|
|
288
|
+
geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
|
|
289
|
+
cameraFittedRef.current = true;
|
|
290
|
+
}
|
|
291
|
+
} else if (!cameraFittedRef.current && geometry.length > 0 && !isStreaming) {
|
|
292
|
+
// Fallback: calculate bounds from geometry array (only when streaming is complete)
|
|
293
|
+
// This ensures we have complete bounds before fitting camera
|
|
294
|
+
const fallbackBounds = {
|
|
295
|
+
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
296
|
+
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// Max coordinate threshold - matches CoordinateHandler's NORMAL_COORD_THRESHOLD
|
|
300
|
+
// Coordinates beyond this are likely corrupted or unshifted original coordinates
|
|
301
|
+
const MAX_VALID_COORD = 10000;
|
|
302
|
+
|
|
303
|
+
for (const meshData of geometry) {
|
|
304
|
+
for (let i = 0; i < meshData.positions.length; i += 3) {
|
|
305
|
+
const x = meshData.positions[i];
|
|
306
|
+
const y = meshData.positions[i + 1];
|
|
307
|
+
const z = meshData.positions[i + 2];
|
|
308
|
+
// Filter out corrupted/unshifted vertices (> 10km from origin)
|
|
309
|
+
const isValid = Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z) &&
|
|
310
|
+
Math.abs(x) < MAX_VALID_COORD && Math.abs(y) < MAX_VALID_COORD && Math.abs(z) < MAX_VALID_COORD;
|
|
311
|
+
if (isValid) {
|
|
312
|
+
fallbackBounds.min.x = Math.min(fallbackBounds.min.x, x);
|
|
313
|
+
fallbackBounds.min.y = Math.min(fallbackBounds.min.y, y);
|
|
314
|
+
fallbackBounds.min.z = Math.min(fallbackBounds.min.z, z);
|
|
315
|
+
fallbackBounds.max.x = Math.max(fallbackBounds.max.x, x);
|
|
316
|
+
fallbackBounds.max.y = Math.max(fallbackBounds.max.y, y);
|
|
317
|
+
fallbackBounds.max.z = Math.max(fallbackBounds.max.z, z);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const maxSize = Math.max(
|
|
323
|
+
fallbackBounds.max.x - fallbackBounds.min.x,
|
|
324
|
+
fallbackBounds.max.y - fallbackBounds.min.y,
|
|
325
|
+
fallbackBounds.max.z - fallbackBounds.min.z
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
if (fallbackBounds.min.x !== Infinity && maxSize > 0 && Number.isFinite(maxSize)) {
|
|
329
|
+
renderer.getCamera().fitToBounds(fallbackBounds.min, fallbackBounds.max);
|
|
330
|
+
geometryBoundsRef.current = fallbackBounds;
|
|
331
|
+
cameraFittedRef.current = true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Note: Background instancing conversion removed
|
|
336
|
+
// Regular MeshData meshes are rendered directly with their correct positions
|
|
337
|
+
// Instancing conversion would require preserving actual mesh transforms, which is complex
|
|
338
|
+
// For now, we render regular meshes directly (fast enough for most cases)
|
|
339
|
+
|
|
340
|
+
// Render throttling: During streaming, only render every STREAM_RENDER_THROTTLE_MS
|
|
341
|
+
// This prevents rendering 28K+ meshes from blocking WASM batch processing
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
const timeSinceLastRender = now - lastStreamRenderTimeRef.current;
|
|
344
|
+
const shouldRender = !isStreaming || timeSinceLastRender >= STREAM_RENDER_THROTTLE_MS;
|
|
345
|
+
|
|
346
|
+
if (shouldRender) {
|
|
347
|
+
renderer.render();
|
|
348
|
+
lastStreamRenderTimeRef.current = now;
|
|
349
|
+
}
|
|
350
|
+
}, [geometry, coordinateInfo, isInitialized, isStreaming]);
|
|
351
|
+
|
|
352
|
+
// Force render when streaming completes (progress goes from <100% to 100% or null)
|
|
353
|
+
const prevIsStreamingRef = useRef(isStreaming);
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
const renderer = rendererRef.current;
|
|
356
|
+
if (!renderer || !isInitialized) return;
|
|
357
|
+
|
|
358
|
+
// If streaming just completed (was streaming, now not), rebuild pending batches and render
|
|
359
|
+
if (prevIsStreamingRef.current && !isStreaming) {
|
|
360
|
+
const device = renderer.getGPUDevice();
|
|
361
|
+
const pipeline = renderer.getPipeline();
|
|
362
|
+
const scene = renderer.getScene();
|
|
363
|
+
|
|
364
|
+
// Rebuild any pending batches that were deferred during streaming
|
|
365
|
+
if (device && pipeline && scene.hasPendingBatches()) {
|
|
366
|
+
scene.rebuildPendingBatches(device, pipeline);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
renderer.render();
|
|
370
|
+
lastStreamRenderTimeRef.current = Date.now();
|
|
371
|
+
}
|
|
372
|
+
prevIsStreamingRef.current = isStreaming;
|
|
373
|
+
}, [isStreaming, isInitialized]);
|
|
374
|
+
|
|
375
|
+
// Apply pending color updates to WebGPU scene
|
|
376
|
+
// Note: Color updates may arrive before viewport is initialized, so we wait
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (!pendingColorUpdates || pendingColorUpdates.size === 0) return;
|
|
379
|
+
|
|
380
|
+
// Wait until viewport is initialized before applying color updates
|
|
381
|
+
if (!isInitialized) return;
|
|
382
|
+
|
|
383
|
+
const renderer = rendererRef.current;
|
|
384
|
+
if (!renderer) return;
|
|
385
|
+
|
|
386
|
+
const device = renderer.getGPUDevice();
|
|
387
|
+
const pipeline = renderer.getPipeline();
|
|
388
|
+
const scene = renderer.getScene();
|
|
389
|
+
|
|
390
|
+
if (device && pipeline) {
|
|
391
|
+
scene.updateMeshColors(pendingColorUpdates, device, pipeline);
|
|
392
|
+
renderer.render();
|
|
393
|
+
clearPendingColorUpdates();
|
|
394
|
+
}
|
|
395
|
+
}, [pendingColorUpdates, isInitialized, clearPendingColorUpdates]);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export default useGeometryStreaming;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keyboard controls hook for the 3D viewport
|
|
7
|
+
* Handles keyboard shortcuts, first-person mode, continuous movement
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, type MutableRefObject } from 'react';
|
|
11
|
+
import type { Renderer } from '@ifc-lite/renderer';
|
|
12
|
+
import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
|
|
13
|
+
import type { SectionPlane } from '@/store';
|
|
14
|
+
import { getEntityBounds } from '../../utils/viewportUtils.js';
|
|
15
|
+
|
|
16
|
+
export interface UseKeyboardControlsParams {
|
|
17
|
+
rendererRef: MutableRefObject<Renderer | null>;
|
|
18
|
+
isInitialized: boolean;
|
|
19
|
+
keyboardHandlersRef: MutableRefObject<{
|
|
20
|
+
handleKeyDown: ((e: KeyboardEvent) => void) | null;
|
|
21
|
+
handleKeyUp: ((e: KeyboardEvent) => void) | null;
|
|
22
|
+
}>;
|
|
23
|
+
firstPersonModeRef: MutableRefObject<boolean>;
|
|
24
|
+
geometryBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>;
|
|
25
|
+
coordinateInfoRef: MutableRefObject<CoordinateInfo | undefined>;
|
|
26
|
+
geometryRef: MutableRefObject<MeshData[] | null>;
|
|
27
|
+
selectedEntityIdRef: MutableRefObject<number | null>;
|
|
28
|
+
hiddenEntitiesRef: MutableRefObject<Set<number>>;
|
|
29
|
+
isolatedEntitiesRef: MutableRefObject<Set<number> | null>;
|
|
30
|
+
selectedModelIndexRef: MutableRefObject<number | undefined>;
|
|
31
|
+
clearColorRef: MutableRefObject<[number, number, number, number]>;
|
|
32
|
+
activeToolRef: MutableRefObject<string>;
|
|
33
|
+
sectionPlaneRef: MutableRefObject<SectionPlane>;
|
|
34
|
+
sectionRangeRef: MutableRefObject<{ min: number; max: number } | null>;
|
|
35
|
+
updateCameraRotationRealtime: (rotation: { azimuth: number; elevation: number }) => void;
|
|
36
|
+
calculateScale: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useKeyboardControls(params: UseKeyboardControlsParams): void {
|
|
40
|
+
const {
|
|
41
|
+
rendererRef,
|
|
42
|
+
isInitialized,
|
|
43
|
+
keyboardHandlersRef,
|
|
44
|
+
firstPersonModeRef,
|
|
45
|
+
geometryBoundsRef,
|
|
46
|
+
coordinateInfoRef,
|
|
47
|
+
geometryRef,
|
|
48
|
+
selectedEntityIdRef,
|
|
49
|
+
hiddenEntitiesRef,
|
|
50
|
+
isolatedEntitiesRef,
|
|
51
|
+
selectedModelIndexRef,
|
|
52
|
+
clearColorRef,
|
|
53
|
+
activeToolRef,
|
|
54
|
+
sectionPlaneRef,
|
|
55
|
+
sectionRangeRef,
|
|
56
|
+
updateCameraRotationRealtime,
|
|
57
|
+
calculateScale,
|
|
58
|
+
} = params;
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const renderer = rendererRef.current;
|
|
62
|
+
if (!renderer || !isInitialized) return;
|
|
63
|
+
|
|
64
|
+
const camera = renderer.getCamera();
|
|
65
|
+
let aborted = false;
|
|
66
|
+
|
|
67
|
+
const keyState: { [key: string]: boolean } = {};
|
|
68
|
+
let moveLoopRunning = false;
|
|
69
|
+
let moveFrameId: number | null = null;
|
|
70
|
+
|
|
71
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
72
|
+
const target = e.target as HTMLElement;
|
|
73
|
+
if (
|
|
74
|
+
target.tagName === 'INPUT' ||
|
|
75
|
+
target.tagName === 'TEXTAREA' ||
|
|
76
|
+
target.isContentEditable
|
|
77
|
+
) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
keyState[e.key.toLowerCase()] = true;
|
|
82
|
+
|
|
83
|
+
// Start movement loop when a movement key is pressed
|
|
84
|
+
const isMovementKey = ['arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(e.key.toLowerCase());
|
|
85
|
+
if (isMovementKey && !moveLoopRunning) {
|
|
86
|
+
moveLoopRunning = true;
|
|
87
|
+
keyboardMove();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Preset views - set view and re-render
|
|
91
|
+
const setViewAndRender = (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right') => {
|
|
92
|
+
const rotation = coordinateInfoRef.current?.buildingRotation;
|
|
93
|
+
camera.setPresetView(view, geometryBoundsRef.current, rotation);
|
|
94
|
+
renderer.render({
|
|
95
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
96
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
97
|
+
selectedId: selectedEntityIdRef.current,
|
|
98
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
99
|
+
clearColor: clearColorRef.current,
|
|
100
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
101
|
+
...sectionPlaneRef.current,
|
|
102
|
+
min: sectionRangeRef.current?.min,
|
|
103
|
+
max: sectionRangeRef.current?.max,
|
|
104
|
+
} : undefined,
|
|
105
|
+
});
|
|
106
|
+
updateCameraRotationRealtime(camera.getRotation());
|
|
107
|
+
calculateScale();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (e.key === '1') setViewAndRender('top');
|
|
111
|
+
if (e.key === '2') setViewAndRender('bottom');
|
|
112
|
+
if (e.key === '3') setViewAndRender('front');
|
|
113
|
+
if (e.key === '4') setViewAndRender('back');
|
|
114
|
+
if (e.key === '5') setViewAndRender('left');
|
|
115
|
+
if (e.key === '6') setViewAndRender('right');
|
|
116
|
+
|
|
117
|
+
// Frame selection (F) - zoom to fit selection, or fit all if nothing selected
|
|
118
|
+
if (e.key === 'f' || e.key === 'F') {
|
|
119
|
+
const selectedId = selectedEntityIdRef.current;
|
|
120
|
+
if (selectedId !== null) {
|
|
121
|
+
// Frame selection - zoom to fit selected element
|
|
122
|
+
const bounds = getEntityBounds(geometryRef.current, selectedId);
|
|
123
|
+
if (bounds) {
|
|
124
|
+
camera.frameBounds(bounds.min, bounds.max, 300);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
// No selection - fit all
|
|
128
|
+
camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
|
|
129
|
+
}
|
|
130
|
+
calculateScale();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Home view (H) - reset to isometric
|
|
134
|
+
if (e.key === 'h' || e.key === 'H') {
|
|
135
|
+
camera.zoomToFit(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 500);
|
|
136
|
+
calculateScale();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Fit all / Zoom extents (Z)
|
|
140
|
+
if (e.key === 'z' || e.key === 'Z') {
|
|
141
|
+
camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
|
|
142
|
+
calculateScale();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Toggle first-person mode
|
|
146
|
+
if (e.key === 'c' || e.key === 'C') {
|
|
147
|
+
firstPersonModeRef.current = !firstPersonModeRef.current;
|
|
148
|
+
camera.enableFirstPersonMode(firstPersonModeRef.current);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const handleKeyUp = (e: KeyboardEvent) => {
|
|
153
|
+
keyState[e.key.toLowerCase()] = false;
|
|
154
|
+
|
|
155
|
+
// Stop movement loop when no movement keys are held
|
|
156
|
+
const anyMovementKey = keyState['arrowup'] || keyState['arrowdown'] || keyState['arrowleft'] || keyState['arrowright'];
|
|
157
|
+
if (!anyMovementKey && moveLoopRunning) {
|
|
158
|
+
moveLoopRunning = false;
|
|
159
|
+
if (moveFrameId !== null) {
|
|
160
|
+
cancelAnimationFrame(moveFrameId);
|
|
161
|
+
moveFrameId = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
keyboardHandlersRef.current.handleKeyDown = handleKeyDown;
|
|
167
|
+
keyboardHandlersRef.current.handleKeyUp = handleKeyUp;
|
|
168
|
+
|
|
169
|
+
const keyboardMove = () => {
|
|
170
|
+
if (aborted || !moveLoopRunning) return;
|
|
171
|
+
|
|
172
|
+
let moved = false;
|
|
173
|
+
const panSpeed = 5;
|
|
174
|
+
|
|
175
|
+
if (firstPersonModeRef.current) {
|
|
176
|
+
// Arrow keys for first-person navigation (camera-relative)
|
|
177
|
+
if (keyState['arrowup']) { camera.moveFirstPerson(-1, 0, 0); moved = true; }
|
|
178
|
+
if (keyState['arrowdown']) { camera.moveFirstPerson(1, 0, 0); moved = true; }
|
|
179
|
+
if (keyState['arrowleft']) { camera.moveFirstPerson(0, 1, 0); moved = true; }
|
|
180
|
+
if (keyState['arrowright']) { camera.moveFirstPerson(0, -1, 0); moved = true; }
|
|
181
|
+
} else {
|
|
182
|
+
// Arrow keys for panning (camera-relative: arrow direction = camera movement)
|
|
183
|
+
if (keyState['arrowup']) { camera.pan(0, -panSpeed, false); moved = true; }
|
|
184
|
+
if (keyState['arrowdown']) { camera.pan(0, panSpeed, false); moved = true; }
|
|
185
|
+
if (keyState['arrowleft']) { camera.pan(panSpeed, 0, false); moved = true; }
|
|
186
|
+
if (keyState['arrowright']) { camera.pan(-panSpeed, 0, false); moved = true; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (moved) {
|
|
190
|
+
renderer.render({
|
|
191
|
+
hiddenIds: hiddenEntitiesRef.current,
|
|
192
|
+
isolatedIds: isolatedEntitiesRef.current,
|
|
193
|
+
selectedId: selectedEntityIdRef.current,
|
|
194
|
+
selectedModelIndex: selectedModelIndexRef.current,
|
|
195
|
+
clearColor: clearColorRef.current,
|
|
196
|
+
sectionPlane: activeToolRef.current === 'section' ? {
|
|
197
|
+
...sectionPlaneRef.current,
|
|
198
|
+
min: sectionRangeRef.current?.min,
|
|
199
|
+
max: sectionRangeRef.current?.max,
|
|
200
|
+
} : undefined,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
moveFrameId = requestAnimationFrame(keyboardMove);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
207
|
+
window.addEventListener('keyup', handleKeyUp);
|
|
208
|
+
|
|
209
|
+
return () => {
|
|
210
|
+
aborted = true;
|
|
211
|
+
moveLoopRunning = false;
|
|
212
|
+
if (moveFrameId !== null) {
|
|
213
|
+
cancelAnimationFrame(moveFrameId);
|
|
214
|
+
}
|
|
215
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
216
|
+
window.removeEventListener('keyup', handleKeyUp);
|
|
217
|
+
};
|
|
218
|
+
}, [isInitialized]);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export default useKeyboardControls;
|