@ifc-lite/viewer 1.25.2 → 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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +30 -27
  2. package/CHANGELOG.md +81 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-ZpTYWE3K.js} +6 -6
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-Ctcu_Sc2.js} +5 -5
  5. package/dist/assets/{deflate-Cfp9t1Df.js → deflate-Cnx0il6E.js} +1 -1
  6. package/dist/assets/{exporters-DfSvJPi4.js → exporters-DSq76AVM.js} +272 -245
  7. package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
  8. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-A5UjhI6L.js} +10 -10
  9. package/dist/assets/{ids-Cu73hD0Y.js → ids-DiLcGTer.js} +21 -21
  10. package/dist/assets/{ifc-lite_bg-ksLBP5cA.wasm → ifc-lite_bg-CEZnhM2e.wasm} +0 -0
  11. package/dist/assets/index-B9Ug2EqU.css +1 -0
  12. package/dist/assets/{index-WSbA5iy6.js → index-BAH8IJVR.js} +35946 -33456
  13. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-BzSkwo5D.js} +1 -1
  14. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-Cg2Rz-D5.js} +1 -1
  15. package/dist/assets/{lzw-C9z0fG2o.js → lzw-BBPPLW-0.js} +1 -1
  16. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-CPojOeGE.js} +1 -1
  17. package/dist/assets/{packbits-jfwifz7C.js → packbits-yLSpjW-V.js} +1 -1
  18. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-8md211IW.js} +2 -2
  19. package/dist/assets/raw-BQrAgxwT.js +1 -0
  20. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-CsRXlgCO.js} +4102 -2658
  21. package/dist/assets/{server-client-Ctk8_Bof.js → server-client-Bk4c1CPO.js} +1 -1
  22. package/dist/assets/{webimage-XFHVyVtC.js → webimage-YafxjjGr.js} +1 -1
  23. package/dist/assets/{zstd-3q5qcl5V.js → zstd-CkSLOiuu.js} +1 -1
  24. package/dist/index.html +7 -7
  25. package/package.json +7 -6
  26. package/src/components/extensions/FlavorDialog.tsx +18 -2
  27. package/src/components/extensions/FlavorListView.tsx +12 -3
  28. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  29. package/src/components/viewer/ClashPanel.tsx +370 -0
  30. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  31. package/src/components/viewer/CommandPalette.tsx +14 -15
  32. package/src/components/viewer/MainToolbar.tsx +155 -175
  33. package/src/components/viewer/ViewerLayout.tsx +5 -0
  34. package/src/components/viewer/Viewport.tsx +49 -9
  35. package/src/components/viewer/ViewportContainer.tsx +45 -3
  36. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  37. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  38. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  39. package/src/hooks/ingest/streamCleanup.ts +45 -0
  40. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  41. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  42. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  43. package/src/hooks/useAlignmentLines3D.ts +164 -0
  44. package/src/hooks/useClash.ts +420 -0
  45. package/src/hooks/useIfcFederation.ts +16 -2
  46. package/src/hooks/useIfcLoader.ts +5 -7
  47. package/src/lib/clash/persistence.ts +308 -0
  48. package/src/lib/geo/effective-georef.test.ts +66 -0
  49. package/src/services/extensions/host.ts +13 -0
  50. package/src/store/constants.ts +33 -25
  51. package/src/store/index.ts +29 -8
  52. package/src/store/slices/clashSlice.ts +251 -0
  53. package/src/store/slices/visibilitySlice.test.ts +23 -5
  54. package/src/store/slices/visibilitySlice.ts +18 -8
  55. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  56. package/dist/assets/index-Bws3UAkj.css +0 -1
  57. package/dist/assets/raw-R2QfzPAR.js +0 -1
@@ -128,7 +128,7 @@ export function BCFOverlay() {
128
128
  const bcfProject = useViewerStore((s) => s.bcfProject);
129
129
  const activeTopicId = useViewerStore((s) => s.activeTopicId);
130
130
  const setActiveTopic = useViewerStore((s) => s.setActiveTopic);
131
- const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible);
131
+ const openWorkspacePanel = useViewerStore((s) => s.openWorkspacePanel);
132
132
  const models = useViewerStore((s) => s.models);
133
133
  const loading = useViewerStore((s) => s.loading);
134
134
  const ifcDataStore = useViewerStore((s) => s.ifcDataStore);
@@ -239,10 +239,11 @@ export function BCFOverlay() {
239
239
  if (!overlay) return;
240
240
  return overlay.onMarkerClick((topicGuid) => {
241
241
  setActiveTopic(topicGuid);
242
- const panelVisible = useViewerStore.getState().bcfPanelVisible;
243
- if (!panelVisible) setBcfPanelVisible(true);
242
+ // Open BCF exclusively so clicking a marker brings it to the front over any
243
+ // other right panel (e.g. clash), instead of leaving it behind.
244
+ openWorkspacePanel('bcf');
244
245
  });
245
- }, [overlayReady, setActiveTopic, setBcfPanelVisible]);
246
+ }, [overlayReady, setActiveTopic, openWorkspacePanel]);
246
247
 
