@ifc-lite/viewer 1.25.2 → 1.27.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 (116) hide show
  1. package/.turbo/turbo-build.log +40 -30
  2. package/CHANGELOG.md +110 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-QeHK_Aud.js} +5 -5
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{deflate-Cfp9t1Df.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DfSvJPi4.js → exporters-B4LbZFeT.js} +1434 -1179
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-Cu73hD0Y.js → ids-DjsGFN10.js} +21 -21
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-WSbA5iy6.js → index-COYokSKc.js} +44122 -38782
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-C9z0fG2o.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-jfwifz7C.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-BAC3a-eN.js} +4235 -2716
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-XFHVyVtC.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-3q5qcl5V.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +13 -9
  38. package/src/components/extensions/FlavorDialog.tsx +18 -2
  39. package/src/components/extensions/FlavorListView.tsx +12 -3
  40. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  41. package/src/components/mcp/data.ts +6 -0
  42. package/src/components/mcp/playground-dispatcher.ts +277 -0
  43. package/src/components/mcp/types.ts +2 -1
  44. package/src/components/ui/combo-input.tsx +163 -0
  45. package/src/components/ui/tabs.tsx +1 -1
  46. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  47. package/src/components/viewer/ClashPanel.tsx +370 -0
  48. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  49. package/src/components/viewer/CommandPalette.tsx +14 -15
  50. package/src/components/viewer/MainToolbar.tsx +155 -175
  51. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  52. package/src/components/viewer/SearchInline.tsx +62 -2
  53. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  54. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  55. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  56. package/src/components/viewer/SearchModal.tsx +19 -6
  57. package/src/components/viewer/ViewerLayout.tsx +5 -0
  58. package/src/components/viewer/Viewport.tsx +64 -9
  59. package/src/components/viewer/ViewportContainer.tsx +45 -3
  60. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  61. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  62. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  63. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  64. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  65. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  66. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  67. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  68. package/src/generated/mcp-catalog.json +4 -0
  69. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  70. package/src/hooks/ingest/streamCleanup.ts +45 -0
  71. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  72. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  73. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  74. package/src/hooks/source-key.ts +35 -0
  75. package/src/hooks/useAlignmentLines3D.ts +139 -0
  76. package/src/hooks/useClash.ts +420 -0
  77. package/src/hooks/useGridLines3D.ts +140 -0
  78. package/src/hooks/useIfcFederation.ts +16 -2
  79. package/src/hooks/useIfcLoader.ts +5 -7
  80. package/src/lib/clash/persistence.ts +308 -0
  81. package/src/lib/geo/effective-georef.test.ts +66 -0
  82. package/src/lib/length-unit-scale.ts +41 -0
  83. package/src/lib/lists/adapter.ts +136 -11
  84. package/src/lib/lists/export/csv.ts +47 -0
  85. package/src/lib/lists/export/index.ts +49 -0
  86. package/src/lib/lists/export/model.ts +111 -0
  87. package/src/lib/lists/export/pdf.ts +67 -0
  88. package/src/lib/lists/export/xlsx.ts +83 -0
  89. package/src/lib/lists/index.ts +2 -0
  90. package/src/lib/search/filter-evaluate.test.ts +81 -0
  91. package/src/lib/search/filter-evaluate.ts +59 -87
  92. package/src/lib/search/filter-match.ts +167 -0
  93. package/src/lib/search/filter-rules.test.ts +25 -0
  94. package/src/lib/search/filter-rules.ts +75 -2
  95. package/src/lib/search/filter-schema.ts +0 -0
  96. package/src/lib/slab-edit.test.ts +72 -0
  97. package/src/lib/slab-edit.ts +159 -19
  98. package/src/sdk/adapters/export-adapter.ts +3 -3
  99. package/src/sdk/adapters/query-adapter.ts +3 -3
  100. package/src/services/extensions/host.ts +13 -0
  101. package/src/store/constants.ts +33 -25
  102. package/src/store/index.ts +29 -8
  103. package/src/store/slices/clashSlice.ts +251 -0
  104. package/src/store/slices/listSlice.ts +6 -0
  105. package/src/store/slices/mutationSlice.ts +14 -6
  106. package/src/store/slices/searchSlice.ts +29 -3
  107. package/src/store/slices/visibilitySlice.test.ts +23 -5
  108. package/src/store/slices/visibilitySlice.ts +18 -8
  109. package/src/utils/nativeSpatialDataStore.ts +6 -0
  110. package/src/utils/serverDataModel.test.ts +6 -0
  111. package/src/utils/serverDataModel.ts +7 -0
  112. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  113. package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
  114. package/dist/assets/index-Bws3UAkj.css +0 -1
  115. package/dist/assets/raw-R2QfzPAR.js +0 -1
  116. package/dist/assets/server-client-Ctk8_Bof.js +0 -626
