@ifc-lite/viewer 1.25.1 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.turbo/turbo-build.log +83 -85
  2. package/CHANGELOG.md +104 -0
  3. package/dist/assets/{basketViewActivator-Dkn92C04.js → basketViewActivator-ZpTYWE3K.js} +6 -6
  4. package/dist/assets/{bcf-DP2AK1-_.js → bcf-Ctcu_Sc2.js} +5 -5
  5. package/dist/assets/{deflate-BYqYwhkl.js → deflate-Cnx0il6E.js} +1 -1
  6. package/dist/assets/exporters-DSq76AVM.js +4687 -0
  7. package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
  8. package/dist/assets/{geotiff-By06vdeL.js → geotiff-A5UjhI6L.js} +10 -10
  9. package/dist/assets/{ids-DDkkb4mo.js → ids-DiLcGTer.js} +4 -4
  10. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  11. package/dist/assets/index-B9Ug2EqU.css +1 -0
  12. package/dist/assets/{index-CqBdDOAZ.js → index-BAH8IJVR.js} +39550 -36936
  13. package/dist/assets/{jpeg-B4IBTphL.js → jpeg-BzSkwo5D.js} +1 -1
  14. package/dist/assets/{lerc-DQ3jI0Ke.js → lerc-Cg2Rz-D5.js} +1 -1
  15. package/dist/assets/{lzw-CtdH775t.js → lzw-BBPPLW-0.js} +1 -1
  16. package/dist/assets/{native-bridge-DA8wxaN_.js → native-bridge-CPojOeGE.js} +1 -1
  17. package/dist/assets/{packbits-DG3zn49C.js → packbits-yLSpjW-V.js} +1 -1
  18. package/dist/assets/parser.worker-8md211IW.js +182 -0
  19. package/dist/assets/raw-BQrAgxwT.js +1 -0
  20. package/dist/assets/{sandbox-D1pQT-5R.js → sandbox-CsRXlgCO.js} +4715 -3081
  21. package/dist/assets/{server-client-D9xO_8yX.js → server-client-Bk4c1CPO.js} +1 -1
  22. package/dist/assets/{webimage-_-qCDjkn.js → webimage-YafxjjGr.js} +1 -1
  23. package/dist/assets/{zstd-DlfgC8gA.js → zstd-CkSLOiuu.js} +1 -1
  24. package/dist/index.html +7 -7
  25. package/package.json +23 -21
  26. package/src/App.tsx +4 -0
  27. package/src/components/extensions/FlavorDialog.tsx +18 -2
  28. package/src/components/extensions/FlavorListView.tsx +12 -3
  29. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  30. package/src/components/viewer/ClashPanel.tsx +370 -0
  31. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  32. package/src/components/viewer/CommandPalette.tsx +19 -16
  33. package/src/components/viewer/MainToolbar.tsx +155 -153
  34. package/src/components/viewer/ViewerLayout.tsx +5 -0
  35. package/src/components/viewer/Viewport.tsx +97 -12
  36. package/src/components/viewer/ViewportContainer.tsx +45 -3
  37. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  38. package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
  39. package/src/components/viewer/useGeometryStreaming.ts +134 -19
  40. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
  41. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
  42. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  43. package/src/hooks/ingest/streamCleanup.ts +45 -0
  44. package/src/hooks/ingest/viewerModelIngest.ts +118 -52
  45. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  46. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  47. package/src/hooks/useAlignmentLines3D.ts +164 -0
  48. package/src/hooks/useClash.ts +420 -0
  49. package/src/hooks/useIfcCache.ts +44 -18
  50. package/src/hooks/useIfcFederation.ts +16 -2
  51. package/src/hooks/useIfcLoader.ts +6 -30
  52. package/src/hooks/useSymbolicAnnotations.ts +170 -35
  53. package/src/lib/clash/persistence.ts +308 -0
  54. package/src/lib/geo/effective-georef.test.ts +66 -0
  55. package/src/services/extensions/host.ts +13 -0
  56. package/src/store/constants.ts +38 -14
  57. package/src/store/index.ts +29 -7
  58. package/src/store/slices/clashSlice.ts +251 -0
  59. package/src/store/slices/visibilitySlice.test.ts +23 -5
  60. package/src/store/slices/visibilitySlice.ts +19 -8
  61. package/src/store/types.ts +9 -0
  62. package/src/utils/serverDataModel.test.ts +51 -1
  63. package/src/utils/serverDataModel.ts +2 -26
  64. package/vite.config.ts +0 -5
  65. package/dist/assets/exporters-CZe0D8N-.js +0 -5957
  66. package/dist/assets/geometry-controller.worker-pD49_fH6.js +0 -7
  67. package/dist/assets/geometry.worker-D4c-06r5.js +0 -1
  68. package/dist/assets/ifc-lite-DxGqDbjO.js +0 -7
  69. package/dist/assets/ifc-lite_bg-BNeu7R_V.wasm +0 -0
  70. package/dist/assets/ifc-lite_bg-DuxUZomW.wasm +0 -0
  71. package/dist/assets/index-Bws3UAkj.css +0 -1
  72. package/dist/assets/parser.worker-BZZcO7DB.js +0 -182
  73. package/dist/assets/raw-DY7Y_acr.js +0 -1
  74. package/dist/assets/wasm-bridge-DMX8Acuf.js +0 -1
  75. package/dist/assets/workerHelpers-Crstj4Oa.js +0 -36
