@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.
- package/.turbo/turbo-build.log +30 -27
- package/CHANGELOG.md +81 -0
- package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-ZpTYWE3K.js} +6 -6
- package/dist/assets/{bcf-7jQby1qi.js → bcf-Ctcu_Sc2.js} +5 -5
- package/dist/assets/{deflate-Cfp9t1Df.js → deflate-Cnx0il6E.js} +1 -1
- package/dist/assets/{exporters-DfSvJPi4.js → exporters-DSq76AVM.js} +272 -245
- package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
- package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-A5UjhI6L.js} +10 -10
- package/dist/assets/{ids-Cu73hD0Y.js → ids-DiLcGTer.js} +21 -21
- package/dist/assets/{ifc-lite_bg-ksLBP5cA.wasm → ifc-lite_bg-CEZnhM2e.wasm} +0 -0
- package/dist/assets/index-B9Ug2EqU.css +1 -0
- package/dist/assets/{index-WSbA5iy6.js → index-BAH8IJVR.js} +35946 -33456
- package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-BzSkwo5D.js} +1 -1
- package/dist/assets/{lerc-Dz6BXOVb.js → lerc-Cg2Rz-D5.js} +1 -1
- package/dist/assets/{lzw-C9z0fG2o.js → lzw-BBPPLW-0.js} +1 -1
- package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-CPojOeGE.js} +1 -1
- package/dist/assets/{packbits-jfwifz7C.js → packbits-yLSpjW-V.js} +1 -1
- package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-8md211IW.js} +2 -2
- package/dist/assets/raw-BQrAgxwT.js +1 -0
- package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-CsRXlgCO.js} +4102 -2658
- package/dist/assets/{server-client-Ctk8_Bof.js → server-client-Bk4c1CPO.js} +1 -1
- package/dist/assets/{webimage-XFHVyVtC.js → webimage-YafxjjGr.js} +1 -1
- package/dist/assets/{zstd-3q5qcl5V.js → zstd-CkSLOiuu.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +7 -6
- 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 +14 -15
- package/src/components/viewer/MainToolbar.tsx +155 -175
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +49 -9
- package/src/components/viewer/ViewportContainer.tsx +45 -3
- package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
- package/src/components/viewer/useGeometryStreaming.ts +21 -1
- package/src/hooks/ingest/streamCleanup.test.ts +41 -0
- package/src/hooks/ingest/streamCleanup.ts +45 -0
- package/src/hooks/ingest/viewerModelIngest.ts +64 -42
- 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/useIfcFederation.ts +16 -2
- package/src/hooks/useIfcLoader.ts +5 -7
- 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 +33 -25
- package/src/store/index.ts +29 -8
- package/src/store/slices/clashSlice.ts +251 -0
- package/src/store/slices/visibilitySlice.test.ts +23 -5
- package/src/store/slices/visibilitySlice.ts +18 -8
- package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
- package/dist/assets/index-Bws3UAkj.css +0 -1
- package/dist/assets/raw-R2QfzPAR.js +0 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
|
|
@@ -2053,13 +2054,10 @@ export function useIfcLoader() {
|
|
|
2053
2054
|
closeGeometryIterator = async () => {
|
|
2054
2055
|
if (geometryIteratorClosed || typeof geometryIterator.return !== 'function') return;
|
|
2055
2056
|
geometryIteratorClosed = true;
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
} catch {
|
|
2061
|
-
// Ignore iterator shutdown failures during recovery.
|
|
2062
|
-
}
|
|
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);
|
|
2063
2061
|
};
|
|
2064
2062
|
|
|
2065
2063
|
while (true) {
|