@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
|
@@ -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
|
+
}
|
package/src/hooks/useIfcCache.ts
CHANGED
|
@@ -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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
typeList =
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
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) {
|