@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.
Files changed (75) hide show
  1. package/.turbo/turbo-build.log +83 -85
  2. package/CHANGELOG.md +104 -0
  3. package/dist/assets/{basketViewActivator-Dkn92C04.js → basketViewActivator-ZpTYWE3K.js} +6 -6
  4. package/dist/assets/{bcf-DP2AK1-_.js → bcf-Ctcu_Sc2.js} +5 -5
  5. package/dist/assets/{deflate-BYqYwhkl.js → deflate-Cnx0il6E.js} +1 -1
  6. package/dist/assets/exporters-DSq76AVM.js +4687 -0
  7. package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
  8. package/dist/assets/{geotiff-By06vdeL.js → geotiff-A5UjhI6L.js} +10 -10
  9. package/dist/assets/{ids-DDkkb4mo.js → ids-DiLcGTer.js} +4 -4
  10. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  11. package/dist/assets/index-B9Ug2EqU.css +1 -0
  12. package/dist/assets/{index-CqBdDOAZ.js → index-BAH8IJVR.js} +39550 -36936
  13. package/dist/assets/{jpeg-B4IBTphL.js → jpeg-BzSkwo5D.js} +1 -1
  14. package/dist/assets/{lerc-DQ3jI0Ke.js → lerc-Cg2Rz-D5.js} +1 -1
  15. package/dist/assets/{lzw-CtdH775t.js → lzw-BBPPLW-0.js} +1 -1
  16. package/dist/assets/{native-bridge-DA8wxaN_.js → native-bridge-CPojOeGE.js} +1 -1
  17. package/dist/assets/{packbits-DG3zn49C.js → packbits-yLSpjW-V.js} +1 -1
  18. package/dist/assets/parser.worker-8md211IW.js +182 -0
  19. package/dist/assets/raw-BQrAgxwT.js +1 -0
  20. package/dist/assets/{sandbox-D1pQT-5R.js → sandbox-CsRXlgCO.js} +4715 -3081
  21. package/dist/assets/{server-client-D9xO_8yX.js → server-client-Bk4c1CPO.js} +1 -1
  22. package/dist/assets/{webimage-_-qCDjkn.js → webimage-YafxjjGr.js} +1 -1
  23. package/dist/assets/{zstd-DlfgC8gA.js → zstd-CkSLOiuu.js} +1 -1
  24. package/dist/index.html +7 -7
  25. package/package.json +23 -21
  26. package/src/App.tsx +4 -0
  27. package/src/components/extensions/FlavorDialog.tsx +18 -2
  28. package/src/components/extensions/FlavorListView.tsx +12 -3
  29. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  30. package/src/components/viewer/ClashPanel.tsx +370 -0
  31. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  32. package/src/components/viewer/CommandPalette.tsx +19 -16
  33. package/src/components/viewer/MainToolbar.tsx +155 -153
  34. package/src/components/viewer/ViewerLayout.tsx +5 -0
  35. package/src/components/viewer/Viewport.tsx +97 -12
  36. package/src/components/viewer/ViewportContainer.tsx +45 -3
  37. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  38. package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
  39. package/src/components/viewer/useGeometryStreaming.ts +134 -19
  40. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
  41. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
  42. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  43. package/src/hooks/ingest/streamCleanup.ts +45 -0
  44. package/src/hooks/ingest/viewerModelIngest.ts +118 -52
  45. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  46. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  47. package/src/hooks/useAlignmentLines3D.ts +164 -0
  48. package/src/hooks/useClash.ts +420 -0
  49. package/src/hooks/useIfcCache.ts +44 -18
  50. package/src/hooks/useIfcFederation.ts +16 -2
  51. package/src/hooks/useIfcLoader.ts +6 -30
  52. package/src/hooks/useSymbolicAnnotations.ts +170 -35
  53. package/src/lib/clash/persistence.ts +308 -0
  54. package/src/lib/geo/effective-georef.test.ts +66 -0
  55. package/src/services/extensions/host.ts +13 -0
  56. package/src/store/constants.ts +38 -14
  57. package/src/store/index.ts +29 -7
  58. package/src/store/slices/clashSlice.ts +251 -0
  59. package/src/store/slices/visibilitySlice.test.ts +23 -5
  60. package/src/store/slices/visibilitySlice.ts +19 -8
  61. package/src/store/types.ts +9 -0
  62. package/src/utils/serverDataModel.test.ts +51 -1
  63. package/src/utils/serverDataModel.ts +2 -26
  64. package/vite.config.ts +0 -5
  65. package/dist/assets/exporters-CZe0D8N-.js +0 -5957
  66. package/dist/assets/geometry-controller.worker-pD49_fH6.js +0 -7
  67. package/dist/assets/geometry.worker-D4c-06r5.js +0 -1
  68. package/dist/assets/ifc-lite-DxGqDbjO.js +0 -7
  69. package/dist/assets/ifc-lite_bg-BNeu7R_V.wasm +0 -0
  70. package/dist/assets/ifc-lite_bg-DuxUZomW.wasm +0 -0
  71. package/dist/assets/index-Bws3UAkj.css +0 -1
  72. package/dist/assets/parser.worker-BZZcO7DB.js +0 -182
  73. package/dist/assets/raw-DY7Y_acr.js +0 -1
  74. package/dist/assets/wasm-bridge-DMX8Acuf.js +0 -1
  75. package/dist/assets/workerHelpers-Crstj4Oa.js +0 -36