@@ -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
  // ============================================================================
@@ -159,6 +161,7 @@ export const TYPE_VISIBILITY_STORAGE_KEYS = {
159
161
  openings: 'ifc-lite-ifc-openings-visible',
160
162
  site: 'ifc-lite-ifc-site-visible',
161
163
  ifcAnnotations: 'ifc-lite-ifc-annotations-visible',
164
+ ifcGrid: 'ifc-lite-ifc-grid-visible',
162
165
  } as const;
163
166
 
164
167
  /** Legacy alias — kept until external callers migrate. */
@@ -178,25 +181,46 @@ function readPersistedBool(key: string, fallback: boolean): boolean {
178
181
 
179
182
  // Semantic defaults applied when no localStorage preference is set.
180
183
  // IfcSpace / IfcOpeningElement off — they cover walls and confuse novices
181
- // on first load. IfcSite + IfcAnnotation/IfcGrid on — both convey
182
- // design intent users expect to see by default.
183
- const SEMANTIC_DEFAULTS = {
184
+ // on first load. IfcSite + IfcAnnotation + IfcGrid on — all three convey
185
+ // design intent users expect to see by default. (Issue #862 split grid
186
+ // into its own toggle so dense-grid models can hide grids without losing
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 = {
184
190
  spaces: false,
185
191
  openings: false,
186
192
  site: true,
187
193
  ifcAnnotations: true,
188
- } as const;
194
+ ifcGrid: true,
195
+ };
189
196
 
190
- export const TYPE_VISIBILITY_DEFAULTS = {
191
- /** IfcSpace visibility persisted across reloads. */
192
- SPACES: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.spaces, SEMANTIC_DEFAULTS.spaces),
193
- /** IfcOpeningElement visibilitypersisted across reloads. */
194
- OPENINGS: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.openings, SEMANTIC_DEFAULTS.openings),
195
- /** IfcSite visibility persisted across reloads. */
196
- SITE: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.site, SEMANTIC_DEFAULTS.site),
197
- /** IfcAnnotation + IfcGrid visibility persisted across reloads. */
198
- IFC_ANNOTATIONS: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, SEMANTIC_DEFAULTS.ifcAnnotations),
199
- } 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
+ }
200
224
 
201
225
  // ============================================================================
202
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,12 +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
- },
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(),
212
222
 
213
223
  // Visibility (multi-model)
