@ifc-lite/viewer 1.19.1 → 1.21.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 (106) hide show
  1. package/.turbo/turbo-build.log +59 -44
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +488 -0
  4. package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
  5. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  6. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  7. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  8. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  9. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  10. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  11. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  12. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  13. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  14. package/dist/assets/index-CSWgTe1s.css +1 -0
  15. package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
  16. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  17. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  18. package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
  19. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  20. package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
  21. package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
  22. package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
  23. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  24. package/dist/index.html +8 -8
  25. package/index.html +1 -1
  26. package/package.json +10 -10
  27. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  28. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  29. package/src/components/viewer/DeviationPanel.tsx +172 -0
  30. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  31. package/src/components/viewer/HoverTooltip.tsx +5 -0
  32. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  33. package/src/components/viewer/IDSPanel.tsx +80 -26
  34. package/src/components/viewer/MainToolbar.tsx +60 -7
  35. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  36. package/src/components/viewer/MobileToolbar.tsx +326 -0
  37. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  38. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  39. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  40. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  41. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  42. package/src/components/viewer/StatusBar.tsx +14 -0
  43. package/src/components/viewer/ViewerLayout.tsx +288 -95
  44. package/src/components/viewer/Viewport.tsx +86 -18
  45. package/src/components/viewer/ViewportContainer.tsx +25 -11
  46. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  47. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  48. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  49. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  50. package/src/components/viewer/selectionHandlers.ts +41 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  52. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  53. package/src/components/viewer/useAnimationLoop.ts +22 -0
  54. package/src/components/viewer/useMouseControls.ts +296 -3
  55. package/src/components/viewer/usePointCloudSync.ts +8 -1
  56. package/src/components/viewer/useRenderUpdates.ts +21 -1
  57. package/src/components/viewer/useTouchControls.ts +100 -41
  58. package/src/hooks/federationLoadGate.test.ts +90 -0
  59. package/src/hooks/federationLoadGate.ts +127 -0
  60. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  61. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  62. package/src/hooks/useDrawingGeneration.ts +81 -8
  63. package/src/hooks/useIDS.ts +90 -10
  64. package/src/hooks/useIfcFederation.ts +94 -16
  65. package/src/hooks/useIfcLoader.ts +289 -64
  66. package/src/hooks/useViewerSelectors.ts +10 -0
  67. package/src/lib/geo/cesium-bridge.ts +84 -67
  68. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  69. package/src/lib/geo/clamp-anchor.ts +57 -0
  70. package/src/lib/geo/effective-georef.test.ts +79 -1
  71. package/src/lib/geo/effective-georef.ts +83 -0
  72. package/src/lib/geo/reproject.ts +26 -13
  73. package/src/lib/geo/terrain-elevation.ts +166 -0
  74. package/src/lib/lens/adapter.ts +1 -1
  75. package/src/lib/llm/context-builder.ts +1 -1
  76. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  77. package/src/lib/perf/memoryAccounting.ts +235 -0
  78. package/src/sdk/adapters/mutation-view.ts +1 -1
  79. package/src/store/constants.ts +39 -2
  80. package/src/store/index.ts +6 -1
  81. package/src/store/slices/cesiumSlice.ts +1 -1
  82. package/src/store/slices/idsSlice.ts +24 -0
  83. package/src/store/slices/loadingSlice.ts +12 -0
  84. package/src/store/slices/pointCloudSlice.ts +72 -1
  85. package/src/store/slices/sectionSlice.test.ts +590 -1
  86. package/src/store/slices/sectionSlice.ts +344 -17
  87. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  88. package/src/store/slices/uiSlice.ts +60 -2
  89. package/src/store/types.ts +42 -0
  90. package/src/store.ts +13 -0
  91. package/src/utils/acquireFileBuffer.test.ts +231 -0
  92. package/src/utils/acquireFileBuffer.ts +128 -0
  93. package/src/utils/ifcConfig.ts +24 -0
  94. package/src/utils/nativeSpatialDataStore.ts +20 -2
  95. package/src/utils/spatialHierarchy.test.ts +116 -0
  96. package/src/utils/spatialHierarchy.ts +23 -0
  97. package/tailwind.config.js +5 -0
  98. package/tsconfig.json +1 -0
  99. package/vite.config.ts +6 -0
  100. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  101. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  102. package/dist/assets/exporters-xbXqEDlO.js +0 -81590
  103. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  104. package/dist/assets/ids-2WdONLlu.js +0 -2033
  105. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  106. package/dist/assets/index-BXeEKqJG.css +0 -1
@@ -7,8 +7,37 @@
7
7
  */
8
8
 
9
9
  import type { StateCreator } from 'zustand';
