@ifc-lite/viewer 1.25.1 → 1.26.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 +83 -85
- package/CHANGELOG.md +104 -0
- package/dist/assets/{basketViewActivator-Dkn92C04.js → basketViewActivator-ZpTYWE3K.js} +6 -6
- package/dist/assets/{bcf-DP2AK1-_.js → bcf-Ctcu_Sc2.js} +5 -5
- package/dist/assets/{deflate-BYqYwhkl.js → deflate-Cnx0il6E.js} +1 -1
- package/dist/assets/exporters-DSq76AVM.js +4687 -0
- package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
- package/dist/assets/{geotiff-By06vdeL.js → geotiff-A5UjhI6L.js} +10 -10
- package/dist/assets/{ids-DDkkb4mo.js → ids-DiLcGTer.js} +4 -4
- package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
- package/dist/assets/index-B9Ug2EqU.css +1 -0
- package/dist/assets/{index-CqBdDOAZ.js → index-BAH8IJVR.js} +39550 -36936
- package/dist/assets/{jpeg-B4IBTphL.js → jpeg-BzSkwo5D.js} +1 -1
- package/dist/assets/{lerc-DQ3jI0Ke.js → lerc-Cg2Rz-D5.js} +1 -1
- package/dist/assets/{lzw-CtdH775t.js → lzw-BBPPLW-0.js} +1 -1
- package/dist/assets/{native-bridge-DA8wxaN_.js → native-bridge-CPojOeGE.js} +1 -1
- package/dist/assets/{packbits-DG3zn49C.js → packbits-yLSpjW-V.js} +1 -1
- package/dist/assets/parser.worker-8md211IW.js +182 -0
- package/dist/assets/raw-BQrAgxwT.js +1 -0
- package/dist/assets/{sandbox-D1pQT-5R.js → sandbox-CsRXlgCO.js} +4715 -3081
- package/dist/assets/{server-client-D9xO_8yX.js → server-client-Bk4c1CPO.js} +1 -1
- package/dist/assets/{webimage-_-qCDjkn.js → webimage-YafxjjGr.js} +1 -1
- package/dist/assets/{zstd-DlfgC8gA.js → zstd-CkSLOiuu.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +23 -21
- package/src/App.tsx +4 -0
- package/src/components/extensions/FlavorDialog.tsx +18 -2
- package/src/components/extensions/FlavorListView.tsx +12 -3
- package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
- package/src/components/viewer/ClashPanel.tsx +370 -0
- package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
- package/src/components/viewer/CommandPalette.tsx +19 -16
- package/src/components/viewer/MainToolbar.tsx +155 -153
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +97 -12
- package/src/components/viewer/ViewportContainer.tsx +45 -3
- package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
- package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
- package/src/components/viewer/useGeometryStreaming.ts +134 -19
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
- package/src/hooks/ingest/streamCleanup.test.ts +41 -0
- package/src/hooks/ingest/streamCleanup.ts +45 -0
- package/src/hooks/ingest/viewerModelIngest.ts +118 -52
- package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
- package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
- package/src/hooks/useAlignmentLines3D.ts +164 -0
- package/src/hooks/useClash.ts +420 -0
- package/src/hooks/useIfcCache.ts +44 -18
- package/src/hooks/useIfcFederation.ts +16 -2
- package/src/hooks/useIfcLoader.ts +6 -30
- package/src/hooks/useSymbolicAnnotations.ts +170 -35
- package/src/lib/clash/persistence.ts +308 -0
- package/src/lib/geo/effective-georef.test.ts +66 -0
- package/src/services/extensions/host.ts +13 -0
- package/src/store/constants.ts +38 -14
- package/src/store/index.ts +29 -7
- package/src/store/slices/clashSlice.ts +251 -0
- package/src/store/slices/visibilitySlice.test.ts +23 -5
- package/src/store/slices/visibilitySlice.ts +19 -8
- package/src/store/types.ts +9 -0
- package/src/utils/serverDataModel.test.ts +51 -1
- package/src/utils/serverDataModel.ts +2 -26
- package/vite.config.ts +0 -5
- package/dist/assets/exporters-CZe0D8N-.js +0 -5957
- package/dist/assets/geometry-controller.worker-pD49_fH6.js +0 -7
- package/dist/assets/geometry.worker-D4c-06r5.js +0 -1
- package/dist/assets/ifc-lite-DxGqDbjO.js +0 -7
- package/dist/assets/ifc-lite_bg-BNeu7R_V.wasm +0 -0
- package/dist/assets/ifc-lite_bg-DuxUZomW.wasm +0 -0
- package/dist/assets/index-Bws3UAkj.css +0 -1
- package/dist/assets/parser.worker-BZZcO7DB.js +0 -182
- package/dist/assets/raw-DY7Y_acr.js +0 -1
- package/dist/assets/wasm-bridge-DMX8Acuf.js +0 -1
- package/dist/assets/workerHelpers-Crstj4Oa.js +0 -36
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
5
|
import { IfcParser, parseIfcx, type IfcDataStore, type PointCloudExtraction } from '@ifc-lite/parser';
|
|
6
|
+
import { WorkerParser } from '@ifc-lite/parser/browser';
|
|
6
7
|
import { GeometryProcessor, GeometryQuality, type CoordinateInfo, type DynamicBatchConfig, type GeometryResult, type MeshData, type PointCloudAsset } from '@ifc-lite/geometry';
|
|
7
8
|
import { loadGLBToMeshData } from '@ifc-lite/cache';
|
|
8
9
|
import type { SchemaVersion } from '../../store/types.js';
|
|
9
10
|
import { calculateMeshBounds, calculateStoreyHeights, createCoordinateInfo, normalizeColor } from '../../utils/localParsingUtils.js';
|
|
11
|
+
import { resolveDataStoreOrAbort } from './resolveDataStoreOrAbort.js';
|
|
12
|
+
import { watchedGeometryStream } from './watchedGeometryStream.js';
|
|
10
13
|
|
|
11
14
|
type RgbaColor = [number, number, number, number];
|
|
12
15
|
|
|
@@ -217,6 +220,18 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
|
|
|
217
220
|
|
|
218
221
|
const parser = new IfcParser();
|
|
219
222
|
const wasmApi = geometryProcessor.getApi();
|
|
223
|
+
const canShareSource = WorkerParser.isSupported();
|
|
224
|
+
const sharedSource = canShareSource ? new SharedArrayBuffer(options.buffer.byteLength) : null;
|
|
225
|
+
if (sharedSource) {
|
|
226
|
+
new Uint8Array(sharedSource).set(new Uint8Array(options.buffer));
|
|
227
|
+
}
|
|
228
|
+
const geometryWillEmitEntityIndex =
|
|
229
|
+
sharedSource !== null
|
|
230
|
+
&& options.fileSizeMB >= 2
|
|
231
|
+
&& typeof Worker !== 'undefined'
|
|
232
|
+
&& typeof navigator !== 'undefined'
|
|
233
|
+
&& (navigator.hardwareConcurrency ?? 1) > 1;
|
|
234
|
+
let workerParser: WorkerParser | null = null;
|
|
220
235
|
const allMeshes: MeshData[] = [];
|
|
221
236
|
const cumulativeColorUpdates = new Map<number, RgbaColor>();
|
|
222
237
|
let finalCoordinateInfo: CoordinateInfo | null = null;
|
|
@@ -224,65 +239,116 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
|
|
|
224
239
|
let estimatedTotal = 0;
|
|
225
240
|
let capturedRtcOffset: { x: number; y: number; z: number } | null = null;
|
|
226
241
|
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
242
|
+
const handleSpatialReady = (partialStore: IfcDataStore) => {
|
|
243
|
+
if (options.shouldAbort?.()) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
options.onSpatialReady?.(normalizeDataStoreStoreys(partialStore));
|
|
247
|
+
};
|
|
248
|
+
const dataStorePromise = sharedSource
|
|
249
|
+
? (() => {
|
|
250
|
+
workerParser = new WorkerParser();
|
|
251
|
+
return workerParser.parseColumnar(sharedSource, {
|
|
252
|
+
waitForEntityIndex: geometryWillEmitEntityIndex,
|
|
253
|
+
onSpatialReady: handleSpatialReady,
|
|
254
|
+
}).catch((error) => {
|
|
255
|
+
console.warn('[viewerModelIngest] Parser worker failed, falling back to main-thread parse:', error);
|
|
256
|
+
return parser.parseColumnar(options.buffer, {
|
|
257
|
+
wasmApi: wasmApi ?? undefined,
|
|
258
|
+
onSpatialReady: handleSpatialReady,
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
})()
|
|
262
|
+
: parser.parseColumnar(options.buffer, {
|
|
263
|
+
wasmApi: wasmApi ?? undefined,
|
|
264
|
+
onSpatialReady: handleSpatialReady,
|
|
265
|
+
});
|
|
236
266
|
|
|
237
|
-
|
|
267
|
+
const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(options.buffer);
|
|
268
|
+
const geometryStream = geometryProcessor.processAdaptive(geometryView, {
|
|
238
269
|
sizeThreshold: 2 * 1024 * 1024,
|
|
239
270
|
batchSize: options.getDynamicBatchSize(options.fileSizeMB),
|
|
240
271
|
sharedRtcOffset: options.sharedRtcOffset,
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
272
|
+
existingSab: sharedSource ?? undefined,
|
|
273
|
+
onEntityIndex: (ids, starts, lengths) => {
|
|
274
|
+
workerParser?.setEntityIndex(ids, starts, lengths);
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
let lastTotalMeshes = 0;
|
|
278
|
+
// The federated/added-model path was missing the size-aware stream watchdog
|
|
279
|
+
// the single-model loader has, so a geometry worker that failed to spawn would
|
|
280
|
+
// hang the load forever on "Processing geometry (N meshes)" instead of
|
|
281
|
+
// surfacing a recoverable error. watchedGeometryStream re-yields each event
|
|
282
|
+
// under that watchdog and bounds iterator teardown on every exit path.
|
|
283
|
+
try {
|
|
284
|
+
for await (const event of watchedGeometryStream(geometryStream, {
|
|
285
|
+
fileName: options.fileName,
|
|
286
|
+
fileSizeMB: options.fileSizeMB,
|
|
287
|
+
shouldAbort: options.shouldAbort,
|
|
288
|
+
getBatchCount: () => batchIndex,
|
|
289
|
+
getLastTotalMeshes: () => lastTotalMeshes,
|
|
290
|
+
})) {
|
|
291
|
+
switch (event.type) {
|
|
292
|
+
case 'start':
|
|
293
|
+
estimatedTotal = event.totalEstimate;
|
|
294
|
+
break;
|
|
295
|
+
case 'colorUpdate':
|
|
296
|
+
for (const [expressId, color] of event.updates) {
|
|
297
|
+
cumulativeColorUpdates.set(expressId, color);
|
|
298
|
+
}
|
|
299
|
+
options.onColorUpdate?.(event.updates);
|
|
300
|
+
break;
|
|
301
|
+
case 'rtcOffset':
|
|
302
|
+
if (event.hasRtc) {
|
|
303
|
+
capturedRtcOffset = event.rtcOffset;
|
|
304
|
+
options.onRtcOffset?.({ rtcOffset: event.rtcOffset });
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
case 'batch':
|
|
308
|
+
batchIndex += 1;
|
|
309
|
+
for (let i = 0; i < event.meshes.length; i++) {
|
|
310
|
+
allMeshes.push(event.meshes[i]);
|
|
311
|
+
}
|
|
312
|
+
finalCoordinateInfo = event.coordinateInfo ?? null;
|
|
313
|
+
lastTotalMeshes = event.totalSoFar;
|
|
314
|
+
options.onBatch?.({
|
|
315
|
+
batchIndex,
|
|
316
|
+
estimatedTotal,
|
|
317
|
+
totalSoFar: event.totalSoFar,
|
|
318
|
+
meshes: event.meshes,
|
|
319
|
+
coordinateInfo: event.coordinateInfo ?? null,
|
|
320
|
+
});
|
|
321
|
+
options.onProgress?.({
|
|
322
|
+
phase: `Processing geometry (${event.totalSoFar} meshes)`,
|
|
323
|
+
percent: 10 + Math.min(80, (allMeshes.length / 1000) * 0.8),
|
|
324
|
+
});
|
|
325
|
+
break;
|
|
326
|
+
case 'complete':
|
|
327
|
+
finalCoordinateInfo = event.coordinateInfo ?? null;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
282
330
|
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
// Watchdog stall (or other stream error): the parser worker may be
|
|
333
|
+
// blocked in `waitForEntityIndex`, which only the geometry pre-pass would
|
|
334
|
+
// unblock. Terminate it here so it doesn't leak — the normal path below
|
|
335
|
+
// still awaits it via resolveDataStoreOrAbort. watchedGeometryStream's
|
|
336
|
+
// finally has already bounded teardown of the geometry iterator itself.
|
|
337
|
+
workerParser?.terminate();
|
|
338
|
+
throw err;
|
|
283
339
|
}
|
|
284
340
|
|
|
285
|
-
|
|
341
|
+
// If the load was cancelled, don't await dataStorePromise: a worker parse
|
|
342
|
+
// started with waitForEntityIndex blocks until the geometry pre-pass hands
|
|
343
|
+
// over the entity index, which never happens once the geometry loop has been
|
|
344
|
+
// aborted above. resolveDataStoreOrAbort terminates the worker and throws an
|
|
345
|
+
// AbortError instead of hanging here.
|
|
346
|
+
const dataStore = normalizeDataStoreStoreys(
|
|
347
|
+
await resolveDataStoreOrAbort(dataStorePromise, {
|
|
348
|
+
aborted: options.shouldAbort?.() ?? false,
|
|
349
|
+
terminate: () => workerParser?.terminate(),
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
286
352
|
if (!finalCoordinateInfo) {
|
|
287
353
|
finalCoordinateInfo = createCoordinateInfo(calculateMeshBounds(allMeshes).bounds);
|
|
288
354
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
|
|
8
|
+
import { watchedGeometryStream } from './watchedGeometryStream.js';
|
|
9
|
+
|
|
10
|
+
/** Build a controllable async source that records when return() is called. */
|
|
11
|
+
function makeSource<T>(values: T[]): { source: AsyncIterable<T>; returned: () => boolean } {
|
|
12
|
+
let didReturn = false;
|
|
13
|
+
const source: AsyncIterable<T> = {
|
|
14
|
+
[Symbol.asyncIterator]() {
|
|
15
|
+
let i = 0;
|
|
16
|
+
return {
|
|
17
|
+
next: async () => (i < values.length
|
|
18
|
+
? { done: false, value: values[i++] }
|
|
19
|
+
: { done: true, value: undefined as unknown as T }),
|
|
20
|
+
return: async () => {
|
|
21
|
+
didReturn = true;
|
|
22
|
+
return { done: true, value: undefined as unknown as T };
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
return { source, returned: () => didReturn };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const baseOpts = {
|
|
31
|
+
fileName: 'test.ifc',
|
|
32
|
+
fileSizeMB: 1,
|
|
33
|
+
getBatchCount: () => 0,
|
|
34
|
+
getLastTotalMeshes: () => 0,
|
|
35
|
+
cleanupMs: 50,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('watchedGeometryStream', () => {
|
|
39
|
+
it('re-yields every event in order then completes', async () => {
|
|
40
|
+
const { source } = makeSource([1, 2, 3]);
|
|
41
|
+
const seen: number[] = [];
|
|
42
|
+
for await (const v of watchedGeometryStream(source, baseOpts)) seen.push(v);
|
|
43
|
+
assert.deepStrictEqual(seen, [1, 2, 3]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('stops early when shouldAbort() turns true', async () => {
|
|
47
|
+
const { source } = makeSource([1, 2, 3, 4]);
|
|
48
|
+
const seen: number[] = [];
|
|
49
|
+
let calls = 0;
|
|
50
|
+
for await (const v of watchedGeometryStream(source, {
|
|
51
|
+
...baseOpts,
|
|
52
|
+
// Abort after the second event has been consumed.
|
|
53
|
+
shouldAbort: () => (++calls > 2),
|
|
54
|
+
})) {
|
|
55
|
+
seen.push(v);
|
|
56
|
+
}
|
|
57
|
+
assert.deepStrictEqual(seen, [1, 2]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('tears down the underlying iterator on normal completion', async () => {
|
|
61
|
+
const { source, returned } = makeSource([1]);
|
|
62
|
+
let count = 0;
|
|
63
|
+
for await (const v of watchedGeometryStream(source, baseOpts)) count += v;
|
|
64
|
+
assert.strictEqual(count, 1);
|
|
65
|
+
assert.strictEqual(returned(), true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('tears down the underlying iterator when the consumer breaks early', async () => {
|
|
69
|
+
const { source, returned } = makeSource([1, 2, 3]);
|
|
70
|
+
const seen: number[] = [];
|
|
71
|
+
for await (const v of watchedGeometryStream(source, baseOpts)) {
|
|
72
|
+
seen.push(v);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
assert.deepStrictEqual(seen, [1]);
|
|
76
|
+
assert.strictEqual(returned(), true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
import { getGeometryStreamWatchdogMs } from '@ifc-lite/geometry';
|
|
6
|
+
import { boundedIteratorReturn } from './streamCleanup.js';
|
|
7
|
+
|
|
8
|
+
export interface WatchedGeometryStreamOptions {
|
|
9
|
+
/** File name, for the stall error message. */
|
|
10
|
+
fileName: string;
|
|
11
|
+
/** File size in MB, feeds the size-aware watchdog deadline. */
|
|
12
|
+
fileSizeMB: number;
|
|
13
|
+
/** Abort the stream cooperatively (e.g. user cancelled the load). */
|
|
14
|
+
shouldAbort?: () => boolean;
|
|
15
|
+
/** Current batch index — feeds the size-aware watchdog deadline. */
|
|
16
|
+
getBatchCount: () => number;
|
|
17
|
+
/** Meshes rendered so far, for the stall error message. */
|
|
18
|
+
getLastTotalMeshes: () => number;
|
|
19
|
+
/** Override the abandon-cleanup deadline (mostly for tests). */
|
|
20
|
+
cleanupMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Drive a geometry stream under a size-aware watchdog, re-yielding every event.
|
|
25
|
+
*
|
|
26
|
+
* The parallel pipeline only ends once EVERY spawned geometry worker reports
|
|
27
|
+
* `complete`; if the browser fails to instantiate a worker (the "Attempting to
|
|
28
|
+
* create a Worker from an empty source" warning) that worker never reports
|
|
29
|
+
* `ready`/`complete` and never fires `onerror`, so the underlying generator can
|
|
30
|
+
* wedge forever, stranding the load on "Processing geometry (N meshes)". Racing
|
|
31
|
+
* each `next()` against a deadline converts that silent wedge into a thrown,
|
|
32
|
+
* recoverable error. On ANY exit — normal completion, abort, consumer `break`,
|
|
33
|
+
* or a watchdog throw — the `finally` bounds the underlying iterator's shutdown
|
|
34
|
+
* so cleanup (the generator's own `finally`: freeing WASM handles, tearing down
|
|
35
|
+
* workers) runs without re-blocking on the very stall the watchdog just escaped.
|
|
36
|
+
*
|
|
37
|
+
* Generic over the event type so the consumer keeps full type-narrowing in its
|
|
38
|
+
* own `switch`.
|
|
39
|
+
*/
|
|
40
|
+
export async function* watchedGeometryStream<T>(
|
|
41
|
+
source: AsyncIterable<T>,
|
|
42
|
+
options: WatchedGeometryStreamOptions,
|
|
43
|
+
): AsyncGenerator<T> {
|
|
44
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
45
|
+
try {
|
|
46
|
+
while (true) {
|
|
47
|
+
const watchdogMs = getGeometryStreamWatchdogMs({
|
|
48
|
+
desktopStableWasm: false,
|
|
49
|
+
batchCount: options.getBatchCount(),
|
|
50
|
+
fileSizeMB: options.fileSizeMB,
|
|
51
|
+
});
|
|
52
|
+
let watchdogId: ReturnType<typeof setTimeout> | null = null;
|
|
53
|
+
let result: IteratorResult<T>;
|
|
54
|
+
try {
|
|
55
|
+
result = await Promise.race([
|
|
56
|
+
iterator.next(),
|
|
57
|
+
new Promise<never>((_, reject) => {
|
|
58
|
+
watchdogId = setTimeout(() => {
|
|
59
|
+
reject(new Error(
|
|
60
|
+
`Geometry stream stalled after ${watchdogMs}ms while loading ${options.fileName}. `
|
|
61
|
+
+ `Last rendered meshes: ${options.getLastTotalMeshes()}. A geometry worker likely failed to start.`,
|
|
62
|
+
));
|
|
63
|
+
}, watchdogMs);
|
|
64
|
+
}),
|
|
65
|
+
]);
|
|
66
|
+
} finally {
|
|
67
|
+
if (watchdogId !== null) clearTimeout(watchdogId);
|
|
68
|
+
}
|
|
69
|
+
if (result.done) return;
|
|
70
|
+
if (options.shouldAbort?.()) return;
|
|
71
|
+
yield result.value;
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
await boundedIteratorReturn(iterator, options.cleanupMs);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Always-on extraction of IfcAlignment centerlines for the 3D viewport.
|
|
7
|
+
*
|
|
8
|
+
* IfcAlignment carries its geometry in the `Axis` curve (an IfcAlignmentCurve
|
|
9
|
+
* or IfcPolyline), not a `Representation`, so it never produces a mesh in the
|
|
10
|
+
* streaming batch mesher. Instead of rendering it as a triangulated ribbon —
|
|
11
|
+
* which reads as a thin solid strip — the WASM `parseAlignmentLines` API
|
|
12
|
+
* samples the directrix into a flat 3D line-list in renderer Y-up world space,
|
|
13
|
+
* which we feed to `renderer.uploadAlignmentLines3D`. This matches how IfcGrid
|
|
14
|
+
* axes and IfcAnnotation curves render as thin lines.
|
|
15
|
+
*
|
|
16
|
+
* Unlike annotations there is no visibility toggle: alignment lines render
|
|
17
|
+
* whenever a loaded model has alignments. The parse runs once per model source
|
|
18
|
+
* and is cached module-globally, so federated views share one parse per source.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
22
|
+
import { GeometryProcessor } from '@ifc-lite/geometry';
|
|
23
|
+
import { useViewerStore } from '@/store';
|
|
24
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
25
|
+
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
26
|
+
|
|
27
|
+
const EMPTY_F32 = new Float32Array(0);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stable per-source cache key — FNV-1a over head/mid/tail byte windows folded
|
|
31
|
+
* with the length, so two structurally distinct sources can't alias even when
|
|
32
|
+
* they share an exact byte length (a real risk in federated views). Identical
|
|
33
|
+
* scheme to `useSymbolicAnnotations`' `sourceKey`.
|
|
34
|
+
*/
|
|
35
|
+
function sourceKey(store: IfcDataStore | null | undefined): string | null {
|
|
36
|
+
const source = store?.source;
|
|
37
|
+
if (!source || source.byteLength === 0) return null;
|
|
38
|
+
const len = source.byteLength;
|
|
39
|
+
const sampleLen = Math.min(32, len);
|
|
40
|
+
const head = source.subarray(0, sampleLen);
|
|
41
|
+
const tail = source.subarray(len - sampleLen, len);
|
|
42
|
+
const midOffset = Math.max(0, Math.floor(len / 2) - Math.floor(sampleLen / 2));
|
|
43
|
+
const mid = source.subarray(midOffset, Math.min(midOffset + sampleLen, len));
|
|
44
|
+
const hashOne = (bytes: Uint8Array): string => {
|
|
45
|
+
let h = 0x811c9dc5;
|
|
46
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
47
|
+
h ^= bytes[i];
|
|
48
|
+
h = Math.imul(h, 0x01000193);
|
|
49
|
+
}
|
|
50
|
+
return (h >>> 0).toString(16);
|
|
51
|
+
};
|
|
52
|
+
return `b${len}-${hashOne(head)}-${hashOne(mid)}-${hashOne(tail)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Shared parse cache ──────────────────────────────────────────────────────
|
|
56
|
+
// One WASM walk per model source; cached so re-renders (and federated views
|
|
57
|
+
// that share a source) don't re-parse.
|
|
58
|
+
const PARSE_CACHE = new Map<string, Float32Array>();
|
|
59
|
+
const PARSE_INFLIGHT = new Map<string, Promise<void>>();
|
|
60
|
+
|
|
61
|
+
type CacheListener = () => void;
|
|
62
|
+
const CACHE_LISTENERS = new Set<CacheListener>();
|
|
63
|
+
function notifyCacheChange(): void {
|
|
64
|
+
for (const fn of CACHE_LISTENERS) fn();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function parseAlignmentLinesFor(store: IfcDataStore): Promise<Float32Array> {
|
|
68
|
+
const source = store.source;
|
|
69
|
+
if (!source || source.byteLength === 0) return EMPTY_F32;
|
|
70
|
+
const processor = new GeometryProcessor();
|
|
71
|
+
try {
|
|
72
|
+
await processor.init();
|
|
73
|
+
const verts = processor.parseAlignmentLines(source);
|
|
74
|
+
return verts && verts.length > 0 ? verts : EMPTY_F32;
|
|
75
|
+
} finally {
|
|
76
|
+
processor.dispose();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ensureParseFor(stores: IfcDataStore[]): void {
|
|
81
|
+
for (const store of stores) {
|
|
82
|
+
const key = sourceKey(store);
|
|
83
|
+
if (!key) continue;
|
|
84
|
+
if (PARSE_CACHE.has(key)) continue;
|
|
85
|
+
if (PARSE_INFLIGHT.has(key)) continue;
|
|
86
|
+
|
|
87
|
+
const promise = (async () => {
|
|
88
|
+
try {
|
|
89
|
+
const verts = await parseAlignmentLinesFor(store);
|
|
90
|
+
PARSE_CACHE.set(key, verts);
|
|
91
|
+
notifyCacheChange();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// Cache empty on failure so we don't retry a doomed parse every tick.
|
|
94
|
+
// eslint-disable-next-line no-console
|
|
95
|
+
console.warn('[useAlignmentLines3D] parse failed:', error);
|
|
96
|
+
PARSE_CACHE.set(key, EMPTY_F32);
|
|
97
|
+
notifyCacheChange();
|
|
98
|
+
} finally {
|
|
99
|
+
PARSE_INFLIGHT.delete(key);
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
PARSE_INFLIGHT.set(key, promise);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Read the active store set from the viewer store. Federation-aware. */
|
|
107
|
+
function useActiveStores(): IfcDataStore[] {
|
|
108
|
+
const { models, ifcDataStore } = useViewerStore(
|
|
109
|
+
useShallow((s) => ({ models: s.models, ifcDataStore: s.ifcDataStore })),
|
|
110
|
+
);
|
|
111
|
+
return useMemo(() => {
|
|
112
|
+
const out: IfcDataStore[] = [];
|
|
113
|
+
if (models.size > 0) {
|
|
114
|
+
for (const [, m] of models) if (m.ifcDataStore) out.push(m.ifcDataStore);
|
|
115
|
+
} else if (ifcDataStore) {
|
|
116
|
+
out.push(ifcDataStore);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}, [models, ifcDataStore]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sample every loaded model's IfcAlignment centerlines into a single flat
|
|
124
|
+
* `[x0,y0,z0, x1,y1,z1, …]` line-list in renderer world space (Y-up,
|
|
125
|
+
* RTC-subtracted, metres). Returns a stable empty array when no model carries
|
|
126
|
+
* an alignment. Always parses (no toggle) — see the file header.
|
|
127
|
+
*/
|
|
128
|
+
export function useAlignmentLines3D(): Float32Array {
|
|
129
|
+
const stores = useActiveStores();
|
|
130
|
+
const [version, setVersion] = useState(0);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
ensureParseFor(stores);
|
|
134
|
+
const listener: CacheListener = () => setVersion((v) => v + 1);
|
|
135
|
+
CACHE_LISTENERS.add(listener);
|
|
136
|
+
return () => {
|
|
137
|
+
CACHE_LISTENERS.delete(listener);
|
|
138
|
+
};
|
|
139
|
+
}, [stores]);
|
|
140
|
+
|
|
141
|
+
return useMemo(() => {
|
|
142
|
+
void version; // depend on parse-completion ticks
|
|
143
|
+
const arrays: Float32Array[] = [];
|
|
144
|
+
let total = 0;
|
|
145
|
+
for (const store of stores) {
|
|
146
|
+
const key = sourceKey(store);
|
|
147
|
+
if (!key) continue;
|
|
148
|
+
const cached = PARSE_CACHE.get(key);
|
|
149
|
+
if (cached && cached.length > 0) {
|
|
150
|
+
arrays.push(cached);
|
|
151
|
+
total += cached.length;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (total === 0) return EMPTY_F32;
|
|
155
|
+
if (arrays.length === 1) return arrays[0];
|
|
156
|
+
const merged = new Float32Array(total);
|
|
157
|
+
let offset = 0;
|
|
158
|
+
for (const a of arrays) {
|
|
159
|
+
merged.set(a, offset);
|
|
160
|
+
offset += a.length;
|
|
161
|
+
}
|
|
162
|
+
return merged;
|
|
163
|
+
}, [stores, version]);
|
|
164
|
+
}
|