214
224
  hiddenEntitiesByModel: new Map(),
@@ -442,6 +452,18 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
442
452
  pointCloudFixedColor: [...POINT_CLOUD_DEFAULTS.pointCloudFixedColor] as [number, number, number, number],
443
453
  });
444
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
+ },
445
467
  }));
446
468
 
447
469
  const STORE_SINGLETON_KEY = '__ifc_lite_viewer_store__';
@@ -0,0 +1,251 @@
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 panel state (Phase 1). Detection itself lives in
7
+ * `@ifc-lite/clash`; this slice holds the panel's UI state, the last result,
8
+ * and the user's persisted detection settings + rule presets (see
9
+ * `lib/clash/persistence.ts`, modeled on the lens slice). Orchestration
10
+ * (gathering elements, running the engine, applying colors / selection /
11
+ * camera, BCF export) lives in the `useClash` hook.
12
+ */
13
+
14
+ import type { StateCreator } from 'zustand';
15
+ import type { ClashResult, ClashGroup, ClashMode, ClashProgress } from '@ifc-lite/clash';
16
+ import {
17
+ buildInitialPresets,
18
+ defaultPresets,
19
+ loadSettings,
20
+ savePresets,
21
+ saveSettings,
22
+ validatePresetName,
23
+ validateSelector,
24
+ CLASH_BOUNDS,
25
+ clampToBounds,
26
+ DEFAULT_CLASH_SETTINGS,
27
+ type ClashPreset,
28
+ type ClashGlobalSettings,
29
+ type ClashSettingsGroupBy,
30
+ type SaveResult,
31
+ } from '@/lib/clash/persistence';
32
+
33
+ export type ClashGroupBy = ClashSettingsGroupBy;
34
+ export type { ClashPreset, ClashGlobalSettings, SaveResult };
35
+
36
+ /** Fields a user supplies when adding a custom rule (id/flags filled in here). */
37
+ export type NewClashPreset = {
38
+ name: string;
39
+ description?: string;
40
+ severity: ClashPreset['severity'];
41
+ selectorA: string;
42
+ selectorB: string;
43
+ };
44
+
45
+ export interface ClashSlice {
46
+ clashPanelVisible: boolean;
47
+ clashResult: ClashResult | null;
48
+ clashGroups: ClashGroup[] | null;
49
+ clashRunning: boolean;
50
+ clashError: string | null;
51
+ /** Live detection progress for the running rule (null when idle). */
52
+ clashProgress: ClashProgress | null;
53
+ /** Detection settings (persisted). */
54
+ clashMode: ClashMode;
55
+ clashTolerance: number;
56
+ clashClearance: number;
57
+ clashClusterEpsilon: number;
58
+ clashReportTouch: boolean;
59
+ /** How the result list is organized (persisted). */
60
+ clashGroupBy: ClashGroupBy;
61
+ /** Built-in + custom rule presets (persisted). */
62
+ clashPresets: ClashPreset[];
63
+ /** Currently focused clash id (for highlight in the list). */
64
+ clashSelectedId: string | null;
65
+
66
+ setClashPanelVisible: (visible: boolean) => void;
67
+ toggleClashPanel: () => void;
68
+ setClashResult: (result: ClashResult | null) => void;
69
+ setClashGroups: (groups: ClashGroup[] | null) => void;
70
+ setClashRunning: (running: boolean) => void;
71
+ setClashError: (error: string | null) => void;
72
+ setClashProgress: (progress: ClashProgress | null) => void;
73
+ setClashMode: (mode: ClashMode) => void;
74
+ setClashTolerance: (tolerance: number) => void;
75
+ setClashClearance: (clearance: number) => void;
76
+ setClashClusterEpsilon: (epsilon: number) => void;
77
+ setClashReportTouch: (reportTouch: boolean) => void;
78
+ setClashGroupBy: (groupBy: ClashGroupBy) => void;
79
+ resetClashSettings: () => void;
80
+ setClashSelectedId: (id: string | null) => void;
81
+ // Preset CRUD (persisted). create/update/import return a SaveResult so the UI
82
+ // can surface quota / cap failures; the rest are best-effort.
83
+ createClashPreset: (input: NewClashPreset) => SaveResult;
84
+ updateClashPreset: (id: string, patch: Partial<Omit<ClashPreset, 'id' | 'builtin'>>) => SaveResult;
85
+ deleteClashPreset: (id: string) => void;
86
+ setClashPresetEnabled: (id: string, enabled: boolean) => void;
87
+ resetClashPresets: () => void;
88
+ importClashPresets: (presets: ClashPreset[]) => SaveResult;
89
+ /**
90
+ * Replace the entire clash config (presets + detection settings) and persist.
91
+ * Used when activating a flavor/profile so each one carries its own rule-set.
92
+ */
93
+ applyClashFlavorConfig: (config: { presets: ClashPreset[]; settings: ClashGlobalSettings }) => void;
94
+ clearClash: () => void;
95
+ }
96
+
97
+ /** Build the persisted settings blob from current slice state. */
98
+ function snapshotSettings(s: ClashSlice): ClashGlobalSettings {
99
+ return {
100
+ mode: s.clashMode,
101
+ tolerance: s.clashTolerance,
102
+ clearance: s.clashClearance,
103
+ clusterEpsilon: s.clashClusterEpsilon,
104
+ reportTouch: s.clashReportTouch,
105
+ groupBy: s.clashGroupBy,
106
+ };
107
+ }
108
+
109
+ export const createClashSlice: StateCreator<ClashSlice, [], [], ClashSlice> = (set, get) => {
110
+ const initial = loadSettings();
111
+ // Persist the current settings snapshot after a state change.
112
+ const persistSettings = () => saveSettings(snapshotSettings(get()));
113
+
114
+ return {
115
+ clashPanelVisible: false,
116
+ clashResult: null,
117
+ clashGroups: null,
118
+ clashRunning: false,
119
+ clashError: null,
120
+ clashProgress: null,
121
+ clashMode: initial.mode,
122
+ clashTolerance: initial.tolerance,
123
+ clashClearance: initial.clearance,
124
+ clashClusterEpsilon: initial.clusterEpsilon,
125
+ clashReportTouch: initial.reportTouch,
126
+ clashGroupBy: initial.groupBy,
127
+ clashPresets: buildInitialPresets(),
128
+ clashSelectedId: null,
129
+
130
+ setClashPanelVisible: (clashPanelVisible) => set({ clashPanelVisible }),
131
+ toggleClashPanel: () => set((s) => ({ clashPanelVisible: !s.clashPanelVisible })),
132
+ setClashResult: (clashResult) => set({ clashResult }),
133
+ setClashGroups: (clashGroups) => set({ clashGroups }),
134
+ setClashRunning: (clashRunning) => set({ clashRunning }),
135
+ setClashError: (clashError) => set({ clashError }),
136
+ setClashProgress: (clashProgress) => set({ clashProgress }),
137
+
138
+ setClashMode: (clashMode) => { set({ clashMode }); persistSettings(); },
139
+ setClashTolerance: (clashTolerance) => {
140
+ set({ clashTolerance: clampToBounds(clashTolerance, CLASH_BOUNDS.tolerance, DEFAULT_CLASH_SETTINGS.tolerance) });
141
+ persistSettings();
142
+ },
143
+ setClashClearance: (clashClearance) => {
144
+ set({ clashClearance: clampToBounds(clashClearance, CLASH_BOUNDS.clearance, DEFAULT_CLASH_SETTINGS.clearance) });
145
+ persistSettings();
146
+ },
147
+ setClashClusterEpsilon: (clashClusterEpsilon) => {
148
+ set({ clashClusterEpsilon: clampToBounds(clashClusterEpsilon, CLASH_BOUNDS.clusterEpsilon, DEFAULT_CLASH_SETTINGS.clusterEpsilon) });
149
+ persistSettings();
150
+ },
151
+ setClashReportTouch: (clashReportTouch) => { set({ clashReportTouch }); persistSettings(); },
152
+ setClashGroupBy: (clashGroupBy) => { set({ clashGroupBy }); persistSettings(); },
153
+ resetClashSettings: () => {
154
+ set({
155
+ clashMode: DEFAULT_CLASH_SETTINGS.mode,
156
+ clashTolerance: DEFAULT_CLASH_SETTINGS.tolerance,
157
+ clashClearance: DEFAULT_CLASH_SETTINGS.clearance,
158
+ clashClusterEpsilon: DEFAULT_CLASH_SETTINGS.clusterEpsilon,
159
+ clashReportTouch: DEFAULT_CLASH_SETTINGS.reportTouch,
160
+ clashGroupBy: DEFAULT_CLASH_SETTINGS.groupBy,
161
+ });
162
+ persistSettings();
163
+ },
164
+
165
+ setClashSelectedId: (clashSelectedId) => set({ clashSelectedId }),
166
+
167
+ createClashPreset: (input) => {
168
+ const name = validatePresetName(input.name);
169
+ const selectorA = validateSelector(input.selectorA);
170
+ const selectorB = validateSelector(input.selectorB);
171
+ if (!name || !selectorA || !selectorB) {
172
+ return { ok: false, reason: 'serialize', message: 'Name and both selectors are required.' };
173
+ }
174
+ const preset: ClashPreset = {
175
+ id: `custom-${crypto.randomUUID()}`,
176
+ name,
177
+ description: input.description?.trim() ?? '',
178
+ severity: input.severity,
179
+ selectorA,
180
+ selectorB,
181
+ enabled: true,
182
+ builtin: false,
183
+ };
184
+ const next = [...get().clashPresets, preset];
185
+ const result = savePresets(next);
186
+ if (result.ok) set({ clashPresets: next });
187
+ return result;
188
+ },
189
+
190
+ updateClashPreset: (id, patch) => {
191
+ const next = get().clashPresets.map((p) => (p.id === id ? { ...p, ...patch } : p));
192
+ const result = savePresets(next);
193
+ if (result.ok) set({ clashPresets: next });
194
+ return result;
195
+ },
196
+
197
+ deleteClashPreset: (id) => {
198
+ const target = get().clashPresets.find((p) => p.id === id);
199
+ if (!target || target.builtin) return; // built-ins are reset, never deleted
200
+ const next = get().clashPresets.filter((p) => p.id !== id);
201
+ savePresets(next);
202
+ set({ clashPresets: next });
203
+ },
204
+
205
+ setClashPresetEnabled: (id, enabled) => {
206
+ const next = get().clashPresets.map((p) => (p.id === id ? { ...p, enabled } : p));
207
+ savePresets(next);
208
+ set({ clashPresets: next });
209
+ },
210
+
211
+ resetClashPresets: () => {
212
+ const next = defaultPresets(); // drops all overrides + customs
213
+ savePresets(next);
214
+ set({ clashPresets: next });
215
+ },
216
+
217
+ importClashPresets: (presets) => {
218
+ const next = [...get().clashPresets, ...presets.filter((p) => !p.builtin)];
219
+ const result = savePresets(next);
220
+ if (result.ok) set({ clashPresets: next });
221
+ return result;
222
+ },
223
+
224
+ applyClashFlavorConfig: ({ presets, settings }) => {
225
+ set({
226
+ clashPresets: presets,
227
+ clashMode: settings.mode,
228
+ clashTolerance: settings.tolerance,
229
+ clashClearance: settings.clearance,
230
+ clashClusterEpsilon: settings.clusterEpsilon,
231
+ clashReportTouch: settings.reportTouch,
232
+ clashGroupBy: settings.groupBy,
233
+ });
234
+ // Persist so the activated flavor's config becomes the working set on reload.
235
+ savePresets(presets);
236
+ saveSettings(settings);
237
+ },
238
+
239
+ clearClash: () =>
240
+ // Keep presets + settings (workspace prefs, like saved lenses): only the
241
+ // run result/panel state is cleared.
242
+ set({
243
+ clashResult: null,
244
+ clashGroups: null,
245
+ clashRunning: false,
246
+ clashError: null,
247
+ clashProgress: null,
248
+ clashSelectedId: null,
249
+ }),
250
+ };
251
+ };
@@ -5,7 +5,7 @@
5
5
  import { describe, it, beforeEach } from 'node:test';
