@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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +30 -27
  2. package/CHANGELOG.md +81 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-ZpTYWE3K.js} +6 -6
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-Ctcu_Sc2.js} +5 -5
  5. package/dist/assets/{deflate-Cfp9t1Df.js → deflate-Cnx0il6E.js} +1 -1
  6. package/dist/assets/{exporters-DfSvJPi4.js → exporters-DSq76AVM.js} +272 -245
  7. package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
  8. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-A5UjhI6L.js} +10 -10
  9. package/dist/assets/{ids-Cu73hD0Y.js → ids-DiLcGTer.js} +21 -21
  10. package/dist/assets/{ifc-lite_bg-ksLBP5cA.wasm → ifc-lite_bg-CEZnhM2e.wasm} +0 -0
  11. package/dist/assets/index-B9Ug2EqU.css +1 -0
  12. package/dist/assets/{index-WSbA5iy6.js → index-BAH8IJVR.js} +35946 -33456
  13. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-BzSkwo5D.js} +1 -1
  14. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-Cg2Rz-D5.js} +1 -1
  15. package/dist/assets/{lzw-C9z0fG2o.js → lzw-BBPPLW-0.js} +1 -1
  16. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-CPojOeGE.js} +1 -1
  17. package/dist/assets/{packbits-jfwifz7C.js → packbits-yLSpjW-V.js} +1 -1
  18. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-8md211IW.js} +2 -2
  19. package/dist/assets/raw-BQrAgxwT.js +1 -0
  20. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-CsRXlgCO.js} +4102 -2658
  21. package/dist/assets/{server-client-Ctk8_Bof.js → server-client-Bk4c1CPO.js} +1 -1
  22. package/dist/assets/{webimage-XFHVyVtC.js → webimage-YafxjjGr.js} +1 -1
  23. package/dist/assets/{zstd-3q5qcl5V.js → zstd-CkSLOiuu.js} +1 -1
  24. package/dist/index.html +7 -7
  25. package/package.json +7 -6
  26. package/src/components/extensions/FlavorDialog.tsx +18 -2
  27. package/src/components/extensions/FlavorListView.tsx +12 -3
  28. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  29. package/src/components/viewer/ClashPanel.tsx +370 -0
  30. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  31. package/src/components/viewer/CommandPalette.tsx +14 -15
  32. package/src/components/viewer/MainToolbar.tsx +155 -175
  33. package/src/components/viewer/ViewerLayout.tsx +5 -0
  34. package/src/components/viewer/Viewport.tsx +49 -9
  35. package/src/components/viewer/ViewportContainer.tsx +45 -3
  36. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  37. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  38. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  39. package/src/hooks/ingest/streamCleanup.ts +45 -0
  40. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  41. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  42. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  43. package/src/hooks/useAlignmentLines3D.ts +164 -0
  44. package/src/hooks/useClash.ts +420 -0
  45. package/src/hooks/useIfcFederation.ts +16 -2
  46. package/src/hooks/useIfcLoader.ts +5 -7
  47. package/src/lib/clash/persistence.ts +308 -0
  48. package/src/lib/geo/effective-georef.test.ts +66 -0
  49. package/src/services/extensions/host.ts +13 -0
  50. package/src/store/constants.ts +33 -25
  51. package/src/store/index.ts +29 -8
  52. package/src/store/slices/clashSlice.ts +251 -0
  53. package/src/store/slices/visibilitySlice.test.ts +23 -5
  54. package/src/store/slices/visibilitySlice.ts +18 -8
  55. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  56. package/dist/assets/index-Bws3UAkj.css +0 -1
  57. package/dist/assets/raw-R2QfzPAR.js +0 -1
@@ -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
  });
@@ -436,6 +436,19 @@ export class ExtensionHostService {
436
436
  } catch (err) {
437
437
  console.warn('[ext-host] lens restore on switch failed:', err);
438
438
  }
439
+ // Restore the flavor's clash config (rule-set + detection settings) from the
440
+ // opaque settings.clash blob, mirroring the lens roundtrip above. Missing /
441
+ // malformed blobs deserialize to null and are skipped (no-op).
442
+ try {
443
+ const { deserializeClashConfig } = await import('@/lib/clash/persistence');
444
+ const config = deserializeClashConfig((target.settings as Record<string, unknown> | undefined)?.clash);
445
+ if (config) {
446
+ const { useViewerStore } = await import('@/store');
447
+ useViewerStore.getState().applyClashFlavorConfig(config);
448
+ }
449
+ } catch (err) {
450
+ console.warn('[ext-host] clash restore on switch failed:', err);
451
+ }
439
452
  this.emit();
