@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.
- package/.turbo/turbo-build.log +59 -44
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +488 -0
- package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
- package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +8 -8
- package/index.html +1 -1
- package/package.json +10 -10
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +60 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +25 -11
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +6 -0
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-xbXqEDlO.js +0 -81590
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-2WdONLlu.js +0 -2033
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-BXeEKqJG.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 {
|
|
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
|
+
});
|