6
6
  import assert from 'node:assert';
7
7
  import { createVisibilitySlice, type VisibilitySlice } from './visibilitySlice.js';
8
- import { TYPE_VISIBILITY_DEFAULTS } from '../constants.js';
8
+ import { getPersistedTypeVisibility } from '../constants.js';
9
9
 
10
10
  describe('VisibilitySlice', () => {
11
11
  let state: VisibilitySlice;
@@ -33,10 +33,13 @@ describe('VisibilitySlice', () => {
33
33
  assert.strictEqual(state.isolatedEntitiesByModel.size, 0);
34
34
  });
35
35
 
36
- it('should have default type visibility', () => {
37
- assert.strictEqual(state.typeVisibility.spaces, TYPE_VISIBILITY_DEFAULTS.SPACES);
38
- assert.strictEqual(state.typeVisibility.openings, TYPE_VISIBILITY_DEFAULTS.OPENINGS);
39
- assert.strictEqual(state.typeVisibility.site, TYPE_VISIBILITY_DEFAULTS.SITE);
36
+ it('should initialise type visibility from persisted preferences', () => {
37
+ const persisted = getPersistedTypeVisibility();
38
+ assert.strictEqual(state.typeVisibility.spaces, persisted.spaces);
39
+ assert.strictEqual(state.typeVisibility.openings, persisted.openings);
40
+ assert.strictEqual(state.typeVisibility.site, persisted.site);
41
+ assert.strictEqual(state.typeVisibility.ifcAnnotations, persisted.ifcAnnotations);
42
+ assert.strictEqual(state.typeVisibility.ifcGrid, persisted.ifcGrid);
40
43
  });
41
44
  });
42
45
 
@@ -306,5 +309,20 @@ describe('VisibilitySlice', () => {
306
309
  state.toggleTypeVisibility('ifcAnnotations');
307
310
  assert.strictEqual(state.typeVisibility.ifcAnnotations, !initial);
308
311
  });
312
+
313
+ it('resetTypeVisibility restores semantic defaults', () => {
314
+ // Flip everything away from defaults first.
315
+ state.toggleTypeVisibility('spaces'); // false -> true
316
+ state.toggleTypeVisibility('site'); // true -> false
317
+ state.toggleTypeVisibility('ifcGrid'); // true -> false
318
+ state.resetTypeVisibility();
319
+ assert.deepStrictEqual(state.typeVisibility, {
320
+ spaces: false,
321
+ openings: false,
322
+ site: true,
323
+ ifcAnnotations: true,
324
+ ifcGrid: true,
325
+ });
326
+ });
309
327
  });