@@ -0,0 +1,420 @@
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
+ * Clash detection orchestration (Phase 1). Gathers `ClashElement`s from every
7
+ * loaded model via the STEP adapter, runs the (robust, in-process) TypeScript
8
+ * engine, and drives the viewer: selecting + framing a clash pair, highlighting
9
+ * all, and exporting a *grouped* BCF. Coloring/identity flow through the
10
+ * renderer's selection channel and the federation registry.
11
+ */
12
+
13
+ import { useCallback } from 'react';
14
+ import { useViewerStore } from '@/store';
15
+ import {
16
+ createClashEngine,
17
+ rulesFromPresets,
18
+ groupClashes,
19
+ type Clash,
20
+ type ClashElement,
21
+ type ClashElementRef,
22
+ type ClashGroup,
23
+ type ClashResult,
24
+ type ClashRule,
25
+ type ClashSeverity,
26
+ type ExclusionSet,
27
+ } from '@ifc-lite/clash';
28
+ import { elementsFromStep } from '@ifc-lite/clash/step';
29
+ import { createBCFFromClashResult } from '@ifc-lite/clash/bcf';
30
+ import { writeBCF } from '@ifc-lite/bcf';
31
+ import { getGlobalRenderer } from '@/hooks/useBCF';
32
+
33
+ interface SelectionRef {
34
+ modelId: string;
35
+ expressId: number;
36
+ }
37
+
38
+ /** How clashes collapse into BCF topics. `storey` is omitted — Clash has no
39
+ * storey, so it degrades to `rule` (see grouping.ts) and would only confuse. */
40
+ export type ClashBcfGroupBy = 'cluster' | 'rule' | 'typePair' | 'element';
41
+
42
+ /** User-controllable settings for a BCF export — "what gets created". */
43
+ export interface ClashBcfConfig {
44
+ /** Grouping dimension → one BCF topic per group. */
45
+ groupBy: ClashBcfGroupBy;
46
+ /** Only clashes of these severities become topics. */
47
+ severities: ClashSeverity[];
48
+ /** Render each topic's viewpoint offscreen and embed a PNG snapshot. */
49
+ includeSnapshots: boolean;
50
+ /** Initial BCF topic status (Open / In Progress / ...). */
51
+ status: string;
52
+ /** Safety cap on topic count; overflow is recorded in one marker topic. */
53
+ maxTopics: number;
54
+ }
55
+
56
+ /** Dark, neutral background for offscreen snapshot captures (Tokyo Night base). */
57
+ const SNAPSHOT_CLEAR_COLOR: [number, number, number, number] = [0.04, 0.05, 0.1, 1];
58
+
59
+ function downloadBlob(blob: Blob, filename: string): void {
60
+ const url = URL.createObjectURL(blob);
61
+ const anchor = document.createElement('a');
62
+ anchor.href = url;
63
+ anchor.download = filename;
64
+ anchor.click();
65
+ URL.revokeObjectURL(url);
66
+ }
67
+
68
+ /** Decode a `data:image/png;base64,...` URL into raw PNG bytes for the BCF zip. */
69
+ function dataUrlToBytes(dataUrl: string): Uint8Array | undefined {
70
+ const comma = dataUrl.indexOf(',');
71
+ if (comma < 0) return undefined;
72
+ try {
73
+ const binary = atob(dataUrl.slice(comma + 1));
74
+ const bytes = new Uint8Array(binary.length);
75
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
76
+ return bytes;
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ /** Drop clashes whose severity is not selected; total is kept consistent. */
83
+ function filterResultBySeverity(result: ClashResult, severities: Set<ClashSeverity>): ClashResult {
84
+ const clashes = result.clashes.filter((c) => severities.has(c.severity));
85
+ return { ...result, clashes, summary: { ...result.summary, total: clashes.length } };
86
+ }
87
+
88
+ export function useClash() {
89
+ const result = useViewerStore((s) => s.clashResult);
90
+ const groups = useViewerStore((s) => s.clashGroups);
91
+ const running = useViewerStore((s) => s.clashRunning);
92
+ const error = useViewerStore((s) => s.clashError);
93
+ const progress = useViewerStore((s) => s.clashProgress);
94
+ const mode = useViewerStore((s) => s.clashMode);
95
+ const tolerance = useViewerStore((s) => s.clashTolerance);
96
+ const clearance = useViewerStore((s) => s.clashClearance);
97
+ const groupBy = useViewerStore((s) => s.clashGroupBy);
98
+ const clusterEpsilon = useViewerStore((s) => s.clashClusterEpsilon);
99
+ const reportTouch = useViewerStore((s) => s.clashReportTouch);
100
+ const clashPresets = useViewerStore((s) => s.clashPresets);
101
+ const selectedId = useViewerStore((s) => s.clashSelectedId);
102
+ const panelVisible = useViewerStore((s) => s.clashPanelVisible);
103
+
104
+ const setMode = useViewerStore((s) => s.setClashMode);
105
+ const setTolerance = useViewerStore((s) => s.setClashTolerance);
106
+ const setClearance = useViewerStore((s) => s.setClashClearance);
107
+ const setGroupBy = useViewerStore((s) => s.setClashGroupBy);
108
+ const setSelectedId = useViewerStore((s) => s.setClashSelectedId);
109
+ const setPanelVisible = useViewerStore((s) => s.setClashPanelVisible);
110
+ const clear = useViewerStore((s) => s.clearClash);
111
+
112
+ /** Build clash elements + merged exclusions from every loaded model. */
113
+ const gatherElements = useCallback((): { elements: ClashElement[]; exclusions: ExclusionSet } => {
114
+ const state = useViewerStore.getState();
115
+ const elements: ClashElement[] = [];
116
+ const exclusions: ExclusionSet = new Set<string>();
117
+ const federation = { toGlobalId: (modelId: string, expressId: number) => state.toGlobalId(modelId, expressId) };
118
+
119
+ for (const [modelId, model] of state.models) {
120
+ const store = model.ifcDataStore;
121
+ const meshes = model.geometryResult?.meshes;
122
+ if (!store || !meshes || meshes.length === 0) continue;
123
+ const built = elementsFromStep({ store, meshes, modelId, federation });
124
+ elements.push(...built.elements);
125
+ for (const key of built.exclusions) exclusions.add(key);
126
+ }
127
+ return { elements, exclusions };
128
+ }, []);
129
+
130
+ const run = useCallback(
131
+ async (rules: ClashRule[]): Promise<void> => {
132
+ const state = useViewerStore.getState();
133
+ state.setClashRunning(true);
134
+ state.setClashError(null);
135
+ // Indeterminate "preparing" state until the engine reports candidate counts.
136
+ state.setClashProgress({ phase: 'broad', rule: '', done: 0, total: 0 });
137
+ try {
138
+ // Let the panel paint the running state before the heavy work.
139
+ await new Promise((resolve) => requestAnimationFrame(resolve));
140
+ const { elements, exclusions } = gatherElements();
141
+ if (elements.length === 0) {
142
+ state.setClashError('No model geometry is loaded. Load an IFC model first.');
143
+ return;
144
+ }
145
+ const engine = createClashEngine({ backend: 'ts' });
146
+ const res = await engine.run(elements, rules, {
147
+ exclusions,
148
+ tolerance: state.clashTolerance,
149
+ // The TS engine yields between chunks, so these updates actually paint.
150
+ onProgress: (p) => useViewerStore.getState().setClashProgress(p),
151
+ });
152
+ state.setClashResult(res);
153
+ // Spatial clustering is the sensible BCF unit; the panel list groups by
154
+ // its own dimension separately. Radius is the user's cluster epsilon.
155
+ state.setClashGroups(groupClashes(res, { by: 'cluster', epsilon: state.clashClusterEpsilon }));
156
+ state.setClashSelectedId(null);
157
+ } catch (err) {
158
+ console.error('[clash] detection run failed', err);
159
+ state.setClashError(err instanceof Error ? err.message : String(err));
160
+ } finally {
161
+ state.setClashRunning(false);
162
+ state.setClashProgress(null);
163
+ }
164
+ },
165
+ [gatherElements],
166
+ );
167
+
168
+ /**
169
+ * Run the user's ENABLED rule set (built-in discipline rules they've kept on,
170
+ * plus any custom presets). With no enabled rules, surface a clear message
171
+ * instead of silently finding nothing.
172
+ */
173
+ const runMatrix = useCallback((): Promise<void> => {
174
+ const enabled = clashPresets.filter((p) => p.enabled);
175
+ if (enabled.length === 0) {
176
+ useViewerStore.getState().setClashError('All rules are disabled — enable at least one in Clash settings (⚙).');
177
+ return Promise.resolve();
178
+ }
179
+ return run(rulesFromPresets(enabled, mode, mode === 'clearance' ? clearance : undefined, reportTouch));
180
+ }, [run, mode, clearance, reportTouch, clashPresets]);
181
+
182
+ /**
183
+ * Detect ALL clashes in the loaded geometry — a single self-clash rule over
184
+ * every element (every element vs every other), no discipline matrix or
185
+ * A/B selectors needed. For a single loaded model this is "all clashes inside
186
+ * the model".
187
+ */
188
+ const runAll = useCallback(
189
+ (): Promise<void> =>
190
+ run([
191
+ {
192
+ id: 'all-clashes',
193
+ name: 'All elements',
194
+ a: '*',
195
+ mode,
196
+ ...(mode === 'clearance' ? { clearance } : {}),
197
+ ...(reportTouch ? { reportTouch: true } : {}),
198
+ },
199
+ ]),
200
+ [run, mode, clearance, reportTouch],
201
+ );
202
+
203
+ const runPreset = useCallback(
204
+ (presetId: string): Promise<void> => {
205
+ const preset = useViewerStore.getState().clashPresets.find((p) => p.id === presetId);
206
+ if (!preset) return Promise.resolve();
207
+ return run(rulesFromPresets([preset], mode, mode === 'clearance' ? clearance : undefined, reportTouch));
208
+ },
209
+ [run, mode, clearance, reportTouch],
210
+ );
211
+
212
+ const refOf = useCallback((ref: ClashElementRef): SelectionRef | null => {
213
+ return useViewerStore.getState().fromGlobalId(ref.ref);
214
+ }, []);
215
+
216
+ /** Select both elements of a clash, highlight them, and frame the camera. */
217
+ const focusClash = useCallback(
218
+ (clash: Clash): void => {
219
+ const state = useViewerStore.getState();
220
+ const a = refOf(clash.a);
221
+ const b = refOf(clash.b);
222
+ const refs = [a, b].filter((r): r is SelectionRef => r !== null);
223
+ if (refs.length === 0) return;
224
+ // The renderer highlights the GLOBAL-id set (`selectedEntityIds`) and
225
+ // `frameSelection` frames it — `clash.X.ref` IS the federated global id
226
+ // (see gatherElements), so drive those, not just the model-aware set.
227
+ const globalIds: number[] = [];
228
+ if (a) globalIds.push(clash.a.ref);
229
+ if (b) globalIds.push(clash.b.ref);
230
+ // Replace any existing selection so the camera frames only this clash pair.
231
+ state.clearEntitySelection();
232
+ state.setSelectedEntityIds(globalIds); // highlight BOTH elements + frame target
233
+ state.addEntitiesToSelection(refs); // model-aware context for the properties panel
234
+ state.setClashSelectedId(clash.id);
235
+ requestAnimationFrame(() => state.cameraCallbacks.frameSelection?.());
236
+ },
237
+ [refOf],
238
+ );
239
+
240
+ /** Highlight every element involved in any clash. */
241
+ const highlightAll = useCallback((): void => {
242
+ const state = useViewerStore.getState();
243
+ const current = state.clashResult;
244
+ if (!current) return;
245
+ // Drive the renderer's global-id highlight set (`selectedEntityIds`); the
246
+ // model-aware set is added alongside for properties / federation context.
247
+ const globalIds = new Set<number>();
248
+ const refs: SelectionRef[] = [];
249
+ for (const clash of current.clashes) {
250
+ for (const el of [clash.a, clash.b]) {
251
+ const ref = refOf(el);
252
+ if (ref) {
253
+ globalIds.add(el.ref);
254
+ refs.push(ref);
255
+ }
256
+ }
257
+ }
258
+ if (globalIds.size === 0) return;
259
+ state.setSelectedEntityIds([...globalIds]);
260
+ state.addEntitiesToSelection(refs);
261
+ }, [refOf]);
262
+
263
+ const clearHighlight = useCallback((): void => {
264
+ useViewerStore.getState().clearEntitySelection();
265
+ setSelectedId(null);
266
+ }, [setSelectedId]);
267
+
268
+ /**
269
+ * Preview what a given export config would produce, WITHOUT building anything:
270
+ * how many clashes survive the severity filter and how many BCF topics they
271
+ * collapse into under the chosen grouping (incl. the overflow marker topic).
272
+ * Cheap (pure grouping) so the dialog can call it on every keystroke.
273
+ */
274
+ const bcfPreview = useCallback((config: ClashBcfConfig): { clashes: number; topics: number } => {
275
+ const state = useViewerStore.getState();
276
+ const current = state.clashResult;
277
+ if (!current) return { clashes: 0, topics: 0 };
278
+ const filtered = filterResultBySeverity(current, new Set(config.severities));
279
+ if (filtered.clashes.length === 0) return { clashes: 0, topics: 0 };
280
+ const groups = groupClashes(filtered, { by: config.groupBy, epsilon: state.clashClusterEpsilon });
281
+ const capped = Math.min(groups.length, config.maxTopics);
282
+ const overflow = groups.length > config.maxTopics ? 1 : 0;
283
+ return { clashes: filtered.clashes.length, topics: capped + overflow };
284
+ }, []);
285
+
286
+ /**
287
+ * Export the current clash result to a BCF 2.1 archive under `config`.
288
+ *
289
+ * Filters by severity, groups along the chosen dimension (one topic per
290
+ * group), and — when `includeSnapshots` is on and a renderer is live —
291
+ * renders each topic's framing viewpoint offscreen and embeds a PNG. The
292
+ * snapshot pass mirrors the IDS batch path: save viewer state, then per group
293
+ * frame the bounds + isolate the members + capture, and restore at the end.
294
+ * `onProgress(done, total)` ticks once per captured snapshot.
295
+ */
296
+ const exportBcf = useCallback(
297
+ async (config: ClashBcfConfig, onProgress?: (done: number, total: number) => void): Promise<void> => {
298
+ const state = useViewerStore.getState();
299
+ const current = state.clashResult;
300
+ if (!current) return;
301
+ const filtered = filterResultBySeverity(current, new Set(config.severities));
302
+ if (filtered.clashes.length === 0) return;
303
+ const groups = groupClashes(filtered, { by: config.groupBy, epsilon: state.clashClusterEpsilon });
304
+
305
+ let restore: (() => void) | undefined;
306
+ let snapshotProvider: ((group: ClashGroup) => Promise<Uint8Array | undefined>) | undefined;
307
+
308
+ if (config.includeSnapshots) {
309
+ const renderer = getGlobalRenderer();
310
+ if (renderer) {
311
+ const saved = {
312
+ selectedEntityId: state.selectedEntityId,
313
+ selectedEntityIds: state.selectedEntityIds,
314
+ isolatedEntities: state.isolatedEntities,
315
+ hiddenEntities: state.hiddenEntities,
316
+ };
317
+ restore = () => {
318
+ useViewerStore.setState({
319
+ selectedEntityId: saved.selectedEntityId,
320
+ selectedEntityIds: saved.selectedEntityIds,
321
+ isolatedEntities: saved.isolatedEntities,
322
+ hiddenEntities: saved.hiddenEntities,
323
+ });
324
+ renderer.render({
325
+ hiddenIds: saved.hiddenEntities,
326
+ isolatedIds: saved.isolatedEntities,
327
+ selectedId: saved.selectedEntityId,
328
+ // Repaint the full multi-selection too — the snapshot loop drove the
329
+ // renderer directly without touching the store, so the store's
330
+ // selectedEntityIds reference never changed and useRenderUpdates
331
+ // won't re-fire. Without this the clash highlight vanishes post-export.
332
+ selectedIds: saved.selectedEntityIds,
333
+ });
334
+ };
335
+ const total = Math.min(groups.length, config.maxTopics);
336
+ const camera = renderer.getCamera();
337
+ let done = 0;
338
+ snapshotProvider = async (group: ClashGroup): Promise<Uint8Array | undefined> => {
339
+ const b = group.bounds;
340
+ await camera.frameBounds(
341
+ { x: b.min[0], y: b.min[1], z: b.min[2] },
342
+ { x: b.max[0], y: b.max[1], z: b.max[2] },
343
+ 1,
344
+ );
345
+ // Isolate just this topic's members so the snapshot is unambiguous;
346
+ // no selection highlight so the captured colours read true.
347
+ const isolation = new Set<number>();
348
+ for (const m of group.members) {
349
+ isolation.add(m.a.ref);
350
+ isolation.add(m.b.ref);
351
+ }
352
+ renderer.render({ isolatedIds: isolation, selectedId: null, clearColor: SNAPSHOT_CLEAR_COLOR });
353
+ const device = renderer.getGPUDevice();
354
+ if (device) await device.queue.onSubmittedWorkDone();
355
+ // Let the compositor present the frame before reading the canvas.
356
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
357
+ const dataUrl = await renderer.captureScreenshot();
358
+ done += 1;
359
+ onProgress?.(done, total);
360
+ return dataUrl ? dataUrlToBytes(dataUrl) : undefined;
361
+ };
362
+ }
363
+ }
364
+
365
+ try {
366
+ const project = await createBCFFromClashResult(filtered, groups, {
367
+ author: 'clash@ifc-lite',
368
+ projectName: 'Clash report',
369
+ status: config.status,
370
+ maxTopics: config.maxTopics,
371
+ ...(snapshotProvider ? { snapshotProvider } : {}),
372
+ });
373
+ const blob = await writeBCF(project);
374
+ downloadBlob(blob, 'clashes.bcfzip');
375
+ } finally {
376
+ restore?.();
377
+ }
378
+ },
379
+ [],
380
+ );
381
+
382
+ const clearAll = useCallback((): void => {
383
+ useViewerStore.getState().clearEntitySelection();
384
+ clear();
385
+ }, [clear]);
386
+
387
+ return {
388
+ // state
389
+ result,
390
+ groups,
391
+ running,
392
+ error,
393
+ progress,
394
+ mode,
395
+ tolerance,
396
+ clearance,
397
+ groupBy,
398
+ selectedId,
399
+ panelVisible,
400
+ // Only enabled presets show as run chips; the settings dialog manages the full set.
401
+ presets: clashPresets.filter((p) => p.enabled),
402
+ // settings
403
+ setMode,
404
+ setTolerance,
405
+ setClearance,
406
+ setGroupBy,
407
+ setPanelVisible,
408
+ // actions
409
+ run,
410
+ runAll,
411
+ runMatrix,
412
+ runPreset,
413
+ focusClash,
414
+ highlightAll,
415
+ clearHighlight,
416
+ exportBcf,
417
+ bcfPreview,
418
+ clearAll,
419
+ };
420
+ }
@@ -13,10 +13,11 @@ import { useCallback } from 'react';
13
13
  import {
14
14
  BinaryCacheWriter,
15
15
  BinaryCacheReader,
16
+ type CachedEntityIndexColumns,
16
17
  type IfcDataStore as CacheDataStore,
17
18
  type GeometryData,
18
19
  } from '@ifc-lite/cache';
19
- import { SpatialHierarchyBuilder, StepTokenizer, CompactEntityIndexBuilder, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
20
+ import { SpatialHierarchyBuilder, StepTokenizer, CompactEntityIndex, CompactEntityIndexBuilder, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
20
21
  import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
21
22
  import type { MeshData } from '@ifc-lite/geometry';
22
23
 
@@ -30,6 +31,27 @@ import { calculateStoreyHeights } from '../utils/localParsingUtils.js';
30
31
  export type { CacheResult } from '../services/cacheService.js';
31
32
  export { getCached, setCached, deleteCached } from '../services/cacheService.js';
32
33
 
34
+ function buildEntityIndexFromCachedColumns(columns: CachedEntityIndexColumns): IfcDataStore['entityIndex'] {
35
+ const byId = new CompactEntityIndex(
36
+ columns.ids,
37
+ columns.byteOffsets,
38
+ columns.byteLengths,
39
+ columns.typeIndices,
40
+ columns.typeNames,
41
+ );
42
+ const byType = new Map<string, number[]>();
43
+ for (let i = 0; i < columns.ids.length; i++) {
44
+ const type = columns.typeNames[columns.typeIndices[i]];
45
+ let ids = byType.get(type);
46
+ if (!ids) {
47
+ ids = [];
48
+ byType.set(type, ids);
49
+ }
50
+ ids.push(columns.ids[i]);
51
+ }
52
+ return { byId, byType };
53
+ }
54
+
33
55
  // ============================================================================
34
56
  // Types
35
57
  // ============================================================================
@@ -107,25 +129,28 @@ export function useIfcCache() {
107
129
  if (cacheResult.sourceBuffer) {
108
130
  dataStore.source = new Uint8Array(cacheResult.sourceBuffer);
109
131
 
110
- // Quick scan to rebuild entity index with byte offsets (needed for on-demand extraction).
111
- // Uses CompactEntityIndexBuilder to fill typed arrays directly during the scan,
112
- // avoiding a temporary array of 4.4M+ objects (~350MB for large files).
113
- const tokenizer = new StepTokenizer(dataStore.source);
114
- const estimatedCount = dataStore.entities?.count ?? 100_000;
115
- const indexBuilder = new CompactEntityIndexBuilder(estimatedCount);
116
- const byType = new Map<string, number[]>();
117
-
118
- for (const ref of tokenizer.scanEntitiesFast()) {
119
- indexBuilder.add(ref.expressId, ref.type, ref.offset, ref.length);
120
- let typeList = byType.get(ref.type);
121
- if (!typeList) {
122
- typeList = [];
123
- byType.set(ref.type, typeList);
132
+ if (result.entityIndex) {
133
+ dataStore.entityIndex = buildEntityIndexFromCachedColumns(result.entityIndex);
134
+ } else {
135
+ // Backward compatibility for v3 caches: rebuild byte offsets from the
136
+ // source once, then future v4 writes persist this section.
137
+ const tokenizer = new StepTokenizer(dataStore.source);
138
+ const estimatedCount = dataStore.entities?.count ?? 100_000;
139
+ const indexBuilder = new CompactEntityIndexBuilder(estimatedCount);
140
+ const byType = new Map<string, number[]>();
141
+
142
+ for (const ref of tokenizer.scanEntitiesFast()) {
143
+ indexBuilder.add(ref.expressId, ref.type, ref.offset, ref.length);
144
+ let typeList = byType.get(ref.type);
145
+ if (!typeList) {
146
+ typeList = [];
147
+ byType.set(ref.type, typeList);
148
+ }
149
+ typeList.push(ref.expressId);
124
150
  }
125
- typeList.push(ref.expressId);
151
+ const compactByIdIndex = indexBuilder.build();
152
+ dataStore.entityIndex = { byId: compactByIdIndex, byType };
126
153
  }
127
- const compactByIdIndex = indexBuilder.build();
128
- dataStore.entityIndex = { byId: compactByIdIndex, byType };
129
154
 
130
155
  // Rebuild on-demand maps from relationships
131
156
  // Pass entityIndex which contains ALL entity types including IfcPropertySet/IfcElementQuantity
@@ -254,6 +279,7 @@ export function useIfcCache() {
254
279
  quantities: dataStore.quantities,
255
280
  relationships: dataStore.relationships,
256
281
  spatialHierarchy: dataStore.spatialHierarchy,
282
+ entityIndex: dataStore.entityIndex,
257
283
  };
258
284
 
259
285
  console.log('[useIfcCache] Writing cache buffer...');
@@ -40,7 +40,7 @@ import {
40
40
  } from './ingest/pointCloudIngest.js';
41
41
  import { getGlobalRenderer } from './useBCF.js';
42
42
  import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
43
- import { getEffectiveGeoreference, getEffectiveHorizontalScale, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
43
+ import { getEffectiveGeoreference, getEffectiveHorizontalScale, hasStandardGeoreferencing, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
44
44
  import { resolveMapUnitToMetreScale } from '../lib/geo/geo-scale.js';
45
45
  import { resolveProjection } from '../lib/geo/reproject.js';
46
46
  import { toast } from '../components/ui/toast.js';
@@ -105,7 +105,21 @@ function extractModelGeoref(
105
105
  mutations?: GeorefMutationDataLike,
106
106
  ): ModelGeoref | null {
107
107
  const georef = getEffectiveGeoreference(dataStore, coordinateInfo, mutations);
108
- if (!georef?.mapConversion || !georef.projectedCRS?.name) return null;
108
+ // Only TRUE georeferencing (real IfcMapConversion + IfcProjectedCRS) may drive
109
+ // federation alignment. A file with no IfcMapConversion gets a synthesised
110
+ // `source: 'siteLocation'` georef (EPSG:4326 from IfcSite RefLatitude/Longitude/
111
+ // Elevation) so it can still be pinned on the location map — but those are
112
+ // geographic degrees plus a raw, un-unit-scaled site elevation, not a projected
113
+ // metric frame. buildGeorefAlignmentTransform assumes projected eastings/
114
+ // northings/height in metres, so feeding it site data places the second model
115
+ // kilometres away: the BIMcollab ARC/STR pair share a site GUID but carry
116
+ // RefElevation 0 vs 20000 mm, and the height term lands ARC ~20 km below STR.
117
+ // Such models have no real georef relationship, so leave them in their own local
118
+ // frames where they overlay correctly. hasStandardGeoreferencing() excludes
119
+ // 'siteLocation' (see effective-georef.test.ts). (Regression from #658.)
120
+ if (!hasStandardGeoreferencing(georef) || !georef?.mapConversion || !georef.projectedCRS?.name) {
121
+ return null;
122
+ }
109
123
  return {
110
124
  mapConversion: georef.mapConversion,
111
125
  projectedCRS: georef.projectedCRS,
@@ -55,6 +55,7 @@ import { useIfcCache, getCached } from './useIfcCache.js';
55
55
  import { useIfcServer } from './useIfcServer.js';
56
56
 
57
57
  import { getMaxExpressId, parseGlbViewerModel, parseIfcxViewerModel } from './ingest/viewerModelIngest.js';
58
+ import { boundedIteratorReturn } from './ingest/streamCleanup.js';
58
59
  import { detectPointCloudFormat, ingestPointCloud } from './ingest/pointCloudIngest.js';
59
60
  import { getGlobalRenderer } from './useBCF.js';
60
61
 
@@ -1905,7 +1906,7 @@ export function useIfcLoader() {
1905
1906
  // risking corruption.
1906
1907
  const parserWasmApi = isNativeFileHandle(file) ? undefined : geometryProcessor.getApi();
1907
1908
  return new IfcParser().parseColumnar(buffer, {
1908
- wasmApi: parserWasmApi,
1909
+ wasmApi: parserWasmApi ?? undefined,
1909
1910
  onSpatialReady: onPartialDataStore,
1910
1911
  });
1911
1912
  };
@@ -2032,34 +2033,12 @@ export function useIfcLoader() {
2032
2033
  // When the parser worker is in use, hand the geometry workers the
2033
2034
  // same SAB so we don't pay the file-bytes copy twice.
2034
2035
  const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(buffer);
2035
- // Phase 2 of single-controller-rayon-design.md — opt-in via
2036
- // localStorage so we can A/B compare against the N-worker
2037
- // baseline without rolling out for everyone. Users (and the
2038
- // benchmark harness) flip this with:
2039
- // localStorage.setItem('ifc-lite:single-controller', '1')
2040
- // and reload. Set to anything else (or unset) for the legacy
2041
- // N-worker path. Safe: if the threaded WASM bundle fails to
2042
- // load (no COI, Safari, etc.) the controller worker falls back
2043
- // to per-task serial execution within the controller itself
2044
- // (par_iter without an initialized pool).
2045
- const useSingleController = (() => {
2046
- try {
2047
- return typeof localStorage !== 'undefined'
2048
- && localStorage.getItem('ifc-lite:single-controller') === '1';
2049
- } catch {
2050
- return false;
2051
- }
2052
- })();
2053
- if (useSingleController) {
2054
- console.log('[useIfc] single-controller path enabled (Phase 2)');
2055
- }
2056
2036
  const geometryEvents = shouldUseDesktopStableWasmGeometry
2057
2037
  ? geometryProcessor.processStreaming(geometryView, undefined, dynamicBatchConfig)
2058
2038
  : geometryProcessor.processAdaptive(geometryView, {
2059
2039
  sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
2060
2040
  batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
2061
2041
  existingSab: sharedSource ?? undefined,
2062
- useSingleController,
2063
2042
  // Hand the streaming pre-pass's entity index to the parser
2064
2043
  // worker so it skips a duplicate ~10 s WASM scan. Safe even
2065
2044
  // when the parser falls back to main-thread (instance is
@@ -2075,13 +2054,10 @@ export function useIfcLoader() {
2075
2054
  closeGeometryIterator = async () => {
2076
2055
  if (geometryIteratorClosed || typeof geometryIterator.return !== 'function') return;
2077
2056
  geometryIteratorClosed = true;
2078
- try {
2079
- // `AsyncIterator.return()` is signed as taking a value in
2080
- // current TS libs; callers conventionally pass `undefined`.
2081
- await geometryIterator.return(undefined);
2082
- } catch {
2083
- // Ignore iterator shutdown failures during recovery.
2084
- }
2057
+ // Bound the shutdown: `return()` cannot interrupt a generator parked
2058
+ // on a stalled worker await, so an unbounded await would re-wedge on
2059
+ // the very stall the watchdog escaped. See boundedIteratorReturn.
2060
+ await boundedIteratorReturn(geometryIterator);
2085
2061
  };
2086
2062
 
2087
2063
  while (true) {