10
- import type { SectionPlane, SectionPlaneAxis, SectionCapStyle, SectionCapHatchId } from '../types.js';
10
+ import type { SectionPlane, SectionPlaneAxis, SectionCapStyle, SectionCapHatchId, CustomSectionPlane } from '../types.js';
11
11
  import { SECTION_PLANE_DEFAULTS, SECTION_CAP_DEFAULTS } from '../constants.js';
12
+ import { planeBasis, nearestCardinalAxis } from '@ifc-lite/renderer';
13
+
14
+ /**
15
+ * Project `pickedAt` onto the current cut plane and return that point as
16
+ * the "anchor on the live plane".
17
+ *
18
+ * The plane equation is `dot(p, normal) = distance`. As the user drags
19
+ * the gizmo (or moves the slider) only `distance` changes — `pickedAt`
20
+ * stays at the original face-pick location, which sits OFF the live
21
+ * plane. Any visual that needs a "point on the current plane" (cap
22
+ * polygon basis origin, 3D drag gizmo position, hatch UV anchor) must
23
+ * use the projected point instead, otherwise it freezes at the original
24
+ * pick location while the actual cut slides along the normal.
25
+ *
26
+ * Derivation: the projection of `pickedAt` onto the plane is
27
+ * `pickedAt + (distance − dot(pickedAt, normal)) · normal`, which moves
28
+ * `pickedAt` along the unit normal by exactly the offset required to
29
+ * satisfy `dot(out, normal) = distance`.
30
+ *
31
+ * Round-trip note: when `distance == dot(pickedAt, normal)` (i.e. just
32
+ * after a fresh face-pick) the result equals `pickedAt`, so the legacy
33
+ * code path that fed `pickedAt` directly is preserved at pick-time.
34
+ */
35
+ export function customPlaneCenter(plane: CustomSectionPlane): [number, number, number] {
36
+ const { pickedAt: p, normal: n, distance: d } = plane;
37
+ const dotPicked = p[0] * n[0] + p[1] * n[1] + p[2] * n[2];
38
+ const k = d - dotPicked;
39
+ return [p[0] + k * n[0], p[1] + k * n[1], p[2] + k * n[2]];
40
+ }
12
41
 
13
42
  // ─── Persistence ─────────────────────────────────────────────────────────
14
43
  // Cap appearance (hatch pattern, colours, spacing, angle, whether the cap is
@@ -21,6 +50,72 @@ const CAP_STYLE_STORAGE_KEY = 'ifc-lite:section-cap-style';
21
50
  const CAP_SHOW_STORAGE_KEY = 'ifc-lite:section-cap-show';
22
51
  const OUTLINES_SHOW_STORAGE_KEY = 'ifc-lite:section-outlines-show';
23
52
 