310
328
  });
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { StateCreator } from 'zustand';
13
13
  import type { TypeVisibility, EntityRef } from '../types.js';
14
- import { TYPE_VISIBILITY_DEFAULTS, TYPE_VISIBILITY_STORAGE_KEYS } from '../constants.js';
14
+ import { getPersistedTypeVisibility, TYPE_VISIBILITY_STORAGE_KEYS, TYPE_VISIBILITY_SEMANTIC_DEFAULTS } from '../constants.js';
15
15
 
16
16
  export interface VisibilitySlice {
17
17
  // State (legacy - single model)
@@ -43,7 +43,9 @@ export interface VisibilitySlice {
43
43
  clearAllFilters: () => void;
44
44
  showAll: () => void;
45
45
  isEntityVisible: (id: number) => boolean;
46
- toggleTypeVisibility: (type: 'spaces' | 'openings' | 'site' | 'ifcAnnotations') => void;
46
+ toggleTypeVisibility: (type: 'spaces' | 'openings' | 'site' | 'ifcAnnotations' | 'ifcGrid') => void;
47
+ /** Restore every type-visibility toggle to its semantic default (and persist). */
48
+ resetTypeVisibility: () => void;
47
49
  /** Set all hidden entities at once (for BCF viewpoint application) */
48
50
  setHiddenEntities: (ids: Set<number>) => void;
49
51
  /** Set all isolated entities at once (for BCF viewpoint with defaultVisibility=false) */
@@ -75,12 +77,8 @@ export const createVisibilitySlice: StateCreator<VisibilitySlice, [], [], Visibi
75
77
  hiddenEntities: new Set(),
76
78
  isolatedEntities: null,
77
79
  classFilter: null,
78
- typeVisibility: {
79
- spaces: TYPE_VISIBILITY_DEFAULTS.SPACES,
80
- openings: TYPE_VISIBILITY_DEFAULTS.OPENINGS,
81
- site: TYPE_VISIBILITY_DEFAULTS.SITE,
82
- ifcAnnotations: TYPE_VISIBILITY_DEFAULTS.IFC_ANNOTATIONS,
83
- },
80
+ // Read persisted toggles fresh so the user's choices survive reloads.
81
+ typeVisibility: getPersistedTypeVisibility(),
84
82
 
85
83
  // Initial state (multi-model)
86
84
  hiddenEntitiesByModel: new Map(),
@@ -210,6 +208,19 @@ export const createVisibilitySlice: StateCreator<VisibilitySlice, [], [], Visibi
210
208
  };
211
209
  }),
