@ifc-lite/viewer 1.19.0 → 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 (129) hide show
  1. package/.turbo/turbo-build.log +59 -43
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +496 -0
  4. package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  7. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  8. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  9. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  10. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  11. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  12. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  13. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  14. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  15. package/dist/assets/index-CSWgTe1s.css +1 -0
  16. package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
  17. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  18. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  19. package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
  20. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  21. package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
  22. package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
  23. package/dist/assets/three-CDRZThFA.js +4057 -0
  24. package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
  25. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  26. package/dist/index.html +10 -9
  27. package/dist/samples/building-architecture.ifc +453 -0
  28. package/dist/samples/hello-wall.ifc +1054 -0
  29. package/dist/samples/infra-bridge.ifc +962 -0
  30. package/index.html +1 -1
  31. package/package.json +15 -10
  32. package/public/samples/building-architecture.ifc +453 -0
  33. package/public/samples/hello-wall.ifc +1054 -0
  34. package/public/samples/infra-bridge.ifc +962 -0
  35. package/src/App.tsx +37 -3
  36. package/src/components/mcp/HeroScene.tsx +876 -0
  37. package/src/components/mcp/McpLanding.tsx +1318 -0
  38. package/src/components/mcp/McpPlayground.tsx +524 -0
  39. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  40. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  41. package/src/components/mcp/README.md +171 -0
  42. package/src/components/mcp/data.ts +659 -0
  43. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  44. package/src/components/mcp/playground-files.ts +107 -0
  45. package/src/components/mcp/playground-uploads.ts +122 -0
  46. package/src/components/mcp/types.ts +65 -0
  47. package/src/components/mcp/use-mcp-page.ts +109 -0
  48. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  49. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  50. package/src/components/viewer/DeviationPanel.tsx +172 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  52. package/src/components/viewer/HoverTooltip.tsx +5 -0
  53. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  54. package/src/components/viewer/IDSPanel.tsx +80 -26
  55. package/src/components/viewer/MainToolbar.tsx +79 -7
  56. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  57. package/src/components/viewer/MobileToolbar.tsx +326 -0
  58. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  59. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  60. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  61. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  62. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  63. package/src/components/viewer/StatusBar.tsx +14 -0
  64. package/src/components/viewer/ViewerLayout.tsx +288 -95
  65. package/src/components/viewer/Viewport.tsx +86 -18
  66. package/src/components/viewer/ViewportContainer.tsx +60 -15
  67. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  68. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  69. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  70. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  71. package/src/components/viewer/selectionHandlers.ts +41 -0
  72. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  73. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  74. package/src/components/viewer/useAnimationLoop.ts +22 -0
  75. package/src/components/viewer/useMouseControls.ts +296 -3
  76. package/src/components/viewer/usePointCloudSync.ts +8 -1
  77. package/src/components/viewer/useRenderUpdates.ts +21 -1
  78. package/src/components/viewer/useTouchControls.ts +100 -41
  79. package/src/generated/mcp-catalog.json +82 -0
  80. package/src/hooks/federationLoadGate.test.ts +90 -0
  81. package/src/hooks/federationLoadGate.ts +127 -0
  82. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  83. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  84. package/src/hooks/useDrawingGeneration.ts +81 -8
  85. package/src/hooks/useIDS.ts +90 -10
  86. package/src/hooks/useIfcFederation.ts +94 -16
  87. package/src/hooks/useIfcLoader.ts +289 -64
  88. package/src/hooks/useViewerSelectors.ts +10 -0
  89. package/src/lib/geo/cesium-bridge.ts +84 -67
  90. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  91. package/src/lib/geo/clamp-anchor.ts +57 -0
  92. package/src/lib/geo/effective-georef.test.ts +79 -1
  93. package/src/lib/geo/effective-georef.ts +83 -0
  94. package/src/lib/geo/reproject.ts +26 -13
  95. package/src/lib/geo/terrain-elevation.ts +166 -0
  96. package/src/lib/lens/adapter.ts +1 -1
  97. package/src/lib/llm/context-builder.ts +1 -1
  98. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  99. package/src/lib/perf/memoryAccounting.ts +235 -0
  100. package/src/sdk/adapters/mutation-view.ts +1 -1
  101. package/src/store/constants.ts +39 -2
  102. package/src/store/index.ts +6 -1
  103. package/src/store/slices/cesiumSlice.ts +1 -1
  104. package/src/store/slices/idsSlice.ts +24 -0
  105. package/src/store/slices/loadingSlice.ts +12 -0
  106. package/src/store/slices/pointCloudSlice.ts +72 -1
  107. package/src/store/slices/sectionSlice.test.ts +590 -1
  108. package/src/store/slices/sectionSlice.ts +344 -17
  109. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  110. package/src/store/slices/uiSlice.ts +60 -2
  111. package/src/store/types.ts +42 -0
  112. package/src/store.ts +13 -0
  113. package/src/utils/acquireFileBuffer.test.ts +231 -0
  114. package/src/utils/acquireFileBuffer.ts +128 -0
  115. package/src/utils/ifcConfig.ts +24 -0
  116. package/src/utils/nativeSpatialDataStore.ts +20 -2
  117. package/src/utils/spatialHierarchy.test.ts +116 -0
  118. package/src/utils/spatialHierarchy.ts +23 -0
  119. package/tailwind.config.js +5 -0
  120. package/tsconfig.json +1 -0
  121. package/vite.config.ts +12 -0
  122. package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
  123. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  124. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  125. package/dist/assets/exporters-BraHBeoi.js +0 -81583
  126. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  127. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  128. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  129. package/dist/assets/index-0XpVr_S5.css +0 -1