247
248
  return (
248
249
  <div
@@ -46,6 +46,10 @@ export interface UseGeometryStreamingParams {
46
46
  geometryContentVersion?: number;
47
47
  coordinateInfo?: CoordinateInfo;
48
48
  isStreaming: boolean;
49
+ /** Number of loaded models. When this increases (a model was added to the
50
+ * federation) the camera must refit to the new combined bounds — otherwise
51
+ * it stays framed on the first model and the newly-added one is off-screen. */
52
+ modelCount?: number;
49
53
  geometryBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>;
50
54
  pendingMeshColorUpdates: Map<number, [number, number, number, number]> | null;
51
55
  pendingColorUpdates: Map<number, [number, number, number, number]> | null;
@@ -95,6 +99,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
95
99
  geometryContentVersion,
96
100
  coordinateInfo,
97
101
  isStreaming,
102
+ modelCount = 0,
98
103
  geometryBoundsRef,
99
104
  pendingMeshColorUpdates,
100
105
  pendingColorUpdates,
@@ -122,6 +127,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
122
127
  const lastFitPolicyKindRef = useRef<'compact' | 'linear' | null>(null);
123
128
  const prevIsStreamingRef = useRef(isStreaming);
124
129
  const lastContentVersionRef = useRef(geometryContentVersion ?? 0);
130
+ const prevModelCountRef = useRef(modelCount);
125
131
  const queuePumpTimerRef = useRef<ReturnType<typeof globalThis.setTimeout> | null>(null);
126
132
 
127
133
  // Only activate the timer-based queue pump when the tab is background-throttled
@@ -200,6 +206,20 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
200
206
  }
201
207
  }
202
208
 
209
+ // A model was added to the federation — refit the camera to the new
210
+ // combined bounds. Without this, `cameraFittedRef` stays true from the
211
+ // first model's fit, so the newly-added model renders off-screen and only
212
+ // its 2D grid overlay shows. Refit only on an INCREASE (a model added),
213
+ // and never mid-stream (the streaming first-fit + finalize refit handle
214
+ // the active model). The combined bounds come from the merged
215
+ // coordinateInfo (union of all visible models).
216
+ if (modelCount > prevModelCountRef.current && !isStreaming) {
217
+ traceGeometrySync(`model added (${prevModelCountRef.current}→${modelCount}) — refitting camera to combined bounds`);
218
+ cameraFittedRef.current = false;
219
+ finalBoundsRefittedRef.current = false;
220
+ }
221
+ prevModelCountRef.current = modelCount;
222
+
203
223
  // Read AFTER the optional reset above so the classification below reflects
204
224
  // the post-reset state (otherwise an in-place update gets misclassified as
205
225
  // "no change" and returns early at currentLength === lastLength).
@@ -400,7 +420,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
400
420
  }
401
421
 
402
422
  renderer.requestRender();
403
- }, [geometry, geometryVersion, geometryContentVersion, coordinateInfo, isInitialized, isStreaming]);
423
+ }, [geometry, geometryVersion, geometryContentVersion, coordinateInfo, isInitialized, isStreaming, modelCount]);
404
424
 
