@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
package/src/hooks/useIfc.ts
CHANGED
|
@@ -3,137 +3,23 @@
|
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Orchestrator hook for loading and processing IFC files
|
|
7
|
+
* Composes sub-hooks for server communication, file loading, and multi-model federation
|
|
8
|
+
*
|
|
9
|
+
* Sub-hooks:
|
|
10
|
+
* - useIfcServer: Server reachability, streaming/Parquet/JSON parsing paths
|
|
11
|
+
* - useIfcLoader: Single-model file loading, format detection, WASM geometry streaming, cache
|
|
12
|
+
* - useIfcFederation: Multi-model federation, addModel, ID offsets, RTC alignment, IFCX layers
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
|
-
import { useMemo,
|
|
11
|
-
import { useViewerStore
|
|
12
|
-
import { IfcParser, detectFormat, parseIfcx, parseFederatedIfcx, type IfcDataStore, type FederatedIfcxParseResult } from '@ifc-lite/parser';
|
|
13
|
-
import { GeometryProcessor, GeometryQuality, type MeshData, type CoordinateInfo } from '@ifc-lite/geometry';
|
|
15
|
+
import { useMemo, useRef } from 'react';
|
|
16
|
+
import { useViewerStore } from '../store.js';
|
|
14
17
|
import { IfcQuery } from '@ifc-lite/query';
|
|
15
|
-
import {
|
|
16
|
-
import { type GeometryData, loadGLBToMeshData } from '@ifc-lite/cache';
|
|
17
|
-
import { IfcTypeEnum, RelationshipType, IfcTypeEnumFromString, IfcTypeEnumToString, EntityFlags, type SpatialHierarchy, type SpatialNode, type EntityTable, type RelationshipGraph } from '@ifc-lite/data';
|
|
18
|
-
import { StringTable } from '@ifc-lite/data';
|
|
19
|
-
import { IfcServerClient, decodeDataModel, type ParquetBatch, type DataModel, type ParquetParseResponse, type ParquetStreamResult, type ParseResponse, type ModelMetadata, type ProcessingStats, type MeshData as ServerMeshData } from '@ifc-lite/server-client';
|
|
18
|
+
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
20
19
|
|
|
21
|
-
//
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
createEmptyBounds,
|
|
26
|
-
updateBoundsFromPositions,
|
|
27
|
-
calculateMeshBounds,
|
|
28
|
-
createCoordinateInfo,
|
|
29
|
-
getRenderIntervalMs,
|
|
30
|
-
getServerStreamIntervalMs,
|
|
31
|
-
calculateStoreyHeights,
|
|
32
|
-
normalizeColor,
|
|
33
|
-
convertFloatColorToBytes,
|
|
34
|
-
} from '../utils/localParsingUtils.js';
|
|
35
|
-
|
|
36
|
-
// Cache hook
|
|
37
|
-
import { useIfcCache, getCached, type CacheResult } from './useIfcCache.js';
|
|
38
|
-
|
|
39
|
-
// Server data model conversion
|
|
40
|
-
import { convertServerDataModel, type ServerParseResult } from '../utils/serverDataModel.js';
|
|
41
|
-
|
|
42
|
-
// Define QuantitySet type inline (matches server-client's QuantitySet interface)
|
|
43
|
-
interface ServerQuantitySet {
|
|
44
|
-
qset_id: number;
|
|
45
|
-
qset_name: string;
|
|
46
|
-
method_of_measurement?: string;
|
|
47
|
-
quantities: Array<{ quantity_name: string; quantity_value: number; quantity_type: string }>;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Extended data store type for IFCX (IFC5) files.
|
|
52
|
-
* IFCX uses schemaVersion 'IFC5' and may include federated composition metadata.
|
|
53
|
-
*/
|
|
54
|
-
interface IfcxDataStore extends Omit<IfcDataStore, 'schemaVersion'> {
|
|
55
|
-
schemaVersion: 'IFC5';
|
|
56
|
-
/** Federated layer info for re-composition */
|
|
57
|
-
_federatedLayers?: Array<{ id: string; name: string; enabled: boolean }>;
|
|
58
|
-
/** Original buffers for re-composition when adding overlays */
|
|
59
|
-
_federatedBuffers?: Array<{ buffer: ArrayBuffer; name: string }>;
|
|
60
|
-
/** Composition statistics */
|
|
61
|
-
_compositionStats?: { totalNodes: number; layersUsed: number; inheritanceResolutions: number; crossLayerReferences: number };
|
|
62
|
-
/** Layer info for display */
|
|
63
|
-
_layerInfo?: Array<{ id: string; name: string; meshCount: number }>;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Convert server mesh data (snake_case) to viewer format (camelCase) */
|
|
67
|
-
function convertServerMesh(m: ServerMeshData): MeshData {
|
|
68
|
-
return {
|
|
69
|
-
expressId: m.express_id,
|
|
70
|
-
positions: new Float32Array(m.positions),
|
|
71
|
-
indices: new Uint32Array(m.indices),
|
|
72
|
-
normals: new Float32Array(m.normals),
|
|
73
|
-
color: m.color,
|
|
74
|
-
ifcType: m.ifc_type,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Server parse result type - union of streaming and non-streaming responses */
|
|
79
|
-
type ServerParseResultType = ParquetParseResponse | ParquetStreamResult | ParseResponse;
|
|
80
|
-
|
|
81
|
-
// Module-level server availability cache - avoids repeated failed connection attempts
|
|
82
|
-
let serverAvailabilityCache: { available: boolean; checkedAt: number } | null = null;
|
|
83
|
-
const SERVER_CHECK_CACHE_MS = 30000; // Re-check server availability every 30 seconds
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Check if server URL is reachable from current origin
|
|
87
|
-
* Returns false immediately if localhost server from non-localhost origin (would cause CORS)
|
|
88
|
-
*/
|
|
89
|
-
function isServerReachable(serverUrl: string): boolean {
|
|
90
|
-
try {
|
|
91
|
-
const server = new URL(serverUrl);
|
|
92
|
-
const isServerLocalhost = server.hostname === 'localhost' || server.hostname === '127.0.0.1';
|
|
93
|
-
|
|
94
|
-
// In browser, check if we're on localhost
|
|
95
|
-
if (typeof window !== 'undefined') {
|
|
96
|
-
const isClientLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
|
97
|
-
|
|
98
|
-
// Skip localhost server when running from remote origin (avoids CORS error in console)
|
|
99
|
-
if (isServerLocalhost && !isClientLocalhost) {
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return true;
|
|
104
|
-
} catch {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Silently check if server is available (no console logging on failure)
|
|
111
|
-
* Returns cached result if recently checked
|
|
112
|
-
*/
|
|
113
|
-
async function isServerAvailable(serverUrl: string, client: IfcServerClient): Promise<boolean> {
|
|
114
|
-
// First check if server is even reachable (prevents CORS errors)
|
|
115
|
-
if (!isServerReachable(serverUrl)) {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const now = Date.now();
|
|
120
|
-
|
|
121
|
-
// Use cached result if recent
|
|
122
|
-
if (serverAvailabilityCache && (now - serverAvailabilityCache.checkedAt) < SERVER_CHECK_CACHE_MS) {
|
|
123
|
-
return serverAvailabilityCache.available;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Perform silent health check
|
|
127
|
-
try {
|
|
128
|
-
await client.health();
|
|
129
|
-
serverAvailabilityCache = { available: true, checkedAt: now };
|
|
130
|
-
return true;
|
|
131
|
-
} catch {
|
|
132
|
-
// Silent failure - don't log network errors for unavailable server
|
|
133
|
-
serverAvailabilityCache = { available: false, checkedAt: now };
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
20
|
+
// Sub-hooks
|
|
21
|
+
import { useIfcLoader } from './useIfcLoader.js';
|
|
22
|
+
import { useIfcFederation } from './useIfcFederation.js';
|
|
137
23
|
|
|
138
24
|
export function useIfc() {
|
|
139
25
|
const {
|
|
@@ -142,19 +28,9 @@ export function useIfc() {
|
|
|
142
28
|
error,
|
|
143
29
|
ifcDataStore,
|
|
144
30
|
geometryResult,
|
|
145
|
-
setLoading,
|
|
146
|
-
setProgress,
|
|
147
|
-
setError,
|
|
148
|
-
setIfcDataStore,
|
|
149
|
-
setGeometryResult,
|
|
150
|
-
appendGeometryBatch,
|
|
151
|
-
updateMeshColors,
|
|
152
|
-
updateCoordinateInfo,
|
|
153
31
|
// Multi-model state and actions
|
|
154
32
|
models,
|
|
155
33
|
activeModelId,
|
|
156
|
-
addModel: storeAddModel,
|
|
157
|
-
removeModel: storeRemoveModel,
|
|
158
34
|
clearAllModels,
|
|
159
35
|
setActiveModel,
|
|
160
36
|
setModelVisibility,
|
|
@@ -164,790 +40,26 @@ export function useIfc() {
|
|
|
164
40
|
getAllVisibleModels,
|
|
165
41
|
hasModels,
|
|
166
42
|
// Federation Registry helpers
|
|
167
|
-
registerModelOffset,
|
|
168
43
|
toGlobalId,
|
|
169
|
-
fromGlobalId,
|
|
170
|
-
findModelForGlobalId,
|
|
171
44
|
} = useViewerStore();
|
|
172
45
|
|
|
173
46
|
// Track if we've already logged for this ifcDataStore
|
|
174
47
|
const lastLoggedDataStoreRef = useRef<typeof ifcDataStore>(null);
|
|
175
48
|
|
|
176
|
-
//
|
|
177
|
-
const {
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Load from server - uses server-side PARALLEL parsing for maximum speed
|
|
181
|
-
* Uses full parse endpoint (not streaming) for all-at-once parallel processing
|
|
182
|
-
*/
|
|
183
|
-
const loadFromServer = useCallback(async (
|
|
184
|
-
file: File,
|
|
185
|
-
buffer: ArrayBuffer
|
|
186
|
-
): Promise<boolean> => {
|
|
187
|
-
try {
|
|
188
|
-
const serverStart = performance.now();
|
|
189
|
-
setProgress({ phase: 'Connecting to server', percent: 5 });
|
|
190
|
-
|
|
191
|
-
const client = new IfcServerClient({ baseUrl: SERVER_URL });
|
|
192
|
-
|
|
193
|
-
// Silent server availability check (cached, no error logging)
|
|
194
|
-
const serverAvailable = await isServerAvailable(SERVER_URL, client);
|
|
195
|
-
if (!serverAvailable) {
|
|
196
|
-
return false; // Silently fall back - caller handles logging
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
setProgress({ phase: 'Processing on server (parallel)', percent: 15 });
|
|
200
|
-
|
|
201
|
-
// Check if Parquet is supported (requires parquet-wasm)
|
|
202
|
-
const parquetSupported = await client.isParquetSupported();
|
|
203
|
-
|
|
204
|
-
let allMeshes: MeshData[];
|
|
205
|
-
let result: ServerParseResultType;
|
|
206
|
-
let parseTime: number;
|
|
207
|
-
let convertTime: number;
|
|
208
|
-
|
|
209
|
-
// Use streaming for large files (>150MB) for progressive rendering
|
|
210
|
-
// Smaller files use non-streaming path (faster - avoids ~1.1s background re-processing overhead)
|
|
211
|
-
// Streaming overhead: ~67 batch serializations + background re-processing (~1100ms)
|
|
212
|
-
// Non-streaming: single serialization (~218ms for 60k meshes)
|
|
213
|
-
// Threshold chosen to balance UX (progressive rendering) vs performance (overhead)
|
|
214
|
-
const fileSizeMB = buffer.byteLength / (1024 * 1024);
|
|
215
|
-
const USE_STREAMING_THRESHOLD_MB = 150;
|
|
216
|
-
|
|
217
|
-
if (parquetSupported && fileSizeMB > USE_STREAMING_THRESHOLD_MB) {
|
|
218
|
-
// STREAMING PATH - for large files, render progressively
|
|
219
|
-
console.log(`[useIfc] Using STREAMING endpoint for large file (${fileSizeMB.toFixed(1)}MB)`);
|
|
220
|
-
|
|
221
|
-
allMeshes = [];
|
|
222
|
-
let totalVertices = 0;
|
|
223
|
-
let totalTriangles = 0;
|
|
224
|
-
let cacheKey = '';
|
|
225
|
-
let streamMetadata: ModelMetadata | null = null;
|
|
226
|
-
let streamStats: ProcessingStats | null = null;
|
|
227
|
-
let batchCount = 0;
|
|
228
|
-
|
|
229
|
-
// Progressive bounds calculation
|
|
230
|
-
const bounds = createEmptyBounds();
|
|
231
|
-
|
|
232
|
-
const parseStart = performance.now();
|
|
233
|
-
|
|
234
|
-
// Throttle server streaming updates - large files get less frequent UI updates
|
|
235
|
-
let lastServerStreamRenderTime = 0;
|
|
236
|
-
const SERVER_STREAM_INTERVAL_MS = getServerStreamIntervalMs(fileSizeMB);
|
|
237
|
-
|
|
238
|
-
// Use streaming endpoint with batch callback
|
|
239
|
-
const streamResult = await client.parseParquetStream(file, (batch: ParquetBatch) => {
|
|
240
|
-
batchCount++;
|
|
241
|
-
|
|
242
|
-
// Convert batch meshes to viewer format (snake_case to camelCase, number[] to TypedArray)
|
|
243
|
-
const batchMeshes: MeshData[] = batch.meshes.map((m: ServerMeshData) => ({
|
|
244
|
-
expressId: m.express_id,
|
|
245
|
-
positions: new Float32Array(m.positions),
|
|
246
|
-
indices: new Uint32Array(m.indices),
|
|
247
|
-
normals: new Float32Array(m.normals),
|
|
248
|
-
color: m.color,
|
|
249
|
-
ifcType: m.ifc_type,
|
|
250
|
-
}));
|
|
251
|
-
|
|
252
|
-
// Update bounds incrementally
|
|
253
|
-
for (const mesh of batchMeshes) {
|
|
254
|
-
updateBoundsFromPositions(bounds, mesh.positions);
|
|
255
|
-
totalVertices += mesh.positions.length / 3;
|
|
256
|
-
totalTriangles += mesh.indices.length / 3;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Add to collection
|
|
260
|
-
allMeshes.push(...batchMeshes);
|
|
261
|
-
|
|
262
|
-
// THROTTLED PROGRESSIVE RENDERING: Update UI at controlled rate
|
|
263
|
-
// First batch renders immediately, subsequent batches throttled
|
|
264
|
-
const now = performance.now();
|
|
265
|
-
const shouldRender = batchCount === 1 || (now - lastServerStreamRenderTime >= SERVER_STREAM_INTERVAL_MS);
|
|
266
|
-
|
|
267
|
-
if (shouldRender) {
|
|
268
|
-
lastServerStreamRenderTime = now;
|
|
269
|
-
|
|
270
|
-
// Update progress
|
|
271
|
-
setProgress({
|
|
272
|
-
phase: `Streaming batch ${batchCount}`,
|
|
273
|
-
percent: Math.min(15 + (batchCount * 5), 85)
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// PROGRESSIVE RENDERING: Set geometry after each batch
|
|
277
|
-
// This allows the user to see geometry appearing progressively
|
|
278
|
-
const coordinateInfo = {
|
|
279
|
-
originShift: { x: 0, y: 0, z: 0 },
|
|
280
|
-
originalBounds: bounds,
|
|
281
|
-
shiftedBounds: bounds,
|
|
282
|
-
hasLargeCoordinates: false,
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
setGeometryResult({
|
|
286
|
-
meshes: [...allMeshes], // Clone to trigger re-render
|
|
287
|
-
totalVertices,
|
|
288
|
-
totalTriangles,
|
|
289
|
-
coordinateInfo,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
parseTime = performance.now() - parseStart;
|
|
295
|
-
cacheKey = streamResult.cache_key;
|
|
296
|
-
streamMetadata = streamResult.metadata;
|
|
297
|
-
streamStats = streamResult.stats;
|
|
298
|
-
|
|
299
|
-
console.log(`[useIfc] Streaming complete in ${parseTime.toFixed(0)}ms`);
|
|
300
|
-
console.log(` ${batchCount} batches, ${allMeshes.length} meshes`);
|
|
301
|
-
console.log(` Cache key: ${cacheKey}`);
|
|
302
|
-
|
|
303
|
-
// Build final result object for data model fetching
|
|
304
|
-
// Note: meshes field is omitted - allMeshes is passed separately to convertServerDataModel
|
|
305
|
-
result = {
|
|
306
|
-
cache_key: cacheKey,
|
|
307
|
-
metadata: streamMetadata,
|
|
308
|
-
stats: streamStats,
|
|
309
|
-
} as ParquetStreamResult;
|
|
310
|
-
convertTime = 0; // Already converted inline
|
|
311
|
-
|
|
312
|
-
// Final geometry set with complete bounds
|
|
313
|
-
// Server already applies RTC shift to mesh positions, so bounds are shifted
|
|
314
|
-
// Reconstruct originalBounds by adding originShift back to shifted bounds
|
|
315
|
-
const originShift = streamMetadata?.coordinate_info?.origin_shift
|
|
316
|
-
? { x: streamMetadata.coordinate_info.origin_shift[0], y: streamMetadata.coordinate_info.origin_shift[1], z: streamMetadata.coordinate_info.origin_shift[2] }
|
|
317
|
-
: { x: 0, y: 0, z: 0 };
|
|
318
|
-
const finalCoordinateInfo = {
|
|
319
|
-
originShift,
|
|
320
|
-
// Original bounds = shifted bounds + originShift (reconstruct world coordinates)
|
|
321
|
-
originalBounds: {
|
|
322
|
-
min: {
|
|
323
|
-
x: bounds.min.x + originShift.x,
|
|
324
|
-
y: bounds.min.y + originShift.y,
|
|
325
|
-
z: bounds.min.z + originShift.z,
|
|
326
|
-
},
|
|
327
|
-
max: {
|
|
328
|
-
x: bounds.max.x + originShift.x,
|
|
329
|
-
y: bounds.max.y + originShift.y,
|
|
330
|
-
z: bounds.max.z + originShift.z,
|
|
331
|
-
},
|
|
332
|
-
},
|
|
333
|
-
// Shifted bounds = bounds as-is (server already applied shift)
|
|
334
|
-
shiftedBounds: bounds,
|
|
335
|
-
// Note: server returns is_geo_referenced but it really means "had large coordinates"
|
|
336
|
-
hasLargeCoordinates: streamMetadata?.coordinate_info?.is_geo_referenced ?? false,
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
setGeometryResult({
|
|
340
|
-
meshes: allMeshes,
|
|
341
|
-
totalVertices,
|
|
342
|
-
totalTriangles,
|
|
343
|
-
coordinateInfo: finalCoordinateInfo,
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
} else if (parquetSupported) {
|
|
347
|
-
// NON-STREAMING PATH - for smaller files, use batch request (with cache check)
|
|
348
|
-
console.log(`[useIfc] Using PARQUET endpoint - 15x smaller payload, faster transfer`);
|
|
349
|
-
|
|
350
|
-
// Use Parquet endpoint - much smaller payload (~15x compression)
|
|
351
|
-
const parseStart = performance.now();
|
|
352
|
-
const parquetResult = await client.parseParquet(file);
|
|
353
|
-
result = parquetResult;
|
|
354
|
-
parseTime = performance.now() - parseStart;
|
|
355
|
-
|
|
356
|
-
console.log(`[useIfc] Server parse response received in ${parseTime.toFixed(0)}ms`);
|
|
357
|
-
console.log(` Server stats: ${parquetResult.stats.total_time_ms}ms total (parse: ${parquetResult.stats.parse_time_ms}ms, geometry: ${parquetResult.stats.geometry_time_ms}ms)`);
|
|
358
|
-
console.log(` Parquet payload: ${(parquetResult.parquet_stats.payload_size / 1024 / 1024).toFixed(2)}MB, decode: ${parquetResult.parquet_stats.decode_time_ms}ms`);
|
|
359
|
-
console.log(` Meshes: ${parquetResult.meshes.length}, Vertices: ${parquetResult.stats.total_vertices}, Triangles: ${parquetResult.stats.total_triangles}`);
|
|
360
|
-
console.log(` Cache key: ${parquetResult.cache_key}`);
|
|
361
|
-
|
|
362
|
-
setProgress({ phase: 'Converting meshes', percent: 70 });
|
|
363
|
-
|
|
364
|
-
// Convert server mesh format to viewer format (TypedArrays)
|
|
365
|
-
const convertStart = performance.now();
|
|
366
|
-
allMeshes = parquetResult.meshes.map((m: ServerMeshData): MeshData => ({
|
|
367
|
-
expressId: m.express_id,
|
|
368
|
-
positions: new Float32Array(m.positions),
|
|
369
|
-
indices: new Uint32Array(m.indices),
|
|
370
|
-
normals: new Float32Array(m.normals),
|
|
371
|
-
color: m.color,
|
|
372
|
-
ifcType: m.ifc_type,
|
|
373
|
-
}));
|
|
374
|
-
convertTime = performance.now() - convertStart;
|
|
375
|
-
console.log(`[useIfc] Mesh conversion: ${convertTime.toFixed(0)}ms for ${allMeshes.length} meshes`);
|
|
376
|
-
} else {
|
|
377
|
-
console.log(`[useIfc] Parquet not available, using JSON endpoint (install parquet-wasm for 15x faster transfer)`);
|
|
378
|
-
console.log(`[useIfc] Using FULL PARSE (parallel) - all geometry processed at once`);
|
|
379
|
-
|
|
380
|
-
// Fallback to JSON endpoint
|
|
381
|
-
const parseStart = performance.now();
|
|
382
|
-
result = await client.parse(file);
|
|
383
|
-
parseTime = performance.now() - parseStart;
|
|
384
|
-
|
|
385
|
-
console.log(`[useIfc] Server parse response received in ${parseTime.toFixed(0)}ms`);
|
|
386
|
-
console.log(` Server stats: ${result.stats.total_time_ms}ms total (parse: ${result.stats.parse_time_ms}ms, geometry: ${result.stats.geometry_time_ms}ms)`);
|
|
387
|
-
console.log(` Meshes: ${result.meshes.length}, Vertices: ${result.stats.total_vertices}, Triangles: ${result.stats.total_triangles}`);
|
|
388
|
-
console.log(` Cache key: ${result.cache_key}`);
|
|
389
|
-
|
|
390
|
-
setProgress({ phase: 'Converting meshes', percent: 70 });
|
|
391
|
-
|
|
392
|
-
// Convert server mesh format to viewer format
|
|
393
|
-
// NOTE: Server sends colors as floats [0-1], viewer expects bytes [0-255]
|
|
394
|
-
const convertStart = performance.now();
|
|
395
|
-
const jsonResult = result as ParseResponse;
|
|
396
|
-
allMeshes = jsonResult.meshes.map((m: ServerMeshData) => ({
|
|
397
|
-
expressId: m.express_id,
|
|
398
|
-
positions: new Float32Array(m.positions),
|
|
399
|
-
indices: new Uint32Array(m.indices),
|
|
400
|
-
normals: m.normals ? new Float32Array(m.normals) : new Float32Array(0),
|
|
401
|
-
color: m.color,
|
|
402
|
-
}));
|
|
403
|
-
convertTime = performance.now() - convertStart;
|
|
404
|
-
console.log(`[useIfc] Mesh conversion: ${convertTime.toFixed(0)}ms for ${allMeshes.length} meshes`);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// For non-streaming paths, calculate bounds and set geometry
|
|
408
|
-
// (Streaming path already handled this progressively)
|
|
409
|
-
const wasStreaming = parquetSupported && fileSizeMB > USE_STREAMING_THRESHOLD_MB;
|
|
410
|
-
|
|
411
|
-
if (!wasStreaming) {
|
|
412
|
-
// Calculate bounds from mesh positions for camera fitting
|
|
413
|
-
// IMPORTANT: Server already applies RTC shift to mesh positions, so bounds calculated
|
|
414
|
-
// from mesh positions are ALREADY in shifted coordinates (small values near origin).
|
|
415
|
-
// We must NOT subtract originShift again - that would give huge negative bounds!
|
|
416
|
-
const { bounds } = calculateMeshBounds(allMeshes);
|
|
417
|
-
|
|
418
|
-
// Build CoordinateInfo correctly for server-shifted meshes:
|
|
419
|
-
// - shiftedBounds = bounds (already shifted by server)
|
|
420
|
-
// - originalBounds = bounds + originShift (reconstruct original world coordinates)
|
|
421
|
-
const serverCoordInfo = result.metadata.coordinate_info;
|
|
422
|
-
const originShift = serverCoordInfo?.origin_shift
|
|
423
|
-
? { x: serverCoordInfo.origin_shift[0], y: serverCoordInfo.origin_shift[1], z: serverCoordInfo.origin_shift[2] }
|
|
424
|
-
: { x: 0, y: 0, z: 0 };
|
|
425
|
-
|
|
426
|
-
// When server already shifted meshes, shiftedBounds IS the calculated bounds
|
|
427
|
-
// (don't use createCoordinateInfo which would subtract originShift again)
|
|
428
|
-
const coordinateInfo: CoordinateInfo = {
|
|
429
|
-
originShift,
|
|
430
|
-
// Original bounds = shifted bounds + originShift (reconstruct world coordinates)
|
|
431
|
-
originalBounds: {
|
|
432
|
-
min: {
|
|
433
|
-
x: bounds.min.x + originShift.x,
|
|
434
|
-
y: bounds.min.y + originShift.y,
|
|
435
|
-
z: bounds.min.z + originShift.z,
|
|
436
|
-
},
|
|
437
|
-
max: {
|
|
438
|
-
x: bounds.max.x + originShift.x,
|
|
439
|
-
y: bounds.max.y + originShift.y,
|
|
440
|
-
z: bounds.max.z + originShift.z,
|
|
441
|
-
},
|
|
442
|
-
},
|
|
443
|
-
// Shifted bounds = bounds as-is (server already applied shift)
|
|
444
|
-
shiftedBounds: {
|
|
445
|
-
min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
|
|
446
|
-
max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
|
|
447
|
-
},
|
|
448
|
-
// Note: server returns is_geo_referenced but it really means "had large coordinates"
|
|
449
|
-
hasLargeCoordinates: serverCoordInfo?.is_geo_referenced ?? false,
|
|
450
|
-
};
|
|
451
|
-
|
|
452
|
-
console.log(`[useIfc] Calculated bounds:`, {
|
|
453
|
-
min: `(${bounds.min.x.toFixed(1)}, ${bounds.min.y.toFixed(1)}, ${bounds.min.z.toFixed(1)})`,
|
|
454
|
-
max: `(${bounds.max.x.toFixed(1)}, ${bounds.max.y.toFixed(1)}, ${bounds.max.z.toFixed(1)})`,
|
|
455
|
-
size: `${(bounds.max.x - bounds.min.x).toFixed(1)} x ${(bounds.max.y - bounds.min.y).toFixed(1)} x ${(bounds.max.z - bounds.min.z).toFixed(1)}`,
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
// Set all geometry at once
|
|
459
|
-
setProgress({ phase: 'Rendering geometry', percent: 80 });
|
|
460
|
-
const renderStart = performance.now();
|
|
461
|
-
setGeometryResult({
|
|
462
|
-
meshes: allMeshes,
|
|
463
|
-
totalVertices: result.stats.total_vertices,
|
|
464
|
-
totalTriangles: result.stats.total_triangles,
|
|
465
|
-
coordinateInfo,
|
|
466
|
-
});
|
|
467
|
-
const renderTime = performance.now() - renderStart;
|
|
468
|
-
console.log(`[useIfc] Geometry set: ${renderTime.toFixed(0)}ms`);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Fetch and decode data model asynchronously (geometry already displayed)
|
|
472
|
-
// Data model is processed on server in background, fetch via separate endpoint
|
|
473
|
-
const cacheKey = result.cache_key;
|
|
474
|
-
|
|
475
|
-
// Start data model fetch in background - don't block rendering
|
|
476
|
-
(async () => {
|
|
477
|
-
setProgress({ phase: 'Fetching data model', percent: 85 });
|
|
478
|
-
const dataModelStart = performance.now();
|
|
479
|
-
|
|
480
|
-
try {
|
|
481
|
-
// If data model was included in response (ParquetParseResponse), use it directly
|
|
482
|
-
// Otherwise, fetch from the data model endpoint
|
|
483
|
-
let dataModelBuffer: ArrayBuffer | null = null;
|
|
484
|
-
if ('data_model' in result && result.data_model) {
|
|
485
|
-
dataModelBuffer = result.data_model;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
if (!dataModelBuffer || dataModelBuffer.byteLength === 0) {
|
|
489
|
-
console.log('[useIfc] Fetching data model from server (background processing)...');
|
|
490
|
-
dataModelBuffer = await client.fetchDataModel(cacheKey);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (!dataModelBuffer) {
|
|
494
|
-
console.log('[useIfc] ⚡ Data model not available - property panel disabled');
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
const dataModel: DataModel = await decodeDataModel(dataModelBuffer);
|
|
499
|
-
|
|
500
|
-
console.log(`[useIfc] Data model decoded in ${(performance.now() - dataModelStart).toFixed(0)}ms`);
|
|
501
|
-
console.log(` Entities: ${dataModel.entities.size}`);
|
|
502
|
-
console.log(` PropertySets: ${dataModel.propertySets.size}`);
|
|
503
|
-
const quantitySetsSize = (dataModel as { quantitySets?: Map<number, unknown> }).quantitySets?.size ?? 0;
|
|
504
|
-
console.log(` QuantitySets: ${quantitySetsSize}`);
|
|
505
|
-
console.log(` Relationships: ${dataModel.relationships.length}`);
|
|
506
|
-
console.log(` Spatial nodes: ${dataModel.spatialHierarchy.nodes.length}`);
|
|
507
|
-
|
|
508
|
-
// Convert server data model to viewer data store format using utility
|
|
509
|
-
// ViewerDataStore is structurally compatible with IfcDataStore
|
|
510
|
-
const dataStore = convertServerDataModel(
|
|
511
|
-
dataModel,
|
|
512
|
-
result as ServerParseResult,
|
|
513
|
-
file,
|
|
514
|
-
allMeshes
|
|
515
|
-
) as unknown as IfcDataStore;
|
|
516
|
-
|
|
517
|
-
setIfcDataStore(dataStore);
|
|
518
|
-
console.log('[useIfc] ✅ Property panel ready with server data model');
|
|
519
|
-
console.log(`[useIfc] Data model loaded in ${(performance.now() - dataModelStart).toFixed(0)}ms (background)`);
|
|
520
|
-
} catch (err) {
|
|
521
|
-
console.warn('[useIfc] Failed to decode data model:', err);
|
|
522
|
-
console.log('[useIfc] ⚡ Skipping data model (decoding failed)');
|
|
523
|
-
}
|
|
524
|
-
})(); // End of async data model fetch block - runs in background, doesn't block
|
|
525
|
-
|
|
526
|
-
// Geometry is ready - mark complete immediately (data model loads in background)
|
|
527
|
-
setProgress({ phase: 'Complete', percent: 100 });
|
|
528
|
-
const totalServerTime = performance.now() - serverStart;
|
|
529
|
-
console.log(`[useIfc] SERVER PARALLEL complete: ${file.name}`);
|
|
530
|
-
console.log(` Total time: ${totalServerTime.toFixed(0)}ms`);
|
|
531
|
-
console.log(` Breakdown: parse=${parseTime.toFixed(0)}ms, convert=${convertTime.toFixed(0)}ms`);
|
|
532
|
-
|
|
533
|
-
return true;
|
|
534
|
-
} catch (err) {
|
|
535
|
-
console.error('[useIfc] Server parse failed:', err);
|
|
536
|
-
return false;
|
|
537
|
-
}
|
|
538
|
-
}, [setProgress, setIfcDataStore, setGeometryResult]);
|
|
539
|
-
|
|
540
|
-
const loadFile = useCallback(async (file: File) => {
|
|
541
|
-
const { resetViewerState, clearAllModels } = useViewerStore.getState();
|
|
542
|
-
|
|
543
|
-
// Track total elapsed time for complete user experience
|
|
544
|
-
const totalStartTime = performance.now();
|
|
545
|
-
|
|
546
|
-
try {
|
|
547
|
-
// Reset all viewer state before loading new file
|
|
548
|
-
// Also clear models Map to ensure clean single-file state
|
|
549
|
-
resetViewerState();
|
|
550
|
-
clearAllModels();
|
|
551
|
-
|
|
552
|
-
setLoading(true);
|
|
553
|
-
setError(null);
|
|
554
|
-
setProgress({ phase: 'Loading file', percent: 0 });
|
|
555
|
-
|
|
556
|
-
// Read file from disk
|
|
557
|
-
const buffer = await file.arrayBuffer();
|
|
558
|
-
const fileSizeMB = buffer.byteLength / (1024 * 1024);
|
|
559
|
-
|
|
560
|
-
// Detect file format (IFCX/IFC5 vs IFC4 STEP vs GLB)
|
|
561
|
-
const format = detectFormat(buffer);
|
|
562
|
-
|
|
563
|
-
// IFCX files must be parsed client-side (server only supports IFC4 STEP)
|
|
564
|
-
if (format === 'ifcx') {
|
|
565
|
-
setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
|
|
566
|
-
|
|
567
|
-
try {
|
|
568
|
-
const ifcxResult = await parseIfcx(buffer, {
|
|
569
|
-
onProgress: (prog: { phase: string; percent: number }) => {
|
|
570
|
-
setProgress({ phase: `IFCX ${prog.phase}`, percent: 10 + (prog.percent * 0.8) });
|
|
571
|
-
},
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
// Convert IFCX meshes to viewer format
|
|
575
|
-
// Note: IFCX geometry extractor already handles Y-up to Z-up conversion
|
|
576
|
-
// and applies transforms correctly in Z-up space, so we just pass through
|
|
577
|
-
|
|
578
|
-
const meshes: MeshData[] = ifcxResult.meshes.map((m: { expressId?: number; express_id?: number; id?: number; positions: Float32Array | number[]; indices: Uint32Array | number[]; normals: Float32Array | number[]; color?: [number, number, number, number] | [number, number, number]; ifcType?: string; ifc_type?: string }) => {
|
|
579
|
-
// IFCX MeshData has: expressId, ifcType, positions (Float32Array), indices (Uint32Array), normals (Float32Array), color
|
|
580
|
-
const positions = m.positions instanceof Float32Array ? m.positions : new Float32Array(m.positions || []);
|
|
581
|
-
const indices = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices || []);
|
|
582
|
-
const normals = m.normals instanceof Float32Array ? m.normals : new Float32Array(m.normals || []);
|
|
583
|
-
|
|
584
|
-
// Normalize color to RGBA format (4 elements)
|
|
585
|
-
const color = normalizeColor(m.color);
|
|
586
|
-
|
|
587
|
-
return {
|
|
588
|
-
expressId: m.expressId || m.express_id || m.id || 0,
|
|
589
|
-
positions,
|
|
590
|
-
indices,
|
|
591
|
-
normals,
|
|
592
|
-
color,
|
|
593
|
-
ifcType: m.ifcType || m.ifc_type || 'IfcProduct',
|
|
594
|
-
};
|
|
595
|
-
}).filter((m: MeshData) => m.positions.length > 0 && m.indices.length > 0); // Filter out empty meshes
|
|
596
|
-
|
|
597
|
-
// Check if this is an overlay-only file (no geometry)
|
|
598
|
-
if (meshes.length === 0) {
|
|
599
|
-
console.warn(`[useIfc] IFCX file "${file.name}" has no geometry - this appears to be an overlay file that adds properties to a base model.`);
|
|
600
|
-
console.warn('[useIfc] To use this file, load it together with a base IFCX file (select both files at once).');
|
|
601
|
-
|
|
602
|
-
// Check if file has data references that suggest it's an overlay
|
|
603
|
-
const hasReferences = ifcxResult.entityCount > 0;
|
|
604
|
-
if (hasReferences) {
|
|
605
|
-
setError(`"${file.name}" is an overlay file with no geometry. Please load it together with a base IFCX file (select all files at once).`);
|
|
606
|
-
setLoading(false);
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// Calculate bounds and statistics
|
|
612
|
-
const { bounds, stats } = calculateMeshBounds(meshes);
|
|
613
|
-
const coordinateInfo = createCoordinateInfo(bounds);
|
|
614
|
-
|
|
615
|
-
setGeometryResult({
|
|
616
|
-
meshes,
|
|
617
|
-
totalVertices: stats.totalVertices,
|
|
618
|
-
totalTriangles: stats.totalTriangles,
|
|
619
|
-
coordinateInfo,
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
// Convert IFCX data model to IfcDataStore format
|
|
623
|
-
// IFCX already provides entities, properties, quantities, relationships, spatialHierarchy
|
|
624
|
-
const dataStore = {
|
|
625
|
-
fileSize: ifcxResult.fileSize,
|
|
626
|
-
schemaVersion: 'IFC5' as const,
|
|
627
|
-
entityCount: ifcxResult.entityCount,
|
|
628
|
-
parseTime: ifcxResult.parseTime,
|
|
629
|
-
source: new Uint8Array(buffer),
|
|
630
|
-
entityIndex: {
|
|
631
|
-
byId: new Map(),
|
|
632
|
-
byType: new Map(),
|
|
633
|
-
},
|
|
634
|
-
strings: ifcxResult.strings,
|
|
635
|
-
entities: ifcxResult.entities,
|
|
636
|
-
properties: ifcxResult.properties,
|
|
637
|
-
quantities: ifcxResult.quantities,
|
|
638
|
-
relationships: ifcxResult.relationships,
|
|
639
|
-
spatialHierarchy: ifcxResult.spatialHierarchy,
|
|
640
|
-
} as IfcxDataStore;
|
|
641
|
-
|
|
642
|
-
// Cast to IfcDataStore for store compatibility (IFC5 schema extension)
|
|
643
|
-
setIfcDataStore(dataStore as unknown as IfcDataStore);
|
|
644
|
-
|
|
645
|
-
setProgress({ phase: 'Complete', percent: 100 });
|
|
646
|
-
setLoading(false);
|
|
647
|
-
return;
|
|
648
|
-
} catch (err: unknown) {
|
|
649
|
-
console.error('[useIfc] IFCX parsing failed:', err);
|
|
650
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
651
|
-
setError(`IFCX parsing failed: ${message}`);
|
|
652
|
-
setLoading(false);
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// GLB files: parse directly to MeshData (no data model, geometry only)
|
|
658
|
-
if (format === 'glb') {
|
|
659
|
-
setProgress({ phase: 'Parsing GLB', percent: 10 });
|
|
660
|
-
|
|
661
|
-
try {
|
|
662
|
-
const meshes = loadGLBToMeshData(new Uint8Array(buffer));
|
|
663
|
-
|
|
664
|
-
if (meshes.length === 0) {
|
|
665
|
-
setError('GLB file contains no geometry');
|
|
666
|
-
setLoading(false);
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const { bounds, stats } = calculateMeshBounds(meshes);
|
|
671
|
-
const coordinateInfo = createCoordinateInfo(bounds);
|
|
672
|
-
|
|
673
|
-
setGeometryResult({
|
|
674
|
-
meshes,
|
|
675
|
-
totalVertices: stats.totalVertices,
|
|
676
|
-
totalTriangles: stats.totalTriangles,
|
|
677
|
-
coordinateInfo,
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
// GLB files have no IFC data model - set a minimal store
|
|
681
|
-
setIfcDataStore(null);
|
|
682
|
-
|
|
683
|
-
setProgress({ phase: 'Complete', percent: 100 });
|
|
684
|
-
|
|
685
|
-
const totalElapsedMs = performance.now() - totalStartTime;
|
|
686
|
-
console.log(`[useIfc] GLB loaded: ${meshes.length} meshes, ${stats.totalTriangles} triangles in ${totalElapsedMs.toFixed(0)}ms`);
|
|
687
|
-
setLoading(false);
|
|
688
|
-
return;
|
|
689
|
-
} catch (err: unknown) {
|
|
690
|
-
console.error('[useIfc] GLB parsing failed:', err);
|
|
691
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
692
|
-
setError(`GLB parsing failed: ${message}`);
|
|
693
|
-
setLoading(false);
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// INSTANT cache lookup: Use filename + size + format version as key (no hashing!)
|
|
699
|
-
// Same filename + same size = same file (fast and reliable enough)
|
|
700
|
-
// Include format version to invalidate old caches when format changes
|
|
701
|
-
const cacheKey = `${file.name}-${buffer.byteLength}-v3`;
|
|
702
|
-
|
|
703
|
-
if (buffer.byteLength >= CACHE_SIZE_THRESHOLD) {
|
|
704
|
-
setProgress({ phase: 'Checking cache', percent: 5 });
|
|
705
|
-
const cacheResult = await getCached(cacheKey);
|
|
706
|
-
if (cacheResult) {
|
|
707
|
-
const success = await loadFromCache(cacheResult, file.name, cacheKey);
|
|
708
|
-
if (success) {
|
|
709
|
-
const totalElapsedMs = performance.now() - totalStartTime;
|
|
710
|
-
console.log(`[useIfc] TOTAL LOAD TIME (from cache): ${totalElapsedMs.toFixed(0)}ms (${(totalElapsedMs / 1000).toFixed(1)}s)`);
|
|
711
|
-
setLoading(false);
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// Try server parsing first (enabled by default for multi-core performance)
|
|
718
|
-
// Only for IFC4 STEP files (server doesn't support IFCX)
|
|
719
|
-
if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '') {
|
|
720
|
-
// Pass buffer directly - server uses File object for parsing, buffer is only for size checks
|
|
721
|
-
const serverSuccess = await loadFromServer(file, buffer);
|
|
722
|
-
if (serverSuccess) {
|
|
723
|
-
const totalElapsedMs = performance.now() - totalStartTime;
|
|
724
|
-
console.log(`[useIfc] TOTAL LOAD TIME (server): ${totalElapsedMs.toFixed(0)}ms (${(totalElapsedMs / 1000).toFixed(1)}s)`);
|
|
725
|
-
setLoading(false);
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
|
-
// Server not available - continue with local WASM (no error logging needed)
|
|
729
|
-
} else if (format === 'unknown') {
|
|
730
|
-
console.warn('[useIfc] Unknown file format - attempting to parse as IFC4 STEP');
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// Using local WASM parsing
|
|
734
|
-
setProgress({ phase: 'Starting geometry streaming', percent: 10 });
|
|
735
|
-
|
|
736
|
-
// Initialize geometry processor first (WASM init is fast if already loaded)
|
|
737
|
-
const geometryProcessor = new GeometryProcessor({
|
|
738
|
-
quality: GeometryQuality.Balanced
|
|
739
|
-
});
|
|
740
|
-
await geometryProcessor.init();
|
|
741
|
-
|
|
742
|
-
// DEFER data model parsing - start it AFTER geometry streaming begins
|
|
743
|
-
// This ensures geometry gets first crack at the CPU for fast first frame
|
|
744
|
-
// Data model parsing is lower priority - UI can work without it initially
|
|
745
|
-
let resolveDataStore: (dataStore: IfcDataStore) => void;
|
|
746
|
-
const dataStorePromise = new Promise<IfcDataStore>((resolve) => {
|
|
747
|
-
resolveDataStore = resolve;
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
const startDataModelParsing = () => {
|
|
751
|
-
// Use main thread - worker parsing disabled (IfcDataStore has closures that can't be serialized)
|
|
752
|
-
const parser = new IfcParser();
|
|
753
|
-
const wasmApi = geometryProcessor.getApi();
|
|
754
|
-
parser.parseColumnar(buffer, {
|
|
755
|
-
wasmApi, // Pass WASM API for 5-10x faster entity scanning
|
|
756
|
-
}).then(dataStore => {
|
|
757
|
-
|
|
758
|
-
// Calculate storey heights from elevation differences if not already populated
|
|
759
|
-
if (dataStore.spatialHierarchy && dataStore.spatialHierarchy.storeyHeights.size === 0 && dataStore.spatialHierarchy.storeyElevations.size > 1) {
|
|
760
|
-
const calculatedHeights = calculateStoreyHeights(dataStore.spatialHierarchy.storeyElevations);
|
|
761
|
-
for (const [storeyId, height] of calculatedHeights) {
|
|
762
|
-
dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
setIfcDataStore(dataStore);
|
|
767
|
-
resolveDataStore(dataStore);
|
|
768
|
-
}).catch(err => {
|
|
769
|
-
console.error('[useIfc] Data model parsing failed:', err);
|
|
770
|
-
});
|
|
771
|
-
};
|
|
772
|
-
|
|
773
|
-
// Schedule data model parsing to start after geometry begins streaming
|
|
774
|
-
setTimeout(startDataModelParsing, 0);
|
|
775
|
-
|
|
776
|
-
// Use adaptive processing: sync for small files, streaming for large files
|
|
777
|
-
let estimatedTotal = 0;
|
|
778
|
-
let totalMeshes = 0;
|
|
779
|
-
const allMeshes: MeshData[] = []; // Collect all meshes for BVH building
|
|
780
|
-
let finalCoordinateInfo: CoordinateInfo | null = null;
|
|
781
|
-
// Capture RTC offset from WASM for proper multi-model alignment
|
|
782
|
-
let capturedRtcOffset: { x: number; y: number; z: number } | null = null;
|
|
783
|
-
|
|
784
|
-
// Clear existing geometry result
|
|
785
|
-
setGeometryResult(null);
|
|
49
|
+
// File loading (single-model path)
|
|
50
|
+
const { loadFile } = useIfcLoader();
|
|
786
51
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
let pendingMeshes: MeshData[] = [];
|
|
799
|
-
let lastRenderTime = 0;
|
|
800
|
-
const RENDER_INTERVAL_MS = getRenderIntervalMs(fileSizeMB);
|
|
801
|
-
|
|
802
|
-
try {
|
|
803
|
-
// Use dynamic batch sizing for optimal throughput
|
|
804
|
-
const dynamicBatchConfig = getDynamicBatchConfig(fileSizeMB);
|
|
805
|
-
|
|
806
|
-
for await (const event of geometryProcessor.processAdaptive(new Uint8Array(buffer), {
|
|
807
|
-
sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
|
|
808
|
-
batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
|
|
809
|
-
})) {
|
|
810
|
-
const eventReceived = performance.now();
|
|
811
|
-
const waitTime = eventReceived - lastBatchTime;
|
|
812
|
-
|
|
813
|
-
switch (event.type) {
|
|
814
|
-
case 'start':
|
|
815
|
-
estimatedTotal = event.totalEstimate;
|
|
816
|
-
break;
|
|
817
|
-
case 'model-open':
|
|
818
|
-
setProgress({ phase: 'Processing geometry', percent: 50 });
|
|
819
|
-
break;
|
|
820
|
-
case 'colorUpdate': {
|
|
821
|
-
// Update colors for already-rendered meshes
|
|
822
|
-
updateMeshColors(event.updates);
|
|
823
|
-
break;
|
|
824
|
-
}
|
|
825
|
-
case 'rtcOffset': {
|
|
826
|
-
// Capture RTC offset from WASM for multi-model alignment
|
|
827
|
-
if (event.hasRtc) {
|
|
828
|
-
capturedRtcOffset = event.rtcOffset;
|
|
829
|
-
}
|
|
830
|
-
break;
|
|
831
|
-
}
|
|
832
|
-
case 'batch': {
|
|
833
|
-
batchCount++;
|
|
834
|
-
totalWaitTime += waitTime;
|
|
835
|
-
|
|
836
|
-
// Track time to first geometry
|
|
837
|
-
if (batchCount === 1) {
|
|
838
|
-
firstGeometryTime = performance.now() - totalStartTime;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
const processStart = performance.now();
|
|
842
|
-
|
|
843
|
-
// Collect meshes for BVH building
|
|
844
|
-
allMeshes.push(...event.meshes);
|
|
845
|
-
finalCoordinateInfo = event.coordinateInfo ?? null;
|
|
846
|
-
totalMeshes = event.totalSoFar;
|
|
847
|
-
|
|
848
|
-
// Accumulate meshes for batched rendering
|
|
849
|
-
pendingMeshes.push(...event.meshes);
|
|
850
|
-
|
|
851
|
-
// FIRST BATCH: Render immediately for fast first frame
|
|
852
|
-
// SUBSEQUENT: Throttle to reduce React re-renders
|
|
853
|
-
const timeSinceLastRender = eventReceived - lastRenderTime;
|
|
854
|
-
const shouldRender = batchCount === 1 || timeSinceLastRender >= RENDER_INTERVAL_MS;
|
|
855
|
-
|
|
856
|
-
if (shouldRender && pendingMeshes.length > 0) {
|
|
857
|
-
appendGeometryBatch(pendingMeshes, event.coordinateInfo);
|
|
858
|
-
pendingMeshes = [];
|
|
859
|
-
lastRenderTime = eventReceived;
|
|
860
|
-
|
|
861
|
-
// Update progress
|
|
862
|
-
const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal / 10, totalMeshes)) * 45);
|
|
863
|
-
setProgress({
|
|
864
|
-
phase: `Rendering geometry (${totalMeshes} meshes)`,
|
|
865
|
-
percent: progressPercent
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
const processTime = performance.now() - processStart;
|
|
870
|
-
totalProcessTime += processTime;
|
|
871
|
-
break;
|
|
872
|
-
}
|
|
873
|
-
case 'complete':
|
|
874
|
-
// Flush any remaining pending meshes
|
|
875
|
-
if (pendingMeshes.length > 0) {
|
|
876
|
-
appendGeometryBatch(pendingMeshes, event.coordinateInfo);
|
|
877
|
-
pendingMeshes = [];
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
finalCoordinateInfo = event.coordinateInfo ?? null;
|
|
881
|
-
|
|
882
|
-
// Store captured RTC offset in coordinate info for multi-model alignment
|
|
883
|
-
if (finalCoordinateInfo && capturedRtcOffset) {
|
|
884
|
-
finalCoordinateInfo.wasmRtcOffset = capturedRtcOffset;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
// Update geometry result with final coordinate info
|
|
888
|
-
updateCoordinateInfo(finalCoordinateInfo);
|
|
889
|
-
|
|
890
|
-
setProgress({ phase: 'Complete', percent: 100 });
|
|
891
|
-
|
|
892
|
-
// Build spatial index and cache in background (non-blocking)
|
|
893
|
-
// Wait for data model to complete first
|
|
894
|
-
dataStorePromise.then(dataStore => {
|
|
895
|
-
// Build spatial index from meshes (in background)
|
|
896
|
-
if (allMeshes.length > 0) {
|
|
897
|
-
const buildIndex = () => {
|
|
898
|
-
try {
|
|
899
|
-
const spatialIndex = buildSpatialIndex(allMeshes);
|
|
900
|
-
dataStore.spatialIndex = spatialIndex;
|
|
901
|
-
setIfcDataStore({ ...dataStore });
|
|
902
|
-
} catch (err) {
|
|
903
|
-
console.warn('[useIfc] Failed to build spatial index:', err);
|
|
904
|
-
}
|
|
905
|
-
};
|
|
906
|
-
|
|
907
|
-
// Use requestIdleCallback if available (type assertion for optional browser API)
|
|
908
|
-
if ('requestIdleCallback' in window) {
|
|
909
|
-
(window as { requestIdleCallback: (cb: () => void, opts?: { timeout: number }) => void }).requestIdleCallback(buildIndex, { timeout: 2000 });
|
|
910
|
-
} else {
|
|
911
|
-
setTimeout(buildIndex, 100);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
// Cache the result in the background (for files above threshold)
|
|
916
|
-
if (buffer.byteLength >= CACHE_SIZE_THRESHOLD && allMeshes.length > 0 && finalCoordinateInfo) {
|
|
917
|
-
const geometryData: GeometryData = {
|
|
918
|
-
meshes: allMeshes,
|
|
919
|
-
totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
|
|
920
|
-
totalTriangles: allMeshes.reduce((sum, m) => sum + m.indices.length / 3, 0),
|
|
921
|
-
coordinateInfo: finalCoordinateInfo,
|
|
922
|
-
};
|
|
923
|
-
saveToCache(cacheKey, dataStore, geometryData, buffer, file.name);
|
|
924
|
-
}
|
|
925
|
-
});
|
|
926
|
-
break;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
lastBatchTime = performance.now();
|
|
930
|
-
}
|
|
931
|
-
} catch (err) {
|
|
932
|
-
console.error('[useIfc] Error in processing:', err);
|
|
933
|
-
setError(err instanceof Error ? err.message : 'Unknown error during geometry processing');
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
// Log developer-friendly summary with key metrics
|
|
937
|
-
const totalElapsedMs = performance.now() - totalStartTime;
|
|
938
|
-
const totalVertices = allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0);
|
|
939
|
-
console.log(
|
|
940
|
-
`[useIfc] ✓ ${file.name} (${fileSizeMB.toFixed(1)}MB) → ` +
|
|
941
|
-
`${allMeshes.length} meshes, ${(totalVertices / 1000).toFixed(0)}k vertices | ` +
|
|
942
|
-
`first: ${firstGeometryTime.toFixed(0)}ms, total: ${totalElapsedMs.toFixed(0)}ms`
|
|
943
|
-
);
|
|
944
|
-
|
|
945
|
-
setLoading(false);
|
|
946
|
-
} catch (err) {
|
|
947
|
-
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
948
|
-
setLoading(false);
|
|
949
|
-
}
|
|
950
|
-
}, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, appendGeometryBatch, updateCoordinateInfo, loadFromCache, saveToCache]);
|
|
52
|
+
// Multi-model federation
|
|
53
|
+
const {
|
|
54
|
+
addModel,
|
|
55
|
+
removeModel,
|
|
56
|
+
getQueryForModel,
|
|
57
|
+
loadFilesSequentially,
|
|
58
|
+
loadFederatedIfcx,
|
|
59
|
+
addIfcxOverlays,
|
|
60
|
+
findModelForEntity,
|
|
61
|
+
resolveGlobalId,
|
|
62
|
+
} = useIfcFederation();
|
|
951
63
|
|
|
952
64
|
// Memoize query to prevent recreation on every render
|
|
953
65
|
// For single-model backward compatibility
|
|
@@ -960,720 +72,6 @@ export function useIfc() {
|
|
|
960
72
|
return new IfcQuery(ifcDataStore);
|
|
961
73
|
}, [ifcDataStore]);
|
|
962
74
|
|
|
963
|
-
/**
|
|
964
|
-
* Add a model to the federation (multi-model support)
|
|
965
|
-
* Uses FederationRegistry to assign unique ID offsets - BULLETPROOF against ID collisions
|
|
966
|
-
* Returns the model ID on success, null on failure
|
|
967
|
-
*/
|
|
968
|
-
const addModel = useCallback(async (
|
|
969
|
-
file: File,
|
|
970
|
-
options?: { name?: string }
|
|
971
|
-
): Promise<string | null> => {
|
|
972
|
-
const modelId = crypto.randomUUID();
|
|
973
|
-
const totalStartTime = performance.now();
|
|
974
|
-
|
|
975
|
-
try {
|
|
976
|
-
// IMPORTANT: Before adding a new model, check if there's a legacy model
|
|
977
|
-
// (loaded via loadFile) that's not in the Map yet. If so, migrate it first.
|
|
978
|
-
const currentModels = useViewerStore.getState().models;
|
|
979
|
-
const currentIfcDataStore = useViewerStore.getState().ifcDataStore;
|
|
980
|
-
const currentGeometryResult = useViewerStore.getState().geometryResult;
|
|
981
|
-
|
|
982
|
-
if (currentModels.size === 0 && currentIfcDataStore && currentGeometryResult) {
|
|
983
|
-
// Migrate the legacy model to the Map
|
|
984
|
-
// Legacy model has offset 0 (IDs are unchanged)
|
|
985
|
-
const legacyModelId = crypto.randomUUID();
|
|
986
|
-
const legacyName = currentIfcDataStore.spatialHierarchy?.project?.name || 'Model 1';
|
|
987
|
-
|
|
988
|
-
// Find max expressId in legacy model for registry
|
|
989
|
-
// IMPORTANT: Include ALL entities, not just meshes, for proper globalId resolution
|
|
990
|
-
const legacyMeshes = currentGeometryResult.meshes || [];
|
|
991
|
-
const legacyMaxExpressIdFromMeshes = legacyMeshes.reduce((max, m) => Math.max(max, m.expressId), 0);
|
|
992
|
-
// FIXED: Use iteration instead of spread to avoid stack overflow with large Maps
|
|
993
|
-
let legacyMaxExpressIdFromEntities = 0;
|
|
994
|
-
if (currentIfcDataStore.entityIndex?.byId) {
|
|
995
|
-
for (const key of currentIfcDataStore.entityIndex.byId.keys()) {
|
|
996
|
-
if (key > legacyMaxExpressIdFromEntities) legacyMaxExpressIdFromEntities = key;
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
const legacyMaxExpressId = Math.max(legacyMaxExpressIdFromMeshes, legacyMaxExpressIdFromEntities);
|
|
1000
|
-
|
|
1001
|
-
// Register legacy model with offset 0 (IDs already in use as-is)
|
|
1002
|
-
const legacyOffset = registerModelOffset(legacyModelId, legacyMaxExpressId);
|
|
1003
|
-
|
|
1004
|
-
const legacyModel: FederatedModel = {
|
|
1005
|
-
id: legacyModelId,
|
|
1006
|
-
name: legacyName,
|
|
1007
|
-
ifcDataStore: currentIfcDataStore,
|
|
1008
|
-
geometryResult: currentGeometryResult,
|
|
1009
|
-
visible: true,
|
|
1010
|
-
collapsed: false,
|
|
1011
|
-
schemaVersion: 'IFC4',
|
|
1012
|
-
loadedAt: Date.now() - 1000,
|
|
1013
|
-
fileSize: 0,
|
|
1014
|
-
idOffset: legacyOffset,
|
|
1015
|
-
maxExpressId: legacyMaxExpressId,
|
|
1016
|
-
};
|
|
1017
|
-
storeAddModel(legacyModel);
|
|
1018
|
-
console.log(`[useIfc] Migrated legacy model "${legacyModel.name}" to federation (offset: ${legacyOffset}, maxId: ${legacyMaxExpressId})`);
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
setLoading(true);
|
|
1022
|
-
setError(null);
|
|
1023
|
-
setProgress({ phase: 'Loading file', percent: 0 });
|
|
1024
|
-
|
|
1025
|
-
// Read file from disk
|
|
1026
|
-
const buffer = await file.arrayBuffer();
|
|
1027
|
-
const fileSizeMB = buffer.byteLength / (1024 * 1024);
|
|
1028
|
-
|
|
1029
|
-
// Detect file format
|
|
1030
|
-
const format = detectFormat(buffer);
|
|
1031
|
-
|
|
1032
|
-
let parsedDataStore: IfcDataStore | null = null;
|
|
1033
|
-
let parsedGeometry: { meshes: MeshData[]; totalVertices: number; totalTriangles: number; coordinateInfo: CoordinateInfo } | null = null;
|
|
1034
|
-
let schemaVersion: SchemaVersion = 'IFC4';
|
|
1035
|
-
|
|
1036
|
-
// IFCX files must be parsed client-side
|
|
1037
|
-
if (format === 'ifcx') {
|
|
1038
|
-
setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
|
|
1039
|
-
|
|
1040
|
-
const ifcxResult = await parseIfcx(buffer, {
|
|
1041
|
-
onProgress: (prog: { phase: string; percent: number }) => {
|
|
1042
|
-
setProgress({ phase: `IFCX ${prog.phase}`, percent: 10 + (prog.percent * 0.8) });
|
|
1043
|
-
},
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
// Convert IFCX meshes to viewer format
|
|
1047
|
-
const meshes: MeshData[] = ifcxResult.meshes.map((m: { expressId?: number; express_id?: number; id?: number; positions: Float32Array | number[]; indices: Uint32Array | number[]; normals: Float32Array | number[]; color?: [number, number, number, number] | [number, number, number]; ifcType?: string; ifc_type?: string }) => {
|
|
1048
|
-
const positions = m.positions instanceof Float32Array ? m.positions : new Float32Array(m.positions || []);
|
|
1049
|
-
const indices = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices || []);
|
|
1050
|
-
const normals = m.normals instanceof Float32Array ? m.normals : new Float32Array(m.normals || []);
|
|
1051
|
-
const color = normalizeColor(m.color);
|
|
1052
|
-
|
|
1053
|
-
return {
|
|
1054
|
-
expressId: m.expressId || m.express_id || m.id || 0,
|
|
1055
|
-
positions,
|
|
1056
|
-
indices,
|
|
1057
|
-
normals,
|
|
1058
|
-
color,
|
|
1059
|
-
ifcType: m.ifcType || m.ifc_type || 'IfcProduct',
|
|
1060
|
-
};
|
|
1061
|
-
}).filter((m: MeshData) => m.positions.length > 0 && m.indices.length > 0);
|
|
1062
|
-
|
|
1063
|
-
// Check if this is an overlay-only IFCX file (no geometry)
|
|
1064
|
-
if (meshes.length === 0 && ifcxResult.entityCount > 0) {
|
|
1065
|
-
console.warn(`[useIfc] IFCX file "${file.name}" has no geometry - this is an overlay file.`);
|
|
1066
|
-
setError(`"${file.name}" is an overlay file with no geometry. Please load it together with a base IFCX file (select all files at once for federated loading).`);
|
|
1067
|
-
setLoading(false);
|
|
1068
|
-
return null;
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
const { bounds, stats } = calculateMeshBounds(meshes);
|
|
1072
|
-
const coordinateInfo = createCoordinateInfo(bounds);
|
|
1073
|
-
|
|
1074
|
-
parsedGeometry = {
|
|
1075
|
-
meshes,
|
|
1076
|
-
totalVertices: stats.totalVertices,
|
|
1077
|
-
totalTriangles: stats.totalTriangles,
|
|
1078
|
-
coordinateInfo,
|
|
1079
|
-
};
|
|
1080
|
-
|
|
1081
|
-
parsedDataStore = {
|
|
1082
|
-
fileSize: ifcxResult.fileSize,
|
|
1083
|
-
schemaVersion: 'IFC5' as const,
|
|
1084
|
-
entityCount: ifcxResult.entityCount,
|
|
1085
|
-
parseTime: ifcxResult.parseTime,
|
|
1086
|
-
source: new Uint8Array(buffer),
|
|
1087
|
-
entityIndex: { byId: new Map(), byType: new Map() },
|
|
1088
|
-
strings: ifcxResult.strings,
|
|
1089
|
-
entities: ifcxResult.entities,
|
|
1090
|
-
properties: ifcxResult.properties,
|
|
1091
|
-
quantities: ifcxResult.quantities,
|
|
1092
|
-
relationships: ifcxResult.relationships,
|
|
1093
|
-
spatialHierarchy: ifcxResult.spatialHierarchy,
|
|
1094
|
-
} as unknown as IfcDataStore; // IFC5 schema extension
|
|
1095
|
-
|
|
1096
|
-
schemaVersion = 'IFC5';
|
|
1097
|
-
|
|
1098
|
-
} else if (format === 'glb') {
|
|
1099
|
-
// GLB files: parse directly to MeshData (geometry only, no IFC data model)
|
|
1100
|
-
setProgress({ phase: 'Parsing GLB', percent: 10 });
|
|
1101
|
-
|
|
1102
|
-
const meshes = loadGLBToMeshData(new Uint8Array(buffer));
|
|
1103
|
-
|
|
1104
|
-
if (meshes.length === 0) {
|
|
1105
|
-
setError('GLB file contains no geometry');
|
|
1106
|
-
setLoading(false);
|
|
1107
|
-
return null;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
const { bounds, stats } = calculateMeshBounds(meshes);
|
|
1111
|
-
const coordinateInfo = createCoordinateInfo(bounds);
|
|
1112
|
-
|
|
1113
|
-
parsedGeometry = {
|
|
1114
|
-
meshes,
|
|
1115
|
-
totalVertices: stats.totalVertices,
|
|
1116
|
-
totalTriangles: stats.totalTriangles,
|
|
1117
|
-
coordinateInfo,
|
|
1118
|
-
};
|
|
1119
|
-
|
|
1120
|
-
// Create a minimal data store for GLB (no IFC properties)
|
|
1121
|
-
parsedDataStore = {
|
|
1122
|
-
fileSize: buffer.byteLength,
|
|
1123
|
-
schemaVersion: 'IFC4' as const,
|
|
1124
|
-
entityCount: meshes.length,
|
|
1125
|
-
parseTime: 0,
|
|
1126
|
-
source: new Uint8Array(0),
|
|
1127
|
-
entityIndex: { byId: new Map(), byType: new Map() },
|
|
1128
|
-
strings: { getString: () => undefined, getStringId: () => undefined, count: 0 } as unknown as IfcDataStore['strings'],
|
|
1129
|
-
entities: { count: 0, getId: () => 0, getType: () => 0, getName: () => undefined, getGlobalId: () => undefined } as unknown as IfcDataStore['entities'],
|
|
1130
|
-
properties: { count: 0, getPropertiesForEntity: () => [], getPropertySetForEntity: () => [] } as unknown as IfcDataStore['properties'],
|
|
1131
|
-
quantities: { count: 0, getQuantitiesForEntity: () => [] } as unknown as IfcDataStore['quantities'],
|
|
1132
|
-
relationships: { count: 0, getRelationships: () => [], getRelated: () => [] } as unknown as IfcDataStore['relationships'],
|
|
1133
|
-
spatialHierarchy: null as unknown as IfcDataStore['spatialHierarchy'],
|
|
1134
|
-
} as unknown as IfcDataStore;
|
|
1135
|
-
|
|
1136
|
-
schemaVersion = 'IFC4'; // GLB doesn't have a schema version, use IFC4 as default
|
|
1137
|
-
|
|
1138
|
-
} else {
|
|
1139
|
-
// IFC4/IFC2X3 STEP format - use WASM parsing
|
|
1140
|
-
setProgress({ phase: 'Starting geometry streaming', percent: 10 });
|
|
1141
|
-
|
|
1142
|
-
const geometryProcessor = new GeometryProcessor({ quality: GeometryQuality.Balanced });
|
|
1143
|
-
await geometryProcessor.init();
|
|
1144
|
-
|
|
1145
|
-
// Parse data model
|
|
1146
|
-
const parser = new IfcParser();
|
|
1147
|
-
const wasmApi = geometryProcessor.getApi();
|
|
1148
|
-
|
|
1149
|
-
const dataStorePromise = parser.parseColumnar(buffer, { wasmApi });
|
|
1150
|
-
|
|
1151
|
-
// Process geometry
|
|
1152
|
-
const allMeshes: MeshData[] = [];
|
|
1153
|
-
let finalCoordinateInfo: CoordinateInfo | null = null;
|
|
1154
|
-
// Capture RTC offset from WASM for proper multi-model alignment
|
|
1155
|
-
let capturedRtcOffset: { x: number; y: number; z: number } | null = null;
|
|
1156
|
-
|
|
1157
|
-
const dynamicBatchConfig = getDynamicBatchConfig(fileSizeMB);
|
|
1158
|
-
|
|
1159
|
-
for await (const event of geometryProcessor.processAdaptive(new Uint8Array(buffer), {
|
|
1160
|
-
sizeThreshold: 2 * 1024 * 1024,
|
|
1161
|
-
batchSize: dynamicBatchConfig,
|
|
1162
|
-
})) {
|
|
1163
|
-
switch (event.type) {
|
|
1164
|
-
case 'batch': {
|
|
1165
|
-
allMeshes.push(...event.meshes);
|
|
1166
|
-
finalCoordinateInfo = event.coordinateInfo ?? null;
|
|
1167
|
-
const progressPercent = 10 + Math.min(80, (allMeshes.length / 1000) * 0.8);
|
|
1168
|
-
setProgress({ phase: `Processing geometry (${allMeshes.length} meshes)`, percent: progressPercent });
|
|
1169
|
-
break;
|
|
1170
|
-
}
|
|
1171
|
-
case 'rtcOffset': {
|
|
1172
|
-
// Capture RTC offset from WASM for multi-model alignment
|
|
1173
|
-
if (event.hasRtc) {
|
|
1174
|
-
capturedRtcOffset = event.rtcOffset;
|
|
1175
|
-
}
|
|
1176
|
-
break;
|
|
1177
|
-
}
|
|
1178
|
-
case 'complete':
|
|
1179
|
-
finalCoordinateInfo = event.coordinateInfo ?? null;
|
|
1180
|
-
break;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
parsedDataStore = await dataStorePromise;
|
|
1185
|
-
|
|
1186
|
-
// Calculate storey heights
|
|
1187
|
-
if (parsedDataStore.spatialHierarchy && parsedDataStore.spatialHierarchy.storeyHeights.size === 0 && parsedDataStore.spatialHierarchy.storeyElevations.size > 1) {
|
|
1188
|
-
const calculatedHeights = calculateStoreyHeights(parsedDataStore.spatialHierarchy.storeyElevations);
|
|
1189
|
-
for (const [storeyId, height] of calculatedHeights) {
|
|
1190
|
-
parsedDataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
// Build spatial index
|
|
1195
|
-
if (allMeshes.length > 0) {
|
|
1196
|
-
try {
|
|
1197
|
-
const spatialIndex = buildSpatialIndex(allMeshes);
|
|
1198
|
-
parsedDataStore.spatialIndex = spatialIndex;
|
|
1199
|
-
} catch (err) {
|
|
1200
|
-
console.warn('[useIfc] Failed to build spatial index:', err);
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
parsedGeometry = {
|
|
1205
|
-
meshes: allMeshes,
|
|
1206
|
-
totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
|
|
1207
|
-
totalTriangles: allMeshes.reduce((sum, m) => sum + m.indices.length / 3, 0),
|
|
1208
|
-
coordinateInfo: finalCoordinateInfo || createCoordinateInfo(calculateMeshBounds(allMeshes).bounds),
|
|
1209
|
-
};
|
|
1210
|
-
|
|
1211
|
-
// Store captured RTC offset in coordinate info for multi-model alignment
|
|
1212
|
-
if (parsedGeometry.coordinateInfo && capturedRtcOffset) {
|
|
1213
|
-
parsedGeometry.coordinateInfo.wasmRtcOffset = capturedRtcOffset;
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
schemaVersion = parsedDataStore.schemaVersion === 'IFC4X3' ? 'IFC4X3' :
|
|
1217
|
-
parsedDataStore.schemaVersion === 'IFC4' ? 'IFC4' : 'IFC2X3';
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
if (!parsedDataStore || !parsedGeometry) {
|
|
1221
|
-
throw new Error('Failed to parse file');
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
// =========================================================================
|
|
1225
|
-
// FEDERATION REGISTRY: Transform expressIds to globally unique IDs
|
|
1226
|
-
// This is the BULLETPROOF fix for multi-model ID collisions
|
|
1227
|
-
// =========================================================================
|
|
1228
|
-
|
|
1229
|
-
// Step 1: Find max expressId in this model
|
|
1230
|
-
// IMPORTANT: Use ALL entities from data store, not just meshes
|
|
1231
|
-
// Spatial containers (IfcProject, IfcSite, etc.) don't have geometry but need valid globalId resolution
|
|
1232
|
-
const maxExpressIdFromMeshes = parsedGeometry.meshes.reduce((max, m) => Math.max(max, m.expressId), 0);
|
|
1233
|
-
// FIXED: Use iteration instead of spread to avoid stack overflow with large Maps
|
|
1234
|
-
let maxExpressIdFromEntities = 0;
|
|
1235
|
-
if (parsedDataStore.entityIndex?.byId) {
|
|
1236
|
-
for (const key of parsedDataStore.entityIndex.byId.keys()) {
|
|
1237
|
-
if (key > maxExpressIdFromEntities) maxExpressIdFromEntities = key;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
const maxExpressId = Math.max(maxExpressIdFromMeshes, maxExpressIdFromEntities);
|
|
1241
|
-
|
|
1242
|
-
// Step 2: Register with federation registry to get unique offset
|
|
1243
|
-
const idOffset = registerModelOffset(modelId, maxExpressId);
|
|
1244
|
-
|
|
1245
|
-
// Step 3: Transform ALL mesh expressIds to globalIds
|
|
1246
|
-
// globalId = originalExpressId + offset
|
|
1247
|
-
// This ensures no two models can have the same ID
|
|
1248
|
-
if (idOffset > 0) {
|
|
1249
|
-
for (const mesh of parsedGeometry.meshes) {
|
|
1250
|
-
mesh.expressId = mesh.expressId + idOffset;
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// =========================================================================
|
|
1255
|
-
// COORDINATE ALIGNMENT: Align new model with existing models using RTC delta
|
|
1256
|
-
// WASM applies per-model RTC offsets. To align models from the same project,
|
|
1257
|
-
// we calculate the difference in RTC offsets and apply it to the new model.
|
|
1258
|
-
//
|
|
1259
|
-
// RTC offset is in IFC coordinates (Z-up). After Z-up to Y-up conversion:
|
|
1260
|
-
// - IFC X → WebGL X
|
|
1261
|
-
// - IFC Y → WebGL -Z
|
|
1262
|
-
// - IFC Z → WebGL Y (vertical)
|
|
1263
|
-
// =========================================================================
|
|
1264
|
-
const existingModels = Array.from(useViewerStore.getState().models.values());
|
|
1265
|
-
if (existingModels.length > 0) {
|
|
1266
|
-
const firstModel = existingModels[0];
|
|
1267
|
-
const firstRtc = firstModel.geometryResult?.coordinateInfo?.wasmRtcOffset;
|
|
1268
|
-
const newRtc = parsedGeometry.coordinateInfo?.wasmRtcOffset;
|
|
1269
|
-
|
|
1270
|
-
// If both models have RTC offsets, use RTC delta for precise alignment
|
|
1271
|
-
if (firstRtc && newRtc) {
|
|
1272
|
-
// Calculate what adjustment is needed to align new model with first model
|
|
1273
|
-
// First model: pos = original - firstRtc
|
|
1274
|
-
// New model: pos = original - newRtc
|
|
1275
|
-
// To align: newPos + adjustment = firstPos (assuming same original)
|
|
1276
|
-
// adjustment = firstRtc - newRtc (add back new's RTC, subtract first's RTC)
|
|
1277
|
-
const adjustX = firstRtc.x - newRtc.x; // IFC X adjustment
|
|
1278
|
-
const adjustY = firstRtc.y - newRtc.y; // IFC Y adjustment
|
|
1279
|
-
const adjustZ = firstRtc.z - newRtc.z; // IFC Z adjustment (vertical)
|
|
1280
|
-
|
|
1281
|
-
// Convert to WebGL coordinates:
|
|
1282
|
-
// IFC X → WebGL X (no change)
|
|
1283
|
-
// IFC Y → WebGL -Z (swap and negate)
|
|
1284
|
-
// IFC Z → WebGL Y (vertical)
|
|
1285
|
-
const webglAdjustX = adjustX;
|
|
1286
|
-
const webglAdjustY = adjustZ; // IFC Z is WebGL Y (vertical)
|
|
1287
|
-
const webglAdjustZ = -adjustY; // IFC Y is WebGL -Z
|
|
1288
|
-
|
|
1289
|
-
const hasSignificantAdjust = Math.abs(webglAdjustX) > 0.01 ||
|
|
1290
|
-
Math.abs(webglAdjustY) > 0.01 ||
|
|
1291
|
-
Math.abs(webglAdjustZ) > 0.01;
|
|
1292
|
-
|
|
1293
|
-
if (hasSignificantAdjust) {
|
|
1294
|
-
console.log(`[useIfc] Aligning model "${file.name}" using RTC adjustment: X=${webglAdjustX.toFixed(2)}m, Y=${webglAdjustY.toFixed(2)}m, Z=${webglAdjustZ.toFixed(2)}m`);
|
|
1295
|
-
|
|
1296
|
-
// Apply adjustment to all mesh vertices
|
|
1297
|
-
// SUBTRACT adjustment: if firstRtc > newRtc, first was shifted MORE,
|
|
1298
|
-
// so new model needs to be shifted in same direction (subtract more)
|
|
1299
|
-
for (const mesh of parsedGeometry.meshes) {
|
|
1300
|
-
const positions = mesh.positions;
|
|
1301
|
-
for (let i = 0; i < positions.length; i += 3) {
|
|
1302
|
-
positions[i] -= webglAdjustX;
|
|
1303
|
-
positions[i + 1] -= webglAdjustY;
|
|
1304
|
-
positions[i + 2] -= webglAdjustZ;
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
// Update coordinate info bounds
|
|
1309
|
-
if (parsedGeometry.coordinateInfo) {
|
|
1310
|
-
parsedGeometry.coordinateInfo.shiftedBounds.min.x -= webglAdjustX;
|
|
1311
|
-
parsedGeometry.coordinateInfo.shiftedBounds.max.x -= webglAdjustX;
|
|
1312
|
-
parsedGeometry.coordinateInfo.shiftedBounds.min.y -= webglAdjustY;
|
|
1313
|
-
parsedGeometry.coordinateInfo.shiftedBounds.max.y -= webglAdjustY;
|
|
1314
|
-
parsedGeometry.coordinateInfo.shiftedBounds.min.z -= webglAdjustZ;
|
|
1315
|
-
parsedGeometry.coordinateInfo.shiftedBounds.max.z -= webglAdjustZ;
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
} else {
|
|
1319
|
-
// No RTC info - can't align reliably. This happens with old cache entries.
|
|
1320
|
-
console.warn(`[useIfc] Cannot align "${file.name}" - missing RTC offset. Clear cache and reload.`);
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
// Create the federated model with offset info
|
|
1325
|
-
const federatedModel: FederatedModel = {
|
|
1326
|
-
id: modelId,
|
|
1327
|
-
name: options?.name ?? file.name,
|
|
1328
|
-
ifcDataStore: parsedDataStore,
|
|
1329
|
-
geometryResult: parsedGeometry,
|
|
1330
|
-
visible: true,
|
|
1331
|
-
collapsed: hasModels(), // Collapse if not first model
|
|
1332
|
-
schemaVersion,
|
|
1333
|
-
loadedAt: Date.now(),
|
|
1334
|
-
fileSize: buffer.byteLength,
|
|
1335
|
-
idOffset,
|
|
1336
|
-
maxExpressId,
|
|
1337
|
-
};
|
|
1338
|
-
|
|
1339
|
-
// Add to store
|
|
1340
|
-
storeAddModel(federatedModel);
|
|
1341
|
-
|
|
1342
|
-
// Also set legacy single-model state for backward compatibility
|
|
1343
|
-
setIfcDataStore(parsedDataStore);
|
|
1344
|
-
setGeometryResult(parsedGeometry);
|
|
1345
|
-
|
|
1346
|
-
setProgress({ phase: 'Complete', percent: 100 });
|
|
1347
|
-
setLoading(false);
|
|
1348
|
-
|
|
1349
|
-
const totalElapsedMs = performance.now() - totalStartTime;
|
|
1350
|
-
console.log(`[useIfc] ✓ Added model ${file.name} (${fileSizeMB.toFixed(1)}MB) | ${totalElapsedMs.toFixed(0)}ms`);
|
|
1351
|
-
|
|
1352
|
-
return modelId;
|
|
1353
|
-
|
|
1354
|
-
} catch (err) {
|
|
1355
|
-
console.error('[useIfc] addModel failed:', err);
|
|
1356
|
-
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
1357
|
-
setLoading(false);
|
|
1358
|
-
return null;
|
|
1359
|
-
}
|
|
1360
|
-
}, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, storeAddModel, hasModels]);
|
|
1361
|
-
|
|
1362
|
-
/**
|
|
1363
|
-
* Remove a model from the federation
|
|
1364
|
-
*/
|
|
1365
|
-
const removeModel = useCallback((modelId: string) => {
|
|
1366
|
-
storeRemoveModel(modelId);
|
|
1367
|
-
|
|
1368
|
-
// Read fresh state from store after removal to avoid stale closure
|
|
1369
|
-
const freshModels = useViewerStore.getState().models;
|
|
1370
|
-
const remaining = Array.from(freshModels.values());
|
|
1371
|
-
if (remaining.length > 0) {
|
|
1372
|
-
const newActive = remaining[0];
|
|
1373
|
-
setIfcDataStore(newActive.ifcDataStore);
|
|
1374
|
-
setGeometryResult(newActive.geometryResult);
|
|
1375
|
-
} else {
|
|
1376
|
-
setIfcDataStore(null);
|
|
1377
|
-
setGeometryResult(null);
|
|
1378
|
-
}
|
|
1379
|
-
}, [storeRemoveModel, setIfcDataStore, setGeometryResult]);
|
|
1380
|
-
|
|
1381
|
-
/**
|
|
1382
|
-
* Get query instance for a specific model
|
|
1383
|
-
*/
|
|
1384
|
-
const getQueryForModel = useCallback((modelId: string): IfcQuery | null => {
|
|
1385
|
-
const model = getModel(modelId);
|
|
1386
|
-
if (!model) return null;
|
|
1387
|
-
return new IfcQuery(model.ifcDataStore);
|
|
1388
|
-
}, [getModel]);
|
|
1389
|
-
|
|
1390
|
-
/**
|
|
1391
|
-
* Load multiple files sequentially (WASM parser isn't thread-safe)
|
|
1392
|
-
* Each file fully loads before the next one starts
|
|
1393
|
-
*/
|
|
1394
|
-
const loadFilesSequentially = useCallback(async (files: File[]): Promise<void> => {
|
|
1395
|
-
for (const file of files) {
|
|
1396
|
-
await addModel(file);
|
|
1397
|
-
}
|
|
1398
|
-
}, [addModel]);
|
|
1399
|
-
|
|
1400
|
-
/**
|
|
1401
|
-
* Load multiple IFCX files as federated layers
|
|
1402
|
-
* Uses IFC5's layer composition system where later files override earlier ones.
|
|
1403
|
-
* Properties from overlay files are merged with the base file(s).
|
|
1404
|
-
*
|
|
1405
|
-
* @param files - Array of IFCX files (first = base/weakest, last = strongest overlay)
|
|
1406
|
-
*
|
|
1407
|
-
* @example
|
|
1408
|
-
* ```typescript
|
|
1409
|
-
* // Load base model with property overlay
|
|
1410
|
-
* await loadFederatedIfcx([
|
|
1411
|
-
* baseFile, // hello-wall.ifcx
|
|
1412
|
-
* fireRatingFile, // add-fire-rating.ifcx (adds FireRating property)
|
|
1413
|
-
* ]);
|
|
1414
|
-
* ```
|
|
1415
|
-
*/
|
|
1416
|
-
/**
|
|
1417
|
-
* Internal: Load federated IFCX from buffers (used by both initial load and add overlay)
|
|
1418
|
-
*/
|
|
1419
|
-
const loadFederatedIfcxFromBuffers = useCallback(async (
|
|
1420
|
-
buffers: Array<{ buffer: ArrayBuffer; name: string }>,
|
|
1421
|
-
options: { resetState?: boolean } = {}
|
|
1422
|
-
): Promise<void> => {
|
|
1423
|
-
const { resetViewerState, clearAllModels } = useViewerStore.getState();
|
|
1424
|
-
|
|
1425
|
-
try {
|
|
1426
|
-
// Always reset viewer state when geometry changes (selection, hidden entities, etc.)
|
|
1427
|
-
// This ensures 3D highlighting works correctly after re-composition
|
|
1428
|
-
resetViewerState();
|
|
1429
|
-
|
|
1430
|
-
// Clear legacy geometry BEFORE clearing models to prevent stale fallback
|
|
1431
|
-
// This avoids a race condition where mergedGeometryResult uses old geometry
|
|
1432
|
-
// during the brief moment when storeModels.size === 0
|
|
1433
|
-
setGeometryResult(null);
|
|
1434
|
-
clearAllModels();
|
|
1435
|
-
|
|
1436
|
-
setLoading(true);
|
|
1437
|
-
setError(null);
|
|
1438
|
-
setProgress({ phase: 'Parsing federated IFCX', percent: 0 });
|
|
1439
|
-
|
|
1440
|
-
// Parse federated IFCX files
|
|
1441
|
-
const result = await parseFederatedIfcx(buffers, {
|
|
1442
|
-
onProgress: (prog: { phase: string; percent: number }) => {
|
|
1443
|
-
setProgress({ phase: `IFCX ${prog.phase}`, percent: prog.percent });
|
|
1444
|
-
},
|
|
1445
|
-
});
|
|
1446
|
-
|
|
1447
|
-
// Convert IFCX meshes to viewer format
|
|
1448
|
-
const meshes: MeshData[] = result.meshes.map((m: { expressId?: number; express_id?: number; id?: number; positions: Float32Array | number[]; indices: Uint32Array | number[]; normals: Float32Array | number[]; color?: [number, number, number, number] | [number, number, number]; ifcType?: string; ifc_type?: string }) => {
|
|
1449
|
-
const positions = m.positions instanceof Float32Array ? m.positions : new Float32Array(m.positions || []);
|
|
1450
|
-
const indices = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices || []);
|
|
1451
|
-
const normals = m.normals instanceof Float32Array ? m.normals : new Float32Array(m.normals || []);
|
|
1452
|
-
const color = normalizeColor(m.color);
|
|
1453
|
-
|
|
1454
|
-
return {
|
|
1455
|
-
expressId: m.expressId || m.express_id || m.id || 0,
|
|
1456
|
-
positions,
|
|
1457
|
-
indices,
|
|
1458
|
-
normals,
|
|
1459
|
-
color,
|
|
1460
|
-
ifcType: m.ifcType || m.ifc_type || 'IfcProduct',
|
|
1461
|
-
};
|
|
1462
|
-
}).filter((m: MeshData) => m.positions.length > 0 && m.indices.length > 0);
|
|
1463
|
-
|
|
1464
|
-
// Calculate bounds
|
|
1465
|
-
const { bounds, stats } = calculateMeshBounds(meshes);
|
|
1466
|
-
const coordinateInfo = createCoordinateInfo(bounds);
|
|
1467
|
-
|
|
1468
|
-
const geometryResult = {
|
|
1469
|
-
meshes,
|
|
1470
|
-
totalVertices: stats.totalVertices,
|
|
1471
|
-
totalTriangles: stats.totalTriangles,
|
|
1472
|
-
coordinateInfo,
|
|
1473
|
-
};
|
|
1474
|
-
|
|
1475
|
-
// NOTE: Do NOT call setGeometryResult() here!
|
|
1476
|
-
// For federated loading, geometry comes from the models Map via mergedGeometryResult.
|
|
1477
|
-
// Calling setGeometryResult() before models are added causes a race condition where
|
|
1478
|
-
// meshes are added to the scene WITHOUT modelIndex, breaking selection highlighting.
|
|
1479
|
-
|
|
1480
|
-
// Get layer info with mesh counts
|
|
1481
|
-
const layers = result.layerStack.getLayers();
|
|
1482
|
-
|
|
1483
|
-
// Create data store from federated result
|
|
1484
|
-
const dataStore = {
|
|
1485
|
-
fileSize: result.fileSize,
|
|
1486
|
-
schemaVersion: 'IFC5' as const,
|
|
1487
|
-
entityCount: result.entityCount,
|
|
1488
|
-
parseTime: result.parseTime,
|
|
1489
|
-
source: new Uint8Array(buffers[0].buffer),
|
|
1490
|
-
entityIndex: {
|
|
1491
|
-
byId: new Map(),
|
|
1492
|
-
byType: new Map(),
|
|
1493
|
-
},
|
|
1494
|
-
strings: result.strings,
|
|
1495
|
-
entities: result.entities,
|
|
1496
|
-
properties: result.properties,
|
|
1497
|
-
quantities: result.quantities,
|
|
1498
|
-
relationships: result.relationships,
|
|
1499
|
-
spatialHierarchy: result.spatialHierarchy,
|
|
1500
|
-
// Federated-specific: store layer info and ORIGINAL BUFFERS for re-composition
|
|
1501
|
-
_federatedLayers: layers.map(l => ({
|
|
1502
|
-
id: l.id,
|
|
1503
|
-
name: l.name,
|
|
1504
|
-
enabled: l.enabled,
|
|
1505
|
-
})),
|
|
1506
|
-
_federatedBuffers: buffers.map(b => ({
|
|
1507
|
-
buffer: b.buffer.slice(0), // Clone buffer
|
|
1508
|
-
name: b.name,
|
|
1509
|
-
})),
|
|
1510
|
-
_compositionStats: result.compositionStats,
|
|
1511
|
-
} as unknown as IfcDataStore; // IFC5 schema extension
|
|
1512
|
-
|
|
1513
|
-
setIfcDataStore(dataStore);
|
|
1514
|
-
|
|
1515
|
-
// Clear existing models and add each layer as a "model" in the Models panel
|
|
1516
|
-
// This shows users all the files that contributed to the composition
|
|
1517
|
-
clearAllModels();
|
|
1518
|
-
|
|
1519
|
-
// Find max expressId for proper ID range tracking
|
|
1520
|
-
// This is needed for resolveGlobalIdFromModels to work correctly
|
|
1521
|
-
let maxExpressId = 0;
|
|
1522
|
-
if (result.entities?.expressId) {
|
|
1523
|
-
for (let i = 0; i < result.entities.count; i++) {
|
|
1524
|
-
const id = result.entities.expressId[i];
|
|
1525
|
-
if (id > maxExpressId) maxExpressId = id;
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
for (let i = 0; i < layers.length; i++) {
|
|
1530
|
-
const layer = layers[i];
|
|
1531
|
-
const layerBuffer = buffers.find(b => b.name === layer.name);
|
|
1532
|
-
|
|
1533
|
-
// Count how many meshes came from this layer
|
|
1534
|
-
// For base layers: count meshes, for overlays: show as data-only
|
|
1535
|
-
const isBaseLayer = i === layers.length - 1; // Last layer (weakest) is typically base
|
|
1536
|
-
|
|
1537
|
-
const layerModel: FederatedModel = {
|
|
1538
|
-
id: layer.id,
|
|
1539
|
-
name: layer.name,
|
|
1540
|
-
ifcDataStore: dataStore, // Share the composed data store
|
|
1541
|
-
geometryResult: isBaseLayer ? geometryResult : {
|
|
1542
|
-
meshes: [],
|
|
1543
|
-
totalVertices: 0,
|
|
1544
|
-
totalTriangles: 0,
|
|
1545
|
-
coordinateInfo,
|
|
1546
|
-
},
|
|
1547
|
-
visible: true,
|
|
1548
|
-
collapsed: i > 0, // Collapse overlays by default
|
|
1549
|
-
schemaVersion: 'IFC5',
|
|
1550
|
-
loadedAt: Date.now() - (layers.length - i) * 100, // Stagger timestamps
|
|
1551
|
-
fileSize: layerBuffer?.buffer.byteLength || 0,
|
|
1552
|
-
// For base layer: set proper ID range for resolveGlobalIdFromModels
|
|
1553
|
-
// Overlays share the same data store so they don't need their own range
|
|
1554
|
-
idOffset: 0,
|
|
1555
|
-
maxExpressId: isBaseLayer ? maxExpressId : 0,
|
|
1556
|
-
// Mark overlay-only layers
|
|
1557
|
-
_isOverlay: !isBaseLayer,
|
|
1558
|
-
_layerIndex: i,
|
|
1559
|
-
} as FederatedModel & { _isOverlay?: boolean; _layerIndex?: number };
|
|
1560
|
-
|
|
1561
|
-
storeAddModel(layerModel);
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
console.log(`[useIfc] Federated IFCX loaded: ${layers.length} layers, ${result.entityCount} entities, ${meshes.length} meshes`);
|
|
1565
|
-
console.log(`[useIfc] Composition stats: ${result.compositionStats.inheritanceResolutions} inheritance resolutions, ${result.compositionStats.crossLayerReferences} cross-layer refs`);
|
|
1566
|
-
console.log(`[useIfc] Layers in Models panel: ${layers.map(l => l.name).join(', ')}`);
|
|
1567
|
-
|
|
1568
|
-
setProgress({ phase: 'Complete', percent: 100 });
|
|
1569
|
-
setLoading(false);
|
|
1570
|
-
} catch (err: unknown) {
|
|
1571
|
-
console.error('[useIfc] Federated IFCX loading failed:', err);
|
|
1572
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1573
|
-
setError(`Federated IFCX loading failed: ${message}`);
|
|
1574
|
-
setLoading(false);
|
|
1575
|
-
}
|
|
1576
|
-
}, [setLoading, setError, setProgress, setGeometryResult, setIfcDataStore, storeAddModel, clearAllModels]);
|
|
1577
|
-
|
|
1578
|
-
const loadFederatedIfcx = useCallback(async (files: File[]): Promise<void> => {
|
|
1579
|
-
if (files.length === 0) {
|
|
1580
|
-
setError('No files provided for federated loading');
|
|
1581
|
-
return;
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
// Check that all files are IFCX format and read buffers
|
|
1585
|
-
const buffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
|
|
1586
|
-
for (const file of files) {
|
|
1587
|
-
const buffer = await file.arrayBuffer();
|
|
1588
|
-
const format = detectFormat(buffer);
|
|
1589
|
-
if (format !== 'ifcx') {
|
|
1590
|
-
setError(`File "${file.name}" is not an IFCX file. Federated loading only supports IFCX files.`);
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
buffers.push({ buffer, name: file.name });
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
await loadFederatedIfcxFromBuffers(buffers);
|
|
1597
|
-
}, [setError, loadFederatedIfcxFromBuffers]);
|
|
1598
|
-
|
|
1599
|
-
/**
|
|
1600
|
-
* Add IFCX overlay files to existing federated model
|
|
1601
|
-
* Re-composes all layers including new overlays
|
|
1602
|
-
* Also handles adding overlays to a single IFCX file that wasn't loaded via federated loading
|
|
1603
|
-
*/
|
|
1604
|
-
const addIfcxOverlays = useCallback(async (files: File[]): Promise<void> => {
|
|
1605
|
-
const currentStore = useViewerStore.getState().ifcDataStore as IfcxDataStore | null;
|
|
1606
|
-
const currentModels = useViewerStore.getState().models;
|
|
1607
|
-
|
|
1608
|
-
// Get existing buffers - either from federated loading or from single file load
|
|
1609
|
-
let existingBuffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
|
|
1610
|
-
|
|
1611
|
-
if (currentStore?._federatedBuffers) {
|
|
1612
|
-
// Already federated - use stored buffers
|
|
1613
|
-
existingBuffers = currentStore._federatedBuffers as Array<{ buffer: ArrayBuffer; name: string }>;
|
|
1614
|
-
} else if (currentStore?.source && currentStore.schemaVersion === 'IFC5') {
|
|
1615
|
-
// Single IFCX file loaded via loadFile() - reconstruct buffer from source
|
|
1616
|
-
// Get the model name from the models map
|
|
1617
|
-
let modelName = 'base.ifcx';
|
|
1618
|
-
for (const [, model] of currentModels) {
|
|
1619
|
-
// Compare object identity (cast needed due to IFC5 schema extension)
|
|
1620
|
-
if ((model.ifcDataStore as unknown) === currentStore || model.schemaVersion === 'IFC5') {
|
|
1621
|
-
modelName = model.name;
|
|
1622
|
-
break;
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
// Convert Uint8Array source back to ArrayBuffer
|
|
1627
|
-
const sourceBuffer = currentStore.source.buffer.slice(
|
|
1628
|
-
currentStore.source.byteOffset,
|
|
1629
|
-
currentStore.source.byteOffset + currentStore.source.byteLength
|
|
1630
|
-
) as ArrayBuffer;
|
|
1631
|
-
|
|
1632
|
-
existingBuffers = [{ buffer: sourceBuffer, name: modelName }];
|
|
1633
|
-
console.log(`[useIfc] Converting single IFCX file "${modelName}" to federated mode`);
|
|
1634
|
-
} else {
|
|
1635
|
-
setError('Cannot add overlays: no IFCX model loaded');
|
|
1636
|
-
return;
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
// Read new overlay buffers
|
|
1640
|
-
const newBuffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
|
|
1641
|
-
for (const file of files) {
|
|
1642
|
-
const buffer = await file.arrayBuffer();
|
|
1643
|
-
const format = detectFormat(buffer);
|
|
1644
|
-
if (format !== 'ifcx') {
|
|
1645
|
-
setError(`File "${file.name}" is not an IFCX file.`);
|
|
1646
|
-
return;
|
|
1647
|
-
}
|
|
1648
|
-
newBuffers.push({ buffer, name: file.name });
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
// Combine: existing layers + new overlays (new overlays are strongest = first in array)
|
|
1652
|
-
const allBuffers = [...newBuffers, ...existingBuffers];
|
|
1653
|
-
|
|
1654
|
-
console.log(`[useIfc] Re-composing federated IFCX with ${newBuffers.length} new overlay(s)`);
|
|
1655
|
-
console.log(`[useIfc] Total layers: ${allBuffers.length} (${existingBuffers.length} existing + ${newBuffers.length} new)`);
|
|
1656
|
-
|
|
1657
|
-
await loadFederatedIfcxFromBuffers(allBuffers, { resetState: false });
|
|
1658
|
-
}, [setError, loadFederatedIfcxFromBuffers]);
|
|
1659
|
-
|
|
1660
|
-
/**
|
|
1661
|
-
* Find which model contains a given globalId
|
|
1662
|
-
* Uses FederationRegistry for O(log N) lookup - BULLETPROOF
|
|
1663
|
-
* Returns the modelId or null if not found
|
|
1664
|
-
*/
|
|
1665
|
-
const findModelForEntity = useCallback((globalId: number): string | null => {
|
|
1666
|
-
return findModelForGlobalId(globalId);
|
|
1667
|
-
}, [findModelForGlobalId]);
|
|
1668
|
-
|
|
1669
|
-
/**
|
|
1670
|
-
* Convert a globalId back to the original (modelId, expressId) pair
|
|
1671
|
-
* Use this when you need to look up properties in the IfcDataStore
|
|
1672
|
-
*/
|
|
1673
|
-
const resolveGlobalId = useCallback((globalId: number): { modelId: string; expressId: number } | null => {
|
|
1674
|
-
return fromGlobalId(globalId);
|
|
1675
|
-
}, [fromGlobalId]);
|
|
1676
|
-
|
|
1677
75
|
return {
|
|
1678
76
|
// Legacy single-model API (backward compatibility)
|
|
1679
77
|
loading,
|