@@ -4,8 +4,75 @@
4
4
 
5
5
  import { describe, it, beforeEach } from 'node:test';
6
6
  import assert from 'node:assert';
7
- import { createSectionSlice, type SectionSlice } from './sectionSlice.js';
7
+ import {
8
+ createSectionSlice,
9
+ customPlaneCenter,
10
+ loadLastSectionMode,
11
+ type SectionSlice,
12
+ } from './sectionSlice.js';
8
13
  import { SECTION_PLANE_DEFAULTS } from '../constants.js';
14
+ import type { CustomSectionPlane } from '../types.js';
15
+
16
+ // ─── Test helpers ──────────────────────────────────────────────────────
17
+ // Replaces the original `dot` + literal-tuple comparison style with two
18
+ // shared helpers that make geometric assertions both stricter and less
19
+ // brittle:
20
+ // • `assertVecClose` — epsilon compare, ignores signed-zero noise
21
+ // (`Math.abs(0 - (-0)) === 0`) so tests don't couple to flipped
22
+ // normal sign quirks (CR feedback PR #650).
23
+ // • `assertOrthonormalBasis` — checks both orthogonality AND unit
24
+ // length of tangent + bitangent. The previous "orthonormal"
25
+ // assertion only checked dot products, so a basis with non-unit
26
+ // tangent/bitangent would pass silently (CR feedback PR #650).
27
+ function assertVecClose(actual: ArrayLike<number>, expected: ArrayLike<number>, eps = 1e-9): void {
28
+ assert.strictEqual(actual.length, expected.length, `length mismatch: ${actual.length} vs ${expected.length}`);
29
+ for (let i = 0; i < expected.length; i++) {
30
+ const diff = Math.abs(actual[i] - expected[i]);
31
+ assert.ok(diff < eps, `axis ${i}: expected ${expected[i]}, got ${actual[i]} (|diff|=${diff})`);
32
+ }
33
+ }
34
+
35
+ function assertOrthonormalBasis(t: number[], b: number[], n: number[]): void {
36
+ const dot = (a: number[], c: number[]) => a[0] * c[0] + a[1] * c[1] + a[2] * c[2];
37
+ const tLen = Math.hypot(t[0], t[1], t[2]);
38
+ const bLen = Math.hypot(b[0], b[1], b[2]);
39
+ assert.ok(Math.abs(tLen - 1) < 1e-9, `tangent must be unit length (got ${tLen})`);
40
+ assert.ok(Math.abs(bLen - 1) < 1e-9, `bitangent must be unit length (got ${bLen})`);
41
+ assert.ok(Math.abs(dot(t, n)) < 1e-9, `tangent must be perpendicular to normal (dot=${dot(t, n)})`);
42
+ assert.ok(Math.abs(dot(b, n)) < 1e-9, `bitangent must be perpendicular to normal (dot=${dot(b, n)})`);
43
+ assert.ok(Math.abs(dot(t, b)) < 1e-9, `tangent must be perpendicular to bitangent (dot=${dot(t, b)})`);
44
+ }
45
+
46
+ // In-memory localStorage shim for tests that exercise the slice's
47
+ // persistence helpers (last-used section mode, issue #243 follow-up).
48
+ // node:test runs without a DOM, so the slice's `typeof window ===
49
+ // 'undefined'` guards short-circuit by default. We install a real
50
+ // `window.localStorage` for the duration of the persistence tests so
51
+ // save/load actually round-trips through code rather than no-opping.
52
+ class MemoryStorage {
53
+ private store = new Map<string, string>();
54
+ get length(): number { return this.store.size; }
55
+ clear(): void { this.store.clear(); }
56
+ getItem(key: string): string | null { return this.store.has(key) ? this.store.get(key)! : null; }
57
+ setItem(key: string, value: string): void { this.store.set(key, String(value)); }
58
+ removeItem(key: string): void { this.store.delete(key); }
59
+ key(i: number): string | null { return Array.from(this.store.keys())[i] ?? null; }
60
+ }
61
+
62
+ function installWindowShim(): { uninstall: () => void; storage: MemoryStorage } {
63
+ const storage = new MemoryStorage();
64
+ const g = globalThis as unknown as { window?: unknown };
65
+ const had = 'window' in g;
66
+ const prev = g.window;
67
+ g.window = { localStorage: storage } as unknown;
68
+ return {
69
+ storage,
70
+ uninstall: () => {
71
+ if (had) g.window = prev;
72
+ else delete g.window;
73
+ },
74
+ };
75
+ }
9
76
 