53
+ // Last-used section mode (issue #243 follow-up). When the user reopens
54
+ // the section tool we restore whichever mode they used last:
55
+ // • 'pick' — face-pick is rearmed (default for first-time users
56
+ // and anyone whose last action was a face pick).
57
+ // • 'cardinal' — restore the previous axis + position + flipped so the
58
+ // cut appears exactly where they left it.
59
+ // Custom (face-picked) planes are NOT persisted: they're tied to the
60
+ // loaded model's world coordinates and would land somewhere meaningless
61
+ // on a different model. Re-arming pick mode lets the user re-cut the
62
+ // equivalent face on the new model with one click.
63
+ const SECTION_MODE_STORAGE_KEY = 'ifc-lite:section-last-mode';
64
+
65
+ export type LastSectionMode =
66
+ | { kind: 'pick' }
67
+ | { kind: 'cardinal'; axis: SectionPlaneAxis; position: number; flipped: boolean };
68
+
69
+ const DEFAULT_LAST_MODE: LastSectionMode = { kind: 'pick' };
70
+
71
+ function isSectionPlaneAxis(v: unknown): v is SectionPlaneAxis {
72
+ return v === 'down' || v === 'front' || v === 'side';
73
+ }
74
+
75
+ export function loadLastSectionMode(): LastSectionMode {
76
+ if (typeof window === 'undefined') return DEFAULT_LAST_MODE;
77
+ try {
78
+ const raw = window.localStorage.getItem(SECTION_MODE_STORAGE_KEY);
79
+ if (!raw) return DEFAULT_LAST_MODE;
80
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
81
+ if (parsed?.kind === 'pick') return { kind: 'pick' };
82
+ if (
83
+ parsed?.kind === 'cardinal' &&
84
+ isSectionPlaneAxis(parsed.axis) &&
85
+ typeof parsed.position === 'number' && Number.isFinite(parsed.position) &&
86
+ typeof parsed.flipped === 'boolean'
87
+ ) {
88
+ // Clamp position to the same [0, 100] range the slice enforces so
89
+ // a tampered or stale value can't poison the slider on restore.
90
+ const position = Math.min(100, Math.max(0, parsed.position));
91
+ return { kind: 'cardinal', axis: parsed.axis, position, flipped: parsed.flipped };
92
+ }
93
+ return DEFAULT_LAST_MODE;
94
+ } catch {
95
+ // Corrupted JSON or storage exception — fall back to the default
96
+ // pick mode silently. We don't warn here because this runs on every
97
+ // panel mount and would spam the console for any user with bad data.
98
+ return DEFAULT_LAST_MODE;
99
+ }
100
+ }
101
+
102
+ function saveLastSectionMode(mode: LastSectionMode): void {
103
+ if (typeof window === 'undefined') return;
104
+ try {
105
+ window.localStorage.setItem(SECTION_MODE_STORAGE_KEY, JSON.stringify(mode));
106
+ } catch {
107
+ // Quota exceeded / private mode — best effort, the preference just
108
+ // doesn't survive this session.
109
+ }
110
+ }
111
+
112
+ function clearLastSectionMode(): void {
113
+ if (typeof window === 'undefined') return;
114
+ try {
115
+ window.localStorage.removeItem(SECTION_MODE_STORAGE_KEY);
116
+ } catch { /* best-effort */ }
117
+ }
118
+
24
119
  const HATCH_IDS: readonly SectionCapHatchId[] = [
25
120
  'solid', 'diagonal', 'crossHatch', 'horizontal',
26
121
  'vertical', 'concrete', 'brick', 'insulation',
@@ -105,9 +200,43 @@ const saveShowCap = (v: boolean) => saveBoolean(CAP_SHOW_STORAGE_KEY,
105
200
  const loadShowOutlines = () => loadBoolean(OUTLINES_SHOW_STORAGE_KEY, SECTION_PLANE_DEFAULTS.SHOW_OUTLINES);
106
201
  const saveShowOutlines = (v: boolean) => saveBoolean(OUTLINES_SHOW_STORAGE_KEY, v);
107
202
 
203
+ /**
204
+ * Live "where will I cut if you click here?" preview, set by the hover
205
+ * dwell handler in `useMouseControls.ts` while `sectionPickMode` is on.
206
+ *
207
+ * `normal` is camera-oriented (matches the face-pick commit policy in
208
+ * `selectionHandlers.ts`) so the preview's arrow points in the same
209
+ * direction the actual cut will keep, and the user sees a visually
210
+ * continuous transition on click. `point` is the world-space hit
211
+ * location. `faceKey` is used by the hover handler to detect "still on
212
+ * the same face" so cursor wobble within a flat surface doesn't
213
+ * retrigger the dwell timer or repaint the overlay.
214
+ */
215
+ export interface SectionPickPreview {
216
+ normal: [number, number, number];
217
+ point: [number, number, number];
218
+ faceKey: string;
219
+ }
220
+
108
221
  export interface SectionSlice {
109
222
  // State
110
223
  sectionPlane: SectionPlane;
224
+ /**
225
+ * When true, the next click on the canvas picks a face and sets the
226
+ * section plane through it (world-space normal + point). Cleared after
227
+ * one pick, a missed click, or a tool change. See
228
+ * `selectionHandlers.ts` for the consumer.
229
+ */
230
+ sectionPickMode: boolean;
231
+ /**
232
+ * Hover preview for the face-pick gesture (issue #243 follow-up).
233
+ * Populated by the dwell handler when the cursor pauses ~200ms over a
234
+ * surface; consumed by `SectionVisualization.tsx` to paint a
235
+ * translucent violet quad + a tiny normal arrow on the hovered face.
236
+ * Cleared on cursor leaving the canvas, moving to a different face,
237
+ * disarming pick mode, or successful commit.
238
+ */
239
+ sectionPickPreview: SectionPickPreview | null;
111
240
 
112
241
  // Actions
113
242
  setSectionPlaneAxis: (axis: SectionPlaneAxis) => void;
@@ -119,6 +248,33 @@ export interface SectionSlice {
119
248
  setSectionShowOutlines: (show: boolean) => void;
120
249
  setSectionCapStyle: (style: Partial<SectionCapStyle>) => void;
121
250
  resetSectionPlane: () => void;
251
+ /**
252
+ * Set the section plane from a face pick. `normal` is the face's world-
253
+ * space unit normal; `point` is any point on the face (typically the
254
+ * raycast hit). The derived plane equation is
255
+ * `dot(worldPos, normal) = dot(point, normal)`.
256
+ *
257
+ * Also writes the nearest cardinal `axis` + `flipped` and a percentage-
258
+ * along-that-axis `position` so legacy consumers (drawings, BCF,
259
+ * tooltips) still see a reasonable axis-aligned approximation.
260
+ */
261
+ setSectionPlaneFromFace: (
262
+ normal: [number, number, number],
263
+ point: [number, number, number],
264
+ bounds?: { min: [number, number, number]; max: [number, number, number] },
265
+ ) => void;
266
+ /** Update only the custom plane's signed distance (drag gizmo / numeric input). */
267
+ setSectionCustomDistance: (distance: number) => void;
268
+ /** Arm/disarm the "next click picks a face" mode. Disarming clears any active hover preview. */
269
+ setSectionPickMode: (enabled: boolean) => void;
270
+ /**
271
+ * Set or clear the live face-pick hover preview. `null` hides the
272
+ * overlay (cursor left the canvas, moved to a new face, or the pick
273
+ * mode was disarmed). Only the dwell-aware hover handler should set
274
+ * this — it is purely a visual hint and does not change `sectionPlane`
275
+ * (commit happens via `setSectionPlaneFromFace` on click).
276
+ */
277
+ setSectionPickPreview: (preview: SectionPickPreview | null) => void;
122
278
  }
123
279
 
124
280
  const getDefaultCapStyle = (): SectionCapStyle => loadCapStyle();
@@ -140,23 +296,73 @@ const getDefaultSectionPlane = (): SectionPlane => ({
140
296
  export const createSectionSlice: StateCreator<SectionSlice, [], [], SectionSlice> = (set) => ({
141
297
  // Initial state
142
298
  sectionPlane: getDefaultSectionPlane(),
299
+ sectionPickMode: false,
300
+ sectionPickPreview: null,
143
301
 
144
302
  // Actions
145
- setSectionPlaneAxis: (axis) => set((state) => ({
146
- // Changing the axis implicitly means "I want to cut now" — enable the clip
147
- // so users don't get stuck in a confusing no-op preview.
148
- sectionPlane: { ...state.sectionPlane, axis, enabled: true },
149
- })),
303
+ setSectionPlaneAxis: (axis) => set((state) => {
304
+ // Persist the cardinal choice so reopening the section tool restores
305
+ // axis + position + flipped (issue #243 follow-up). Position and
306
+ // flipped come from current state — picking an axis doesn't reset
307
+ // either, it just switches which axis the slider walks along.
308
+ saveLastSectionMode({
309
+ kind: 'cardinal',
310
+ axis,
311
+ position: state.sectionPlane.position,
312
+ flipped: state.sectionPlane.flipped,
313
+ });
314
+ return {
315
+ // Changing the axis implicitly means "I want to cut now" — enable the clip
316
+ // so users don't get stuck in a confusing no-op preview. Also drop any
317
+ // custom (face-picked) plane so the cardinal preset takes over cleanly.
318
+ sectionPlane: { ...state.sectionPlane, axis, enabled: true, custom: undefined },
319
+ };
320
+ }),
150
321
 
151
322
  setSectionPlanePosition: (position) => set((state) => {
152
323
  // Clamp position to valid range [0, 100]
153
324
  const clampedPosition = Math.min(100, Math.max(0, Number(position) || 0));
154
- return {
155
- // Moving the slider also enables the cut previously you had to press
156
- // "Cutting" separately, which led to the "it just jitters, doesn't cut"
157
- // feedback from users.
158
- sectionPlane: { ...state.sectionPlane, position: clampedPosition, enabled: true },
159
- };
325
+ // Slider semantics differ between cardinal and custom modes:
326
+ // cardinal: percentage along the axis between bounds extents.
327
+ // custom: percentage along the picked normal between the bounds-
328
+ // diagonal extents centred on `pickedAt`. The renderer translates
329
+ // that to a signed `distance`; the action below just stores the
330
+ // percentage and updates `custom.distance` to match.
331
+ const next: SectionPlane = { ...state.sectionPlane, position: clampedPosition, enabled: true };
332
+ // Persist the cardinal slider position so the user gets the same cut
333
+ // back on reopen (issue #243 follow-up). Custom-mode position drives
334
+ // a face-anchored distance which we deliberately don't persist —
335
+ // those coordinates are model-relative and meaningless across files.
336
+ if (!state.sectionPlane.custom) {
337
+ saveLastSectionMode({
338
+ kind: 'cardinal',
339
+ axis: state.sectionPlane.axis,
340
+ position: clampedPosition,
341
+ flipped: state.sectionPlane.flipped,
342
+ });
343
+ }
344
+ if (state.sectionPlane.custom) {
345
+ const c = state.sectionPlane.custom;
346
+ // Re-anchor distance from percentage. The half-extent is derived
347
+ // from the renderer-supplied bounds when we have them — at this
348
+ // point in the slice we don't, so we use the existing distance as
349
+ // the anchor and shift it by the percentage delta. This keeps the
350
+ // slider responsive without the slice needing a bounds dependency.
351
+ // The renderer cap path uses `custom.distance` verbatim regardless,
352
+ // so the visual stays accurate.
353
+ const dPct = (clampedPosition - state.sectionPlane.position) / 100;
354
+ const dot = c.pickedAt[0] * c.normal[0] + c.pickedAt[1] * c.normal[1] + c.pickedAt[2] * c.normal[2];
355
+ // 100% of slider span = ~bounds-diagonal; without bounds, fall
356
+ // back to a generous fixed step (10 world units per 100%). The
357
+ // SectionPanel updates this with the real bounds via
358
+ // `setSectionCustomDistance` once they're known.
359
+ const fallbackSpan = 10;
360
+ next.custom = { ...c, distance: c.distance + dPct * fallbackSpan };
361
+ // Keep `pickedAt` so future deltas remain anchored to the original
362
+ // pick — only `distance` (and on flip, `normal`) ever change.
363
+ void dot;
364
+ }
365
+ return { sectionPlane: next };
160
366
  }),
161
367
 
162
368
  toggleSectionPlane: () => set((state) => ({
@@ -167,9 +373,30 @@ export const createSectionSlice: StateCreator<SectionSlice, [], [], SectionSlice
167
373
  sectionPlane: { ...state.sectionPlane, enabled },
168
374
  })),
169
375
 
170
- flipSectionPlane: () => set((state) => ({
171
- sectionPlane: { ...state.sectionPlane, flipped: !state.sectionPlane.flipped },
172
- })),
376
+ flipSectionPlane: () => set((state) => {
377
+ // A plane is geometrically defined by `(normal, distance)`. Which
378
+ // half-space is kept is a separate choice expressed by `flipped`.
379
+ // The renderer's clip shader applies `flipped` independently
380
+ // (`side = flipped ? -1 : 1`, then `distToPlane * side`), so toggling
381
+ // the boolean alone is sufficient to swap the visible half-space —
382
+ // for both cardinal and custom planes. Mutating `custom.normal` /
383
+ // `custom.distance` here as well would double-cancel the shader's
384
+ // own flip (negate-and-negate-again leaves the same half-space
385
+ // clipped) and the flip button would have no visible effect.
386
+ const flipped = !state.sectionPlane.flipped;
387
+ // Persist the flipped state alongside axis + position for cardinal
388
+ // mode (issue #243 follow-up). Custom-mode flips aren't persisted
389
+ // because the whole custom plane (anchored at a model point) isn't.
390
+ if (!state.sectionPlane.custom) {
391
+ saveLastSectionMode({
392
+ kind: 'cardinal',
393
+ axis: state.sectionPlane.axis,
394
+ position: state.sectionPlane.position,
395
+ flipped,
396
+ });
397
+ }
398
+ return { sectionPlane: { ...state.sectionPlane, flipped } };
399
+ }),
173
400
 
174
401
  setSectionShowCap: (showCap) => set((state) => {
175
402
  saveShowCap(showCap);
@@ -189,7 +416,9 @@ export const createSectionSlice: StateCreator<SectionSlice, [], [], SectionSlice
189
416
 
190
417
  resetSectionPlane: () => set(() => {
191
418
  // Reset clears persisted cap style too — users asking for defaults expect
192
- // the defaults to stick on the next reload.
419
+ // the defaults to stick on the next reload. Same goes for the
420
+ // last-used-mode preference (issue #243 follow-up): a reset should
421
+ // bring everyone back to the default pick mode on next reopen.
193
422
  try {
194
423
  if (typeof window !== 'undefined') {
195
424
  window.localStorage.removeItem(CAP_STYLE_STORAGE_KEY);
@@ -199,6 +428,104 @@ export const createSectionSlice: StateCreator<SectionSlice, [], [], SectionSlice
199
428
  } catch (error) {
200
429
  console.warn('[section] failed to clear persisted cap preferences', error);
201
430
  }
202
- return { sectionPlane: getDefaultSectionPlane() };
431
+ clearLastSectionMode();
432
+ return { sectionPlane: getDefaultSectionPlane(), sectionPickMode: false, sectionPickPreview: null };
433
+ }),
434
+
435
+ setSectionPlaneFromFace: (normal, point, bounds) => set((state) => {
436
+ const nx = normal[0]; const ny = normal[1]; const nz = normal[2];
437
+ const len = Math.hypot(nx, ny, nz);
438
+ if (!Number.isFinite(len) || len < 1e-6) {
439
+ // Degenerate normal — disarm pick mode but don't poison the
440
+ // renderer with NaNs. Also clear any in-flight hover preview so
441
+ // the violet quad doesn't linger after a bogus pick attempt.
442
+ console.warn('[section] face-pick received a degenerate normal; ignoring');
443
+ return { sectionPickMode: false, sectionPickPreview: null };
444
+ }
445
+ const unit: [number, number, number] = [nx / len, ny / len, nz / len];
446
+ const distance = point[0] * unit[0] + point[1] * unit[1] + point[2] * unit[2];
447
+ const basis = planeBasis(unit);
448
+ const cardinal = nearestCardinalAxis(unit);
449
+
450
+ // Re-compute `position` along the chosen cardinal so legacy axis-aligned
451
+ // consumers (drawings export, BCF) get a percentage that lines up with
452
+ // the picked plane rather than whatever slider value was set before.
453
+ // Without `bounds` we keep the previous value (P2 CR comment on PR
454
+ // #581) — the SectionPanel passes bounds when available.
455
+ let position = state.sectionPlane.position;
456
+ if (bounds) {
457
+ const axisIdx = cardinal.axis === 'side' ? 0 : cardinal.axis === 'down' ? 1 : 2;
458
+ const axisMin = bounds.min[axisIdx];
459
+ const axisMax = bounds.max[axisIdx];
460
+ const range = axisMax - axisMin;
461
+ if (range > 1e-6) {
462
+ const along = point[axisIdx];
463
+ position = Math.min(100, Math.max(0,
464
+ ((along - axisMin) / range) * 100,
465
+ ));
466
+ }
467
+ }
468
+
469
+ const custom: CustomSectionPlane = {
470
+ normal: unit,
471
+ distance,
472
+ pickedAt: [point[0], point[1], point[2]],
473
+ tangent: basis.tangent,
474
+ bitangent: basis.bitangent,
475
+ };
476
+
477
+ // Last-used mode is "pick" — reopening the panel rearms face-pick
478
+ // rather than restoring a cardinal cut. We deliberately don't store
479
+ // the custom plane itself (model-relative coords).
480
+ saveLastSectionMode({ kind: 'pick' });
481
+
482
+ return {
483
+ sectionPlane: {
484
+ ...state.sectionPlane,
485
+ axis: cardinal.axis,
486
+ flipped: cardinal.flipped,
487
+ position,
488
+ enabled: true,
489
+ custom,
490
+ },
491
+ sectionPickMode: false,
492
+ // Commit consumes the preview — the violet quad transitions
493
+ // visually into the actual cap on the next render. Clearing here
494
+ // (rather than waiting for the hover handler) avoids a frame of
495
+ // double-render where both preview and cap paint the same face.
496
+ sectionPickPreview: null,
497
+ };
498
+ }),
499
+
500
+ setSectionCustomDistance: (distance) => set((state) => {
501
+ if (!state.sectionPlane.custom || !Number.isFinite(distance)) {
502
+ return state;
503
+ }
504
+ return {
505
+ sectionPlane: {
506
+ ...state.sectionPlane,
507
+ custom: { ...state.sectionPlane.custom, distance },
508
+ },
509
+ };
510
+ }),
511
+
512
+ setSectionPickMode: (enabled) => set(() => (
513
+ // Disarming pick mode also drops any hovering preview overlay so
514
+ // it doesn't linger after the user toggles off (Esc, second toggle
515
+ // press, tool change). Re-arming starts fresh.
516
+ enabled
517
+ ? { sectionPickMode: true }
518
+ : { sectionPickMode: false, sectionPickPreview: null }
519
+ )),
520
+
521
+ setSectionPickPreview: (preview) => set((state) => {
522
+ // Setting a preview while pick mode is OFF would put the violet
523
+ // quad on screen with no way to commit it — guard against that so
524
+ // a stale hover event firing after disarm doesn't reintroduce the
525
+ // overlay.
526
+ if (preview !== null && !state.sectionPickMode) {
527
+ return state;
528
+ }
529
+ return { sectionPickPreview: preview };
203
530
  }),
204
531
  });
@@ -0,0 +1,217 @@
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
+ import { describe, it, beforeEach, afterEach } from 'node:test';
6
+ import assert from 'node:assert';
7
+
8
+ // Stand-up an in-memory localStorage shim before importing the slice
9
+ // — the constants module reads `localStorage` at import-time to seed
10
+ // the initial `mergeLayers` value, and we want each test case to
11
+ // control that seed deterministically.
12
+ //
13
+ // The shim is also what the slice's `setMergeLayers` writes back to.
14
+ interface MutableStorage {
15
+ store: Record<string, string>;
16
+ }
17
+
18
+ const STORAGE_KEY = 'ifc-lite-merge-layers';
19
+
20
+ function installLocalStorage(initial: Record<string, string> = {}): MutableStorage {
21
+ const handle: MutableStorage = { store: { ...initial } };
22
+ const storage = {
23
+ getItem: (key: string) => (key in handle.store ? handle.store[key] : null),
24
+ setItem: (key: string, value: string) => {
25
+ handle.store[key] = String(value);
26
+ },
27
+ removeItem: (key: string) => {
28
+ delete handle.store[key];
29
+ },
30
+ clear: () => {
31
+ handle.store = {};
32
+ },
33
+ key: (i: number) => Object.keys(handle.store)[i] ?? null,
34
+ get length() {
35
+ return Object.keys(handle.store).length;
36
+ },
37
+ };
38
+ Object.defineProperty(globalThis, 'localStorage', {
39
+ value: storage,
40
+ configurable: true,
41
+ writable: true,
42
+ });
43
+ // `window` is referenced by both `getInitialMergeLayers` (as a
44
+ // browser-environment guard) and `getInitialTheme` (which calls
45
+ // `matchMedia`). Both are evaluated at module-import time inside
46
+ // `constants.ts`, so the shim must answer both before we import the
47
+ // slice. A minimal `matchMedia` stub returning `{matches: false}`
48
+ // is enough to drive `getInitialTheme` down the light-mode branch.
49
+ Object.defineProperty(globalThis, 'window', {
50
+ value: globalThis,
51
+ configurable: true,
52
+ writable: true,
53
+ });
54
+ Object.defineProperty(globalThis, 'matchMedia', {
55
+ value: () => ({
56
+ matches: false,
57
+ media: '',
58
+ onchange: null,
59
+ addListener: () => {},
60
+ removeListener: () => {},
61
+ addEventListener: () => {},
62
+ removeEventListener: () => {},
63
+ dispatchEvent: () => false,
64
+ }),
65
+ configurable: true,
66
+ writable: true,
67
+ });
68
+ // `document` is touched by uiSlice's `applyThemeClasses` — provide
69
+ // a minimal `documentElement.classList.toggle` stub so the slice
70
+ // can be constructed without DOM globals.
71
+ Object.defineProperty(globalThis, 'document', {
72
+ value: {
73
+ documentElement: {
74
+ classList: {
75
+ toggle: () => {},
76
+ add: () => {},
77
+ remove: () => {},
78
+ contains: () => false,
79
+ },
80
+ },
81
+ },
82
+ configurable: true,
83
+ writable: true,
84
+ });
85
+ return handle;
86
+ }
87
+
88
+ function uninstallLocalStorage(): void {
89
+ Reflect.deleteProperty(globalThis as Record<string, unknown>, 'localStorage');
90
+ Reflect.deleteProperty(globalThis as Record<string, unknown>, 'window');
91
+ Reflect.deleteProperty(globalThis as Record<string, unknown>, 'matchMedia');
92
+ Reflect.deleteProperty(globalThis as Record<string, unknown>, 'document');
93
+ }
94
+
95
+ /**
96
+ * Build a fresh slice instance with whatever cross-slice fields the
97
+ * setter needs to read. The viewer store wires this slice on top of
98
+ * the federated model map + the legacy single-model `geometryResult`;
99
+ * we mirror the same shape so the test exercises the production
100
+ * branch verbatim.
101
+ *
102
+ * NOTE on module caching: Node's ESM loader caches modules by URL.
103
+ * `getInitialMergeLayers` (in `constants.ts`) reads localStorage at
104
+ * import-time, so the first test in this file determines the seed
105
+ * baked into `UI_DEFAULTS.MERGE_LAYERS`. We expose `initialFromUiDefaults`
106
+ * so the "reads from localStorage on construction" test can probe the
107
+ * value directly without relying on a re-import (which Node 22's ESM
108
+ * loader rejects with `ERR_UNKNOWN_BUILTIN_MODULE` for relative paths).
109
+ */
110
+ async function buildSlice(crossSlice: { models?: Map<string, unknown>; geometryResult?: { meshes: unknown[] } | null } = {}) {
111
+ const mod = await import('./uiSlice.js');
112
+ const createUISlice = (mod as { createUISlice: (...args: unknown[]) => unknown }).createUISlice;
113
+ let state: Record<string, unknown> = {
114
+ models: crossSlice.models ?? new Map(),
115
+ geometryResult: crossSlice.geometryResult ?? null,
116
+ };
117
+ const setState = (partial: unknown) => {
118
+ if (typeof partial === 'function') {
119
+ const updates = (partial as (s: Record<string, unknown>) => Record<string, unknown>)(state);
120
+ state = { ...state, ...updates };
121
+ } else {
122
+ state = { ...state, ...(partial as Record<string, unknown>) };
123
+ }
124
+ };
125
+ const get = () => state;
126
+ state = {
127
+ ...state,
128
+ ...(createUISlice as (set: unknown, get: unknown, api: unknown) => Record<string, unknown>)(setState, get, {}),
129
+ };
130
+ return {
131
+ get state() {
132
+ return state;
133
+ },
134
+ };
135
+ }
136
+
137
+ describe('UISlice — merge-layers', () => {
138
+ let storage: MutableStorage | null = null;
139
+
140
+ beforeEach(() => {
141
+ storage = installLocalStorage();
142
+ });
143
+
144
+ afterEach(() => {
145
+ storage = null;
146
+ uninstallLocalStorage();
147
+ });
148
+
149
+ it('defaults mergeLayers to false when localStorage is empty', async () => {
150
+ const slice = await buildSlice();
151
+ assert.strictEqual(slice.state.mergeLayers, false);
152
+ assert.strictEqual(slice.state.mergeLayersPendingReload, false);
153
+ });
154
+
155
+ it('reads the seeded value from localStorage via UI_DEFAULTS', async () => {
156
+ // The slice seeds `mergeLayers` from `UI_DEFAULTS.MERGE_LAYERS`,
157
+ // which is evaluated at module-import time. Because ESM modules
158
+ // load once per process, this assertion proves the slice respects
159
+ // whatever value `UI_DEFAULTS` carried at startup — and confirms
160
+ // that the slice's initial state matches the defaults table.
161
+ const constantsMod = await import('../constants.js');
162
+ const slice = await buildSlice();
163
+ assert.strictEqual(slice.state.mergeLayers, constantsMod.UI_DEFAULTS.MERGE_LAYERS);
164
+ });
165
+
166
+ it('writes mergeLayers to localStorage on setMergeLayers', async () => {
167
+ const slice = await buildSlice();
168
+ (slice.state.setMergeLayers as (v: boolean) => void)(true);
169
+ assert.strictEqual(storage!.store[STORAGE_KEY], 'true');
170
+ (slice.state.setMergeLayers as (v: boolean) => void)(false);
171
+ assert.strictEqual(storage!.store[STORAGE_KEY], 'false');
172
+ });
173
+
174
+ it('does NOT set pendingReload when no model is loaded', async () => {
175
+ const slice = await buildSlice({ models: new Map(), geometryResult: null });
176
+ (slice.state.setMergeLayers as (v: boolean) => void)(true);
177
+ assert.strictEqual(slice.state.mergeLayers, true);
178
+ assert.strictEqual(slice.state.mergeLayersPendingReload, false);
179
+ });
180
+
181
+ it('sets pendingReload when a federated model is loaded', async () => {
182
+ const models = new Map();
183
+ models.set('m1', { id: 'm1' });
184
+ const slice = await buildSlice({ models });
185
+ (slice.state.setMergeLayers as (v: boolean) => void)(true);
186
+ assert.strictEqual(slice.state.mergeLayersPendingReload, true);
187
+ });
188
+
189
+ it('sets pendingReload when legacy geometryResult has meshes', async () => {
190
+ const slice = await buildSlice({ geometryResult: { meshes: [{}] } });
191
+ (slice.state.setMergeLayers as (v: boolean) => void)(true);
192
+ assert.strictEqual(slice.state.mergeLayersPendingReload, true);
193
+ });
194
+
195
+ it('is a no-op when the value matches the current flag', async () => {
196
+ const slice = await buildSlice({ geometryResult: { meshes: [{}] } });
197
+ // First flip: false → true, pending reload because a model is loaded
198
+ (slice.state.setMergeLayers as (v: boolean) => void)(true);
199
+ assert.strictEqual(slice.state.mergeLayersPendingReload, true);
200
+ // Second flip to the same value should not toggle pending again
201
+ // after a manual clear.
202
+ (slice.state.clearMergeLayersPendingReload as () => void)();
203
+ assert.strictEqual(slice.state.mergeLayersPendingReload, false);
204
+ (slice.state.setMergeLayers as (v: boolean) => void)(true);
205
+ assert.strictEqual(slice.state.mergeLayersPendingReload, false);
206
+ });
207
+
208
+ it('clearMergeLayersPendingReload flips the flag back to false', async () => {
209
+ const slice = await buildSlice({ geometryResult: { meshes: [{}] } });
210
+ (slice.state.setMergeLayers as (v: boolean) => void)(true);
211
+ assert.strictEqual(slice.state.mergeLayersPendingReload, true);
212
+ (slice.state.clearMergeLayersPendingReload as () => void)();
213
+ assert.strictEqual(slice.state.mergeLayersPendingReload, false);
214
+ // mergeLayers itself is unaffected by the dismiss.
215
+ assert.strictEqual(slice.state.mergeLayers, true);
216
+ });
217
+ });