@ifc-lite/viewer 1.27.0 → 1.28.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/.turbo/turbo-build.log +38 -38
- package/CHANGELOG.md +64 -0
- package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-BNRDNuUJ.js} +8 -8
- package/dist/assets/{bcf-QeHK_Aud.js → bcf-DCwCuP7n.js} +56 -56
- package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
- package/dist/assets/{deflate-B-d0SYQM.js → deflate-DNGgs8Ur.js} +1 -1
- package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
- package/dist/assets/e57-source-2wI9jkCA.js +1 -0
- package/dist/assets/{exporters-B4LbZFeT.js → exporters-B9v81gi9.js} +1249 -1140
- package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
- package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-D-YCLS4g.js} +10 -10
- package/dist/assets/{ids-DjsGFN10.js → ids-CCpq-5d3.js} +952 -945
- package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-DbgS5EUA.wasm} +0 -0
- package/dist/assets/{index-COYokSKc.js → index-Bgb3_Pu_.js} +41073 -38715
- package/dist/assets/index-BtbXFKsX.css +1 -0
- package/dist/assets/{index.es-CY202jA3.js → index.es-CWfqZyyr.js} +9 -9
- package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-DGOAeUqU.js} +1 -1
- package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-XPLU2Wkq.js} +4 -4
- package/dist/assets/lens-C4p1kQ0p.js +1 -0
- package/dist/assets/{lerc-DmW0_tgf.js → lerc-1PMSCHwX.js} +1 -1
- package/dist/assets/{lzw-oWetY-d6.js → lzw-C65U9lNM.js} +1 -1
- package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-XxXos6yI.js} +2 -2
- package/dist/assets/{packbits-F8Nkp4NY.js → packbits-BdMWXC3m.js} +1 -1
- package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
- package/dist/assets/{pdf-Dsh3HPZB.js → pdf-CRwaZf3s.js} +10 -10
- package/dist/assets/raw-CJgQdyuZ.js +1 -0
- package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-0sDo3g3m.js} +2960 -2552
- package/dist/assets/server-client-cTCJ-853.js +719 -0
- package/dist/assets/{webimage-BLV1dgmd.js → webimage-BtakWX7W.js} +1 -1
- package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-B1YOg2QB.js} +8 -8
- package/dist/assets/{zstd-C_1HxVrA.js → zstd-CmwsbxmM.js} +1 -1
- package/dist/index.html +9 -9
- package/package.json +24 -23
- package/src/components/mcp/playground-dispatcher.ts +3 -0
- package/src/components/mcp/playground-files.ts +33 -1
- package/src/components/viewer/CommandPalette.tsx +6 -1
- package/src/components/viewer/ComparePanel.tsx +420 -0
- package/src/components/viewer/HierarchyPanel.tsx +46 -7
- package/src/components/viewer/MainToolbar.tsx +19 -2
- package/src/components/viewer/PropertiesPanel.tsx +71 -2
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +3 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
- package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
- package/src/components/viewer/hierarchy/types.ts +1 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
- package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
- package/src/hooks/federationLoadGate.test.ts +12 -2
- package/src/hooks/federationLoadGate.ts +9 -2
- package/src/hooks/ingest/federationAlign.ts +481 -0
- package/src/hooks/ingest/viewerModelIngest.ts +3 -212
- package/src/hooks/useCompare.ts +0 -0
- package/src/hooks/useCompareOverlay.ts +119 -0
- package/src/hooks/useDrawingGeneration.ts +23 -1
- package/src/hooks/useIfc.ts +1 -1
- package/src/hooks/useIfcCache.ts +32 -9
- package/src/hooks/useIfcFederation.ts +42 -810
- package/src/hooks/useIfcLoader.ts +361 -488
- package/src/hooks/useIfcServer.ts +3 -0
- package/src/hooks/useLens.ts +5 -1
- package/src/hooks/useSymbolicAnnotations.ts +70 -38
- package/src/lib/compare/buildFingerprints.ts +173 -0
- package/src/lib/compare/describeChange.ts +0 -0
- package/src/lib/compare/geometricData.test.ts +54 -0
- package/src/lib/compare/geometricData.ts +37 -0
- package/src/lib/compare/overlay.test.ts +99 -0
- package/src/lib/compare/overlay.ts +91 -0
- package/src/lib/geo/cesium-placement.ts +1 -1
- package/src/lib/geo/reproject.ts +4 -1
- package/src/lib/llm/script-edit-ops.ts +23 -0
- package/src/lib/llm/stream-client.ts +8 -1
- package/src/lib/search/result-export.ts +7 -1
- package/src/sdk/adapters/export-adapter.ts +6 -1
- package/src/store/globalId.ts +15 -13
- package/src/store/index.ts +16 -1
- package/src/store/slices/cesiumSlice.ts +8 -1
- package/src/store/slices/compareSlice.ts +96 -0
- package/src/store/slices/lensSlice.ts +8 -0
- package/src/utils/acquireFileBuffer.test.ts +12 -4
- package/src/utils/desktopModelSnapshot.ts +2 -1
- package/src/utils/loadingUtils.ts +32 -0
- package/src/utils/spatialHierarchy.test.ts +53 -1
- package/src/utils/spatialHierarchy.ts +42 -2
- package/src/vite-env.d.ts +2 -0
- package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
- package/dist/assets/e57-source-CQHxE8n3.js +0 -1
- package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
- package/dist/assets/index-ajK6D32J.css +0 -1
- package/dist/assets/lens-PYsLu_MA.js +0 -1
- package/dist/assets/parser.worker-D591Zu_-.js +0 -182
- package/dist/assets/raw-D9iw0tmc.js +0 -1
- package/dist/assets/server-client-Cjwnm7il.js +0 -706
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
- package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
- package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { useCallback, useRef } from 'react';
|
|
14
14
|
import { flushSync } from 'react-dom';
|
|
15
15
|
import { useShallow } from 'zustand/react/shallow';
|
|
16
|
-
import { getViewerStoreApi, useViewerStore } from '@/store';
|
|
16
|
+
import { getViewerStoreApi, useViewerStore, type FederatedModel } from '@/store';
|
|
17
17
|
import { IfcParser, detectFormat, type IfcDataStore } from '@ifc-lite/parser';
|
|
18
18
|
import { WorkerParser } from '@ifc-lite/parser/browser';
|
|
19
19
|
import { memoryAccounting } from '../lib/perf/memoryAccounting.js';
|
|
@@ -23,10 +23,11 @@ import {
|
|
|
23
23
|
getGeometryStreamWatchdogMs as getGeometryStreamWatchdogMsImpl,
|
|
24
24
|
type MeshData,
|
|
25
25
|
type CoordinateInfo,
|
|
26
|
+
type GeometryResult,
|
|
26
27
|
} from '@ifc-lite/geometry';
|
|
27
28
|
import { acquireFileBuffer, type AcquiredBuffer } from '../utils/acquireFileBuffer.js';
|
|
28
29
|
import initIfcLiteWasm, { IfcAPI } from '@ifc-lite/wasm';
|
|
29
|
-
import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
|
|
30
|
+
import { buildSpatialIndexGuarded, buildSpatialIndexForModel } from '../utils/loadingUtils.js';
|
|
30
31
|
import { type GeometryData } from '@ifc-lite/cache';
|
|
31
32
|
|
|
32
33
|
import { SERVER_URL, USE_SERVER, CACHE_SIZE_THRESHOLD, CACHE_MAX_SOURCE_SIZE, HUGE_NATIVE_FILE_THRESHOLD, getDynamicBatchConfig } from '../utils/ifcConfig.js';
|
|
@@ -58,6 +59,35 @@ import { getMaxExpressId, parseGlbViewerModel, parseIfcxViewerModel } from './in
|
|
|
58
59
|
import { boundedIteratorReturn } from './ingest/streamCleanup.js';
|
|
59
60
|
import { detectPointCloudFormat, ingestPointCloud } from './ingest/pointCloudIngest.js';
|
|
60
61
|
import { getGlobalRenderer } from './useBCF.js';
|
|
62
|
+
import { extractModelGeoref, alignGeometryToReference, findReferenceGeorefModel } from './ingest/federationAlign.js';
|
|
63
|
+
import { toast } from '../components/ui/toast.js';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where a {@link useIfcLoader.loadFile} call should land the model.
|
|
67
|
+
*
|
|
68
|
+
* `primary` is the historical single-model load: it resets all viewer state,
|
|
69
|
+
* clears the model map, and streams progressively into the active slot.
|
|
70
|
+
* `federated` is an additional model joining an existing federation — it does
|
|
71
|
+
* NOT reset state, carries the pre-allocated `modelId`, and the shared RTC
|
|
72
|
+
* origin picked by the federation gate. Both flow through the SAME geometry
|
|
73
|
+
* pipeline + the SAME `finalizeModel`, so load-time behaviour can never again
|
|
74
|
+
* diverge between the two (the cause of the model-diff "all geometry changed"
|
|
75
|
+
* bug). The georef anchor + the user's saved georef edits are resolved inside
|
|
76
|
+
* `finalizeModel` from the live store, exactly as the old federated path did.
|
|
77
|
+
* Default is `primary`.
|
|
78
|
+
*/
|
|
79
|
+
export type LoadTarget =
|
|
80
|
+
| { kind: 'primary' }
|
|
81
|
+
| {
|
|
82
|
+
kind: 'federated';
|
|
83
|
+
modelId: string;
|
|
84
|
+
name?: string;
|
|
85
|
+
visible?: boolean;
|
|
86
|
+
collapsed?: boolean;
|
|
87
|
+
loadedAt?: number;
|
|
88
|
+
/** Shared RTC offset from the earliest existing model (IFC Z-up). */
|
|
89
|
+
sharedRtcOffset?: { x: number; y: number; z: number };
|
|
90
|
+
};
|
|
61
91
|
|
|
62
92
|
/**
|
|
63
93
|
* Compute a fast content fingerprint from the first and last 4KB of a buffer.
|
|
@@ -160,13 +190,6 @@ async function getMetadataScanApi(): Promise<IfcAPI> {
|
|
|
160
190
|
|
|
161
191
|
const ENABLE_HUGE_TIME_FLUSH = import.meta.env.VITE_IFC_ENABLE_HUGE_TIME_FLUSH === 'true';
|
|
162
192
|
|
|
163
|
-
async function* startDisabledNativeDesktopRendererModel(
|
|
164
|
-
_path: string,
|
|
165
|
-
_cacheKey?: string,
|
|
166
|
-
): AsyncGenerator<any, void, unknown> {
|
|
167
|
-
throw new Error('Native desktop renderer is disabled');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
193
|
/**
|
|
171
194
|
* Hook providing file loading operations for single-model path
|
|
172
195
|
* Includes binary cache support for fast subsequent loads
|
|
@@ -216,78 +239,181 @@ export function useIfcLoader() {
|
|
|
216
239
|
// Server operations from extracted hook
|
|
217
240
|
const { loadFromServer } = useIfcServer();
|
|
218
241
|
|
|
219
|
-
const loadFile = useCallback(async (
|
|
242
|
+
const loadFile = useCallback(async (
|
|
243
|
+
file: File | NativeFileHandle,
|
|
244
|
+
target: LoadTarget = { kind: 'primary' },
|
|
245
|
+
) => {
|
|
220
246
|
const { resetViewerState, clearAllModels } = useViewerStore.getState();
|
|
221
|
-
|
|
222
|
-
|
|
247
|
+
// Only a primary (destructive, replace-everything) load bumps the session.
|
|
248
|
+
// Federated adds are independent and run concurrently — they capture the
|
|
249
|
+
// current session without invalidating each other; a subsequent primary
|
|
250
|
+
// load still bumps it and aborts any in-flight federated adds.
|
|
251
|
+
const currentSession = target.kind === 'primary'
|
|
252
|
+
? ++loadSessionRef.current
|
|
253
|
+
: loadSessionRef.current;
|
|
254
|
+
// Federated adds carry a pre-allocated id; primary loads mint a fresh one.
|
|
255
|
+
const modelId = target.kind === 'federated' ? target.modelId : crypto.randomUUID();
|
|
223
256
|
|
|
224
257
|
// Track total elapsed time for complete user experience
|
|
225
258
|
const totalStartTime = performance.now();
|
|
226
259
|
|
|
227
260
|
try {
|
|
228
|
-
// Reset all viewer state before loading new file
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
261
|
+
// Reset all viewer state before loading new file — PRIMARY ONLY. A
|
|
262
|
+
// federated add must never wipe model #1; it joins the existing map.
|
|
263
|
+
if (target.kind === 'primary') {
|
|
264
|
+
resetViewerState();
|
|
265
|
+
clearAllModels();
|
|
266
|
+
}
|
|
232
267
|
|
|
233
268
|
// Reset memory accounting so per-load summaries don't accumulate across files.
|
|
234
269
|
memoryAccounting.reset();
|
|
235
270
|
memoryAccounting.recordPhase({ phase: 'load-start' });
|
|
236
271
|
|
|
237
272
|
setLoading(true);
|
|
238
|
-
setGeometryStreamingActive(false);
|
|
239
273
|
setError(null);
|
|
240
|
-
setBoundedGeometryMode(false);
|
|
241
|
-
setGeometryProgress(null);
|
|
242
|
-
setMetadataProgress(null);
|
|
243
274
|
setProgress({ phase: 'Loading file', percent: 0 });
|
|
244
275
|
|
|
245
276
|
const fileName = file.name;
|
|
246
277
|
const fileSize = file.size;
|
|
247
278
|
const fileSizeMB = fileSize / (1024 * 1024);
|
|
248
279
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
280
|
+
// PRIMARY owns the active-model slots + top-level UI/memory flags and
|
|
281
|
+
// creates the model record. A federated add leaves all of that untouched
|
|
282
|
+
// (model #1 must not be disturbed) and registers atomically at finalize
|
|
283
|
+
// via addModel — so it creates NO placeholder entry here (which also
|
|
284
|
+
// keeps the `collapsed` default counting only the other models).
|
|
285
|
+
if (target.kind === 'primary') {
|
|
286
|
+
setGeometryStreamingActive(false);
|
|
287
|
+
setBoundedGeometryMode(false);
|
|
288
|
+
setGeometryProgress(null);
|
|
289
|
+
setMetadataProgress(null);
|
|
290
|
+
|
|
291
|
+
upsertModel({
|
|
292
|
+
id: modelId,
|
|
293
|
+
name: fileName,
|
|
294
|
+
ifcDataStore: null,
|
|
295
|
+
geometryResult: null,
|
|
296
|
+
visible: true,
|
|
297
|
+
collapsed: false,
|
|
298
|
+
schemaVersion: 'IFC4',
|
|
299
|
+
loadedAt: Date.now(),
|
|
300
|
+
fileSize,
|
|
301
|
+
sourceFile: file,
|
|
302
|
+
idOffset: 0,
|
|
303
|
+
maxExpressId: 0,
|
|
304
|
+
loadState: 'pending',
|
|
263
305
|
geometryLoadState: 'pending',
|
|
264
306
|
metadataLoadState: 'idle',
|
|
265
307
|
interactiveReady: false,
|
|
266
308
|
nativeMetadata: null,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
309
|
+
cacheState: 'none',
|
|
310
|
+
loadError: null,
|
|
311
|
+
});
|
|
312
|
+
updateModel(modelId, {
|
|
313
|
+
loadState: 'streaming-geometry',
|
|
314
|
+
geometryLoadState: 'opening',
|
|
315
|
+
metadataLoadState: 'idle',
|
|
316
|
+
interactiveReady: false,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
276
319
|
|
|
277
|
-
|
|
320
|
+
// The ONE finalizer for every format/platform/role. Primary keeps the
|
|
321
|
+
// historical updateModel-only behaviour; federated runs the georef-align
|
|
322
|
+
// → id-offset → relabel → spatial-index → addModel sequence lifted
|
|
323
|
+
// verbatim from the old useIfcFederation.addModel block (same order).
|
|
324
|
+
const finalizeModel = async (
|
|
278
325
|
dataStore: IfcDataStore | null,
|
|
279
|
-
geometryResult:
|
|
326
|
+
geometryResult: GeometryResult | null,
|
|
280
327
|
schemaVersion: 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5',
|
|
281
328
|
patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null; pointCloudHandleId?: number },
|
|
282
|
-
) => {
|
|
329
|
+
): Promise<void> => {
|
|
330
|
+
if (target.kind === 'federated') {
|
|
331
|
+
if (!dataStore || !geometryResult) {
|
|
332
|
+
throw new Error('Federated model is missing its data store or geometry');
|
|
333
|
+
}
|
|
334
|
+
// Georef alignment against the federation anchor (resolved live from
|
|
335
|
+
// the store, exactly as the former addModel finalize did).
|
|
336
|
+
const referenceGeoref = findReferenceGeorefModel()?.georef ?? null;
|
|
337
|
+
const parsedGeorefMutations = useViewerStore.getState().georefMutations.get(modelId);
|
|
338
|
+
const parsedGeoref = extractModelGeoref(dataStore, geometryResult.coordinateInfo, parsedGeorefMutations);
|
|
339
|
+
let preAlignmentPositions: Float32Array[] | undefined;
|
|
340
|
+
let preAlignmentNormals: (Float32Array | undefined)[] | undefined;
|
|
341
|
+
let preAlignmentCoordinateInfo: CoordinateInfo | undefined;
|
|
342
|
+
let federationAlignmentStatus: FederatedModel['federationAlignmentStatus'] = 'none';
|
|
343
|
+
if (referenceGeoref && parsedGeoref) {
|
|
344
|
+
setProgress({ phase: 'Aligning georeferenced model', percent: 90 });
|
|
345
|
+
preAlignmentPositions = geometryResult.meshes.map((mesh) => new Float32Array(mesh.positions));
|
|
346
|
+
preAlignmentNormals = geometryResult.meshes.map((mesh) =>
|
|
347
|
+
mesh.normals && mesh.normals.length > 0 ? new Float32Array(mesh.normals) : undefined,
|
|
348
|
+
);
|
|
349
|
+
preAlignmentCoordinateInfo = geometryResult.coordinateInfo;
|
|
350
|
+
const status = await alignGeometryToReference(geometryResult, parsedGeoref, referenceGeoref);
|
|
351
|
+
federationAlignmentStatus = status;
|
|
352
|
+
if (status === 'reprojected') {
|
|
353
|
+
toast.info(
|
|
354
|
+
`Reprojected "${file.name}" from ${parsedGeoref.projectedCRS.name} `
|
|
355
|
+
+ `to ${referenceGeoref.projectedCRS.name} for federation alignment.`,
|
|
356
|
+
);
|
|
357
|
+
} else if (status === 'failed') {
|
|
358
|
+
toast.error(
|
|
359
|
+
`Could not align "${file.name}" with the federation anchor — `
|
|
360
|
+
+ `${parsedGeoref.projectedCRS.name} → ${referenceGeoref.projectedCRS.name} `
|
|
361
|
+
+ 'reprojection failed. The model is shown in its own local frame and may '
|
|
362
|
+
+ 'appear at the wrong real-world position.',
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
} else if (parsedGeoref) {
|
|
366
|
+
federationAlignmentStatus = 'anchor';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Federation registry: transform expressIds to globally-unique ids.
|
|
370
|
+
const maxExpressId = getMaxExpressId(dataStore, geometryResult.meshes);
|
|
371
|
+
const idOffset = registerModelOffset(modelId, maxExpressId);
|
|
372
|
+
if (idOffset > 0) {
|
|
373
|
+
for (const mesh of geometryResult.meshes) mesh.expressId = mesh.expressId + idOffset;
|
|
374
|
+
for (const asset of geometryResult.pointClouds ?? []) asset.expressId = asset.expressId + idOffset;
|
|
375
|
+
}
|
|
376
|
+
if (idOffset > 0 && patch?.pointCloudHandleId !== undefined) {
|
|
377
|
+
const renderer = getGlobalRenderer();
|
|
378
|
+
if (renderer && geometryResult.pointClouds && geometryResult.pointClouds.length > 0) {
|
|
379
|
+
renderer.relabelPointCloudAsset({ id: patch.pointCloudHandleId }, geometryResult.pointClouds[0].expressId);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const federatedModel: FederatedModel = {
|
|
383
|
+
id: modelId,
|
|
384
|
+
name: target.name ?? file.name,
|
|
385
|
+
ifcDataStore: dataStore,
|
|
386
|
+
geometryResult,
|
|
387
|
+
visible: target.visible ?? true,
|
|
388
|
+
collapsed: target.collapsed ?? (useViewerStore.getState().models.size > 0),
|
|
389
|
+
schemaVersion,
|
|
390
|
+
loadedAt: target.loadedAt ?? Date.now(),
|
|
391
|
+
fileSize: buffer.byteLength,
|
|
392
|
+
sourceFile: file,
|
|
393
|
+
idOffset,
|
|
394
|
+
maxExpressId,
|
|
395
|
+
pointCloudHandleId: patch?.pointCloudHandleId,
|
|
396
|
+
preAlignmentPositions,
|
|
397
|
+
preAlignmentNormals,
|
|
398
|
+
preAlignmentCoordinateInfo,
|
|
399
|
+
federationAlignmentStatus,
|
|
400
|
+
};
|
|
401
|
+
useViewerStore.getState().addModel(federatedModel);
|
|
402
|
+
// Spatial index AFTER id offset + alignment (final ids + world positions)
|
|
403
|
+
// and AFTER addModel so it attaches to THIS model, not the active slot.
|
|
404
|
+
buildSpatialIndexForModel(geometryResult.meshes, modelId, dataStore);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// PRIMARY — unchanged from the former finalizePrimaryModel.
|
|
283
409
|
let idOffset = 0;
|
|
284
410
|
let maxExpressId = 0;
|
|
285
411
|
if (dataStore && geometryResult) {
|
|
286
412
|
maxExpressId = getMaxExpressId(dataStore, geometryResult.meshes);
|
|
287
|
-
idOffset = registerModelOffset(
|
|
413
|
+
idOffset = registerModelOffset(modelId, maxExpressId);
|
|
288
414
|
}
|
|
289
415
|
|
|
290
|
-
updateModel(
|
|
416
|
+
updateModel(modelId, {
|
|
291
417
|
ifcDataStore: dataStore,
|
|
292
418
|
geometryResult,
|
|
293
419
|
schemaVersion,
|
|
@@ -307,367 +433,17 @@ export function useIfcLoader() {
|
|
|
307
433
|
return 'IFC2X3';
|
|
308
434
|
};
|
|
309
435
|
|
|
310
|
-
// Native renderer streaming path is currently disabled — the
|
|
311
|
-
// `huge native file` block further down handles real desktop
|
|
312
|
-
// streaming. This branch is retained as a scaffold for the future
|
|
313
|
-
// always-on native renderer integration.
|
|
314
|
-
const NATIVE_RENDERER_PATH_ENABLED = false as boolean;
|
|
315
|
-
if (
|
|
316
|
-
NATIVE_RENDERER_PATH_ENABLED &&
|
|
317
|
-
isNativeFileHandle(file) &&
|
|
318
|
-
fileName.toLowerCase().endsWith('.ifc')
|
|
319
|
-
) {
|
|
320
|
-
// Re-narrow `file` for the body — TS occasionally drops the
|
|
321
|
-
// type-predicate result inside a dead branch.
|
|
322
|
-
const nativeFile: NativeFileHandle = file;
|
|
323
|
-
const harnessRequest = getActiveHarnessRequest();
|
|
324
|
-
const nativeCacheKey = computeNativeCacheKey(nativeFile);
|
|
325
|
-
const shouldUseNativeCache = nativeFile.size >= CACHE_SIZE_THRESHOLD;
|
|
326
|
-
const hugeNativeMode = nativeFile.size >= HUGE_NATIVE_FILE_THRESHOLD;
|
|
327
|
-
let firstBatchWaitMs: number | null = null;
|
|
328
|
-
let firstVisibleGeometryMs: number | null = null;
|
|
329
|
-
let modelOpenMs: number | null = null;
|
|
330
|
-
let streamCompleteMs: number | null = null;
|
|
331
|
-
let batchCount = 0;
|
|
332
|
-
let totalMeshes = 0;
|
|
333
|
-
let spatialReadyMs: number | null = null;
|
|
334
|
-
let metadataStartMs: number | null = null;
|
|
335
|
-
let metadataReadCompleteMs: number | null = null;
|
|
336
|
-
let metadataParseStartMs: number | null = null;
|
|
337
|
-
let metadataCompleteMs: number | null = null;
|
|
338
|
-
let metadataFailedMs: number | null = null;
|
|
339
|
-
let metadataReadDurationMs: number | null = null;
|
|
340
|
-
let metadataBufferCopyDurationMs: number | null = null;
|
|
341
|
-
let metadataParseDurationMs: number | null = null;
|
|
342
|
-
let metadataSnapshotWritePromise: Promise<void> | null = null;
|
|
343
|
-
let metadataParsingPromise: Promise<void> | null = null;
|
|
344
|
-
let metadataParsingStarted = false;
|
|
345
|
-
let geometryCompleted = false;
|
|
346
|
-
let nativeGeometryCacheHit = false;
|
|
347
|
-
let nativeMetadataSnapshotHit = false;
|
|
348
|
-
let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
|
|
349
|
-
let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
|
|
350
|
-
let finalCoordinateInfo: CoordinateInfo | null = null;
|
|
351
|
-
|
|
352
|
-
console.log(`[useIfc] Native renderer load: ${fileName}, size: ${fileSizeMB.toFixed(2)}MB`);
|
|
353
|
-
void logToDesktopTerminal(
|
|
354
|
-
'info',
|
|
355
|
-
`[useIfc] Native renderer load start: ${fileName} (${fileSizeMB.toFixed(2)} MB) path=${file.path}`
|
|
356
|
-
);
|
|
357
|
-
|
|
358
|
-
setBoundedGeometryMode(true);
|
|
359
|
-
setGeometryResult(null);
|
|
360
|
-
setIfcDataStore(null);
|
|
361
|
-
setProgress({ phase: 'Starting native renderer', percent: 10 });
|
|
362
|
-
|
|
363
|
-
const queueNativeMetadataSnapshotWrite = (
|
|
364
|
-
dataStore: IfcDataStore,
|
|
365
|
-
sourceBuffer: ArrayBuffer,
|
|
366
|
-
) => {
|
|
367
|
-
metadataSnapshotWritePromise = (async () => {
|
|
368
|
-
await yieldToUiThread();
|
|
369
|
-
if (typeof requestAnimationFrame === 'function') {
|
|
370
|
-
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
371
|
-
}
|
|
372
|
-
if (!shouldUseNativeCache) return;
|
|
373
|
-
try {
|
|
374
|
-
const { setNativeModelSnapshot } = await import('../services/desktop-cache.js');
|
|
375
|
-
const snapshotBuffer = await buildDesktopMetadataSnapshot(dataStore, sourceBuffer);
|
|
376
|
-
await setNativeModelSnapshot(nativeCacheKey, snapshotBuffer);
|
|
377
|
-
} catch (error) {
|
|
378
|
-
void logToDesktopTerminal(
|
|
379
|
-
'warn',
|
|
380
|
-
`[useIfc] Native metadata snapshot write failed for ${fileName}: ${error instanceof Error ? error.message : String(error)}`
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
})();
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
const finalizeNativeMetadata = (dataStore: IfcDataStore) => {
|
|
387
|
-
if (dataStore.spatialHierarchy && dataStore.spatialHierarchy.storeyHeights.size === 0 && dataStore.spatialHierarchy.storeyElevations.size > 1) {
|
|
388
|
-
const calculatedHeights = calculateStoreyHeights(dataStore.spatialHierarchy.storeyElevations);
|
|
389
|
-
for (const [storeyId, height] of calculatedHeights) {
|
|
390
|
-
dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
setIfcDataStore(dataStore);
|
|
394
|
-
finalizePrimaryModel(
|
|
395
|
-
dataStore,
|
|
396
|
-
null,
|
|
397
|
-
getSchemaVersion(dataStore),
|
|
398
|
-
{
|
|
399
|
-
loadState: geometryCompleted ? 'complete' : 'hydrating-metadata',
|
|
400
|
-
cacheState: nativeGeometryCacheHit ? 'hit' : shouldUseNativeCache ? 'writing' : 'none',
|
|
401
|
-
},
|
|
402
|
-
);
|
|
403
|
-
};
|
|
404
|
-
|
|
405
|
-
const startNativeMetadataParsing = (): Promise<void> | null => {
|
|
406
|
-
if (metadataParsingStarted) return metadataParsingPromise;
|
|
407
|
-
metadataParsingStarted = true;
|
|
408
|
-
metadataStartMs = performance.now() - totalStartTime;
|
|
409
|
-
updateModel(primaryModelId, { loadState: 'hydrating-metadata' });
|
|
410
|
-
void logToDesktopTerminal(
|
|
411
|
-
'info',
|
|
412
|
-
`[useIfc] Native metadata parse start for ${fileName} source=${nativeMetadataSource} gate=${nativeMetadataStartGate}`
|
|
413
|
-
);
|
|
414
|
-
|
|
415
|
-
metadataParsingPromise = (async () => {
|
|
416
|
-
const metadataReadStart = performance.now();
|
|
417
|
-
let parseStart = 0;
|
|
418
|
-
|
|
419
|
-
if (nativeMetadataSnapshotHit) {
|
|
420
|
-
try {
|
|
421
|
-
const { getNativeModelSnapshot } = await import('../services/desktop-cache.js');
|
|
422
|
-
const snapshotBuffer = await getNativeModelSnapshot(nativeCacheKey);
|
|
423
|
-
if (snapshotBuffer) {
|
|
424
|
-
metadataReadCompleteMs = performance.now() - totalStartTime;
|
|
425
|
-
metadataReadDurationMs = performance.now() - metadataReadStart;
|
|
426
|
-
metadataParseStartMs = performance.now() - totalStartTime;
|
|
427
|
-
parseStart = performance.now();
|
|
428
|
-
const dataStore = await restoreDesktopMetadataSnapshot(snapshotBuffer);
|
|
429
|
-
if (spatialReadyMs === null) {
|
|
430
|
-
spatialReadyMs = performance.now() - totalStartTime;
|
|
431
|
-
}
|
|
432
|
-
metadataCompleteMs = performance.now() - totalStartTime;
|
|
433
|
-
metadataParseDurationMs = performance.now() - parseStart;
|
|
434
|
-
finalizeNativeMetadata(dataStore);
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
} catch (error) {
|
|
438
|
-
nativeMetadataSnapshotHit = false;
|
|
439
|
-
nativeMetadataSource = 'ifc-parse';
|
|
440
|
-
void logToDesktopTerminal(
|
|
441
|
-
'warn',
|
|
442
|
-
`[useIfc] Native metadata snapshot hydration failed for ${fileName}, falling back to IFC parse: ${error instanceof Error ? error.message : String(error)}`
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const bytes = await readNativeFile(file.path);
|
|
448
|
-
if (loadSessionRef.current !== currentSession) return;
|
|
449
|
-
metadataReadCompleteMs = performance.now() - totalStartTime;
|
|
450
|
-
metadataReadDurationMs = performance.now() - metadataReadStart;
|
|
451
|
-
const copyStart = performance.now();
|
|
452
|
-
const metadataBuffer = toExactArrayBuffer(bytes);
|
|
453
|
-
metadataBufferCopyDurationMs = performance.now() - copyStart;
|
|
454
|
-
metadataParseStartMs = performance.now() - totalStartTime;
|
|
455
|
-
parseStart = performance.now();
|
|
456
|
-
const parser = new IfcParser();
|
|
457
|
-
const wasmApi = hugeNativeMode ? await getMetadataScanApi() : undefined;
|
|
458
|
-
const dataStore = await parser.parseColumnar(metadataBuffer, {
|
|
459
|
-
wasmApi,
|
|
460
|
-
yieldIntervalMs: hugeNativeMode ? 32 : undefined,
|
|
461
|
-
deferPropertyAtomIndex: hugeNativeMode,
|
|
462
|
-
disableWorkerScan: false,
|
|
463
|
-
onSpatialReady: (partialStore) => {
|
|
464
|
-
if (loadSessionRef.current !== currentSession) return;
|
|
465
|
-
if (spatialReadyMs === null) {
|
|
466
|
-
spatialReadyMs = performance.now() - totalStartTime;
|
|
467
|
-
}
|
|
468
|
-
setIfcDataStore(partialStore);
|
|
469
|
-
},
|
|
470
|
-
});
|
|
471
|
-
queueNativeMetadataSnapshotWrite(dataStore, metadataBuffer);
|
|
472
|
-
metadataCompleteMs = performance.now() - totalStartTime;
|
|
473
|
-
metadataParseDurationMs = performance.now() - parseStart;
|
|
474
|
-
finalizeNativeMetadata(dataStore);
|
|
475
|
-
})().catch((error) => {
|
|
476
|
-
if (loadSessionRef.current !== currentSession) return;
|
|
477
|
-
metadataFailedMs = performance.now() - totalStartTime;
|
|
478
|
-
updateModel(primaryModelId, {
|
|
479
|
-
loadState: 'error',
|
|
480
|
-
loadError: error instanceof Error ? error.message : String(error),
|
|
481
|
-
});
|
|
482
|
-
void logToDesktopTerminal(
|
|
483
|
-
'warn',
|
|
484
|
-
`[useIfc] Native metadata parse failed for ${fileName}: ${error instanceof Error ? error.message : String(error)}`
|
|
485
|
-
);
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
return metadataParsingPromise;
|
|
489
|
-
};
|
|
490
|
-
|
|
491
|
-
if (shouldUseNativeCache) {
|
|
492
|
-
const { hasNativeGeometryCache, hasNativeModelSnapshot } = await import('../services/desktop-cache.js');
|
|
493
|
-
setProgress({ phase: 'Checking native cache', percent: 5 });
|
|
494
|
-
nativeGeometryCacheHit = await hasNativeGeometryCache(nativeCacheKey);
|
|
495
|
-
nativeMetadataSnapshotHit = nativeGeometryCacheHit ? await hasNativeModelSnapshot(nativeCacheKey) : false;
|
|
496
|
-
nativeMetadataSource = nativeGeometryCacheHit && nativeMetadataSnapshotHit ? 'snapshot' : 'ifc-parse';
|
|
497
|
-
nativeMetadataStartGate = 'immediate';
|
|
498
|
-
updateModel(primaryModelId, { cacheState: nativeGeometryCacheHit ? 'hit' : 'miss' });
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (nativeMetadataStartGate === 'immediate') {
|
|
502
|
-
startNativeMetadataParsing();
|
|
503
|
-
} else {
|
|
504
|
-
void logToDesktopTerminal(
|
|
505
|
-
'info',
|
|
506
|
-
`[useIfc] Deferring native metadata to ${nativeMetadataStartGate} for ${fileName}`
|
|
507
|
-
);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const nativeStream = await startDisabledNativeDesktopRendererModel(
|
|
511
|
-
file.path,
|
|
512
|
-
shouldUseNativeCache ? nativeCacheKey : undefined,
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
for await (const event of nativeStream) {
|
|
516
|
-
switch (event.type) {
|
|
517
|
-
case 'sessionReady':
|
|
518
|
-
void logToDesktopTerminal(
|
|
519
|
-
'info',
|
|
520
|
-
event.cacheHit
|
|
521
|
-
? `[useIfc] Native renderer cache hit for ${fileName}`
|
|
522
|
-
: `[useIfc] Native renderer cold load for ${fileName}`
|
|
523
|
-
);
|
|
524
|
-
break;
|
|
525
|
-
case 'modelOpen':
|
|
526
|
-
modelOpenMs = performance.now() - totalStartTime;
|
|
527
|
-
setProgress({ phase: 'Streaming geometry into native renderer', percent: 35 });
|
|
528
|
-
break;
|
|
529
|
-
case 'batch':
|
|
530
|
-
batchCount = event.batchCount;
|
|
531
|
-
totalMeshes = event.totalMeshes;
|
|
532
|
-
if (firstBatchWaitMs === null) {
|
|
533
|
-
firstBatchWaitMs = performance.now() - totalStartTime;
|
|
534
|
-
}
|
|
535
|
-
setProgress({
|
|
536
|
-
phase: `Uploading native geometry (${(event.totalMeshes ?? 0).toLocaleString()} meshes)`,
|
|
537
|
-
percent: Math.min(85, 35 + Math.log10(Math.max(10, event.totalMeshes ?? 0)) * 12),
|
|
538
|
-
});
|
|
539
|
-
break;
|
|
540
|
-
case 'firstFrame':
|
|
541
|
-
firstVisibleGeometryMs = performance.now() - totalStartTime;
|
|
542
|
-
if (nativeMetadataStartGate === 'afterInteractiveGeometry' && !metadataParsingStarted) {
|
|
543
|
-
startNativeMetadataParsing();
|
|
544
|
-
}
|
|
545
|
-
break;
|
|
546
|
-
case 'complete':
|
|
547
|
-
geometryCompleted = true;
|
|
548
|
-
streamCompleteMs = performance.now() - totalStartTime;
|
|
549
|
-
totalMeshes = event.totalMeshes;
|
|
550
|
-
finalCoordinateInfo = event.coordinateInfo;
|
|
551
|
-
updateCoordinateInfo(event.coordinateInfo);
|
|
552
|
-
if (nativeMetadataStartGate === 'afterGeometryComplete' && !metadataParsingStarted) {
|
|
553
|
-
startNativeMetadataParsing();
|
|
554
|
-
}
|
|
555
|
-
updateModel(primaryModelId, {
|
|
556
|
-
loadState: metadataParsingStarted ? 'hydrating-metadata' : 'complete',
|
|
557
|
-
cacheState: nativeGeometryCacheHit ? 'hit' : shouldUseNativeCache ? 'writing' : 'none',
|
|
558
|
-
});
|
|
559
|
-
setProgress({
|
|
560
|
-
phase: metadataParsingStarted ? 'Geometry ready, hydrating metadata' : 'Native geometry ready',
|
|
561
|
-
percent: metadataParsingStarted ? 92 : 100,
|
|
562
|
-
});
|
|
563
|
-
break;
|
|
564
|
-
case 'error':
|
|
565
|
-
throw new Error(event.message);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
if (harnessRequest?.waitForMetadataCompletion) {
|
|
570
|
-
if (!metadataParsingStarted) {
|
|
571
|
-
startNativeMetadataParsing();
|
|
572
|
-
}
|
|
573
|
-
if (metadataParsingPromise) {
|
|
574
|
-
await metadataParsingPromise;
|
|
575
|
-
}
|
|
576
|
-
if (metadataSnapshotWritePromise) {
|
|
577
|
-
await metadataSnapshotWritePromise;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
if (firstVisibleGeometryMs === null && streamCompleteMs !== null) {
|
|
582
|
-
firstVisibleGeometryMs = streamCompleteMs;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
if (!metadataParsingStarted) {
|
|
586
|
-
setLoading(false);
|
|
587
|
-
} else if (!harnessRequest?.waitForMetadataCompletion) {
|
|
588
|
-
setLoading(false);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
await finalizeActiveHarnessRun({
|
|
592
|
-
schemaVersion: 1,
|
|
593
|
-
source: 'desktop-native',
|
|
594
|
-
mode: harnessRequest ? 'startup-harness' : 'manual',
|
|
595
|
-
success: true,
|
|
596
|
-
runLabel: harnessRequest?.runLabel,
|
|
597
|
-
cache: {
|
|
598
|
-
key: nativeCacheKey,
|
|
599
|
-
hit: nativeGeometryCacheHit,
|
|
600
|
-
manifestMeshCount: null,
|
|
601
|
-
manifestShardCount: null,
|
|
602
|
-
},
|
|
603
|
-
file: {
|
|
604
|
-
path: file.path,
|
|
605
|
-
name: file.name,
|
|
606
|
-
sizeBytes: file.size,
|
|
607
|
-
sizeMB: fileSizeMB,
|
|
608
|
-
},
|
|
609
|
-
timings: {
|
|
610
|
-
modelOpenMs,
|
|
611
|
-
firstBatchWaitMs,
|
|
612
|
-
firstAppendGeometryBatchMs: null,
|
|
613
|
-
firstVisibleGeometryMs,
|
|
614
|
-
streamCompleteMs,
|
|
615
|
-
totalWallClockMs: performance.now() - totalStartTime,
|
|
616
|
-
metadataStartMs,
|
|
617
|
-
metadataReadCompleteMs,
|
|
618
|
-
metadataParseStartMs,
|
|
619
|
-
spatialReadyMs,
|
|
620
|
-
metadataCompleteMs,
|
|
621
|
-
metadataFailedMs,
|
|
622
|
-
metadataReadDurationMs,
|
|
623
|
-
metadataBufferCopyDurationMs,
|
|
624
|
-
metadataParseDurationMs,
|
|
625
|
-
nativeRendererFirstFrameMs: firstVisibleGeometryMs,
|
|
626
|
-
},
|
|
627
|
-
batches: {
|
|
628
|
-
estimatedTotal: shouldUseNativeCache ? totalMeshes : null,
|
|
629
|
-
totalBatches: batchCount,
|
|
630
|
-
totalMeshes,
|
|
631
|
-
firstBatchMeshes: null,
|
|
632
|
-
firstPayloadKind: 'native-renderer',
|
|
633
|
-
},
|
|
634
|
-
nativeStats: finalCoordinateInfo
|
|
635
|
-
? {
|
|
636
|
-
parseTimeMs: null,
|
|
637
|
-
entityScanTimeMs: null,
|
|
638
|
-
lookupTimeMs: null,
|
|
639
|
-
preprocessTimeMs: null,
|
|
640
|
-
geometryTimeMs: streamCompleteMs,
|
|
641
|
-
totalTimeMs: streamCompleteMs,
|
|
642
|
-
firstChunkReadyTimeMs: firstBatchWaitMs,
|
|
643
|
-
firstChunkPackTimeMs: null,
|
|
644
|
-
firstChunkEmittedTimeMs: null,
|
|
645
|
-
firstChunkEmitTimeMs: null,
|
|
646
|
-
}
|
|
647
|
-
: null,
|
|
648
|
-
metadata: {
|
|
649
|
-
started: metadataParsingStarted,
|
|
650
|
-
metadataStartMs,
|
|
651
|
-
metadataReadCompleteMs,
|
|
652
|
-
metadataParseStartMs,
|
|
653
|
-
spatialReadyMs,
|
|
654
|
-
metadataCompleteMs,
|
|
655
|
-
metadataFailedMs,
|
|
656
|
-
metadataReadDurationMs,
|
|
657
|
-
metadataBufferCopyDurationMs,
|
|
658
|
-
metadataParseDurationMs,
|
|
659
|
-
},
|
|
660
|
-
firstBatchTelemetry: null,
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
436
|
|
|
666
437
|
// Desktop native streaming path is reserved for truly large IFC files.
|
|
667
438
|
// Mid-size files are more stable on the shared WASM/web loader and still
|
|
668
439
|
// provide full viewer parity without the native streaming complexity.
|
|
440
|
+
// PRIMARY only: the native path paints the active slot and isn't target-
|
|
441
|
+
// aware, so a federated huge .ifc routes through the awaited WASM stream
|
|
442
|
+
// (which gates active-model writes) instead — matching the former
|
|
443
|
+
// federated path, which always used the WASM ingest regardless of size.
|
|
669
444
|
if (
|
|
670
|
-
|
|
445
|
+
target.kind === 'primary'
|
|
446
|
+
&& isNativeFileHandle(file)
|
|
671
447
|
&& fileName.toLowerCase().endsWith('.ifc')
|
|
672
448
|
&& file.size >= HUGE_NATIVE_FILE_THRESHOLD
|
|
673
449
|
) {
|
|
@@ -819,7 +595,7 @@ export function useIfcLoader() {
|
|
|
819
595
|
const stateAfterAppend = useViewerStore.getState();
|
|
820
596
|
void logToDesktopTerminal(
|
|
821
597
|
'info',
|
|
822
|
-
`[useIfc] Store after append for ${fileName}: activeModelId=${stateAfterAppend.activeModelId ?? 'null'} legacyMeshes=${stateAfterAppend.geometryResult?.meshes.length ?? 0} modelMeshes=${stateAfterAppend.models.get(
|
|
598
|
+
`[useIfc] Store after append for ${fileName}: activeModelId=${stateAfterAppend.activeModelId ?? 'null'} legacyMeshes=${stateAfterAppend.geometryResult?.meshes.length ?? 0} modelMeshes=${stateAfterAppend.models.get(modelId)?.geometryResult?.meshes.length ?? 0} geometryTick=${stateAfterAppend.geometryUpdateTick}`
|
|
823
599
|
);
|
|
824
600
|
loggedFirstAppendStoreState = true;
|
|
825
601
|
}
|
|
@@ -843,7 +619,7 @@ export function useIfcLoader() {
|
|
|
843
619
|
const stateAfterAppend = useViewerStore.getState();
|
|
844
620
|
void logToDesktopTerminal(
|
|
845
621
|
'info',
|
|
846
|
-
`[useIfc] Store after append for ${fileName}: activeModelId=${stateAfterAppend.activeModelId ?? 'null'} legacyMeshes=${stateAfterAppend.geometryResult?.meshes.length ?? 0} modelMeshes=${stateAfterAppend.models.get(
|
|
622
|
+
`[useIfc] Store after append for ${fileName}: activeModelId=${stateAfterAppend.activeModelId ?? 'null'} legacyMeshes=${stateAfterAppend.geometryResult?.meshes.length ?? 0} modelMeshes=${stateAfterAppend.models.get(modelId)?.geometryResult?.meshes.length ?? 0} geometryTick=${stateAfterAppend.geometryUpdateTick}`
|
|
847
623
|
);
|
|
848
624
|
loggedFirstAppendStoreState = true;
|
|
849
625
|
}
|
|
@@ -891,7 +667,7 @@ export function useIfcLoader() {
|
|
|
891
667
|
if (geometryCompleted) {
|
|
892
668
|
nativeLoadStage = 'complete';
|
|
893
669
|
}
|
|
894
|
-
|
|
670
|
+
void finalizeModel(
|
|
895
671
|
dataStore,
|
|
896
672
|
useViewerStore.getState().geometryResult,
|
|
897
673
|
getSchemaVersion(dataStore),
|
|
@@ -900,7 +676,7 @@ export function useIfcLoader() {
|
|
|
900
676
|
cacheState: nativeGeometryCacheHit ? 'hit' : shouldUseNativeCache ? 'writing' : 'none',
|
|
901
677
|
},
|
|
902
678
|
);
|
|
903
|
-
updateModel(
|
|
679
|
+
updateModel(modelId, {
|
|
904
680
|
geometryLoadState: geometryCompleted ? 'complete' : 'interactive',
|
|
905
681
|
metadataLoadState: 'complete',
|
|
906
682
|
interactiveReady: true,
|
|
@@ -923,10 +699,10 @@ export function useIfcLoader() {
|
|
|
923
699
|
}
|
|
924
700
|
const state = useViewerStore.getState();
|
|
925
701
|
const currentGeometryResult =
|
|
926
|
-
state.models.get(
|
|
702
|
+
state.models.get(modelId)?.geometryResult ??
|
|
927
703
|
state.geometryResult;
|
|
928
704
|
setIfcDataStore(spatialDataStore);
|
|
929
|
-
|
|
705
|
+
void finalizeModel(
|
|
930
706
|
spatialDataStore,
|
|
931
707
|
currentGeometryResult,
|
|
932
708
|
nativeMetadata.schemaVersion,
|
|
@@ -1015,7 +791,7 @@ export function useIfcLoader() {
|
|
|
1015
791
|
let lastMetadataProgressPercent = -1;
|
|
1016
792
|
startMetadataStallWatch();
|
|
1017
793
|
setMetadataProgress({ phase: 'Bootstrapping metadata', percent: 5, indeterminate: hugeNativeMode });
|
|
1018
|
-
updateModel(
|
|
794
|
+
updateModel(modelId, {
|
|
1019
795
|
loadState: 'hydrating-metadata',
|
|
1020
796
|
metadataLoadState: 'bootstrapping',
|
|
1021
797
|
});
|
|
@@ -1037,7 +813,7 @@ export function useIfcLoader() {
|
|
|
1037
813
|
try {
|
|
1038
814
|
spatialReadyMs = performance.now() - totalStartTime;
|
|
1039
815
|
hydrateNativeSpatialDataStore(restoredSnapshot);
|
|
1040
|
-
updateModel(
|
|
816
|
+
updateModel(modelId, {
|
|
1041
817
|
nativeMetadata: restoredSnapshot,
|
|
1042
818
|
schemaVersion: restoredSnapshot.schemaVersion,
|
|
1043
819
|
metadataLoadState: 'spatial-ready',
|
|
@@ -1075,7 +851,7 @@ export function useIfcLoader() {
|
|
|
1075
851
|
`[useIfc] Applying native metadata to store for ${fileName}`
|
|
1076
852
|
);
|
|
1077
853
|
hydrateNativeSpatialDataStore(nativeMetadata);
|
|
1078
|
-
updateModel(
|
|
854
|
+
updateModel(modelId, {
|
|
1079
855
|
nativeMetadata,
|
|
1080
856
|
schemaVersion: nativeMetadata.schemaVersion,
|
|
1081
857
|
metadataLoadState: 'spatial-ready',
|
|
@@ -1091,7 +867,7 @@ export function useIfcLoader() {
|
|
|
1091
867
|
}
|
|
1092
868
|
metadataCompleteMs = performance.now() - totalStartTime;
|
|
1093
869
|
metadataParseDurationMs = performance.now() - parseStartTime;
|
|
1094
|
-
updateModel(
|
|
870
|
+
updateModel(modelId, {
|
|
1095
871
|
loadState: geometryCompleted ? 'complete' : 'hydrating-metadata',
|
|
1096
872
|
metadataLoadState: 'lazy',
|
|
1097
873
|
});
|
|
@@ -1219,7 +995,7 @@ export function useIfcLoader() {
|
|
|
1219
995
|
stopMetadataStallWatch();
|
|
1220
996
|
metadataFailedMs = performance.now() - totalStartTime;
|
|
1221
997
|
console.warn('[useIfc] Native metadata parsing failed:', error);
|
|
1222
|
-
updateModel(
|
|
998
|
+
updateModel(modelId, {
|
|
1223
999
|
loadState: 'error',
|
|
1224
1000
|
metadataLoadState: 'error',
|
|
1225
1001
|
loadError: error instanceof Error ? error.message : String(error),
|
|
@@ -1256,7 +1032,7 @@ export function useIfcLoader() {
|
|
|
1256
1032
|
: false;
|
|
1257
1033
|
nativeMetadataSource = nativeMetadataSnapshotHit ? 'snapshot' : 'ifc-parse';
|
|
1258
1034
|
nativeMetadataStartGate = 'immediate';
|
|
1259
|
-
updateModel(
|
|
1035
|
+
updateModel(modelId, { cacheState: nativeGeometryCacheHit ? 'hit' : 'miss' });
|
|
1260
1036
|
void logToDesktopTerminal(
|
|
1261
1037
|
'info',
|
|
1262
1038
|
nativeGeometryCacheHit
|
|
@@ -1319,7 +1095,7 @@ export function useIfcLoader() {
|
|
|
1319
1095
|
firstGeometryTime = performance.now() - totalStartTime;
|
|
1320
1096
|
jsFirstChunkReceivedMs = event.nativeTelemetry?.jsReceivedTimeMs ?? firstGeometryTime;
|
|
1321
1097
|
firstNativeBatchTelemetry = event.nativeTelemetry ?? null;
|
|
1322
|
-
updateModel(
|
|
1098
|
+
updateModel(modelId, {
|
|
1323
1099
|
geometryLoadState: 'interactive',
|
|
1324
1100
|
interactiveReady: true,
|
|
1325
1101
|
});
|
|
@@ -1411,7 +1187,7 @@ export function useIfcLoader() {
|
|
|
1411
1187
|
? { phase: 'Preparing metadata', percent: nativeMetadataStartGate === 'afterGeometryComplete' ? 5 : 0, indeterminate: false }
|
|
1412
1188
|
: { phase: 'Metadata complete', percent: 100 }
|
|
1413
1189
|
);
|
|
1414
|
-
updateModel(
|
|
1190
|
+
updateModel(modelId, {
|
|
1415
1191
|
loadState: hugeNativeMode ? 'hydrating-metadata' : 'complete',
|
|
1416
1192
|
geometryLoadState: 'complete',
|
|
1417
1193
|
metadataLoadState: hugeNativeMode ? 'bootstrapping' : 'complete',
|
|
@@ -1621,7 +1397,7 @@ export function useIfcLoader() {
|
|
|
1621
1397
|
const renderer = getGlobalRenderer();
|
|
1622
1398
|
if (!renderer) {
|
|
1623
1399
|
setError('Renderer not initialised — try again after the viewer mounts.');
|
|
1624
|
-
updateModel(
|
|
1400
|
+
updateModel(modelId, { loadState: 'error', loadError: 'renderer-missing' });
|
|
1625
1401
|
setLoading(false);
|
|
1626
1402
|
return;
|
|
1627
1403
|
}
|
|
@@ -1677,17 +1453,17 @@ export function useIfcLoader() {
|
|
|
1677
1453
|
const isAbort = err instanceof DOMException && err.name === 'AbortError';
|
|
1678
1454
|
if (isAbort) {
|
|
1679
1455
|
console.log(
|
|
1680
|
-
`[useIfc] pointcloud ingest cancelled (model=${
|
|
1456
|
+
`[useIfc] pointcloud ingest cancelled (model=${modelId}, handle=${ingest.rendererHandle.id})`,
|
|
1681
1457
|
);
|
|
1682
|
-
updateModel(
|
|
1458
|
+
updateModel(modelId, { loadState: 'error', loadError: 'cancelled' });
|
|
1683
1459
|
setError(null);
|
|
1684
1460
|
setProgress({ phase: 'Cancelled', percent: 0 });
|
|
1685
1461
|
} else {
|
|
1686
1462
|
console.error(
|
|
1687
|
-
`[useIfc] pointcloud ingest failed (format=${format}, model=${
|
|
1463
|
+
`[useIfc] pointcloud ingest failed (format=${format}, model=${modelId}):`,
|
|
1688
1464
|
err,
|
|
1689
1465
|
);
|
|
1690
|
-
updateModel(
|
|
1466
|
+
updateModel(modelId, { loadState: 'error', loadError: message });
|
|
1691
1467
|
setError(`${format.toUpperCase()} parsing failed: ${message}`);
|
|
1692
1468
|
}
|
|
1693
1469
|
clearOwnedCanceller();
|
|
@@ -1702,9 +1478,13 @@ export function useIfcLoader() {
|
|
|
1702
1478
|
renderer.removePointCloudAsset(ingest.rendererHandle);
|
|
1703
1479
|
return;
|
|
1704
1480
|
}
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1481
|
+
// Primary owns the active-model slots; a federated add must not touch
|
|
1482
|
+
// them (finalizeModel's federated branch wires via addModel instead).
|
|
1483
|
+
if (target.kind === 'primary') {
|
|
1484
|
+
setGeometryResult(ingest.geometryResult);
|
|
1485
|
+
setIfcDataStore(ingest.dataStore);
|
|
1486
|
+
}
|
|
1487
|
+
await finalizeModel(ingest.dataStore, ingest.geometryResult, ingest.schemaVersion, {
|
|
1708
1488
|
pointCloudHandleId: ingest.rendererHandle.id,
|
|
1709
1489
|
});
|
|
1710
1490
|
setProgress({ phase: 'Complete', percent: 100 });
|
|
@@ -1719,9 +1499,11 @@ export function useIfcLoader() {
|
|
|
1719
1499
|
|
|
1720
1500
|
try {
|
|
1721
1501
|
const result = await parseIfcxViewerModel(buffer, setProgress);
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1502
|
+
if (target.kind === 'primary') {
|
|
1503
|
+
setGeometryResult(result.geometryResult);
|
|
1504
|
+
setIfcDataStore(result.dataStore);
|
|
1505
|
+
}
|
|
1506
|
+
await finalizeModel(result.dataStore, result.geometryResult, result.schemaVersion);
|
|
1725
1507
|
|
|
1726
1508
|
setProgress({ phase: 'Complete', percent: 100 });
|
|
1727
1509
|
setLoading(false);
|
|
@@ -1731,13 +1513,13 @@ export function useIfcLoader() {
|
|
|
1731
1513
|
console.warn(`[useIfc] IFCX file "${file.name}" has no geometry - this appears to be an overlay file that adds properties to a base model.`);
|
|
1732
1514
|
console.warn('[useIfc] To use this file, load it together with a base IFCX file (select both files at once).');
|
|
1733
1515
|
setError(`"${file.name}" is an overlay file with no geometry. Please load it together with a base IFCX file (select all files at once).`);
|
|
1734
|
-
updateModel(
|
|
1516
|
+
updateModel(modelId, { loadState: 'error', loadError: 'overlay-only-ifcx' });
|
|
1735
1517
|
setLoading(false);
|
|
1736
1518
|
return;
|
|
1737
1519
|
}
|
|
1738
1520
|
console.error('[useIfc] IFCX parsing failed:', err);
|
|
1739
1521
|
const message = err instanceof Error ? err.message : String(err);
|
|
1740
|
-
updateModel(
|
|
1522
|
+
updateModel(modelId, { loadState: 'error', loadError: message });
|
|
1741
1523
|
setError(`IFCX parsing failed: ${message}`);
|
|
1742
1524
|
setLoading(false);
|
|
1743
1525
|
return;
|
|
@@ -1751,9 +1533,18 @@ export function useIfcLoader() {
|
|
|
1751
1533
|
|
|
1752
1534
|
try {
|
|
1753
1535
|
const result = await parseGlbViewerModel(buffer);
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1536
|
+
if (target.kind === 'primary') {
|
|
1537
|
+
setGeometryResult(result.geometryResult);
|
|
1538
|
+
setIfcDataStore(null);
|
|
1539
|
+
}
|
|
1540
|
+
// Primary keeps the historical null data store (GLB has no entities);
|
|
1541
|
+
// a federated add needs the minimal store so finalizeModel can offset
|
|
1542
|
+
// ids + register the model (matches the old addModel GLB path).
|
|
1543
|
+
await finalizeModel(
|
|
1544
|
+
target.kind === 'federated' ? result.dataStore : null,
|
|
1545
|
+
result.geometryResult,
|
|
1546
|
+
result.schemaVersion,
|
|
1547
|
+
);
|
|
1757
1548
|
|
|
1758
1549
|
setProgress({ phase: 'Complete', percent: 100 });
|
|
1759
1550
|
|
|
@@ -1762,7 +1553,7 @@ export function useIfcLoader() {
|
|
|
1762
1553
|
} catch (err: unknown) {
|
|
1763
1554
|
console.error('[useIfc] GLB parsing failed:', err);
|
|
1764
1555
|
const message = err instanceof Error ? err.message : String(err);
|
|
1765
|
-
updateModel(
|
|
1556
|
+
updateModel(modelId, { loadState: 'error', loadError: message });
|
|
1766
1557
|
setError(`GLB parsing failed: ${message}`);
|
|
1767
1558
|
setLoading(false);
|
|
1768
1559
|
return;
|
|
@@ -1776,14 +1567,19 @@ export function useIfcLoader() {
|
|
|
1776
1567
|
// persisted key filename-safe and independent of the original filename.
|
|
1777
1568
|
const cacheKey = `ifc-${buffer.byteLength}-${fingerprint}-v4`;
|
|
1778
1569
|
|
|
1779
|
-
|
|
1570
|
+
// Cache + server are PRIMARY-ONLY: a federated add is WASM-only with no
|
|
1571
|
+
// cache/server round-trip (matches the former parseStepBufferViewerModel).
|
|
1572
|
+
if (target.kind === 'primary' && buffer.byteLength >= CACHE_SIZE_THRESHOLD) {
|
|
1780
1573
|
setProgress({ phase: 'Checking cache', percent: 5 });
|
|
1781
1574
|
const cacheResult = await getCached(cacheKey);
|
|
1782
1575
|
if (cacheResult) {
|
|
1783
|
-
|
|
1576
|
+
// Pass the freshly read file buffer as the source fallback: the
|
|
1577
|
+
// desktop cache doesn't persist a sourceBuffer, and without one the
|
|
1578
|
+
// restored store can't carry the lazy entity accessors.
|
|
1579
|
+
const cacheLoadResult = await loadFromCache(cacheResult, file.name, cacheKey, buffer);
|
|
1784
1580
|
if (cacheLoadResult.success) {
|
|
1785
1581
|
const state = useViewerStore.getState();
|
|
1786
|
-
|
|
1582
|
+
await finalizeModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore), {
|
|
1787
1583
|
loadState: 'complete',
|
|
1788
1584
|
cacheState: 'hit',
|
|
1789
1585
|
});
|
|
@@ -1798,12 +1594,12 @@ export function useIfcLoader() {
|
|
|
1798
1594
|
// Only for IFC4 STEP files (server doesn't support IFCX). Native
|
|
1799
1595
|
// file handles (Tauri) don't have an HTTP-uploadable body, so skip
|
|
1800
1596
|
// the server path and fall through to the WASM loader.
|
|
1801
|
-
if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '' && !isNativeFileHandle(file)) {
|
|
1597
|
+
if (target.kind === 'primary' && format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '' && !isNativeFileHandle(file)) {
|
|
1802
1598
|
// Pass buffer directly - server uses File object for parsing, buffer is only for size checks
|
|
1803
1599
|
const serverSuccess = await loadFromServer(file, buffer, () => loadSessionRef.current !== currentSession);
|
|
1804
1600
|
if (serverSuccess) {
|
|
1805
1601
|
const state = useViewerStore.getState();
|
|
1806
|
-
|
|
1602
|
+
await finalizeModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore));
|
|
1807
1603
|
console.log(`[useIfc] TOTAL LOAD TIME (server): ${(performance.now() - totalStartTime).toFixed(0)}ms`);
|
|
1808
1604
|
setLoading(false);
|
|
1809
1605
|
return;
|
|
@@ -1814,7 +1610,11 @@ export function useIfcLoader() {
|
|
|
1814
1610
|
|
|
1815
1611
|
// Using local WASM parsing
|
|
1816
1612
|
setProgress({ phase: 'Starting geometry streaming', percent: 10 });
|
|
1817
|
-
|
|
1613
|
+
// Global streaming flag is a PRIMARY (active-model) concern; a federated
|
|
1614
|
+
// add must not toggle it (the former federated path never did).
|
|
1615
|
+
if (target.kind === 'primary') {
|
|
1616
|
+
setGeometryStreamingActive(true);
|
|
1617
|
+
}
|
|
1818
1618
|
|
|
1819
1619
|
const shouldUseDesktopStableWasmGeometry =
|
|
1820
1620
|
isNativeFileHandle(file)
|
|
@@ -1831,6 +1631,11 @@ export function useIfcLoader() {
|
|
|
1831
1631
|
mergeLayers: mergeLayersAtLoad,
|
|
1832
1632
|
});
|
|
1833
1633
|
await geometryProcessor.init();
|
|
1634
|
+
// Issue #924: enable RTC-invariant per-entity geometry fingerprints so
|
|
1635
|
+
// the model-compare feature can detect geometry changes. The hash rides
|
|
1636
|
+
// on each MeshData.geometryHash (and through the worker pool); cost is
|
|
1637
|
+
// the O(verts) quantized hash, negligible next to tessellation.
|
|
1638
|
+
geometryProcessor.enableGeometryHashes();
|
|
1834
1639
|
|
|
1835
1640
|
// Allocate (or reuse) a SharedArrayBuffer so the parser worker and
|
|
1836
1641
|
// the geometry workers read the same memory zero-copy. When
|
|
@@ -1881,7 +1686,10 @@ export function useIfcLoader() {
|
|
|
1881
1686
|
partialStore.spatialHierarchy.storeyHeights.set(storeyId, height);
|
|
1882
1687
|
}
|
|
1883
1688
|
}
|
|
1884
|
-
setIfcDataStore
|
|
1689
|
+
// PRIMARY only: setIfcDataStore writes the ACTIVE model. A federated
|
|
1690
|
+
// add must not touch model #1's store — it wires its own via
|
|
1691
|
+
// finalizeModel → addModel once dataStorePromise resolves.
|
|
1692
|
+
if (target.kind === 'primary') setIfcDataStore(partialStore);
|
|
1885
1693
|
};
|
|
1886
1694
|
|
|
1887
1695
|
const onFullDataStore = (dataStore: IfcDataStore) => {
|
|
@@ -1893,7 +1701,10 @@ export function useIfcLoader() {
|
|
|
1893
1701
|
dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
|
|
1894
1702
|
}
|
|
1895
1703
|
}
|
|
1896
|
-
|
|
1704
|
+
// PRIMARY only (active-model write); federated wires via finalizeModel.
|
|
1705
|
+
// resolveDataStore stays unconditional so the federated finalizePromise
|
|
1706
|
+
// still resolves and registers the model.
|
|
1707
|
+
if (target.kind === 'primary') setIfcDataStore(dataStore);
|
|
1897
1708
|
console.log(`[useIfc] Data model parsing complete for ${file.name}: ${metadataCompleteMs.toFixed(0)}ms`);
|
|
1898
1709
|
memoryAccounting.endPhase('parser-worker');
|
|
1899
1710
|
memoryAccounting.recordPhase({ phase: 'parser-complete' });
|
|
@@ -2001,8 +1812,11 @@ export function useIfcLoader() {
|
|
|
2001
1812
|
let metadataCompleteMs: number | null = null;
|
|
2002
1813
|
let metadataFailedMs: number | null = null;
|
|
2003
1814
|
|
|
2004
|
-
// Clear existing geometry result
|
|
2005
|
-
|
|
1815
|
+
// Clear existing geometry result — PRIMARY only (federated must not
|
|
1816
|
+
// disturb the active model's geometry).
|
|
1817
|
+
if (target.kind === 'primary') {
|
|
1818
|
+
setGeometryResult(null);
|
|
1819
|
+
}
|
|
2006
1820
|
|
|
2007
1821
|
// Timing instrumentation
|
|
2008
1822
|
let batchCount = 0;
|
|
@@ -2025,6 +1839,11 @@ export function useIfcLoader() {
|
|
|
2025
1839
|
|
|
2026
1840
|
// Declare at function scope so the catch block can always reach it.
|
|
2027
1841
|
let closeGeometryIterator: (() => Promise<void>) | null = null;
|
|
1842
|
+
// The background finalize (spatial index / cache for primary; align +
|
|
1843
|
+
// addModel for federated). Primary leaves it running in the background
|
|
1844
|
+
// for a fast first frame; federated MUST await it so the model is
|
|
1845
|
+
// registered before loadFile resolves (loadFilesSequentially relies on it).
|
|
1846
|
+
let finalizePromise: Promise<void> | null = null;
|
|
2028
1847
|
|
|
2029
1848
|
try {
|
|
2030
1849
|
// Use dynamic batch sizing for optimal throughput
|
|
@@ -2039,6 +1858,9 @@ export function useIfcLoader() {
|
|
|
2039
1858
|
sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
|
|
2040
1859
|
batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
|
|
2041
1860
|
existingSab: sharedSource ?? undefined,
|
|
1861
|
+
// Federated adds share the anchor's RTC origin so all models sit in
|
|
1862
|
+
// one coordinate space (pixel-perfect alignment, no post-shift).
|
|
1863
|
+
sharedRtcOffset: target.kind === 'federated' ? target.sharedRtcOffset : undefined,
|
|
2042
1864
|
// Hand the streaming pre-pass's entity index to the parser
|
|
2043
1865
|
// worker so it skips a duplicate ~10 s WASM scan. Safe even
|
|
2044
1866
|
// when the parser falls back to main-thread (instance is
|
|
@@ -2141,29 +1963,39 @@ export function useIfcLoader() {
|
|
|
2141
1963
|
totalMeshes = event.totalSoFar;
|
|
2142
1964
|
lastTotalMeshes = event.totalSoFar;
|
|
2143
1965
|
|
|
2144
|
-
|
|
2145
|
-
|
|
1966
|
+
if (target.kind === 'primary') {
|
|
1967
|
+
// Accumulate meshes for batched rendering
|
|
1968
|
+
for (let i = 0; i < event.meshes.length; i++) pendingMeshes.push(event.meshes[i]);
|
|
2146
1969
|
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
1970
|
+
// FIRST BATCH: Render immediately for fast first frame
|
|
1971
|
+
// SUBSEQUENT: Throttle to reduce React re-renders
|
|
1972
|
+
const timeSinceLastRender = eventReceived - lastRenderTime;
|
|
1973
|
+
const shouldRender = batchCount === 1 || timeSinceLastRender >= RENDER_INTERVAL_MS;
|
|
2151
1974
|
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
1975
|
+
if (shouldRender && pendingMeshes.length > 0) {
|
|
1976
|
+
if (firstAppendGeometryBatchMs === null) {
|
|
1977
|
+
firstAppendGeometryBatchMs = performance.now() - totalStartTime;
|
|
1978
|
+
console.log(`[useIfc] First appendGeometryBatch for ${file.name}: ${firstAppendGeometryBatchMs.toFixed(0)}ms`);
|
|
1979
|
+
}
|
|
1980
|
+
appendGeometryBatch(pendingMeshes, event.coordinateInfo);
|
|
1981
|
+
pendingMeshes = [];
|
|
1982
|
+
lastRenderTime = eventReceived;
|
|
1983
|
+
markFirstVisibleGeometry();
|
|
1984
|
+
|
|
1985
|
+
// Update progress
|
|
1986
|
+
const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal / 10, totalMeshes)) * 45);
|
|
1987
|
+
setProgress({
|
|
1988
|
+
phase: `Rendering geometry (${totalMeshes} meshes)`,
|
|
1989
|
+
percent: progressPercent
|
|
1990
|
+
});
|
|
2156
1991
|
}
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
// Update progress
|
|
2163
|
-
const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal / 10, totalMeshes)) * 45);
|
|
1992
|
+
} else {
|
|
1993
|
+
// Federated add: accumulate into allMeshes only (done above) and
|
|
1994
|
+
// surface progress — it paints atomically at completion via
|
|
1995
|
+
// finalizeModel's addModel, never touching the active slot.
|
|
2164
1996
|
setProgress({
|
|
2165
|
-
phase: `
|
|
2166
|
-
percent:
|
|
1997
|
+
phase: `Processing geometry (${totalMeshes} meshes)`,
|
|
1998
|
+
percent: 10 + Math.min(80, (allMeshes.length / 1000) * 0.8),
|
|
2167
1999
|
});
|
|
2168
2000
|
}
|
|
2169
2001
|
|
|
@@ -2171,8 +2003,9 @@ export function useIfcLoader() {
|
|
|
2171
2003
|
}
|
|
2172
2004
|
case 'complete':
|
|
2173
2005
|
streamCompleteMs = performance.now() - totalStartTime;
|
|
2174
|
-
// Flush
|
|
2175
|
-
|
|
2006
|
+
// Flush remaining pending meshes — PRIMARY only. A federated add
|
|
2007
|
+
// never pushed to pendingMeshes; it paints atomically at finalize.
|
|
2008
|
+
if (target.kind === 'primary' && pendingMeshes.length > 0) {
|
|
2176
2009
|
if (firstAppendGeometryBatchMs === null) {
|
|
2177
2010
|
firstAppendGeometryBatchMs = performance.now() - totalStartTime;
|
|
2178
2011
|
console.log(`[useIfc] First appendGeometryBatch for ${file.name}: ${firstAppendGeometryBatchMs.toFixed(0)}ms`);
|
|
@@ -2184,40 +2017,58 @@ export function useIfcLoader() {
|
|
|
2184
2017
|
|
|
2185
2018
|
finalCoordinateInfo = event.coordinateInfo ?? null;
|
|
2186
2019
|
|
|
2187
|
-
//
|
|
2188
|
-
// No need to start it here — it runs concurrently with geometry.
|
|
2189
|
-
|
|
2190
|
-
// Apply all accumulated color updates in a single store update
|
|
2191
|
-
// instead of one updateMeshColors() call per colorUpdate event.
|
|
2192
|
-
if (cumulativeColorUpdates.size > 0) {
|
|
2193
|
-
updateMeshColors(cumulativeColorUpdates);
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
// Store captured RTC offset in coordinate info for multi-model alignment
|
|
2020
|
+
// Store captured RTC offset in coordinate info for multi-model alignment.
|
|
2197
2021
|
if (finalCoordinateInfo && capturedRtcOffset) {
|
|
2198
2022
|
finalCoordinateInfo.wasmRtcOffset = capturedRtcOffset;
|
|
2199
2023
|
}
|
|
2200
2024
|
|
|
2201
|
-
|
|
2202
|
-
|
|
2025
|
+
if (target.kind === 'primary') {
|
|
2026
|
+
// Active-model writes — PRIMARY only. Federated meshes already
|
|
2027
|
+
// carry colours (applied during streaming) and their coordinate
|
|
2028
|
+
// info rides the geometryResult handed to addModel at finalize.
|
|
2029
|
+
if (cumulativeColorUpdates.size > 0) {
|
|
2030
|
+
updateMeshColors(cumulativeColorUpdates);
|
|
2031
|
+
}
|
|
2032
|
+
updateCoordinateInfo(finalCoordinateInfo);
|
|
2033
|
+
}
|
|
2203
2034
|
|
|
2204
2035
|
setProgress({ phase: 'Complete', percent: 100 });
|
|
2205
2036
|
memoryAccounting.endPhase('geometry');
|
|
2206
2037
|
memoryAccounting.recordPhase({ phase: 'geometry-complete' });
|
|
2207
2038
|
console.log(memoryAccounting.formatSummary());
|
|
2208
2039
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
2209
|
-
if (loadSessionRef.current === currentSession) {
|
|
2040
|
+
if (loadSessionRef.current === currentSession && target.kind === 'primary') {
|
|
2210
2041
|
setGeometryStreamingActive(false);
|
|
2211
2042
|
}
|
|
2212
2043
|
console.log(`[useIfc] Geometry streaming complete: ${batchCount} batches, ${lastTotalMeshes} meshes`);
|
|
2213
2044
|
console.log(`[useIfc] Stream complete for ${file.name}: ${streamCompleteMs.toFixed(0)}ms`);
|
|
2214
2045
|
|
|
2215
|
-
//
|
|
2216
|
-
|
|
2217
|
-
dataStorePromise.then(async dataStore => {
|
|
2046
|
+
// Finalize once the data model is ready (parses in parallel).
|
|
2047
|
+
finalizePromise = dataStorePromise.then(async dataStore => {
|
|
2218
2048
|
// Guard: skip if user loaded a new file since this load started
|
|
2219
2049
|
if (loadSessionRef.current !== currentSession) return;
|
|
2220
|
-
|
|
2050
|
+
|
|
2051
|
+
if (target.kind === 'federated') {
|
|
2052
|
+
// Build the model's geometryResult from the accumulated meshes —
|
|
2053
|
+
// federated never streamed into the active slot — and hand it to
|
|
2054
|
+
// finalizeModel, which aligns, offsets ids, builds the spatial
|
|
2055
|
+
// index, and registers the model via addModel. NOT cached (the
|
|
2056
|
+
// former federated path never cached); allMeshes stays alive as
|
|
2057
|
+
// the model's geometryResult.meshes, so it is NOT cleared.
|
|
2058
|
+
applyColorUpdatesToMeshes(allMeshes, cumulativeColorUpdates);
|
|
2059
|
+
const federatedGeometry: GeometryResult = {
|
|
2060
|
+
meshes: allMeshes,
|
|
2061
|
+
totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
|
|
2062
|
+
totalTriangles: allMeshes.reduce((sum, m) => sum + m.indices.length / 3, 0),
|
|
2063
|
+
coordinateInfo: finalCoordinateInfo ?? createCoordinateInfo(calculateMeshBounds(allMeshes).bounds),
|
|
2064
|
+
};
|
|
2065
|
+
await finalizeModel(dataStore, federatedGeometry, getSchemaVersion(dataStore), {
|
|
2066
|
+
loadState: 'complete',
|
|
2067
|
+
});
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
await finalizeModel(dataStore, useViewerStore.getState().geometryResult, getSchemaVersion(dataStore), {
|
|
2221
2072
|
loadState: 'complete',
|
|
2222
2073
|
cacheState: buffer.byteLength >= CACHE_SIZE_THRESHOLD ? 'writing' : 'none',
|
|
2223
2074
|
});
|
|
@@ -2261,10 +2112,19 @@ export function useIfcLoader() {
|
|
|
2261
2112
|
}).catch(err => {
|
|
2262
2113
|
// Data model parsing failed - spatial index and caching skipped
|
|
2263
2114
|
console.warn('[useIfc] Skipping spatial index/cache - data model unavailable:', err);
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2116
|
+
if (target.kind === 'federated') {
|
|
2117
|
+
// No placeholder model exists for a federated add (it is only
|
|
2118
|
+
// registered on success via finalizeModel→addModel), so
|
|
2119
|
+
// updateModel would no-op and the failure would vanish —
|
|
2120
|
+
// addModel just returns null. Surface it to the user instead.
|
|
2121
|
+
toast.error(`Failed to load "${file.name}": ${message}`);
|
|
2122
|
+
} else {
|
|
2123
|
+
updateModel(modelId, {
|
|
2124
|
+
loadState: 'error',
|
|
2125
|
+
loadError: message,
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2268
2128
|
});
|
|
2269
2129
|
break;
|
|
2270
2130
|
}
|
|
@@ -2275,6 +2135,11 @@ export function useIfcLoader() {
|
|
|
2275
2135
|
if (closeGeometryIterator) {
|
|
2276
2136
|
await closeGeometryIterator();
|
|
2277
2137
|
}
|
|
2138
|
+
// The parser worker may be parked in `waitForEntityIndex` (the aborted
|
|
2139
|
+
// geometry pre-pass would have unblocked it); it self-terminates on its
|
|
2140
|
+
// own watchdog. Swallow the now-orphaned dataStorePromise rejection so
|
|
2141
|
+
// it doesn't surface as an unhandled rejection.
|
|
2142
|
+
void dataStorePromise.catch(() => {});
|
|
2278
2143
|
if (loadSessionRef.current !== currentSession) return;
|
|
2279
2144
|
console.error('[useIfc] Error in processing:', err);
|
|
2280
2145
|
setError(err instanceof Error ? err.message : 'Unknown error during geometry processing');
|
|
@@ -2285,6 +2150,14 @@ export function useIfcLoader() {
|
|
|
2285
2150
|
|
|
2286
2151
|
if (loadSessionRef.current !== currentSession) return;
|
|
2287
2152
|
|
|
2153
|
+
// Federated adds register the model inside finalizePromise (georef align
|
|
2154
|
+
// → id offset → spatial index → addModel). Await it so loadFile resolves
|
|
2155
|
+
// only AFTER the model is in the map — loadFilesSequentially loads the
|
|
2156
|
+
// next file serially and relies on this ordering for id-offset assignment.
|
|
2157
|
+
if (target.kind === 'federated' && finalizePromise) {
|
|
2158
|
+
await finalizePromise;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2288
2161
|
if (firstVisibleGeometryMs === null && firstAppendGeometryBatchMs !== null) {
|
|
2289
2162
|
await new Promise<void>((resolve) => {
|
|
2290
2163
|
const fallbackTimer = globalThis.setTimeout(() => {
|
|
@@ -2314,7 +2187,7 @@ export function useIfcLoader() {
|
|
|
2314
2187
|
setGeometryStreamingActive(false);
|
|
2315
2188
|
} catch (err) {
|
|
2316
2189
|
if (loadSessionRef.current !== currentSession) return;
|
|
2317
|
-
updateModel(
|
|
2190
|
+
updateModel(modelId, {
|
|
2318
2191
|
loadState: 'error',
|
|
2319
2192
|
loadError: err instanceof Error ? err.message : String(err),
|
|
2320
2193
|
});
|