10
77
  describe('SectionSlice', () => {
11
78
  let state: SectionSlice;
@@ -109,6 +176,10 @@ describe('SectionSlice', () => {
109
176
 
110
177
  describe('setSectionShowCap', () => {
111
178
  it('should toggle the showCap flag without touching clipping', () => {
179
+ // Explicitly enable clipping first so we can assert "cap toggle
180
+ // didn't disable it". Default `enabled` is now `false` (issue
181
+ // #243 follow-up: opening the section tool starts uncut).
182
+ state.setSectionPlaneEnabled(true);
112
183
  assert.strictEqual(state.sectionPlane.showCap, true);
113
184
  state.setSectionShowCap(false);
114
185
  assert.strictEqual(state.sectionPlane.showCap, false);
@@ -119,6 +190,7 @@ describe('SectionSlice', () => {
119
190
 
120
191
  describe('setSectionShowOutlines', () => {
121
192
  it('should toggle the showOutlines flag independently of showCap and clipping', () => {
193
+ state.setSectionPlaneEnabled(true);
122
194
  assert.strictEqual(state.sectionPlane.showOutlines, true);
123
195
  state.setSectionShowOutlines(false);
124
196
  assert.strictEqual(state.sectionPlane.showOutlines, false);
@@ -154,6 +226,10 @@ describe('SectionSlice', () => {
154
226
 
155
227
  describe('toggleSectionPlane', () => {
156
228
  it('should toggle enabled from true to false', () => {
229
+ // Default is now `false` (issue #243 follow-up). Set explicitly
230
+ // so this test exercises the true → false transition regardless
231
+ // of the default.
232
+ state.setSectionPlaneEnabled(true);
157
233
  assert.strictEqual(state.sectionPlane.enabled, true);
158
234
  state.toggleSectionPlane();
159
235
  assert.strictEqual(state.sectionPlane.enabled, false);
@@ -180,6 +256,359 @@ describe('SectionSlice', () => {
180
256
  });
181
257
  });
182
258
 
259
+ describe('face-pick (custom plane)', () => {
260
+ it('setSectionPlaneFromFace stores a unit-length normal + signed distance', () => {
261
+ // Non-unit input: the slice should renormalise before persisting.
262
+ state.setSectionPlaneFromFace([2, 0, 0], [3, 4, 5]);
263
+ const c = state.sectionPlane.custom;
264
+ assert.ok(c, 'custom plane should be set');
265
+ // Use the epsilon helper so signed-zero noise from renormalisation
266
+ // (e.g. `0 / 2 === 0` vs `-0 / 2 === -0`) doesn't cause spurious
267
+ // failures (CR feedback PR #650).
268
+ assertVecClose(c!.normal, [1, 0, 0]);
269
+ assert.strictEqual(c!.distance, 3); // dot([3,4,5], [1,0,0])
270
+ assert.deepStrictEqual(c!.pickedAt, [3, 4, 5]);
271
+ assert.strictEqual(state.sectionPlane.enabled, true);
272
+ assert.strictEqual(state.sectionPickMode, false);
273
+ });
274
+
275
+ it('setSectionPlaneFromFace updates axis + flipped to the signed-dominant cardinal', () => {
276
+ // CR P1 from #581: dropping the sign produced inverted exports.
277
+ state.setSectionPlaneFromFace([-1, 0, 0], [0, 0, 0]);
278
+ assert.strictEqual(state.sectionPlane.axis, 'side');
279
+ assert.strictEqual(state.sectionPlane.flipped, true);
280
+
281
+ state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 0]);
282
+ assert.strictEqual(state.sectionPlane.axis, 'front');
283
+ assert.strictEqual(state.sectionPlane.flipped, false);
284
+ });
285
+
286
+ it('setSectionPlaneFromFace updates position % when bounds are supplied', () => {
287
+ // CR P2 from #581: leaving position stale produced wrong fallback cuts.
288
+ state.setSectionPlaneFromFace(
289
+ [0, 1, 0],
290
+ [0, 5, 0],
291
+ { min: [0, 0, 0], max: [10, 10, 10] },
292
+ );
293
+ assert.strictEqual(state.sectionPlane.position, 50);
294
+ });
295
+
296
+ it('setSectionPlaneFromFace stores an orthonormal tangent + bitangent', () => {
297
+ state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 0]);
298
+ const c = state.sectionPlane.custom!;
299
+ assertOrthonormalBasis([...c.tangent], [...c.bitangent], [...c.normal]);
300
+ });
301
+
302
+ it('setSectionPlaneFromFace ignores a degenerate (zero-length) normal', () => {
303
+ state.setSectionPickMode(true);
304
+ state.setSectionPlaneFromFace([0, 0, 0], [1, 2, 3]);
305
+ assert.strictEqual(state.sectionPlane.custom, undefined);
306
+ assert.strictEqual(state.sectionPickMode, false);
307
+ });
308
+
309
+ it('setSectionPlaneAxis clears any custom plane', () => {
310
+ state.setSectionPlaneFromFace([1, 0, 0], [5, 0, 0]);
311
+ assert.ok(state.sectionPlane.custom);
312
+ state.setSectionPlaneAxis('down');
313
+ assert.strictEqual(state.sectionPlane.custom, undefined);
314
+ assert.strictEqual(state.sectionPlane.axis, 'down');
315
+ });
316
+
317
+ it('flipSectionPlane toggles `flipped` without mutating custom geometry', () => {
318
+ // The renderer applies `flipped` independently in the clip shader
319
+ // (`side = flipped ? -1 : 1`). Mutating `normal` / `distance` here
320
+ // as well would double-cancel and the flip button would have no
321
+ // visible effect — see flipSectionPlane in the slice.
322
+ state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 5]);
323
+ const before = state.sectionPlane.custom!;
324
+ assert.strictEqual(state.sectionPlane.flipped, false);
325
+ assert.strictEqual(before.distance, 5);
326
+
327
+ state.flipSectionPlane();
328
+ const after = state.sectionPlane.custom!;
329
+ assert.strictEqual(state.sectionPlane.flipped, true);
330
+ // Geometry is untouched — only the `flipped` boolean changes.
331
+ assert.deepStrictEqual(after.normal, before.normal);
332
+ assert.strictEqual( after.distance, before.distance);
333
+ assert.deepStrictEqual(after.pickedAt, before.pickedAt);
334
+ assert.deepStrictEqual(after.tangent, before.tangent);
335
+ assert.deepStrictEqual(after.bitangent, before.bitangent);
336
+ });
337
+
338
+ it('flipSectionPlane is its own inverse — two flips return to the original state', () => {
339
+ state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 5]);
340
+ const original = state.sectionPlane.custom!;
341
+ const originalFlipped = state.sectionPlane.flipped;
342
+
343
+ state.flipSectionPlane();
344
+ state.flipSectionPlane();
345
+
346
+ const after = state.sectionPlane.custom!;
347
+ assert.strictEqual(state.sectionPlane.flipped, originalFlipped);
348
+ // Geometry must never have been mutated through the round-trip.
349
+ assert.deepStrictEqual(after.normal, original.normal);
350
+ assert.strictEqual( after.distance, original.distance);
351
+ assert.deepStrictEqual(after.pickedAt, original.pickedAt);
352
+ assert.deepStrictEqual(after.tangent, original.tangent);
353
+ assert.deepStrictEqual(after.bitangent, original.bitangent);
354
+ });
355
+
356
+ it('flipSectionPlane toggles `flipped` for cardinal planes too (no custom)', () => {
357
+ assert.strictEqual(state.sectionPlane.custom, undefined);
358
+ assert.strictEqual(state.sectionPlane.flipped, false);
359
+ state.flipSectionPlane();
360
+ assert.strictEqual(state.sectionPlane.flipped, true);
361
+ assert.strictEqual(state.sectionPlane.custom, undefined);
362
+ state.flipSectionPlane();
363
+ assert.strictEqual(state.sectionPlane.flipped, false);
364
+ });
365
+
366
+ it('setSectionCustomDistance updates distance without touching anything else', () => {
367
+ state.setSectionPlaneFromFace([0, 1, 0], [0, 3, 0]);
368
+ const before = state.sectionPlane.custom!;
369
+ state.setSectionCustomDistance(7);
370
+ const after = state.sectionPlane.custom!;
371
+ assert.strictEqual(after.distance, 7);
372
+ assert.deepStrictEqual(after.normal, before.normal);
373
+ assert.deepStrictEqual(after.pickedAt, before.pickedAt);
374
+ assert.deepStrictEqual(after.tangent, before.tangent);
375
+ });
376
+
377
+ it('setSectionCustomDistance is a no-op without a custom plane', () => {
378
+ assert.strictEqual(state.sectionPlane.custom, undefined);
379
+ state.setSectionCustomDistance(42);
380
+ assert.strictEqual(state.sectionPlane.custom, undefined);
381
+ });
382
+
383
+ it('setSectionPickMode arms / disarms pick mode', () => {
384
+ assert.strictEqual(state.sectionPickMode, false);
385
+ state.setSectionPickMode(true);
386
+ assert.strictEqual(state.sectionPickMode, true);
387
+ state.setSectionPickMode(false);
388
+ assert.strictEqual(state.sectionPickMode, false);
389
+ });
390
+
391
+ it('setSectionPickPreview stores the preview only while pick mode is armed', () => {
392
+ // Mode OFF: a stray late-fired hover event must not put the
393
+ // overlay back on screen with no way to commit it.
394
+ assert.strictEqual(state.sectionPickMode, false);
395
+ state.setSectionPickPreview({
396
+ normal: [0, 1, 0],
397
+ point: [1, 2, 3],
398
+ faceKey: 'mode-off',
399
+ });
400
+ assert.strictEqual(state.sectionPickPreview, null);
401
+
402
+ // Mode ON: preview is accepted.
403
+ state.setSectionPickMode(true);
404
+ const p: import('./sectionSlice.js').SectionPickPreview = {
405
+ normal: [0, 1, 0],
406
+ point: [4, 5, 6],
407
+ faceKey: 'mode-on',
408
+ };
409
+ state.setSectionPickPreview(p);
410
+ assert.deepStrictEqual(state.sectionPickPreview, p);
411
+
412
+ // Explicit clear is always allowed (the hover handler uses
413
+ // `null` to hide the overlay even after disarm — the inverse
414
+ // case of the guard above).
415
+ state.setSectionPickPreview(null);
416
+ assert.strictEqual(state.sectionPickPreview, null);
417
+ });
418
+
419
+ it('setSectionPickMode(false) clears any active preview', () => {
420
+ state.setSectionPickMode(true);
421
+ state.setSectionPickPreview({
422
+ normal: [1, 0, 0],
423
+ point: [0, 0, 0],
424
+ faceKey: 'fk',
425
+ });
426
+ assert.ok(state.sectionPickPreview);
427
+ state.setSectionPickMode(false);
428
+ assert.strictEqual(state.sectionPickPreview, null);
429
+ assert.strictEqual(state.sectionPickMode, false);
430
+ });
431
+
432
+ it('setSectionPlaneFromFace clears the preview on commit', () => {
433
+ // Visually continuous handoff — the preview disappears the same
434
+ // frame the cap appears so we don't double-paint the face.
435
+ state.setSectionPickMode(true);
436
+ state.setSectionPickPreview({
437
+ normal: [0, 0, 1],
438
+ point: [0, 0, 5],
439
+ faceKey: 'fk',
440
+ });
441
+ state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 5]);
442
+ assert.strictEqual(state.sectionPickPreview, null);
443
+ });
444
+
445
+ it('setSectionPlaneFromFace clears the preview even on a degenerate normal', () => {
446
+ state.setSectionPickMode(true);
447
+ state.setSectionPickPreview({
448
+ normal: [0, 0, 1],
449
+ point: [0, 0, 5],
450
+ faceKey: 'fk',
451
+ });
452
+ state.setSectionPlaneFromFace([0, 0, 0], [1, 2, 3]);
453
+ assert.strictEqual(state.sectionPickPreview, null);
454
+ assert.strictEqual(state.sectionPickMode, false);
455
+ });
456
+
457
+ it('resetSectionPlane clears the preview', () => {
458
+ state.setSectionPickMode(true);
459
+ state.setSectionPickPreview({
460
+ normal: [0, 1, 0],
461
+ point: [0, 0, 0],
462
+ faceKey: 'fk',
463
+ });
464
+ state.resetSectionPlane();
465
+ assert.strictEqual(state.sectionPickPreview, null);
466
+ });
467
+
468
+ it('resetSectionPlane clears the custom plane and disarms pick mode', () => {
469
+ state.setSectionPlaneFromFace([1, 0, 0], [5, 0, 0]);
470
+ state.setSectionPickMode(true);
471
+ state.resetSectionPlane();
472
+ assert.strictEqual(state.sectionPlane.custom, undefined);
473
+ assert.strictEqual(state.sectionPickMode, false);
474
+ });
475
+ });
476
+
477
+ // Mirrors the auto-arm useEffect in `SectionPanel.tsx` that flips
478
+ // `sectionPickMode` on after a 200ms debounce when the panel mounts and
479
+ // turns it off on unmount. We exercise the same lifecycle here at the
480
+ // slice level because the viewer app has no React component test harness
481
+ // (tests run via `node:test` + `tsx`); the component-side code path is a
482
+ // single setTimeout + setSectionPickMode call so re-creating the timer
483
+ // semantics here is sufficient regression coverage.
484
+ describe('section panel auto-arm lifecycle', () => {
485
+ it('mounting arms pick mode after the 200ms debounce', async () => {
486
+ assert.strictEqual(state.sectionPickMode, false);
487
+ // Mirror the panel's mount effect: schedule the arm, wait, observe.
488
+ const t = setTimeout(() => state.setSectionPickMode(true), 200);
489
+ try {
490
+ // Still false before the debounce fires — guards against the
491
+ // tool-open click bleeding through into the canvas pick handler.
492
+ assert.strictEqual(state.sectionPickMode, false);
493
+ await new Promise((resolve) => setTimeout(resolve, 220));
494
+ assert.strictEqual(state.sectionPickMode, true);
495
+ } finally {
496
+ clearTimeout(t);
497
+ }
498
+ });
499
+
500
+ it('unmounting before the debounce fires never arms pick mode', async () => {
501
+ assert.strictEqual(state.sectionPickMode, false);
502
+ const t = setTimeout(() => state.setSectionPickMode(true), 200);
503
+ // Immediate unmount: cancel the pending arm + disarm explicitly,
504
+ // matching the cleanup function in the panel's useEffect.
505
+ clearTimeout(t);
506
+ state.setSectionPickMode(false);
507
+ // Wait past the original debounce window — pick mode must stay off
508
+ // because the timer was cancelled.
509
+ await new Promise((resolve) => setTimeout(resolve, 220));
510
+ assert.strictEqual(state.sectionPickMode, false);
511
+ });
512
+
513
+ it('unmounting after auto-arm disarms pick mode and clears any preview', () => {
514
+ // Simulate "panel was mounted long enough for the debounce to land,
515
+ // user closed the tool". The cleanup must drop pick mode and any
516
+ // hover preview so the next tool doesn't inherit the violet quad.
517
+ state.setSectionPickMode(true);
518
+ state.setSectionPickPreview({
519
+ normal: [0, 1, 0],
520
+ point: [0, 0, 0],
521
+ faceKey: 'unmount-preview',
522
+ });
523
+ assert.strictEqual(state.sectionPickMode, true);
524
+ assert.ok(state.sectionPickPreview);
525
+
526
+ // Cleanup body in the panel's useEffect.
527
+ state.setSectionPickMode(false);
528
+
529
+ assert.strictEqual(state.sectionPickMode, false);
530
+ assert.strictEqual(state.sectionPickPreview, null);
531
+ });
532
+
533
+ it('clicking a cardinal axis while armed still works and clears any custom plane', () => {
534
+ // Regression guard for the demoted cardinal-axis row: even though
535
+ // the buttons are visually secondary now, clicking one must commit
536
+ // the cardinal cut and clear any face-picked custom plane (the
537
+ // existing behaviour the panel relied on).
538
+ state.setSectionPlaneFromFace([1, 0, 0], [5, 0, 0]);
539
+ assert.ok(state.sectionPlane.custom);
540
+ state.setSectionPickMode(true);
541
+
542
+ state.setSectionPlaneAxis('front');
543
+
544
+ assert.strictEqual(state.sectionPlane.custom, undefined);
545
+ assert.strictEqual(state.sectionPlane.axis, 'front');
546
+ assert.strictEqual(state.sectionPlane.enabled, true);
547
+ });
548
+ });
549
+
550
+ describe('customPlaneCenter', () => {
551
+ // Bug guard for the cap polygons + 3D drag gizmo "anchored at original
552
+ // pick" regression: as `distance` drifts (drag/slider) the visual
553
+ // center of the plane must slide along the normal, not stay glued to
554
+ // the original pickedAt — otherwise the cap and gizmo render at the
555
+ // pick location while the geometry clip moves to the new distance.
556
+ it('returns pickedAt unchanged when distance == dot(pickedAt, normal)', () => {
557
+ const plane: CustomSectionPlane = {
558
+ normal: [1, 0, 0],
559
+ distance: 10,
560
+ pickedAt: [10, 0, 0],
561
+ tangent: [0, 1, 0],
562
+ bitangent: [0, 0, 1],
563
+ };
564
+ const center = customPlaneCenter(plane);
565
+ assert.deepStrictEqual(center, [10, 0, 0]);
566
+ });
567
+
568
+ it('slides along the normal as distance changes (axis-aligned)', () => {
569
+ const base: CustomSectionPlane = {
570
+ normal: [1, 0, 0],
571
+ distance: 25,
572
+ pickedAt: [10, 0, 0],
573
+ tangent: [0, 1, 0],
574
+ bitangent: [0, 0, 1],
575
+ };
576
+ assert.deepStrictEqual(customPlaneCenter(base), [25, 0, 0]);
577
+
578
+ const zeroed: CustomSectionPlane = { ...base, distance: 0 };
579
+ assert.deepStrictEqual(customPlaneCenter(zeroed), [0, 0, 0]);
580
+ });
581
+
582
+ it('produces a point that satisfies dot(center, normal) == distance for an arbitrary normal', () => {
583
+ const inv = 1 / Math.sqrt(3);
584
+ const plane: CustomSectionPlane = {
585
+ normal: [inv, inv, inv],
586
+ distance: 4.2,
587
+ pickedAt: [1, 2, 3],
588
+ tangent: [1, 0, 0], // unused by the projection
589
+ bitangent: [0, 1, 0],
590
+ };
591
+ const c = customPlaneCenter(plane);
592
+ const dot = c[0] * plane.normal[0] + c[1] * plane.normal[1] + c[2] * plane.normal[2];
593
+ assert.ok(Math.abs(dot - plane.distance) < 1e-9, `dot(center, normal) = ${dot}, want ${plane.distance}`);
594
+ });
595
+
596
+ it('preserves the lateral (in-plane) offset of pickedAt — center is the perpendicular projection', () => {
597
+ // Slide pickedAt along the normal only — the projection should land
598
+ // exactly on the plane and keep the orthogonal components intact.
599
+ const plane: CustomSectionPlane = {
600
+ normal: [0, 1, 0],
601
+ distance: 5,
602
+ pickedAt: [7, 9, 4], // off-plane by (9 − 5) = 4 along +Y
603
+ tangent: [1, 0, 0],
604
+ bitangent: [0, 0, 1],
605
+ };
606
+ const c = customPlaneCenter(plane);
607
+ // X and Z (in-plane) preserved; Y projected to the plane.
608
+ assert.deepStrictEqual(c, [7, 5, 4]);
609
+ });
610
+ });
611
+
183
612
  describe('resetSectionPlane', () => {
184
613
  it('should reset to default values', () => {
185
614
  state.setSectionPlaneAxis('side');
@@ -203,3 +632,163 @@ describe('SectionSlice', () => {
203
632
  });
204
633
  });
205
634
  });
635
+
636
+ // Last-used section mode persistence (issue #243 follow-up).
637
+ //
638
+ // These tests run in their own top-level describe with an installed
639
+ // `window.localStorage` shim because the slice's persistence helpers
640
+ // short-circuit when `window` is undefined. Keeping the shim scoped to
641
+ // this block (install in beforeEach, uninstall in afterEach) means the
642
+ // rest of the suite still exercises the no-window code path.
643
+ describe('SectionSlice — last-used mode persistence', () => {
644
+ const SECTION_MODE_KEY = 'ifc-lite:section-last-mode';
645
+ let state: SectionSlice;
646
+ let setState: (partial: Partial<SectionSlice> | ((state: SectionSlice) => Partial<SectionSlice>)) => void;
647
+ let shim: ReturnType<typeof installWindowShim>;
648
+
649
+ beforeEach(() => {
650
+ shim = installWindowShim();
651
+ setState = (partial) => {
652
+ if (typeof partial === 'function') {
653
+ const updates = partial(state);
654
+ state = { ...state, ...updates };
655
+ } else {
656
+ state = { ...state, ...partial };
657
+ }
658
+ };
659
+ state = createSectionSlice(setState, () => state, {} as any);
660
+ });
661
+
662
+ // No afterEach hook is registered (node:test exposes it differently
663
+ // across versions). Each beforeEach reinstalls the shim, replacing
664
+ // the previous global, so leakage between tests is bounded.
665
+
666
+ describe('default `enabled` and storage state', () => {
667
+ it('default enabled is `false` so opening the section tool starts uncut', () => {
668
+ // Bug #1 from PR #650: `ENABLED: true` here meant a Down cut
669
+ // appeared the moment the panel mounted, before the auto-arm
670
+ // useEffect could install pick mode.
671
+ assert.strictEqual(SECTION_PLANE_DEFAULTS.ENABLED, false);
672
+ assert.strictEqual(state.sectionPlane.enabled, false);
673
+ });
674
+ });
675
+
676
+ describe('loadLastSectionMode', () => {
677
+ it('returns the default pick mode when storage is empty', () => {
678
+ assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' });
679
+ });
680
+
681
+ it('round-trips a stored pick entry', () => {
682
+ shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({ kind: 'pick' }));
683
+ assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' });
684
+ });
685
+
686
+ it('round-trips a valid cardinal entry', () => {
687
+ shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({
688
+ kind: 'cardinal', axis: 'side', position: 33.5, flipped: true,
689
+ }));
690
+ assert.deepStrictEqual(loadLastSectionMode(), {
691
+ kind: 'cardinal', axis: 'side', position: 33.5, flipped: true,
692
+ });
693
+ });
694
+
695
+ it('clamps cardinal `position` to [0, 100] on restore', () => {
696
+ // Belt-and-braces: position is clamped at the slice level too,
697
+ // but a tampered or stale value shouldn't poison the slider.
698
+ shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({
699
+ kind: 'cardinal', axis: 'down', position: 9999, flipped: false,
700
+ }));
701
+ const m = loadLastSectionMode();
702
+ assert.strictEqual(m.kind, 'cardinal');
703
+ if (m.kind === 'cardinal') assert.strictEqual(m.position, 100);
704
+ });
705
+
706
+ it('falls back to pick when JSON is corrupted', () => {
707
+ shim.storage.setItem(SECTION_MODE_KEY, 'not-valid-json{');
708
+ assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' });
709
+ });
710
+
711
+ it('falls back to pick on an unknown `kind`', () => {
712
+ shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({ kind: 'martian' }));
713
+ assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' });
714
+ });
715
+
716
+ it('falls back to pick on a cardinal entry with a bad axis', () => {
717
+ shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({
718
+ kind: 'cardinal', axis: 'sideways', position: 50, flipped: false,
719
+ }));
720
+ assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' });
721
+ });
722
+
723
+ it('falls back to pick on a cardinal entry with a non-finite position', () => {
724
+ shim.storage.setItem(SECTION_MODE_KEY, JSON.stringify({
725
+ kind: 'cardinal', axis: 'down', position: 'oops', flipped: false,
726
+ }));
727
+ assert.deepStrictEqual(loadLastSectionMode(), { kind: 'pick' });
728
+ });
729
+ });
730
+
731
+ describe('save side-effects on slice actions', () => {
732
+ it('setSectionPlaneAxis writes a cardinal entry to localStorage', () => {
733
+ state.setSectionPlaneAxis('front');
734
+ const raw = shim.storage.getItem(SECTION_MODE_KEY);
735
+ assert.ok(raw, 'expected a stored entry');
736
+ assert.deepStrictEqual(JSON.parse(raw!), {
737
+ kind: 'cardinal', axis: 'front',
738
+ position: state.sectionPlane.position,
739
+ flipped: state.sectionPlane.flipped,
740
+ });
741
+ });
742
+
743
+ it('setSectionPlanePosition writes a cardinal entry (position carries through)', () => {
744
+ state.setSectionPlanePosition(42.5);
745
+ const raw = shim.storage.getItem(SECTION_MODE_KEY);
746
+ assert.ok(raw);
747
+ const parsed = JSON.parse(raw!);
748
+ assert.strictEqual(parsed.kind, 'cardinal');
749
+ assert.strictEqual(parsed.position, 42.5);
750
+ });
751
+
752
+ it('flipSectionPlane (cardinal mode) writes the new `flipped` to localStorage', () => {
753
+ state.flipSectionPlane();
754
+ const raw = shim.storage.getItem(SECTION_MODE_KEY);
755
+ assert.ok(raw);
756
+ const parsed = JSON.parse(raw!);
757
+ assert.strictEqual(parsed.kind, 'cardinal');
758
+ assert.strictEqual(parsed.flipped, true);
759
+ });
760
+
761
+ it('setSectionPlanePosition does NOT write while in custom mode', () => {
762
+ // Custom-mode position drives a model-relative distance which we
763
+ // deliberately don't persist — re-arm pick mode on next open
764
+ // instead so the user can re-cut on a different model.
765
+ state.setSectionPlaneFromFace([1, 0, 0], [5, 0, 0]);
766
+ // The face-pick wrote `{ kind: 'pick' }`; clear it so we can
767
+ // observe whether the subsequent slider move overwrites it.
768
+ shim.storage.removeItem(SECTION_MODE_KEY);
769
+ state.setSectionPlanePosition(60);
770
+ assert.strictEqual(shim.storage.getItem(SECTION_MODE_KEY), null);
771
+ });
772
+
773
+ it('flipSectionPlane does NOT write while in custom mode', () => {
774
+ state.setSectionPlaneFromFace([1, 0, 0], [5, 0, 0]);
775
+ shim.storage.removeItem(SECTION_MODE_KEY);
776
+ state.flipSectionPlane();
777
+ assert.strictEqual(shim.storage.getItem(SECTION_MODE_KEY), null);
778
+ });
779
+
780
+ it('setSectionPlaneFromFace writes `{ kind: "pick" }` to localStorage', () => {
781
+ state.setSectionPlaneFromFace([0, 0, 1], [0, 0, 5]);
782
+ const raw = shim.storage.getItem(SECTION_MODE_KEY);
783
+ assert.ok(raw);
784
+ assert.deepStrictEqual(JSON.parse(raw!), { kind: 'pick' });
785
+ });
786
+
787
+ it('resetSectionPlane removes the storage key', () => {
788
+ state.setSectionPlaneAxis('front');
789
+ assert.ok(shim.storage.getItem(SECTION_MODE_KEY));
790
+ state.resetSectionPlane();
791
+ assert.strictEqual(shim.storage.getItem(SECTION_MODE_KEY), null);
792
+ });
793
+ });
794
+ });