440
453
  }
441
454
 
@@ -6,6 +6,8 @@
6
6
  * Store constants - extracted magic numbers for maintainability
7
7
  */
8
8
 
9
+ import type { TypeVisibility } from './types.js';
10
+
9
11
  // ============================================================================
10
12
  // Camera Defaults
11
13
  // ============================================================================
@@ -182,37 +184,43 @@ function readPersistedBool(key: string, fallback: boolean): boolean {
182
184
  // on first load. IfcSite + IfcAnnotation + IfcGrid on — all three convey
183
185
  // design intent users expect to see by default. (Issue #862 split grid
184
186
  // into its own toggle so dense-grid models can hide grids without losing
185
- // dimensions/labels.)
186
- const SEMANTIC_DEFAULTS = {
187
+ // dimensions/labels.) Exported so the "Reset" action in the visibility
188
+ // menu can restore these without re-deriving them.
189
+ export const TYPE_VISIBILITY_SEMANTIC_DEFAULTS: TypeVisibility = {
187
190
  spaces: false,
188
191
  openings: false,
189
192
  site: true,
190
193
  ifcAnnotations: true,
191
194
  ifcGrid: true,
192
- } as const;
195
+ };
193
196
 
194
- export const TYPE_VISIBILITY_DEFAULTS = {
195
- /** IfcSpace visibility persisted across reloads. */
196
- SPACES: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.spaces, SEMANTIC_DEFAULTS.spaces),
197
- /** IfcOpeningElement visibilitypersisted across reloads. */
198
- OPENINGS: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.openings, SEMANTIC_DEFAULTS.openings),
199
- /** IfcSite visibility persisted across reloads. */
200
- SITE: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.site, SEMANTIC_DEFAULTS.site),
201
- /** IfcAnnotation visibility (text, dimensions, leaders) persisted. */
202
- IFC_ANNOTATIONS: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, SEMANTIC_DEFAULTS.ifcAnnotations),
203
- /**
204
- * IfcGrid visibility (axis lines + bubble tags) — persisted. Issue
205
- * #862. Migration: if the new key isn't set yet, fall back to the
206
- * legacy combined `ifcAnnotations` preference. That way a user who
207
- * previously turned the combined "Annotations & Grids" toggle off
208
- * keeps grids hidden after upgrade, instead of grids silently
209
- * reappearing (PR #868 review, chatgpt-codex P2).
210
- */
211
- IFC_GRID: readPersistedBool(
212
- TYPE_VISIBILITY_STORAGE_KEYS.ifcGrid,
213
- readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, SEMANTIC_DEFAULTS.ifcGrid),
214
- ),
215
- } as const;
197
+ /**
198
+ * Resolve the full type-visibility preference set from localStorage.
199
+ *
200
+ * Read fresh on EVERY call not captured once at module load. The store
201
+ * applies this both at boot (slice init) and on every new-file load
202
+ * (`resetViewerState`). A module-level constant would snapshot localStorage
203
+ * at first import and then go stale after the first in-session toggle, so
204
+ * loading a second model would silently revert the user's choices (e.g.
205
+ * "Show Annotations" flipping back on). Reading live keeps every toggle
206
+ * sticky across reloads AND across model swaps within a session.
207
+ */
208
+ export function getPersistedTypeVisibility(): TypeVisibility {
209
+ return {
210
+ spaces: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.spaces, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.spaces),
211
+ openings: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.openings, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.openings),
212
+ site: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.site, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.site),
213
+ ifcAnnotations: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.ifcAnnotations),
214
+ // Issue #862. Migration: if the new grid key isn't set yet, fall back to
215
+ // the legacy combined `ifcAnnotations` preference so a user who turned
216
+ // the old "Annotations & Grids" toggle off keeps grids hidden after
217
+ // upgrade instead of grids silently reappearing (PR #868 review).
218
+ ifcGrid: readPersistedBool(
219
+ TYPE_VISIBILITY_STORAGE_KEYS.ifcGrid,
220
+ readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.ifcGrid),
221
+ ),
222
+ };
223
+ }
216
224
 
217
225
  // ============================================================================
218
226
  // Data Defaults
@@ -33,6 +33,7 @@ import { createExtensionsSlice, type ExtensionsSlice } from './slices/extensions
33
33
  import { createListSlice, type ListSlice } from './slices/listSlice.js';
