@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
@@ -40,7 +40,7 @@ import {
40
40
  } from './ingest/pointCloudIngest.js';
41
41
  import { getGlobalRenderer } from './useBCF.js';
42
42
  import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
43
- import { getEffectiveGeoreference, getEffectiveHorizontalScale, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
43
+ import { getEffectiveGeoreference, getEffectiveHorizontalScale, hasStandardGeoreferencing, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
44
44
  import { resolveMapUnitToMetreScale } from '../lib/geo/geo-scale.js';
45
45
  import { resolveProjection } from '../lib/geo/reproject.js';
46
46
  import { toast } from '../components/ui/toast.js';
@@ -105,7 +105,21 @@ function extractModelGeoref(
105
105
  mutations?: GeorefMutationDataLike,
106
106
  ): ModelGeoref | null {
107
107
  const georef = getEffectiveGeoreference(dataStore, coordinateInfo, mutations);
108
- if (!georef?.mapConversion || !georef.projectedCRS?.name) return null;
108
+ // Only TRUE georeferencing (real IfcMapConversion + IfcProjectedCRS) may drive
109
+ // federation alignment. A file with no IfcMapConversion gets a synthesised
110
+ // `source: 'siteLocation'` georef (EPSG:4326 from IfcSite RefLatitude/Longitude/
111
+ // Elevation) so it can still be pinned on the location map — but those are
112
+ // geographic degrees plus a raw, un-unit-scaled site elevation, not a projected
113
+ // metric frame. buildGeorefAlignmentTransform assumes projected eastings/
114
+ // northings/height in metres, so feeding it site data places the second model
115
+ // kilometres away: the BIMcollab ARC/STR pair share a site GUID but carry
116
+ // RefElevation 0 vs 20000 mm, and the height term lands ARC ~20 km below STR.
117
+ // Such models have no real georef relationship, so leave them in their own local
118
+ // frames where they overlay correctly. hasStandardGeoreferencing() excludes
119
+ // 'siteLocation' (see effective-georef.test.ts). (Regression from #658.)
120
+ if (!hasStandardGeoreferencing(georef) || !georef?.mapConversion || !georef.projectedCRS?.name) {
121
+ return null;
122
+ }
109
123
  return {
110
124
  mapConversion: georef.mapConversion,
111
125
  projectedCRS: georef.projectedCRS,
@@ -55,6 +55,7 @@ import { useIfcCache, getCached } from './useIfcCache.js';
55
55
  import { useIfcServer } from './useIfcServer.js';
56
56
 
57
57
  import { getMaxExpressId, parseGlbViewerModel, parseIfcxViewerModel } from './ingest/viewerModelIngest.js';
58
+ import { boundedIteratorReturn } from './ingest/streamCleanup.js';
58
59
  import { detectPointCloudFormat, ingestPointCloud } from './ingest/pointCloudIngest.js';
59
60
  import { getGlobalRenderer } from './useBCF.js';
60
61
 
@@ -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
- try {
2057
- // `AsyncIterator.return()` is signed as taking a value in
2058
- // current TS libs; callers conventionally pass `undefined`.
2059
- await geometryIterator.return(undefined);
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) {
@@ -0,0 +1,308 @@
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
+ * localStorage persistence for clash detection settings + the user's rule preset
7
+ * set. Mirrors the lens slice's "built-ins + overrides + custom" model and the
8
+ * scripts module's quota-safe `SaveResult`:
9
+ *
10
+ * - Presets: the built-in `CLASH_RULE_PRESETS` are always present (projected to
11
+ * editable items with `enabled`/`builtin`); the user may toggle/edit them
12
+ * (stored as overrides) and add custom presets. Only customs + modified
13
+ * built-ins are persisted, so shipping a new built-in just works.
14
+ * - Settings: one flat JSON blob (mode/tolerance/clearance/clusterEpsilon/
15
+ * reportTouch/groupBy), every numeric clamped to a sane range on load.
16
+ */
17
+
18
+ import {
19
+ CLASH_RULE_PRESETS,
20
+ type ClashRulePreset,
21
+ type ClashMode,
22
+ type ClashSeverity,
23
+ } from '@ifc-lite/clash';
24
+
25
+ /** A built-in or user-defined clash rule preset, with editor/runtime flags. */
26
+ export type ClashPreset = ClashRulePreset & { enabled: boolean; builtin: boolean };
27
+
28
+ /** How the panel groups the flat clash list (display only). */
29
+ export type ClashSettingsGroupBy = 'severity' | 'rule' | 'typePair';
30
+
31
+ /** Global detection settings, persisted as one blob. */
32
+ export interface ClashGlobalSettings {
33
+ mode: ClashMode;
34
+ tolerance: number;
35
+ clearance: number;
36
+ clusterEpsilon: number;
37
+ reportTouch: boolean;
38
+ groupBy: ClashSettingsGroupBy;
39
+ }
40
+
41
+ export type SaveResult =
42
+ | { ok: true }
43
+ | { ok: false; reason: 'quota' | 'serialize' | 'too_many'; message: string };
44
+
45
+ const PRESETS_KEY = 'ifc-lite-clash-presets';
46
+ const SETTINGS_KEY = 'ifc-lite-clash-settings';
47
+ const SCHEMA_VERSION = 1;
48
+
49
+ const MAX_PRESETS = 200;
50
+ const MAX_NAME = 100;
51
+
52
+ /** [min, max] clamps applied to settings numerics on load and on commit. */
53
+ export const CLASH_BOUNDS = {
54
+ tolerance: [0, 1] as const,
55
+ clearance: [0, 5] as const,
56
+ clusterEpsilon: [0.01, 50] as const,
57
+ };
58
+
59
+ export const DEFAULT_CLASH_SETTINGS: ClashGlobalSettings = {
60
+ mode: 'hard',
61
+ tolerance: 0.002,
62
+ clearance: 0.05,
63
+ clusterEpsilon: 1.5,
64
+ reportTouch: false,
65
+ groupBy: 'severity',
66
+ };
67
+
68
+ const BUILTIN_PRESET_IDS = new Set(CLASH_RULE_PRESETS.map((p) => p.id));
69
+ const SEVERITIES: ClashSeverity[] = ['critical', 'major', 'minor', 'info'];
70
+ const GROUP_BYS: ClashSettingsGroupBy[] = ['severity', 'rule', 'typePair'];
71
+
72
+ export function clampToBounds(value: unknown, [min, max]: readonly [number, number], fallback: number): number {
73
+ const n = typeof value === 'number' ? value : Number(value);
74
+ if (!Number.isFinite(n)) return fallback;
75
+ return Math.min(max, Math.max(min, n));
76
+ }
77
+
78
+ /** Trim + length-cap a preset name; null if empty (invalid). */
79
+ export function validatePresetName(name: string): string | null {
80
+ const t = name.trim();
81
+ return t ? t.slice(0, MAX_NAME) : null;
82
+ }
83
+
84
+ /** Trim a selector; null if empty (invalid). An empty selector matches everything. */
85
+ export function validateSelector(selector: string): string | null {
86
+ const t = selector.trim();
87
+ return t ? t : null;
88
+ }
89
+
90
+ function isValidStoredPreset(p: unknown): p is ClashPreset {
91
+ if (!p || typeof p !== 'object') return false;
92
+ const r = p as Record<string, unknown>;
93
+ return (
94
+ typeof r.id === 'string' && r.id.length > 0 &&
95
+ typeof r.name === 'string' && r.name.trim().length > 0 &&
96
+ typeof r.selectorA === 'string' && r.selectorA.trim().length > 0 &&
97
+ typeof r.selectorB === 'string' && r.selectorB.trim().length > 0 &&
98
+ typeof r.severity === 'string' && SEVERITIES.includes(r.severity as ClashSeverity)
99
+ );
100
+ }
101
+
102
+ /** Read stored presets, accepting the versioned wrapper or a legacy bare array. */
103
+ function readStoredPresets(): ClashPreset[] {
104
+ try {
105
+ const raw = localStorage.getItem(PRESETS_KEY);
106
+ if (!raw) return [];
107
+ const parsed: unknown = JSON.parse(raw);
108
+ const list = Array.isArray(parsed)
109
+ ? parsed
110
+ : parsed && typeof parsed === 'object' && Array.isArray((parsed as { presets?: unknown }).presets)
111
+ ? (parsed as { presets: unknown[] }).presets
112
+ : [];
113
+ return list
114
+ .filter(isValidStoredPreset)
115
+ .map((p) => ({
116
+ id: p.id,
117
+ name: p.name,
118
+ description: typeof p.description === 'string' ? p.description : '',
119
+ severity: p.severity,
120
+ selectorA: p.selectorA,
121
+ selectorB: p.selectorB,
122
+ enabled: p.enabled !== false,
123
+ builtin: BUILTIN_PRESET_IDS.has(p.id),
124
+ }));
125
+ } catch {
126
+ return [];
127
+ }
128
+ }
129
+
130
+ /** The pristine built-in preset set, no overrides/customs — the "reset" target. */
131
+ export function defaultPresets(): ClashPreset[] {
132
+ return CLASH_RULE_PRESETS.map((p) => ({ ...p, enabled: true, builtin: true }));
133
+ }
134
+
135
+ /**
136
+ * The full preset list shown to the user: every built-in (with any saved
137
+ * override applied) followed by custom presets. Built-ins are always present
138
+ * even if storage is empty or dropped them.
139
+ */
140
+ /**
141
+ * Resolve a stored (customs + modified-built-ins) list into the full preset
142
+ * list: every built-in (with any override applied), then customs. Built-ins are
143
+ * always present, so a list from an older app version still picks up new ones.
144
+ */
145
+ export function mergeStoredPresets(stored: ClashPreset[]): ClashPreset[] {
146
+ const overrides = new Map(stored.filter((p) => p.builtin).map((p) => [p.id, p]));
147
+ const builtins: ClashPreset[] = CLASH_RULE_PRESETS.map(
148
+ (p) => overrides.get(p.id) ?? { ...p, enabled: true, builtin: true },
149
+ );
150
+ const custom = stored.filter((p) => !p.builtin);
151
+ return [...builtins, ...custom];
152
+ }
153
+
154
+ export function buildInitialPresets(): ClashPreset[] {
155
+ return mergeStoredPresets(readStoredPresets());
156
+ }
157
+
158
+ function builtinDiffersFromDefault(p: ClashPreset): boolean {
159
+ const orig = CLASH_RULE_PRESETS.find((b) => b.id === p.id);
160
+ if (!orig) return true;
161
+ return (
162
+ !p.enabled ||
163
+ p.name !== orig.name ||
164
+ p.severity !== orig.severity ||
165
+ p.selectorA !== orig.selectorA ||
166
+ p.selectorB !== orig.selectorB ||
167
+ p.description !== orig.description
168
+ );
169
+ }
170
+
171
+ /** The minimal stored shape: customs + only the built-ins that differ from default. */
172
+ export function presetsToStore(presets: ClashPreset[]): ClashPreset[] {
173
+ return [
174
+ ...presets.filter((p) => !p.builtin),
175
+ ...presets.filter((p) => p.builtin && builtinDiffersFromDefault(p)),
176
+ ];
177
+ }
178
+
179
+ /** Persist only custom presets + modified built-ins (quota-safe). */
180
+ export function savePresets(presets: ClashPreset[]): SaveResult {
181
+ const custom = presets.filter((p) => !p.builtin);
182
+ if (custom.length > MAX_PRESETS) {
183
+ return { ok: false, reason: 'too_many', message: `Too many custom rules (max ${MAX_PRESETS}).` };
184
+ }
185
+ const toStore = presetsToStore(presets);
186
+ let payload: string;
187
+ try {
188
+ payload = JSON.stringify({ schemaVersion: SCHEMA_VERSION, presets: toStore });
189
+ } catch {
190
+ return { ok: false, reason: 'serialize', message: 'Could not serialize clash rules.' };
191
+ }
192
+ try {
193
+ localStorage.setItem(PRESETS_KEY, payload);
194
+ return { ok: true };
195
+ } catch {
196
+ return { ok: false, reason: 'quota', message: 'Browser storage is full — clash rules were not saved.' };
197
+ }
198
+ }
199
+
200
+ /** Coerce arbitrary input into valid, bounds-clamped settings (defaults on junk). */
201
+ export function normalizeSettings(raw: unknown): ClashGlobalSettings {
202
+ const s = (raw && typeof raw === 'object' && 'settings' in raw
203
+ ? (raw as { settings: unknown }).settings
204
+ : raw) as Partial<ClashGlobalSettings> | null;
205
+ if (!s || typeof s !== 'object') return { ...DEFAULT_CLASH_SETTINGS };
206
+ return {
207
+ mode: s.mode === 'clearance' ? 'clearance' : 'hard',
208
+ tolerance: clampToBounds(s.tolerance, CLASH_BOUNDS.tolerance, DEFAULT_CLASH_SETTINGS.tolerance),
209
+ clearance: clampToBounds(s.clearance, CLASH_BOUNDS.clearance, DEFAULT_CLASH_SETTINGS.clearance),
210
+ clusterEpsilon: clampToBounds(s.clusterEpsilon, CLASH_BOUNDS.clusterEpsilon, DEFAULT_CLASH_SETTINGS.clusterEpsilon),
211
+ reportTouch: s.reportTouch === true,
212
+ groupBy: GROUP_BYS.includes(s.groupBy as ClashSettingsGroupBy) ? (s.groupBy as ClashSettingsGroupBy) : 'severity',
213
+ };
214
+ }
215
+
216
+ export function loadSettings(): ClashGlobalSettings {
217
+ try {
218
+ const raw = localStorage.getItem(SETTINGS_KEY);
219
+ if (!raw) return { ...DEFAULT_CLASH_SETTINGS };
220
+ return normalizeSettings(JSON.parse(raw));
221
+ } catch {
222
+ return { ...DEFAULT_CLASH_SETTINGS };
223
+ }
224
+ }
225
+
226
+ export function saveSettings(settings: ClashGlobalSettings): SaveResult {
227
+ try {
228
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify({ schemaVersion: SCHEMA_VERSION, settings }));
229
+ return { ok: true };
230
+ } catch {
231
+ return { ok: false, reason: 'quota', message: 'Browser storage is full — clash settings were not saved.' };
232
+ }
233
+ }
234
+
235
+ /** Download the user's presets (customs + modified built-ins) as a JSON file. */
236
+ export function exportPresets(presets: ClashPreset[]): void {
237
+ const custom = presets.filter((p) => !p.builtin || builtinDiffersFromDefault(p));
238
+ const blob = new Blob([JSON.stringify({ schemaVersion: SCHEMA_VERSION, presets: custom }, null, 2)], {
239
+ type: 'application/json',
240
+ });
241
+ const url = URL.createObjectURL(blob);
242
+ const a = document.createElement('a');
243
+ a.href = url;
244
+ a.download = 'clash-rules.clash-presets.json';
245
+ a.click();
246
+ URL.revokeObjectURL(url);
247
+ }
248
+
249
+ /** Parse an exported file into custom presets (ids regenerated, `builtin` stripped). */
250
+ export async function importPresets(file: File): Promise<ClashPreset[]> {
251
+ const text = await file.text();
252
+ const parsed: unknown = JSON.parse(text);
253
+ const list = Array.isArray(parsed)
254
+ ? parsed
255
+ : parsed && typeof parsed === 'object' && Array.isArray((parsed as { presets?: unknown }).presets)
256
+ ? (parsed as { presets: unknown[] }).presets
257
+ : [];
258
+ return list.filter(isValidStoredPreset).map((p) => ({
259
+ id: `custom-${crypto.randomUUID()}`,
260
+ name: p.name.slice(0, MAX_NAME),
261
+ description: typeof p.description === 'string' ? p.description : '',
262
+ severity: p.severity,
263
+ selectorA: p.selectorA,
264
+ selectorB: p.selectorB,
265
+ enabled: p.enabled !== false,
266
+ builtin: false,
267
+ }));
268
+ }
269
+
270
+ // ── Flavor integration ───────────────────────────────────────────────────────
271
+ // Clash config rides inside a flavor's generic `settings.clash` blob, so each
272
+ // flavor/profile carries its own rule-set + detection settings (and they travel
273
+ // with flavor export/import). Serialize stores the minimal shape (customs +
274
+ // modified built-ins + settings); deserialize rebuilds the full, validated state.
275
+
276
+ /** Plain-JSON snapshot of clash config stored in a flavor. */
277
+ export interface ClashFlavorConfig {
278
+ schemaVersion: number;
279
+ settings: ClashGlobalSettings;
280
+ /** Customs + modified built-ins only (built-ins are re-merged on restore). */
281
+ presets: ClashPreset[];
282
+ }
283
+
284
+ export function serializeClashConfig(presets: ClashPreset[], settings: ClashGlobalSettings): ClashFlavorConfig {
285
+ return { schemaVersion: SCHEMA_VERSION, settings: { ...settings }, presets: presetsToStore(presets) };
286
+ }
287
+
288
+ /**
289
+ * Rebuild clash state from a flavor blob: the full resolved preset list (defaults
290
+ * + the blob's overrides/customs) and bounds-clamped settings. Returns null when
291
+ * the blob is missing/garbage so the caller can skip the restore.
292
+ */
293
+ export function deserializeClashConfig(blob: unknown): { presets: ClashPreset[]; settings: ClashGlobalSettings } | null {
294
+ if (!blob || typeof blob !== 'object') return null;
295
+ const b = blob as Partial<ClashFlavorConfig>;
296
+ const storedRaw = Array.isArray(b.presets) ? b.presets : [];
297
+ const stored = storedRaw.filter(isValidStoredPreset).map((p) => ({
298
+ id: p.id,
299
+ name: p.name,
300
+ description: typeof p.description === 'string' ? p.description : '',
301
+ severity: p.severity,
302
+ selectorA: p.selectorA,
303
+ selectorB: p.selectorB,
304
+ enabled: p.enabled !== false,
305
+ builtin: BUILTIN_PRESET_IDS.has(p.id),
306
+ }));
307
+ return { presets: mergeStoredPresets(stored), settings: normalizeSettings(b.settings) };
308
+ }
@@ -8,6 +8,7 @@ import assert from 'node:assert';
8
8
  import {
9
9
  detectScaleUnitMismatch,
10
10
  getEffectiveHorizontalScale,
11
+ hasStandardGeoreferencing,
11
12
  inferMapUnitScale,
12
13
  mergeMapConversion,
13
14
  mergeProjectedCRS,
@@ -237,4 +238,69 @@ describe('effective georeferencing', () => {
237
238
  assert.strictEqual(m!.effectiveScale, 2);
238
239
  });
239
240
  });
241
+
242
+ describe('hasStandardGeoreferencing (federation alignment gate)', () => {
243
+ // Federation affine alignment (extractModelGeoref → buildGeorefAlignmentTransform)
244
+ // gates on this predicate. A site-location-only georef must NOT qualify: it is
245
+ // EPSG:4326 lat/long degrees + a raw, un-unit-scaled IfcSite RefElevation, which
246
+ // the projected-CRS transform misreads as metres and flings the second federated
247
+ // model kilometres away. These tests lock that invariant.
248
+ const mapConversion: MapConversion = {
249
+ id: 1,
250
+ sourceCRS: 0,
251
+ targetCRS: 0,
252
+ eastings: 100,
253
+ northings: 200,
254
+ orthogonalHeight: 5,
255
+ };
256
+
257
+ it('rejects synthesised site-location georef even with a CRS name + map conversion', () => {
258
+ assert.strictEqual(
259
+ hasStandardGeoreferencing({
260
+ source: 'siteLocation',
261
+ projectedCRS: { id: 1, name: 'EPSG:4326' },
262
+ mapConversion,
263
+ }),
264
+ false,
265
+ );
266
+ });
267
+
268
+ it('accepts true IfcMapConversion + IfcProjectedCRS georef', () => {
269
+ assert.strictEqual(
270
+ hasStandardGeoreferencing({
271
+ source: 'mapConversion',
272
+ projectedCRS: { id: 1, name: 'EPSG:28992' },
273
+ mapConversion,
274
+ }),
275
+ true,
276
+ );
277
+ });
278
+
279
+ it('rejects georef missing a map conversion', () => {
280
+ assert.strictEqual(
281
+ hasStandardGeoreferencing({
282
+ source: 'mapConversion',
283
+ projectedCRS: { id: 1, name: 'EPSG:28992' },
284
+ mapConversion: undefined,
285
+ }),
286
+ false,
287
+ );
288
+ });
289
+
290
+ it('rejects georef missing a projected CRS name', () => {
291
+ assert.strictEqual(
292
+ hasStandardGeoreferencing({
293
+ source: 'mapConversion',
294
+ projectedCRS: { id: 1, name: '' },
295
+ mapConversion,
296
+ }),
297
+ false,
298
+ );
299
+ });
300
+
301
+ it('rejects null / undefined', () => {
302
+ assert.strictEqual(hasStandardGeoreferencing(null), false);
303
+ assert.strictEqual(hasStandardGeoreferencing(undefined), false);
304
+ });
305
+ });
240
306
  });
@@ -0,0 +1,41 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Model length-unit → metres scale, memoised per `IfcDataStore`.
7
+ *
8
+ * The viewer's render + authoring space is **metres**: the geometry
9
+ * pipeline bakes the file's length-unit scale into tessellated vertices,
10
+ * raycast hit-points come back in metres, and `spatialHierarchy.
11
+ * storeyElevations` are pre-scaled. But raw coordinate reads straight off
12
+ * the STEP model — split footprints, placement chains — arrive in the
13
+ * file's **native** units (e.g. millimetres). Multiply those by this
14
+ * factor to bring them into the same metre space as everything else.
15
+ *
16
+ * Returns `1` when the scale can't be determined (already-metres models,
17
+ * or bounded-geometry mode having released the source buffer) — the
18
+ * safe identity that leaves native-unit reads untouched.
19
+ */
20
+
21
+ import { extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
22
+
23
+ const scaleCache = new WeakMap<IfcDataStore, number>();
24
+
25
+ export function getModelLengthUnitScale(dataStore: IfcDataStore | null | undefined): number {
26
+ if (!dataStore) return 1;
27
+ const cached = scaleCache.get(dataStore);
28
+ if (cached !== undefined) return cached;
29
+
30
+ // The columnar parser stashes the scale on the store; the wasm fast
31
+ // path does not, so fall back to extracting it from the source bytes.
32
+ let scale = typeof dataStore.lengthUnitScale === 'number' ? dataStore.lengthUnitScale : undefined;
33
+ if (scale === undefined || !Number.isFinite(scale) || scale <= 0) {
34
+ if (!dataStore.source?.length || !dataStore.entityIndex) return 1;
35
+ scale = extractLengthUnitScale(dataStore.source, dataStore.entityIndex);
36
+ }
37
+ if (!Number.isFinite(scale) || scale <= 0) scale = 1;
38
+
39
+ scaleCache.set(dataStore, scale);
40
+ return scale;
41
+ }
@@ -11,10 +11,31 @@
11
11
  * and Tag which are not stored during the fast initial parse.
12
12
  */
13
13
 
14
- import type { IfcDataStore } from '@ifc-lite/parser';
15
- import { extractPropertiesOnDemand, extractQuantitiesOnDemand, extractEntityAttributesOnDemand } from '@ifc-lite/parser';
14
+ import type { IfcDataStore, MaterialInfo } from '@ifc-lite/parser';
15
+ import {
16
+ extractPropertiesOnDemand,
17
+ extractQuantitiesOnDemand,
18
+ extractEntityAttributesOnDemand,
19
+ extractMaterialsOnDemand,
20
+ extractClassificationsOnDemand,
21
+ } from '@ifc-lite/parser';
16
22
  import type { PropertySet, QuantitySet } from '@ifc-lite/data';
17
- import type { ListDataProvider } from '@ifc-lite/lists';
23
+ import { ENTITY_ATTRIBUTES } from '@ifc-lite/lists';
24
+ import type { ListDataProvider, ListClassificationRef, DiscoveredColumns } from '@ifc-lite/lists';
25
+
26
+ /** Collect every material-name string an element exposes — top-level
27
+ * material plus layer / constituent / profile names and list members. */
28
+ function materialNamesOf(info: MaterialInfo | null): string[] {
29
+ if (!info) return [];
30
+ const names: string[] = [];
31
+ const push = (s: string | undefined) => { if (s) names.push(s); };
32
+ push(info.name);
33
+ for (const l of info.layers ?? []) { push(l.materialName); push(l.name); }
34
+ for (const c of info.constituents ?? []) { push(c.materialName); push(c.name); }
35
+ for (const p of info.profiles ?? []) { push(p.materialName); push(p.name); }
36
+ for (const m of info.materials ?? []) push(m.name);
37
+ return names;
38
+ }
18
39
 
19
40
  /**
20
41
  * Create a ListDataProvider backed by an IfcDataStore.
@@ -26,6 +47,10 @@ export function createListDataProvider(store: IfcDataStore): ListDataProvider {
26
47
  // but are needed for list display. Cache avoids re-parsing per column.
27
48
  const attrCache = new Map<number, { description: string; objectType: string; tag: string }>();
28
49
 
50
+ // Lazily materialised list of every non-empty express id — used for
51
+ // class-less list targeting. Cached because the provider outlives a run.
52
+ let allIdsCache: number[] | null = null;
53
+
29
54
  function getOnDemandAttrs(id: number): { description: string; objectType: string; tag: string } {
30
55
  const cached = attrCache.get(id);
31
56
  if (cached) return cached;
@@ -42,6 +67,23 @@ export function createListDataProvider(store: IfcDataStore): ListDataProvider {
42
67
  return empty;
43
68
  }
44
69
 
70
+ // Complete column discovery is cached — the provider outlives a builder
71
+ // open, and the scan touches every entity that declares a pset/qto.
72
+ let columnsCache: DiscoveredColumns | null = null;
73
+
74
+ const usesOnDemandProps = !!store.onDemandPropertyMap && store.source?.length > 0;
75
+ const usesOnDemandQtos = !!store.onDemandQuantityMap && store.source?.length > 0;
76
+
77
+ function getPropertySetsFor(entityId: number): PropertySet[] {
78
+ if (usesOnDemandProps) return extractPropertiesOnDemand(store, entityId) as PropertySet[];
79
+ return store.properties?.getForEntity(entityId) ?? [];
80
+ }
81
+
82
+ function getQuantitySetsFor(entityId: number): QuantitySet[] {
83
+ if (usesOnDemandQtos) return extractQuantitiesOnDemand(store, entityId) as QuantitySet[];
84
+ return store.quantities?.getForEntity(entityId) ?? [];
85
+ }
86
+
45
87
  return {
46
88
  getEntitiesByType: (type) => store.entities.getByType(type),
47
89
 
@@ -52,18 +94,101 @@ export function createListDataProvider(store: IfcDataStore): ListDataProvider {
52
94
  getEntityTag: (id) => getOnDemandAttrs(id).tag,
53
95
  getEntityTypeName: (id) => store.entities.getTypeName(id),
54
96
 
55
- getPropertySets(entityId: number): PropertySet[] {
56
- if (store.onDemandPropertyMap && store.source?.length > 0) {
57
- return extractPropertiesOnDemand(store, entityId) as PropertySet[];
97
+ getPropertySets: getPropertySetsFor,
98
+ getQuantitySets: getQuantitySetsFor,
99
+
100
+ getAllEntityIds(): number[] {
101
+ if (allIdsCache) return allIdsCache;
102
+ // Restrict "all elements" to geometry-bearing (selectable) products.
103
+ // The raw expressId column also holds relationships, property sets,
104
+ // materials, classifications and other non-element records — a
105
+ // class-less list should not surface those as rows.
106
+ const ids: number[] = [];
107
+ const col = store.entities.expressId;
108
+ for (let i = 0; i < col.length; i++) {
109
+ const id = col[i];
110
+ if (id && store.entities.hasGeometry(id)) ids.push(id);
58
111
  }
59
- return store.properties?.getForEntity(entityId) ?? [];
112
+ allIdsCache = ids;
113
+ return ids;
114
+ },
115
+
116
+ getMaterialNames(entityId: number): string[] {
117
+ return materialNamesOf(extractMaterialsOnDemand(store, entityId));
118
+ },
119
+
120
+ getClassifications(entityId: number): ListClassificationRef[] {
121
+ return extractClassificationsOnDemand(store, entityId).map((c) => ({
122
+ system: c.system,
123
+ code: c.identification,
124
+ name: c.name,
125
+ }));
60
126
  },
61
127
 
62
- getQuantitySets(entityId: number): QuantitySet[] {
63
- if (store.onDemandQuantityMap && store.source?.length > 0) {
64
- return extractQuantitiesOnDemand(store, entityId) as QuantitySet[];
128
+ getStoreyName(entityId: number): string {
129
+ const hierarchy = store.spatialHierarchy;
130
+ if (!hierarchy) return '';
131
+ const storeyId = hierarchy.elementToStorey.get(entityId);
132
+ if (!storeyId) return '';
133
+ return store.entities.getName(storeyId) || '';
134
+ },
135
+
136
+ discoverAllColumns(): DiscoveredColumns {
137
+ if (columnsCache) return columnsCache;
138
+
139
+ const properties = new Map<string, Set<string>>();
140
+ const quantities = new Map<string, Set<string>>();
141
+
142
+ const ingestProps = (id: number) => {
143
+ for (const set of getPropertySetsFor(id)) {
144
+ if (!set.name) continue;
145
+ let bucket = properties.get(set.name);
146
+ if (!bucket) { bucket = new Set(); properties.set(set.name, bucket); }
147
+ for (const p of set.properties) if (p.name) bucket.add(p.name);
148
+ }
149
+ };
150
+ const ingestQtos = (id: number) => {
151
+ for (const set of getQuantitySetsFor(id)) {
152
+ if (!set.name) continue;
153
+ let bucket = quantities.get(set.name);
154
+ if (!bucket) { bucket = new Set(); quantities.set(set.name, bucket); }
155
+ for (const q of set.quantities) if (q.name) bucket.add(q.name);
156
+ }
157
+ };
158
+
159
+ // On-demand path: scan exactly the entities that declare a pset/qto —
160
+ // the minimal complete set (every distinct set/property in the model).
161
+ if (usesOnDemandProps && store.onDemandPropertyMap) {
162
+ for (const id of store.onDemandPropertyMap.keys()) ingestProps(id);
163
+ }
164
+ if (usesOnDemandQtos && store.onDemandQuantityMap) {
165
+ for (const id of store.onDemandQuantityMap.keys()) ingestQtos(id);
65
166
  }
66
- return store.quantities?.getForEntity(entityId) ?? [];
167
+ // Table path (e.g. server-loaded models): scan the entity column using
168
+ // the pre-built tables. Capped so it can't run away on huge models.
169
+ if (!usesOnDemandProps || !usesOnDemandQtos) {
170
+ const col = store.entities.expressId;
171
+ const CAP = 100_000;
172
+ for (let i = 0, seen = 0; i < col.length && seen < CAP; i++) {
173
+ const id = col[i];
174
+ if (!id) continue;
175
+ seen++;
176
+ if (!usesOnDemandProps) ingestProps(id);
177
+ if (!usesOnDemandQtos) ingestQtos(id);
178
+ }
179
+ }
180
+
181
+ const toSorted = (m: Map<string, Set<string>>) => {
182
+ const out = new Map<string, string[]>();
183
+ for (const [k, s] of m) out.set(k, Array.from(s).sort());
184
+ return out;
185
+ };
186
+ columnsCache = {
187
+ attributes: [...ENTITY_ATTRIBUTES],
188
+ properties: toSorted(properties),
189
+ quantities: toSorted(quantities),
190
+ };
191
+ return columnsCache;
67
192
  },
68
193
  };
69
194
  }