@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.
- package/.turbo/turbo-build.log +59 -43
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +496 -0
- package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- 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-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +10 -9
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/index.html +1 -1
- package/package.json +15 -10
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- 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 +79 -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 +60 -15
- 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/generated/mcp-catalog.json +82 -0
- 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 +12 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-BraHBeoi.js +0 -81583
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.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
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|