212
210
 
211
+ resetTypeVisibility: () => set(() => {
212
+ // Restore semantic defaults and persist them per-key (same storage
213
+ // pattern as toggleTypeVisibility) so the reset survives reloads.
214
+ if (typeof window !== 'undefined') {
215
+ (Object.keys(TYPE_VISIBILITY_STORAGE_KEYS) as (keyof typeof TYPE_VISIBILITY_STORAGE_KEYS)[])
216
+ .forEach((key) => {
217
+ try { localStorage.setItem(TYPE_VISIBILITY_STORAGE_KEYS[key], String(TYPE_VISIBILITY_SEMANTIC_DEFAULTS[key])); }
218
+ catch { /* private-mode storage rejection — non-fatal */ }
219
+ });
220
+ }
221
+ return { typeVisibility: { ...TYPE_VISIBILITY_SEMANTIC_DEFAULTS } };
222
+ }),
223
+
213
224
  // Actions (multi-model)
214
225
  hideEntityInModel: (modelId, expressId) => set((state) => {
215
226
  const newMap = new Map(state.hiddenEntitiesByModel);
@@ -199,6 +199,15 @@ export interface TypeVisibility {
199
199
  site: boolean;
200
200
  /** IfcAnnotation (2D symbolic curves) - on by default when present */
201
201
  ifcAnnotations: boolean;
202
+ /**
203
+ * IfcGrid axis lines + bubble tags — split from `ifcAnnotations`
204
+ * (issue #862). Default true to match the legacy combined behaviour;
205
+ * users with dense grids that obscure components can hide grids while
206
+ * keeping annotations on. Unlike `ifcAnnotations`, grids are also
207
+ * section-clipped when a 3D section plane is active so each storey's
208
+ * grid lines only show for storeys near the cut.
209
+ */
210
+ ifcGrid: boolean;
202
211
  }
203
212
 
204
213
  // ============================================================================