405
425
  useEffect(() => {
406
426
  return () => {
@@ -0,0 +1,41 @@
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 { boundedIteratorReturn } from './streamCleanup.js';
9
+
10
+ describe('boundedIteratorReturn', () => {
11
+ it('resolves promptly even when return() never settles (the stalled-worker case)', async () => {
12
+ // Mirrors a geometry generator parked on an unresolved await: its return()
13
+ // can never settle, so an unbounded await would re-wedge the caller.
14
+ const iterator = { return: () => new Promise<never>(() => { /* never settles */ }) };
15
+ const start = Date.now();
16
+ await boundedIteratorReturn(iterator, 50);
17
+ const elapsed = Date.now() - start;
18
+ assert.ok(elapsed < 1000, `expected bounded (<1000ms), took ${elapsed}ms`);
19
+ });
20
+
21
+ it('awaits a fast return() to completion (lets the generator finally run)', async () => {
22
+ let returned = false;
23
+ const iterator = {
24
+ return: async () => {
25
+ returned = true;
26
+ return { done: true, value: undefined };
27
+ },
28
+ };
29
+ await boundedIteratorReturn(iterator, 1000);
30
+ assert.strictEqual(returned, true);
31
+ });
32
+
33
+ it('swallows a rejecting return() without throwing', async () => {
34
+ const iterator = { return: () => Promise.reject(new Error('teardown blew up')) };
35
+ await assert.doesNotReject(() => boundedIteratorReturn(iterator, 1000));
36
+ });
37
+
38
+ it('is a no-op when the iterator has no return()', async () => {
39
+ await assert.doesNotReject(() => boundedIteratorReturn({}, 1000));
40
+ });
41
+ });
@@ -0,0 +1,45 @@
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
+ /** How long to wait for an abandoned geometry iterator to shut down before
6
+ * giving up on it. Generous enough for a healthy generator to run its
7
+ * `finally` (freeing WASM handles, terminating workers), short enough that a
8
+ * wedged one never holds the caller hostage. */
9
+ export const GEOMETRY_ITERATOR_CLEANUP_MS = 2000;
10
+
11
+ interface ClosableAsyncIterator {
12
+ return?: (value?: unknown) => Promise<unknown> | unknown;
13
+ }
14
+
15
+ /**
16
+ * Abandon an async iterator without letting its shutdown wedge the caller.
17
+ *
18
+ * `AsyncIterator.return()` cannot interrupt a generator parked on an unresolved
19
+ * `await` — e.g. the geometry drain loop suspended waiting on a worker that
20
+ * failed to instantiate ("Worker from an empty source") and therefore never
21
+ * resolves the promise. Awaiting `return()` unbounded would re-block on the
22
+ * exact stall the stream watchdog just escaped, swallowing the timeout error so
23
+ * the load hangs in cleanup instead of surfacing a recoverable failure. Racing
24
+ * it against a deadline guarantees the caller always proceeds; a healthy
25
+ * generator still resolves well within the deadline so its `finally` runs.
26
+ */
27
+ export async function boundedIteratorReturn(
28
+ iterator: ClosableAsyncIterator,
29
+ cleanupMs: number = GEOMETRY_ITERATOR_CLEANUP_MS,
30
+ ): Promise<void> {
31
+ if (typeof iterator.return !== 'function') return;
32
+ let timer: ReturnType<typeof setTimeout> | null = null;
33
+ try {
34
+ await Promise.race([
35
+ Promise.resolve(iterator.return(undefined)).catch(() => {
36
+ /* cleanup — safe to ignore */
37
+ }),
38
+ new Promise<void>((resolve) => {
39
+ timer = setTimeout(resolve, cleanupMs);
40
+ }),
41
+ ]);
42
+ } finally {
43
+ if (timer !== null) clearTimeout(timer);
44
+ }
45
+ }
@@ -9,6 +9,7 @@ import { loadGLBToMeshData } from '@ifc-lite/cache';
9
9
  import type { SchemaVersion } from '../../store/types.js';
10
10
  import { calculateMeshBounds, calculateStoreyHeights, createCoordinateInfo, normalizeColor } from '../../utils/localParsingUtils.js';
11
11
  import { resolveDataStoreOrAbort } from './resolveDataStoreOrAbort.js';
12
+ import { watchedGeometryStream } from './watchedGeometryStream.js';
12
13
 
13
14
  type RgbaColor = [number, number, number, number];
14
15
 
@@ -264,7 +265,7 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
264
265
  });
265
266
 
266
267
  const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(options.buffer);
267
- for await (const event of geometryProcessor.processAdaptive(geometryView, {
268
+ const geometryStream = geometryProcessor.processAdaptive(geometryView, {
268
269
  sizeThreshold: 2 * 1024 * 1024,
269
270
  batchSize: options.getDynamicBatchSize(options.fileSizeMB),
270
271
  sharedRtcOffset: options.sharedRtcOffset,
@@ -272,48 +273,69 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
272
273
  onEntityIndex: (ids, starts, lengths) => {
273
274
  workerParser?.setEntityIndex(ids, starts, lengths);
274
275
  },
275
- })) {
276
- if (options.shouldAbort?.()) {
277
- break;
278
- }
279
- switch (event.type) {
280
- case 'start':
281
- estimatedTotal = event.totalEstimate;
282
- break;
283
- case 'colorUpdate':
284
- for (const [expressId, color] of event.updates) {
285
- cumulativeColorUpdates.set(expressId, color);
286
- }
287
- options.onColorUpdate?.(event.updates);
288
- break;
289
- case 'rtcOffset':
290
- if (event.hasRtc) {
291
- capturedRtcOffset = event.rtcOffset;
292
- options.onRtcOffset?.({ rtcOffset: event.rtcOffset });
293
- }
294
- break;
295
- case 'batch':
296
- batchIndex += 1;
297
- for (let i = 0; i < event.meshes.length; i++) {
298
- allMeshes.push(event.meshes[i]);
299
- }
300
- finalCoordinateInfo = event.coordinateInfo ?? null;
301
- options.onBatch?.({
302
- batchIndex,
303
- estimatedTotal,
304
- totalSoFar: event.totalSoFar,
305
- meshes: event.meshes,
306
- coordinateInfo: event.coordinateInfo ?? null,
307
- });
308
- options.onProgress?.({
309
- phase: `Processing geometry (${event.totalSoFar} meshes)`,
310
- percent: 10 + Math.min(80, (allMeshes.length / 1000) * 0.8),
311
- });
312
- break;
313
- case 'complete':
314
- finalCoordinateInfo = event.coordinateInfo ?? null;
315
- break;
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
+ }
316
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;
317
339
  }
318
340
 
319
341
  // If the load was cancelled, don't await dataStorePromise: a worker parse
@@ -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
+ }