@@ -0,0 +1,139 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Always-on extraction of IfcAlignment centerlines for the 3D viewport.
7
+ *
8
+ * IfcAlignment carries its geometry in the `Axis` curve (an IfcAlignmentCurve
9
+ * or IfcPolyline), not a `Representation`, so it never produces a mesh in the
10
+ * streaming batch mesher. Instead of rendering it as a triangulated ribbon —
11
+ * which reads as a thin solid strip — the WASM `parseAlignmentLines` API
12
+ * samples the directrix into a flat 3D line-list in renderer Y-up world space,
13
+ * which we feed to `renderer.uploadAlignmentLines3D`. This matches how IfcGrid
14
+ * axes and IfcAnnotation curves render as thin lines.
15
+ *
16
+ * Unlike annotations there is no visibility toggle: alignment lines render
17
+ * whenever a loaded model has alignments. The parse runs once per model source
18
+ * and is cached module-globally, so federated views share one parse per source.
19
+ */
20
+
21
+ import { useEffect, useMemo, useState } from 'react';
22
+ import { GeometryProcessor } from '@ifc-lite/geometry';
23
+ import { useViewerStore } from '@/store';
24
+ import { useShallow } from 'zustand/react/shallow';
25
+ import type { IfcDataStore } from '@ifc-lite/parser';
26
+ import { sourceKey } from './source-key.js';
27
+
28
+ const EMPTY_F32 = new Float32Array(0);
29
+
30
+ // ─── Shared parse cache ──────────────────────────────────────────────────────
31
+ // One WASM walk per model source; cached so re-renders (and federated views
32
+ // that share a source) don't re-parse.
33
+ const PARSE_CACHE = new Map<string, Float32Array>();
34
+ const PARSE_INFLIGHT = new Map<string, Promise<void>>();
35
+
36
+ type CacheListener = () => void;
37
+ const CACHE_LISTENERS = new Set<CacheListener>();
38
+ function notifyCacheChange(): void {
39
+ for (const fn of CACHE_LISTENERS) fn();
40
+ }
41
+
42
+ async function parseAlignmentLinesFor(store: IfcDataStore): Promise<Float32Array> {
43
+ const source = store.source;
44
+ if (!source || source.byteLength === 0) return EMPTY_F32;
45
+ const processor = new GeometryProcessor();
46
+ try {
47
+ await processor.init();
48
+ const verts = processor.parseAlignmentLines(source);
49
+ return verts && verts.length > 0 ? verts : EMPTY_F32;
50
+ } finally {
51
+ processor.dispose();
52
+ }
53
+ }
54
+
55
+ function ensureParseFor(stores: IfcDataStore[]): void {
56
+ for (const store of stores) {
57
+ const key = sourceKey(store);
58
+ if (!key) continue;
59
+ if (PARSE_CACHE.has(key)) continue;
60
+ if (PARSE_INFLIGHT.has(key)) continue;
61
+
62
+ const promise = (async () => {
63
+ try {
64
+ const verts = await parseAlignmentLinesFor(store);
65
+ PARSE_CACHE.set(key, verts);
66
+ notifyCacheChange();
67
+ } catch (error) {
68
+ // Cache empty on failure so we don't retry a doomed parse every tick.
69
+ // eslint-disable-next-line no-console
70
+ console.warn('[useAlignmentLines3D] parse failed:', error);
71
+ PARSE_CACHE.set(key, EMPTY_F32);
72
+ notifyCacheChange();
73
+ } finally {
74
+ PARSE_INFLIGHT.delete(key);
75
+ }
76
+ })();
77
+ PARSE_INFLIGHT.set(key, promise);
78
+ }
79
+ }
80
+
81
+ /** Read the active store set from the viewer store. Federation-aware. */
82
+ function useActiveStores(): IfcDataStore[] {
83
+ const { models, ifcDataStore } = useViewerStore(
84
+ useShallow((s) => ({ models: s.models, ifcDataStore: s.ifcDataStore })),
85
+ );
86
+ return useMemo(() => {
87
+ const out: IfcDataStore[] = [];
88
+ if (models.size > 0) {
89
+ for (const [, m] of models) if (m.ifcDataStore) out.push(m.ifcDataStore);
90
+ } else if (ifcDataStore) {
91
+ out.push(ifcDataStore);
92
+ }
93
+ return out;
94
+ }, [models, ifcDataStore]);
95
+ }
96
+
97
+ /**
98
+ * Sample every loaded model's IfcAlignment centerlines into a single flat
99
+ * `[x0,y0,z0, x1,y1,z1, …]` line-list in renderer world space (Y-up,
100
+ * RTC-subtracted, metres). Returns a stable empty array when no model carries
101
+ * an alignment. Always parses (no toggle) — see the file header.
102
+ */
103
+ export function useAlignmentLines3D(): Float32Array {
104
+ const stores = useActiveStores();
105
+ const [version, setVersion] = useState(0);
106
+
107
+ useEffect(() => {
108
+ ensureParseFor(stores);
109
+ const listener: CacheListener = () => setVersion((v) => v + 1);
110
+ CACHE_LISTENERS.add(listener);
111
+ return () => {
112
+ CACHE_LISTENERS.delete(listener);
113
+ };
114
+ }, [stores]);
115
+
116
+ return useMemo(() => {
117
+ void version; // depend on parse-completion ticks
118
+ const arrays: Float32Array[] = [];
119
+ let total = 0;
120
+ for (const store of stores) {
121
+ const key = sourceKey(store);
122
+ if (!key) continue;
123
+ const cached = PARSE_CACHE.get(key);
124
+ if (cached && cached.length > 0) {
125
+ arrays.push(cached);
126
+ total += cached.length;
127
+ }
128
+ }
129
+ if (total === 0) return EMPTY_F32;
130
+ if (arrays.length === 1) return arrays[0];
131
+ const merged = new Float32Array(total);
132
+ let offset = 0;
133
+ for (const a of arrays) {
134
+ merged.set(a, offset);
135
+ offset += a.length;
136
+ }
137
+ return merged;
138
+ }, [stores, version]);
139
+ }
@@ -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
+ }
@@ -0,0 +1,140 @@
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
+ * Extraction of IfcGrid / IfcGridAxis centerlines for the 3D viewport
7
+ * (issue #967, follow-up to #945/#966).
8
+ *
9
+ * IfcGrid carries its axes as IfcGridAxis curves (not a `Representation`), so
10
+ * they never produce a mesh in the streaming batch mesher. The WASM
11
+ * `parseGridLines` API resolves every axis through the same placement +
12
+ * unit-scale + RTC pipeline as the meshes and returns a flat 3D line-list in
13
+ * renderer Y-up world space, which we feed to `renderer.uploadGridLines3D`.
14
+ * This mirrors `useAlignmentLines3D`.
15
+ *
16
+ * Unlike alignment (always-on), grids are gated by the `ifcGrid` type-visibility
17
+ * toggle — but the parse itself is unconditional and cached; the Viewport only
18
+ * uploads/clears based on the toggle.
19
+ */
20
+
21
+ import { useEffect, useMemo, useState } from 'react';
22
+ import { GeometryProcessor } from '@ifc-lite/geometry';
23
+ import { useViewerStore } from '@/store';
24
+ import { useShallow } from 'zustand/react/shallow';
25
+ import type { IfcDataStore } from '@ifc-lite/parser';
26
+ import { sourceKey } from './source-key.js';
27
+
28
+ const EMPTY_F32 = new Float32Array(0);
29
+
30
+ // ─── Shared parse cache ──────────────────────────────────────────────────────
31
+ // One WASM walk per model source; cached so re-renders (and federated views
32
+ // that share a source) don't re-parse.
33
+ const PARSE_CACHE = new Map<string, Float32Array>();
34
+ const PARSE_INFLIGHT = new Map<string, Promise<void>>();
35
+
36
+ type CacheListener = () => void;
37
+ const CACHE_LISTENERS = new Set<CacheListener>();
38
+ function notifyCacheChange(): void {
39
+ for (const fn of CACHE_LISTENERS) fn();
40
+ }
41
+
42
+ async function parseGridLinesFor(store: IfcDataStore): Promise<Float32Array> {
43
+ const source = store.source;
44
+ if (!source || source.byteLength === 0) return EMPTY_F32;
45
+ const processor = new GeometryProcessor();
46
+ try {
47
+ await processor.init();
48
+ const verts = processor.parseGridLines(source);
49
+ return verts && verts.length > 0 ? verts : EMPTY_F32;
50
+ } finally {
51
+ processor.dispose();
52
+ }
53
+ }
54
+
55
+ function ensureParseFor(stores: IfcDataStore[]): void {
56
+ for (const store of stores) {
57
+ const key = sourceKey(store);
58
+ if (!key) continue;
59
+ if (PARSE_CACHE.has(key)) continue;
60
+ if (PARSE_INFLIGHT.has(key)) continue;
61
+
62
+ const promise = (async () => {
63
+ try {
64
+ const verts = await parseGridLinesFor(store);
65
+ PARSE_CACHE.set(key, verts);
66
+ notifyCacheChange();
67
+ } catch (error) {
68
+ // Cache empty on failure so we don't retry a doomed parse every tick.
69
+ // eslint-disable-next-line no-console
70
+ console.warn('[useGridLines3D] parse failed:', error);
71
+ PARSE_CACHE.set(key, EMPTY_F32);
72
+ notifyCacheChange();
73
+ } finally {
74
+ PARSE_INFLIGHT.delete(key);
75
+ }
76
+ })();
77
+ PARSE_INFLIGHT.set(key, promise);
78
+ }
79
+ }
80
+
81
+ /** Read the active store set from the viewer store. Federation-aware. */
82
+ function useActiveStores(): IfcDataStore[] {
83
+ const { models, ifcDataStore } = useViewerStore(
84
+ useShallow((s) => ({ models: s.models, ifcDataStore: s.ifcDataStore })),
85
+ );
86
+ return useMemo(() => {
87
+ const out: IfcDataStore[] = [];
88
+ if (models.size > 0) {
89
+ for (const [, m] of models) if (m.ifcDataStore) out.push(m.ifcDataStore);
90
+ } else if (ifcDataStore) {
91
+ out.push(ifcDataStore);
92
+ }
93
+ return out;
94
+ }, [models, ifcDataStore]);
95
+ }
96
+
97
+ /**
98
+ * Sample every loaded model's IfcGridAxis lines into a single flat
99
+ * `[x0,y0,z0, x1,y1,z1, …]` line-list in renderer world space (Y-up,
100
+ * RTC-subtracted, metres). Returns a stable empty array when no model carries a
101
+ * grid. Parsing is unconditional + cached; the Viewport gates rendering on the
102
+ * `ifcGrid` type-visibility toggle.
103
+ */
104
+ export function useGridLines3D(): Float32Array {
105
+ const stores = useActiveStores();
106
+ const [version, setVersion] = useState(0);
107
+
108
+ useEffect(() => {
109
+ ensureParseFor(stores);
110
+ const listener: CacheListener = () => setVersion((v) => v + 1);
111
+ CACHE_LISTENERS.add(listener);
112
+ return () => {
113
+ CACHE_LISTENERS.delete(listener);
114
+ };
115
+ }, [stores]);
116
+
117
+ return useMemo(() => {
118
+ void version; // depend on parse-completion ticks
119
+ const arrays: Float32Array[] = [];
120
+ let total = 0;
121
+ for (const store of stores) {
122
+ const key = sourceKey(store);
123
+ if (!key) continue;
124
+ const cached = PARSE_CACHE.get(key);
125
+ if (cached && cached.length > 0) {
126
+ arrays.push(cached);
127
+ total += cached.length;
128
+ }
129
+ }
130
+ if (total === 0) return EMPTY_F32;
131
+ if (arrays.length === 1) return arrays[0];
132
+ const merged = new Float32Array(total);
133
+ let offset = 0;
134
+ for (const a of arrays) {
135
+ merged.set(a, offset);
136
+ offset += a.length;
137
+ }
138
+ return merged;
139
+ }, [stores, version]);
140
+ }