34
34
  import { createPinboardSlice, type PinboardSlice } from './slices/pinboardSlice.js';
35
35
  import { createLensSlice, type LensSlice } from './slices/lensSlice.js';
36
+ import { createClashSlice, type ClashSlice } from './slices/clashSlice.js';
36
37
  import { createScriptSlice, type ScriptSlice } from './slices/scriptSlice.js';
37
38
  import { createChatSlice, type ChatSlice } from './slices/chatSlice.js';
38
39
  import { createCesiumSlice, type CesiumSlice } from './slices/cesiumSlice.js';
@@ -49,7 +50,7 @@ import { createPointCloudSlice, type PointCloudSlice, POINT_CLOUD_DEFAULTS } fro
49
50
  import { invalidateVisibleBasketCache } from './basketVisibleSet.js';
50
51
 
51
52
  // Import constants for reset function
52
- import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS, TYPE_VISIBILITY_DEFAULTS } from './constants.js';
53
+ import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS, getPersistedTypeVisibility } from './constants.js';
53
54
 
54
55
  // Re-export types for consumers
55
56
  export type * from './types.js';
@@ -129,6 +130,7 @@ export type ViewerState = LoadingSlice &
129
130
  ListSlice &
130
131
  PinboardSlice &
131
132
  LensSlice &
133
+ ClashSlice &
132
134
  ScriptSlice &
133
135
  ChatSlice &
134
136
  CesiumSlice &
@@ -144,6 +146,16 @@ export type ViewerState = LoadingSlice &
144
146
  PointCloudSlice &
145
147
  ExtensionsSlice & {
146
148
  resetViewerState: () => void;
149
+ /**
150
+ * Open one right-side analysis panel and close the others, so the chosen
151
+ * panel is always the topmost/active one. The right panel renders a single
152
+ * mutually-exclusive chain (lens → clash → ids → bcf → extensions), so
153
+ * leaving a sibling flag set would keep the higher-precedence panel on top
154
+ * (the cause of "I have to close clash before I see BCF"). Also un-collapses
155
+ * the right panel. Routed through by the toolbar, command palette, and the
156
+ * BCF overlay so every entry point behaves identically.
157
+ */
158
+ openWorkspacePanel: (panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extensions') => void;
147
159
  };
148
160
 
149
161
  /**
@@ -169,6 +181,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
169
181
  ...createListSlice(...args),
170
182
  ...createPinboardSlice(...args),
171
183
  ...createLensSlice(...args),
184
+ ...createClashSlice(...args),
172
185
  ...createScriptSlice(...args),
173
186
  ...createChatSlice(...args),
174
187
  ...createCesiumSlice(...args),
@@ -203,13 +216,9 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
203
216
  hiddenEntities: new Set(),
204
217
  isolatedEntities: null,
205
218
  classFilter: null,
206
- typeVisibility: {
207
- spaces: TYPE_VISIBILITY_DEFAULTS.SPACES,
208
- openings: TYPE_VISIBILITY_DEFAULTS.OPENINGS,
209
- site: TYPE_VISIBILITY_DEFAULTS.SITE,
210
- ifcAnnotations: TYPE_VISIBILITY_DEFAULTS.IFC_ANNOTATIONS,
211
- ifcGrid: TYPE_VISIBILITY_DEFAULTS.IFC_GRID,
212
- },
219
+ // Re-read persisted toggles on every file load so a new model never
220
+ // reverts the user's visibility choices (e.g. "Show Annotations").
221
+ typeVisibility: getPersistedTypeVisibility(),
213
222
 
214
223
  // Visibility (multi-model)
215
224
  hiddenEntitiesByModel: new Map(),
@@ -443,6 +452,18 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
443
452
  pointCloudFixedColor: [...POINT_CLOUD_DEFAULTS.pointCloudFixedColor] as [number, number, number, number],
444
453
  });
445
454
  },
455
+
456
+ openWorkspacePanel: (panel) => {
457
+ const [set] = args;
458
+ set({
459
+ bcfPanelVisible: panel === 'bcf',
460
+ idsPanelVisible: panel === 'ids',
461
+ lensPanelVisible: panel === 'lens',
462
+ clashPanelVisible: panel === 'clash',
463
+ extensionsPanelVisible: panel === 'extensions',
464
+ rightPanelCollapsed: false,
465
+ });
466
+ },
446
467
  }));
447
468
 
448
469
  const STORE_SINGLETON_KEY = '__ifc_lite_viewer_store__';