@ifc-lite/viewer 1.17.6 → 1.18.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 +17 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +513 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +10 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +69 -10
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +11 -1
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/hooks/useIfcLoader.ts +22 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +4 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +70 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store.ts +14 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -8,10 +8,138 @@
|
|
|
8
8
|
|
|
9
9
|
import { type StateCreator } from 'zustand';
|
|
10
10
|
import type { ViewerState } from '../index.js';
|
|
11
|
-
import type { MutablePropertyView } from '@ifc-lite/mutations';
|
|
11
|
+
import type { MutablePropertyView, NewEntity, IfcAttributeValue } from '@ifc-lite/mutations';
|
|
12
|
+
import { StoreEditor } from '@ifc-lite/mutations';
|
|
12
13
|
import type { Mutation, ChangeSet, PropertyValue } from '@ifc-lite/mutations';
|
|
13
14
|
import { PropertyValueType, QuantityType } from '@ifc-lite/data';
|
|
15
|
+
import {
|
|
16
|
+
addBeamToStore,
|
|
17
|
+
addColumnToStore,
|
|
18
|
+
addDoorToStore,
|
|
19
|
+
addMemberToStore,
|
|
20
|
+
addPlateToStore,
|
|
21
|
+
addRoofToStore,
|
|
22
|
+
addSlabToStore,
|
|
23
|
+
addSpaceToStore,
|
|
24
|
+
addWallToStore,
|
|
25
|
+
addWindowToStore,
|
|
26
|
+
resolveSpatialAnchor,
|
|
27
|
+
duplicateInStore,
|
|
28
|
+
resolveDuplicateSource,
|
|
29
|
+
generateSpacesFromWalls,
|
|
30
|
+
type BeamInStoreParams,
|
|
31
|
+
type ColumnInStoreParams,
|
|
32
|
+
type DoorInStoreParams,
|
|
33
|
+
type DuplicateInStoreOptions,
|
|
34
|
+
type GenerateSpacesOptions,
|
|
35
|
+
type GenerateSpacesResult,
|
|
36
|
+
type MemberInStoreParams,
|
|
37
|
+
type PlateInStoreParams,
|
|
38
|
+
type RoofInStoreParams,
|
|
39
|
+
type SlabInStoreParams,
|
|
40
|
+
type SpaceInStoreParams,
|
|
41
|
+
type WallInStoreParams,
|
|
42
|
+
type WindowInStoreParams,
|
|
43
|
+
} from '@ifc-lite/create';
|
|
14
44
|
import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
|
|
45
|
+
import type { MeshData } from '@ifc-lite/geometry';
|
|
46
|
+
import { getEntityBounds } from '@/utils/viewportUtils';
|
|
47
|
+
import { toGlobalIdFromModels } from '../globalId.js';
|
|
48
|
+
import { buildElementMesh, type ElementMeshPayload } from './addElementMeshes.js';
|
|
49
|
+
import type { AddElementType } from './addElementSlice.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* IFC-space directions for {@link MutationSlice.duplicateEntity}.
|
|
53
|
+
*
|
|
54
|
+
* Axes match the IFC storey-local frame, which the user already sees
|
|
55
|
+
* in the Raw STEP tab:
|
|
56
|
+
* - +X / -X — east / west
|
|
57
|
+
* - +Y / -Y — north / south
|
|
58
|
+
* - +Z / -Z — up / down
|
|
59
|
+
*
|
|
60
|
+
* The slice converts these to a viewer-space delta when cloning the
|
|
61
|
+
* source's meshes for immediate render.
|
|
62
|
+
*/
|
|
63
|
+
export type DuplicateDirection = '+X' | '-X' | '+Y' | '-Y' | '+Z' | '-Z';
|
|
64
|
+
|
|
65
|
+
/** Default direction used when neither the menu nor `⌘D` provides one. */
|
|
66
|
+
export const DUPLICATE_DEFAULT_DIRECTION: DuplicateDirection = '+X';
|
|
67
|
+
|
|
68
|
+
/** Fallback step in metres when the source has no mesh in geometry. */
|
|
69
|
+
const DUPLICATE_FALLBACK_STEP = 1;
|
|
70
|
+
|
|
71
|
+
interface ViewerBox {
|
|
72
|
+
/** Per-axis sizes in viewer scene coordinates. */
|
|
73
|
+
size: { x: number; y: number; z: number };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compute the IFC-space offset for a directional duplicate, sized to
|
|
78
|
+
* the source's bounding box so the duplicate sits next to the source
|
|
79
|
+
* (edge-to-edge) rather than overlapping it.
|
|
80
|
+
*
|
|
81
|
+
* Mapping (renderer is Y-up, IFC is Z-up):
|
|
82
|
+
* viewer X = IFC X (matching axis)
|
|
83
|
+
* viewer Y = IFC Z (up)
|
|
84
|
+
* viewer Z = -IFC Y (forward)
|
|
85
|
+
*/
|
|
86
|
+
function ifcOffsetForDirection(dir: DuplicateDirection, bbox: ViewerBox): [number, number, number] {
|
|
87
|
+
const sx = bbox.size.x || DUPLICATE_FALLBACK_STEP;
|
|
88
|
+
const sy = bbox.size.z || DUPLICATE_FALLBACK_STEP; // viewer Z → IFC Y
|
|
89
|
+
const sz = bbox.size.y || DUPLICATE_FALLBACK_STEP; // viewer Y → IFC Z
|
|
90
|
+
switch (dir) {
|
|
91
|
+
case '+X': return [sx, 0, 0];
|
|
92
|
+
case '-X': return [-sx, 0, 0];
|
|
93
|
+
case '+Y': return [0, sy, 0];
|
|
94
|
+
case '-Y': return [0, -sy, 0];
|
|
95
|
+
case '+Z': return [0, 0, sz];
|
|
96
|
+
case '-Z': return [0, 0, -sz];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Convert an IFC-space delta to the viewer's Y-up scene frame. */
|
|
101
|
+
function viewerDeltaFromIfc(ifc: [number, number, number]): { x: number; y: number; z: number } {
|
|
102
|
+
return { x: ifc[0], y: ifc[2], z: -ifc[1] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Clone every mesh tagged with `sourceGlobalId` and translate its
|
|
107
|
+
* vertex positions by `viewerOffset`. Normals are reused (translation
|
|
108
|
+
* doesn't affect orientation). Returns an empty array when the source
|
|
109
|
+
* isn't currently in the geometry result — caller falls back to
|
|
110
|
+
* relying on the export-only overlay.
|
|
111
|
+
*/
|
|
112
|
+
function cloneMeshesWithOffset(
|
|
113
|
+
meshes: MeshData[] | undefined,
|
|
114
|
+
sourceGlobalId: number,
|
|
115
|
+
newGlobalId: number,
|
|
116
|
+
viewerOffset: { x: number; y: number; z: number },
|
|
117
|
+
): MeshData[] {
|
|
118
|
+
if (!meshes || meshes.length === 0) return [];
|
|
119
|
+
const out: MeshData[] = [];
|
|
120
|
+
for (const m of meshes) {
|
|
121
|
+
if (m.expressId !== sourceGlobalId) continue;
|
|
122
|
+
const positions = new Float32Array(m.positions.length);
|
|
123
|
+
for (let i = 0; i < m.positions.length; i += 3) {
|
|
124
|
+
positions[i] = m.positions[i] + viewerOffset.x;
|
|
125
|
+
positions[i + 1] = m.positions[i + 1] + viewerOffset.y;
|
|
126
|
+
positions[i + 2] = m.positions[i + 2] + viewerOffset.z;
|
|
127
|
+
}
|
|
128
|
+
out.push({
|
|
129
|
+
expressId: newGlobalId,
|
|
130
|
+
positions,
|
|
131
|
+
normals: m.normals,
|
|
132
|
+
indices: m.indices,
|
|
133
|
+
color: m.color,
|
|
134
|
+
ifcType: m.ifcType,
|
|
135
|
+
modelIndex: m.modelIndex,
|
|
136
|
+
// Per-vertex entity ids only matter for color-merged batches;
|
|
137
|
+
// a single-mesh duplicate carries one expressId everywhere.
|
|
138
|
+
entityIds: m.entityIds ? new Uint32Array(m.entityIds.length).fill(newGlobalId) : undefined,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
15
143
|
|
|
16
144
|
/** Tracks georeferencing field mutations per model */
|
|
17
145
|
export interface GeorefMutationData {
|
|
@@ -23,6 +151,14 @@ export interface MutationSlice {
|
|
|
23
151
|
// State
|
|
24
152
|
/** Mutation views per model */
|
|
25
153
|
mutationViews: Map<string, MutablePropertyView>;
|
|
154
|
+
/** Per-model StoreEditor caches (created on demand). Keyed by mutation-view modelId. */
|
|
155
|
+
storeEditors: Map<string, StoreEditor>;
|
|
156
|
+
/**
|
|
157
|
+
* Tombstoned overlay entities, keyed by `${modelId}:${expressId}`. Stashed
|
|
158
|
+
* so undo of a `removeEntity` on a freshly-added overlay entity can replay
|
|
159
|
+
* the same NewEntity record back into the view.
|
|
160
|
+
*/
|
|
161
|
+
removedNewEntities: Map<string, NewEntity>;
|
|
26
162
|
/** All change sets */
|
|
27
163
|
changeSets: Map<string, ChangeSet>;
|
|
28
164
|
/** Active change set ID */
|
|
@@ -124,6 +260,115 @@ export interface MutationSlice {
|
|
|
124
260
|
oldValue?: string
|
|
125
261
|
) => Mutation | null;
|
|
126
262
|
|
|
263
|
+
// Actions - Store-Level Mutations (raw STEP entity edits)
|
|
264
|
+
/**
|
|
265
|
+
* Edit a positional STEP argument by zero-based index. Used by the Raw
|
|
266
|
+
* STEP editor for non-IfcRoot entities (profile dimensions, cartesian
|
|
267
|
+
* point coords, etc.) where the attribute has no symbolic name.
|
|
268
|
+
*/
|
|
269
|
+
setPositionalAttribute: (
|
|
270
|
+
modelId: string,
|
|
271
|
+
entityId: number,
|
|
272
|
+
index: number,
|
|
273
|
+
value: IfcAttributeValue
|
|
274
|
+
) => Mutation | null;
|
|
275
|
+
/**
|
|
276
|
+
* Tombstone an entity (existing source entity) or forget it (overlay-only).
|
|
277
|
+
* Returns true if the entity was known to the store or overlay.
|
|
278
|
+
*/
|
|
279
|
+
removeEntity: (modelId: string, expressId: number) => boolean;
|
|
280
|
+
/**
|
|
281
|
+
* Add a fully-anchored IfcColumn (and its sub-graph) to a parsed model.
|
|
282
|
+
* Returns the new column's expressId, or null if the model can't be
|
|
283
|
+
* resolved or the storey anchor lookup fails.
|
|
284
|
+
*/
|
|
285
|
+
addColumn: (
|
|
286
|
+
modelId: string,
|
|
287
|
+
storeyExpressId: number,
|
|
288
|
+
params: ColumnInStoreParams
|
|
289
|
+
) => { expressId: number } | { error: string };
|
|
290
|
+
/** Add an IfcWall anchored to a storey. */
|
|
291
|
+
addWall: (
|
|
292
|
+
modelId: string,
|
|
293
|
+
storeyExpressId: number,
|
|
294
|
+
params: WallInStoreParams
|
|
295
|
+
) => { expressId: number } | { error: string };
|
|
296
|
+
/** Add an IfcSlab anchored to a storey. */
|
|
297
|
+
addSlab: (
|
|
298
|
+
modelId: string,
|
|
299
|
+
storeyExpressId: number,
|
|
300
|
+
params: SlabInStoreParams
|
|
301
|
+
) => { expressId: number } | { error: string };
|
|
302
|
+
/** Add an IfcBeam anchored to a storey. */
|
|
303
|
+
addBeam: (
|
|
304
|
+
modelId: string,
|
|
305
|
+
storeyExpressId: number,
|
|
306
|
+
params: BeamInStoreParams
|
|
307
|
+
) => { expressId: number } | { error: string };
|
|
308
|
+
/** Add a free-standing IfcDoor anchored to a storey. */
|
|
309
|
+
addDoor: (
|
|
310
|
+
modelId: string,
|
|
311
|
+
storeyExpressId: number,
|
|
312
|
+
params: DoorInStoreParams
|
|
313
|
+
) => { expressId: number } | { error: string };
|
|
314
|
+
/** Add a free-standing IfcWindow anchored to a storey. */
|
|
315
|
+
addWindow: (
|
|
316
|
+
modelId: string,
|
|
317
|
+
storeyExpressId: number,
|
|
318
|
+
params: WindowInStoreParams
|
|
319
|
+
) => { expressId: number } | { error: string };
|
|
320
|
+
/** Add an IfcSpace (room) — rectangle or polygon footprint. */
|
|
321
|
+
addSpace: (
|
|
322
|
+
modelId: string,
|
|
323
|
+
storeyExpressId: number,
|
|
324
|
+
params: SpaceInStoreParams
|
|
325
|
+
) => { expressId: number } | { error: string };
|
|
326
|
+
/** Add an IfcRoof (flat roof) — slab-like rectangle or polygon. */
|
|
327
|
+
addRoof: (
|
|
328
|
+
modelId: string,
|
|
329
|
+
storeyExpressId: number,
|
|
330
|
+
params: RoofInStoreParams
|
|
331
|
+
) => { expressId: number } | { error: string };
|
|
332
|
+
/** Add an IfcPlate (thin flat element) — slab-like rectangle or polygon. */
|
|
333
|
+
addPlate: (
|
|
334
|
+
modelId: string,
|
|
335
|
+
storeyExpressId: number,
|
|
336
|
+
params: PlateInStoreParams
|
|
337
|
+
) => { expressId: number } | { error: string };
|
|
338
|
+
/** Add an IfcMember (generic structural — brace, post, strut). */
|
|
339
|
+
addMember: (
|
|
340
|
+
modelId: string,
|
|
341
|
+
storeyExpressId: number,
|
|
342
|
+
params: MemberInStoreParams
|
|
343
|
+
) => { expressId: number } | { error: string };
|
|
344
|
+
/**
|
|
345
|
+
* Auto-generate IfcSpace volumes for every enclosed area formed by
|
|
346
|
+
* the storey's walls (existing + overlay). When `dryRun: true` the
|
|
347
|
+
* detection runs but no IfcSpace is emitted — useful for live UI
|
|
348
|
+
* previews.
|
|
349
|
+
*/
|
|
350
|
+
generateSpacesFromWalls: (
|
|
351
|
+
modelId: string,
|
|
352
|
+
storeyExpressId: number,
|
|
353
|
+
options?: GenerateSpacesOptions,
|
|
354
|
+
) => GenerateSpacesResult | { error: string };
|
|
355
|
+
/**
|
|
356
|
+
* Duplicate an existing IfcRoot product in a chosen direction.
|
|
357
|
+
* Offset magnitude is one source-bbox dimension along the picked
|
|
358
|
+
* IFC axis (so a 3m wall steps 3m, a 0.4m column steps 0.4m).
|
|
359
|
+
* Geometry is shared with the source via Representation reference
|
|
360
|
+
* AND mirrored into the renderer's mesh list with the offset
|
|
361
|
+
* applied — so the duplicate appears in 3D the moment the action
|
|
362
|
+
* fires, not just in the export overlay. Returns the new entity's
|
|
363
|
+
* express id, or an error message.
|
|
364
|
+
*/
|
|
365
|
+
duplicateEntity: (
|
|
366
|
+
modelId: string,
|
|
367
|
+
sourceExpressId: number,
|
|
368
|
+
direction?: DuplicateDirection,
|
|
369
|
+
options?: DuplicateInStoreOptions
|
|
370
|
+
) => { expressId: number; globalId: number } | { error: string };
|
|
371
|
+
|
|
127
372
|
// Actions - Undo/Redo
|
|
128
373
|
/** Undo last mutation for a model */
|
|
129
374
|
undo: (modelId: string) => void;
|
|
@@ -167,6 +412,155 @@ function generateChangeSetId(): string {
|
|
|
167
412
|
return `cs_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
168
413
|
}
|
|
169
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Get-or-create the per-model `StoreEditor`. The editor pairs a parsed
|
|
417
|
+
* `IfcDataStore` with a `MutablePropertyView`; both must already exist
|
|
418
|
+
* (the data store comes from `models`, the view from PropertiesPanel's
|
|
419
|
+
* lazy-init effect). Returns null if either is missing.
|
|
420
|
+
*/
|
|
421
|
+
function getOrCreateStoreEditor(
|
|
422
|
+
get: () => ViewerState,
|
|
423
|
+
set: (partial: Partial<ViewerState>) => void,
|
|
424
|
+
modelId: string,
|
|
425
|
+
): StoreEditor | null {
|
|
426
|
+
const state = get();
|
|
427
|
+
const cached = state.storeEditors.get(modelId);
|
|
428
|
+
if (cached) return cached;
|
|
429
|
+
|
|
430
|
+
const view = state.mutationViews.get(modelId);
|
|
431
|
+
if (!view) return null;
|
|
432
|
+
|
|
433
|
+
const model = state.models.get(modelId);
|
|
434
|
+
const dataStore = model?.ifcDataStore;
|
|
435
|
+
if (!dataStore) return null;
|
|
436
|
+
|
|
437
|
+
const editor = new StoreEditor(dataStore, view);
|
|
438
|
+
const next = new Map(state.storeEditors);
|
|
439
|
+
next.set(modelId, editor);
|
|
440
|
+
set({ storeEditors: next });
|
|
441
|
+
return editor;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Shared dispatcher for the wall/slab/beam in-store builders. Mirrors the
|
|
446
|
+
* structure of `addColumn` (resolve store/view/editor/anchor → run the
|
|
447
|
+
* builder → push a CREATE_ENTITY undo entry → mark dirty + bump version)
|
|
448
|
+
* without copy-pasting that block per element type.
|
|
449
|
+
*/
|
|
450
|
+
function runInStoreElementBuilder(
|
|
451
|
+
get: () => ViewerState,
|
|
452
|
+
set: (partial: Partial<ViewerState> | ((s: ViewerState) => Partial<ViewerState>)) => void,
|
|
453
|
+
modelId: string,
|
|
454
|
+
storeyExpressId: number,
|
|
455
|
+
ifcType: string,
|
|
456
|
+
errorContext: string,
|
|
457
|
+
build: (editor: StoreEditor, anchor: ReturnType<typeof resolveSpatialAnchor>) => number,
|
|
458
|
+
meshPayload?: ElementMeshPayload,
|
|
459
|
+
): { expressId: number } | { error: string } {
|
|
460
|
+
const state = get();
|
|
461
|
+
const model = state.models.get(modelId);
|
|
462
|
+
const dataStore = model?.ifcDataStore;
|
|
463
|
+
if (!dataStore) return { error: `No model loaded for id "${modelId}"` };
|
|
464
|
+
|
|
465
|
+
const view = state.mutationViews.get(modelId);
|
|
466
|
+
if (!view) return { error: 'Model has no editable mutation view yet' };
|
|
467
|
+
|
|
468
|
+
const editor = getOrCreateStoreEditor(get, set, modelId);
|
|
469
|
+
if (!editor) return { error: 'Failed to create store editor' };
|
|
470
|
+
|
|
471
|
+
let entityId: number;
|
|
472
|
+
try {
|
|
473
|
+
const anchor = resolveSpatialAnchor(dataStore, storeyExpressId);
|
|
474
|
+
entityId = build(editor, anchor);
|
|
475
|
+
} catch (err) {
|
|
476
|
+
return { error: err instanceof Error ? err.message : `Failed to ${errorContext}` };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Build a renderer-frame mesh for the new element so it appears in
|
|
480
|
+
// 3D the moment the action commits — the ImportError-only behaviour
|
|
481
|
+
// before this would only surface the change after an export+reparse.
|
|
482
|
+
if (meshPayload) {
|
|
483
|
+
const storeyElevation =
|
|
484
|
+
dataStore.spatialHierarchy?.storeyElevations?.get(storeyExpressId) ?? 0;
|
|
485
|
+
const globalId = toGlobalIdFromModels(state.models, modelId, entityId);
|
|
486
|
+
const mesh = buildElementMesh({
|
|
487
|
+
type: meshPayload.type,
|
|
488
|
+
globalId,
|
|
489
|
+
storeyElevation,
|
|
490
|
+
payload: meshPayload,
|
|
491
|
+
});
|
|
492
|
+
if (mesh) {
|
|
493
|
+
const cross = get() as unknown as {
|
|
494
|
+
appendGeometryBatch?: (batch: MeshData[]) => void;
|
|
495
|
+
};
|
|
496
|
+
cross.appendGeometryBatch?.([mesh]);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
set((s) => {
|
|
501
|
+
const newUndoStacks = new Map(s.undoStacks);
|
|
502
|
+
const stack = newUndoStacks.get(modelId) || [];
|
|
503
|
+
const mutation: Mutation = {
|
|
504
|
+
id: `mut_${ifcType.toLowerCase()}_${entityId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
505
|
+
type: 'CREATE_ENTITY',
|
|
506
|
+
timestamp: Date.now(),
|
|
507
|
+
modelId,
|
|
508
|
+
entityId,
|
|
509
|
+
attributeName: ifcType,
|
|
510
|
+
};
|
|
511
|
+
newUndoStacks.set(modelId, [...stack, mutation]);
|
|
512
|
+
|
|
513
|
+
const newRedoStacks = new Map(s.redoStacks);
|
|
514
|
+
newRedoStacks.set(modelId, []);
|
|
515
|
+
|
|
516
|
+
const newDirty = new Set(s.dirtyModels);
|
|
517
|
+
newDirty.add(modelId);
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
undoStacks: newUndoStacks,
|
|
521
|
+
redoStacks: newRedoStacks,
|
|
522
|
+
dirtyModels: newDirty,
|
|
523
|
+
mutationVersion: s.mutationVersion + 1,
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
return { expressId: entityId };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Build the polygon corner ring used by slab/roof/plate/space mesh
|
|
532
|
+
* previews from a builder param object that may be in rectangle or
|
|
533
|
+
* polygon mode. Rectangle = 4 corners CCW from `Position` +
|
|
534
|
+
* Width/Depth; polygon = the `OuterCurve` lifted to 3D at z = 0.
|
|
535
|
+
*/
|
|
536
|
+
function profileCornersFromParams(
|
|
537
|
+
params:
|
|
538
|
+
| { Profile?: 'rectangle'; Position: [number, number, number]; Width: number; Depth: number }
|
|
539
|
+
| { Profile: 'polygon'; OuterCurve: Array<[number, number]>; Position?: [number, number, number] },
|
|
540
|
+
): Array<[number, number, number]> {
|
|
541
|
+
if ('Profile' in params && params.Profile === 'polygon') {
|
|
542
|
+
const z = params.Position?.[2] ?? 0;
|
|
543
|
+
return params.OuterCurve.map(([x, y]) => [x, y, z]);
|
|
544
|
+
}
|
|
545
|
+
const rect = params as {
|
|
546
|
+
Position: [number, number, number]; Width: number; Depth: number;
|
|
547
|
+
};
|
|
548
|
+
const [px, py, pz] = rect.Position;
|
|
549
|
+
return [
|
|
550
|
+
[px, py, pz],
|
|
551
|
+
[px + rect.Width, py, pz],
|
|
552
|
+
[px + rect.Width, py + rect.Depth, pz],
|
|
553
|
+
[px, py + rect.Depth, pz],
|
|
554
|
+
];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Decode the `@N` form used to encode positional indices into Mutation.attributeName. */
|
|
558
|
+
function positionalIndex(attributeName: string | undefined): number | null {
|
|
559
|
+
if (!attributeName || attributeName[0] !== '@') return null;
|
|
560
|
+
const n = Number(attributeName.slice(1));
|
|
561
|
+
return Number.isFinite(n) && n >= 0 && Number.isInteger(n) ? n : null;
|
|
562
|
+
}
|
|
563
|
+
|
|
170
564
|
export const createMutationSlice: StateCreator<
|
|
171
565
|
ViewerState,
|
|
172
566
|
[],
|
|
@@ -175,6 +569,8 @@ export const createMutationSlice: StateCreator<
|
|
|
175
569
|
> = (set, get) => ({
|
|
176
570
|
// Initial state
|
|
177
571
|
mutationViews: new Map(),
|
|
572
|
+
storeEditors: new Map(),
|
|
573
|
+
removedNewEntities: new Map(),
|
|
178
574
|
changeSets: new Map(),
|
|
179
575
|
activeChangeSetId: null,
|
|
180
576
|
undoStacks: new Map(),
|
|
@@ -253,9 +649,23 @@ export const createMutationSlice: StateCreator<
|
|
|
253
649
|
set((state) => {
|
|
254
650
|
const newViews = new Map(state.mutationViews);
|
|
255
651
|
newViews.delete(modelId);
|
|
652
|
+
const newEditors = new Map(state.storeEditors);
|
|
653
|
+
newEditors.delete(modelId);
|
|
256
654
|
const newDirty = new Set(state.dirtyModels);
|
|
257
655
|
newDirty.delete(modelId);
|
|
258
|
-
|
|
656
|
+
// Drop any stashed undo payloads owned by this model so they don't
|
|
657
|
+
// leak into future mutation views with the same id.
|
|
658
|
+
const newRemoved = new Map(state.removedNewEntities);
|
|
659
|
+
const prefix = `${modelId}:`;
|
|
660
|
+
for (const key of [...newRemoved.keys()]) {
|
|
661
|
+
if (key.startsWith(prefix)) newRemoved.delete(key);
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
mutationViews: newViews,
|
|
665
|
+
storeEditors: newEditors,
|
|
666
|
+
dirtyModels: newDirty,
|
|
667
|
+
removedNewEntities: newRemoved,
|
|
668
|
+
};
|
|
259
669
|
});
|
|
260
670
|
},
|
|
261
671
|
|
|
@@ -462,6 +872,402 @@ export const createMutationSlice: StateCreator<
|
|
|
462
872
|
return mutation;
|
|
463
873
|
},
|
|
464
874
|
|
|
875
|
+
// Store-Level Mutations
|
|
876
|
+
setPositionalAttribute: (modelId, entityId, index, value) => {
|
|
877
|
+
const view = get().mutationViews.get(modelId);
|
|
878
|
+
if (!view) return null;
|
|
879
|
+
|
|
880
|
+
const editor = getOrCreateStoreEditor(get, set, modelId);
|
|
881
|
+
if (!editor) return null;
|
|
882
|
+
|
|
883
|
+
// Capture prior overlay value (if any) for undo. We can't recover the
|
|
884
|
+
// base STEP value from here without parsing the source — that's the
|
|
885
|
+
// RawStepRow's job — so undo of "first override" simply removes the
|
|
886
|
+
// override, falling back to the original buffer value.
|
|
887
|
+
const prior = view.getPositionalMutationsForEntity(entityId)?.get(index);
|
|
888
|
+
editor.setPositionalAttribute(entityId, index, value);
|
|
889
|
+
|
|
890
|
+
set((state) => {
|
|
891
|
+
const newUndoStacks = new Map(state.undoStacks);
|
|
892
|
+
const stack = newUndoStacks.get(modelId) || [];
|
|
893
|
+
const mutation: Mutation = {
|
|
894
|
+
id: `mut_pos_${entityId}_${index}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
895
|
+
type: 'UPDATE_POSITIONAL_ATTRIBUTE',
|
|
896
|
+
timestamp: Date.now(),
|
|
897
|
+
modelId,
|
|
898
|
+
entityId,
|
|
899
|
+
attributeName: `@${index}`,
|
|
900
|
+
oldValue: (prior ?? null) as PropertyValue,
|
|
901
|
+
newValue: value as PropertyValue,
|
|
902
|
+
};
|
|
903
|
+
newUndoStacks.set(modelId, [...stack, mutation]);
|
|
904
|
+
|
|
905
|
+
const newRedoStacks = new Map(state.redoStacks);
|
|
906
|
+
newRedoStacks.set(modelId, []);
|
|
907
|
+
|
|
908
|
+
const newDirty = new Set(state.dirtyModels);
|
|
909
|
+
newDirty.add(modelId);
|
|
910
|
+
|
|
911
|
+
return {
|
|
912
|
+
undoStacks: newUndoStacks,
|
|
913
|
+
redoStacks: newRedoStacks,
|
|
914
|
+
dirtyModels: newDirty,
|
|
915
|
+
mutationVersion: state.mutationVersion + 1,
|
|
916
|
+
};
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// Return the mutation we just pushed onto the undo stack.
|
|
920
|
+
const stack = get().undoStacks.get(modelId);
|
|
921
|
+
return stack ? stack[stack.length - 1] : null;
|
|
922
|
+
},
|
|
923
|
+
|
|
924
|
+
removeEntity: (modelId, expressId) => {
|
|
925
|
+
const view = get().mutationViews.get(modelId);
|
|
926
|
+
if (!view) return false;
|
|
927
|
+
const editor = getOrCreateStoreEditor(get, set, modelId);
|
|
928
|
+
if (!editor) return false;
|
|
929
|
+
|
|
930
|
+
// Stash the overlay record (if any) BEFORE the editor forgets it, so
|
|
931
|
+
// undo can re-add the exact same NewEntity. For source-buffer entities
|
|
932
|
+
// there's nothing to stash — undo just removes the tombstone.
|
|
933
|
+
const overlayRecord = view.getNewEntity(expressId);
|
|
934
|
+
const removed = editor.removeEntity(expressId);
|
|
935
|
+
if (!removed) return false;
|
|
936
|
+
|
|
937
|
+
set((state) => {
|
|
938
|
+
const newRemoved = new Map(state.removedNewEntities);
|
|
939
|
+
if (overlayRecord) {
|
|
940
|
+
newRemoved.set(`${modelId}:${expressId}`, overlayRecord);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const newUndoStacks = new Map(state.undoStacks);
|
|
944
|
+
const stack = newUndoStacks.get(modelId) || [];
|
|
945
|
+
const mutation: Mutation = {
|
|
946
|
+
id: `mut_del_${expressId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
947
|
+
type: 'DELETE_ENTITY',
|
|
948
|
+
timestamp: Date.now(),
|
|
949
|
+
modelId,
|
|
950
|
+
entityId: expressId,
|
|
951
|
+
};
|
|
952
|
+
newUndoStacks.set(modelId, [...stack, mutation]);
|
|
953
|
+
|
|
954
|
+
const newRedoStacks = new Map(state.redoStacks);
|
|
955
|
+
newRedoStacks.set(modelId, []);
|
|
956
|
+
|
|
957
|
+
const newDirty = new Set(state.dirtyModels);
|
|
958
|
+
newDirty.add(modelId);
|
|
959
|
+
|
|
960
|
+
return {
|
|
961
|
+
removedNewEntities: newRemoved,
|
|
962
|
+
undoStacks: newUndoStacks,
|
|
963
|
+
redoStacks: newRedoStacks,
|
|
964
|
+
dirtyModels: newDirty,
|
|
965
|
+
mutationVersion: state.mutationVersion + 1,
|
|
966
|
+
};
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
return true;
|
|
970
|
+
},
|
|
971
|
+
|
|
972
|
+
addColumn: (modelId, storeyExpressId, params) => {
|
|
973
|
+
const state = get();
|
|
974
|
+
const model = state.models.get(modelId);
|
|
975
|
+
const dataStore = model?.ifcDataStore;
|
|
976
|
+
if (!dataStore) return { error: `No model loaded for id "${modelId}"` };
|
|
977
|
+
|
|
978
|
+
// The dialog passes the same modelId used by the model store; mutation
|
|
979
|
+
// views are keyed identically (no legacy normalization needed in the
|
|
980
|
+
// multi-model path the dialog operates in).
|
|
981
|
+
const view = state.mutationViews.get(modelId);
|
|
982
|
+
if (!view) return { error: 'Model has no editable mutation view yet' };
|
|
983
|
+
|
|
984
|
+
const editor = getOrCreateStoreEditor(get, set, modelId);
|
|
985
|
+
if (!editor) return { error: 'Failed to create store editor' };
|
|
986
|
+
|
|
987
|
+
let columnId: number;
|
|
988
|
+
try {
|
|
989
|
+
const anchor = resolveSpatialAnchor(dataStore, storeyExpressId);
|
|
990
|
+
const result = addColumnToStore(editor, anchor, params);
|
|
991
|
+
columnId = result.columnId;
|
|
992
|
+
} catch (err) {
|
|
993
|
+
return { error: err instanceof Error ? err.message : 'Failed to add column' };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Inject a renderer-frame box mesh so the column appears in 3D
|
|
997
|
+
// immediately. Same coordinate-frame plumbing as
|
|
998
|
+
// `runInStoreElementBuilder`, kept inline since this action
|
|
999
|
+
// pre-dates the shared helper.
|
|
1000
|
+
const storeyElevationCol =
|
|
1001
|
+
dataStore.spatialHierarchy?.storeyElevations?.get(storeyExpressId) ?? 0;
|
|
1002
|
+
const columnGlobalId = toGlobalIdFromModels(state.models, modelId, columnId);
|
|
1003
|
+
const columnMesh = buildElementMesh({
|
|
1004
|
+
type: 'column',
|
|
1005
|
+
globalId: columnGlobalId,
|
|
1006
|
+
storeyElevation: storeyElevationCol,
|
|
1007
|
+
payload: {
|
|
1008
|
+
type: 'column',
|
|
1009
|
+
params: { Width: params.Width, Depth: params.Depth, Height: params.Height },
|
|
1010
|
+
position: params.Position,
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
if (columnMesh) {
|
|
1014
|
+
const cross = get() as unknown as {
|
|
1015
|
+
appendGeometryBatch?: (batch: MeshData[]) => void;
|
|
1016
|
+
};
|
|
1017
|
+
cross.appendGeometryBatch?.([columnMesh]);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
set((s) => {
|
|
1021
|
+
const newUndoStacks = new Map(s.undoStacks);
|
|
1022
|
+
const stack = newUndoStacks.get(modelId) || [];
|
|
1023
|
+
const mutation: Mutation = {
|
|
1024
|
+
id: `mut_col_${columnId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
1025
|
+
type: 'CREATE_ENTITY',
|
|
1026
|
+
timestamp: Date.now(),
|
|
1027
|
+
modelId,
|
|
1028
|
+
entityId: columnId,
|
|
1029
|
+
attributeName: 'IFCCOLUMN',
|
|
1030
|
+
};
|
|
1031
|
+
newUndoStacks.set(modelId, [...stack, mutation]);
|
|
1032
|
+
|
|
1033
|
+
const newRedoStacks = new Map(s.redoStacks);
|
|
1034
|
+
newRedoStacks.set(modelId, []);
|
|
1035
|
+
|
|
1036
|
+
const newDirty = new Set(s.dirtyModels);
|
|
1037
|
+
newDirty.add(modelId);
|
|
1038
|
+
|
|
1039
|
+
return {
|
|
1040
|
+
undoStacks: newUndoStacks,
|
|
1041
|
+
redoStacks: newRedoStacks,
|
|
1042
|
+
dirtyModels: newDirty,
|
|
1043
|
+
mutationVersion: s.mutationVersion + 1,
|
|
1044
|
+
};
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
return { expressId: columnId };
|
|
1048
|
+
},
|
|
1049
|
+
|
|
1050
|
+
addWall: (modelId, storeyExpressId, params) => {
|
|
1051
|
+
return runInStoreElementBuilder(
|
|
1052
|
+
get, set, modelId, storeyExpressId, 'IFCWALL', 'add wall',
|
|
1053
|
+
(editor, anchor) => addWallToStore(editor, anchor, params).wallId,
|
|
1054
|
+
{ type: 'wall', params: { Thickness: params.Thickness, Height: params.Height }, start: params.Start, end: params.End },
|
|
1055
|
+
);
|
|
1056
|
+
},
|
|
1057
|
+
|
|
1058
|
+
addSlab: (modelId, storeyExpressId, params) => {
|
|
1059
|
+
return runInStoreElementBuilder(
|
|
1060
|
+
get, set, modelId, storeyExpressId, 'IFCSLAB', 'add slab',
|
|
1061
|
+
(editor, anchor) => addSlabToStore(editor, anchor, params).slabId,
|
|
1062
|
+
{ type: 'slab', params: { Width: 0, Depth: 0, Thickness: params.Thickness }, corners: profileCornersFromParams(params) },
|
|
1063
|
+
);
|
|
1064
|
+
},
|
|
1065
|
+
|
|
1066
|
+
addBeam: (modelId, storeyExpressId, params) => {
|
|
1067
|
+
return runInStoreElementBuilder(
|
|
1068
|
+
get, set, modelId, storeyExpressId, 'IFCBEAM', 'add beam',
|
|
1069
|
+
(editor, anchor) => addBeamToStore(editor, anchor, params).beamId,
|
|
1070
|
+
{ type: 'beam', params: { Width: params.Width, Height: params.Height }, start: params.Start, end: params.End },
|
|
1071
|
+
);
|
|
1072
|
+
},
|
|
1073
|
+
|
|
1074
|
+
addDoor: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
|
|
1075
|
+
get, set, modelId, storeyExpressId, 'IFCDOOR', 'add door',
|
|
1076
|
+
(editor, anchor) => addDoorToStore(editor, anchor, params).doorId,
|
|
1077
|
+
{ type: 'door', params: { Width: params.Width, Height: params.Height, FrameThickness: params.FrameThickness ?? 0.05 }, position: params.Position },
|
|
1078
|
+
),
|
|
1079
|
+
|
|
1080
|
+
addWindow: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
|
|
1081
|
+
get, set, modelId, storeyExpressId, 'IFCWINDOW', 'add window',
|
|
1082
|
+
(editor, anchor) => addWindowToStore(editor, anchor, params).windowId,
|
|
1083
|
+
{ type: 'window', params: { Width: params.Width, Height: params.Height, FrameThickness: params.FrameThickness ?? 0.05 }, position: params.Position },
|
|
1084
|
+
),
|
|
1085
|
+
|
|
1086
|
+
addSpace: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
|
|
1087
|
+
get, set, modelId, storeyExpressId, 'IFCSPACE', 'add space',
|
|
1088
|
+
(editor, anchor) => addSpaceToStore(editor, anchor, params).spaceId,
|
|
1089
|
+
{ type: 'space', params: { Width: 0, Depth: 0, Height: params.Height }, corners: profileCornersFromParams(params) },
|
|
1090
|
+
),
|
|
1091
|
+
|
|
1092
|
+
addRoof: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
|
|
1093
|
+
get, set, modelId, storeyExpressId, 'IFCROOF', 'add roof',
|
|
1094
|
+
(editor, anchor) => addRoofToStore(editor, anchor, params).roofId,
|
|
1095
|
+
{ type: 'roof', params: { Width: 0, Depth: 0, Thickness: params.Thickness }, corners: profileCornersFromParams(params) },
|
|
1096
|
+
),
|
|
1097
|
+
|
|
1098
|
+
addPlate: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
|
|
1099
|
+
get, set, modelId, storeyExpressId, 'IFCPLATE', 'add plate',
|
|
1100
|
+
(editor, anchor) => addPlateToStore(editor, anchor, params).plateId,
|
|
1101
|
+
{ type: 'plate', params: { Width: 0, Depth: 0, Thickness: params.Thickness }, corners: profileCornersFromParams(params) },
|
|
1102
|
+
),
|
|
1103
|
+
|
|
1104
|
+
addMember: (modelId, storeyExpressId, params) => runInStoreElementBuilder(
|
|
1105
|
+
get, set, modelId, storeyExpressId, 'IFCMEMBER', 'add member',
|
|
1106
|
+
(editor, anchor) => addMemberToStore(editor, anchor, params).memberId,
|
|
1107
|
+
{ type: 'member', params: { Width: params.Width, Height: params.Height }, start: params.Start, end: params.End },
|
|
1108
|
+
),
|
|
1109
|
+
|
|
1110
|
+
generateSpacesFromWalls: (modelId, storeyExpressId, options) => {
|
|
1111
|
+
const state = get();
|
|
1112
|
+
const model = state.models.get(modelId);
|
|
1113
|
+
const dataStore = model?.ifcDataStore;
|
|
1114
|
+
if (!dataStore) return { error: `No model loaded for id "${modelId}"` };
|
|
1115
|
+
const view = state.mutationViews.get(modelId);
|
|
1116
|
+
if (!view) return { error: 'Model has no editable mutation view yet' };
|
|
1117
|
+
|
|
1118
|
+
// For dryRun the editor isn't strictly needed — we still create
|
|
1119
|
+
// one (cheap) so the helper signature can stay uniform.
|
|
1120
|
+
const editor = getOrCreateStoreEditor(get, set, modelId);
|
|
1121
|
+
if (!editor) return { error: 'Failed to create store editor' };
|
|
1122
|
+
|
|
1123
|
+
let result: GenerateSpacesResult;
|
|
1124
|
+
try {
|
|
1125
|
+
result = generateSpacesFromWalls(
|
|
1126
|
+
editor,
|
|
1127
|
+
dataStore,
|
|
1128
|
+
storeyExpressId,
|
|
1129
|
+
options,
|
|
1130
|
+
// The view exposes getNewEntities — pass it in so overlay-only
|
|
1131
|
+
// walls (placed via the Add Element tool) participate in the
|
|
1132
|
+
// detection without needing a flush to STEP first.
|
|
1133
|
+
{
|
|
1134
|
+
getNewEntities: () => view.getNewEntities(),
|
|
1135
|
+
},
|
|
1136
|
+
);
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
return { error: err instanceof Error ? err.message : 'Failed to generate spaces' };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// dryRun → nothing emitted; skip undo / dirty bookkeeping.
|
|
1142
|
+
if (!result.emitted.length) return result;
|
|
1143
|
+
|
|
1144
|
+
set((s) => {
|
|
1145
|
+
const newUndoStacks = new Map(s.undoStacks);
|
|
1146
|
+
const stack = [...(newUndoStacks.get(modelId) ?? [])];
|
|
1147
|
+
const ts = Date.now();
|
|
1148
|
+
for (const e of result.emitted) {
|
|
1149
|
+
stack.push({
|
|
1150
|
+
id: `mut_ifcspace_${e.result.spaceId}_${ts}_${Math.random().toString(36).substring(2, 9)}`,
|
|
1151
|
+
type: 'CREATE_ENTITY',
|
|
1152
|
+
timestamp: ts,
|
|
1153
|
+
modelId,
|
|
1154
|
+
entityId: e.result.spaceId,
|
|
1155
|
+
attributeName: 'IFCSPACE',
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
newUndoStacks.set(modelId, stack);
|
|
1159
|
+
|
|
1160
|
+
const newRedoStacks = new Map(s.redoStacks);
|
|
1161
|
+
newRedoStacks.set(modelId, []);
|
|
1162
|
+
|
|
1163
|
+
const newDirty = new Set(s.dirtyModels);
|
|
1164
|
+
newDirty.add(modelId);
|
|
1165
|
+
|
|
1166
|
+
return {
|
|
1167
|
+
undoStacks: newUndoStacks,
|
|
1168
|
+
redoStacks: newRedoStacks,
|
|
1169
|
+
dirtyModels: newDirty,
|
|
1170
|
+
mutationVersion: s.mutationVersion + 1,
|
|
1171
|
+
};
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
return result;
|
|
1175
|
+
},
|
|
1176
|
+
|
|
1177
|
+
duplicateEntity: (modelId, sourceExpressId, direction = DUPLICATE_DEFAULT_DIRECTION, options) => {
|
|
1178
|
+
const state = get();
|
|
1179
|
+
const model = state.models.get(modelId);
|
|
1180
|
+
const dataStore = model?.ifcDataStore;
|
|
1181
|
+
if (!dataStore) return { error: `No model loaded for id "${modelId}"` };
|
|
1182
|
+
|
|
1183
|
+
const view = state.mutationViews.get(modelId);
|
|
1184
|
+
if (!view) return { error: 'Model has no editable mutation view yet' };
|
|
1185
|
+
|
|
1186
|
+
const editor = getOrCreateStoreEditor(get, set, modelId);
|
|
1187
|
+
if (!editor) return { error: 'Failed to create store editor' };
|
|
1188
|
+
|
|
1189
|
+
// Source's bounding box drives the offset magnitude. Multi-model
|
|
1190
|
+
// federations key meshes by globalId — route through the central
|
|
1191
|
+
// conversion helper so federation/single-model semantics stay in
|
|
1192
|
+
// one place (legacy stores fall through to expressId === globalId).
|
|
1193
|
+
const sourceGlobalId = toGlobalIdFromModels(state.models, modelId, sourceExpressId);
|
|
1194
|
+
const meshes = state.geometryResult?.meshes;
|
|
1195
|
+
const sourceBounds = getEntityBounds(meshes ?? null, sourceGlobalId);
|
|
1196
|
+
const bbox: ViewerBox = sourceBounds
|
|
1197
|
+
? {
|
|
1198
|
+
size: {
|
|
1199
|
+
x: Math.max(sourceBounds.max.x - sourceBounds.min.x, 0),
|
|
1200
|
+
y: Math.max(sourceBounds.max.y - sourceBounds.min.y, 0),
|
|
1201
|
+
z: Math.max(sourceBounds.max.z - sourceBounds.min.z, 0),
|
|
1202
|
+
},
|
|
1203
|
+
}
|
|
1204
|
+
: { size: { x: DUPLICATE_FALLBACK_STEP, y: DUPLICATE_FALLBACK_STEP, z: DUPLICATE_FALLBACK_STEP } };
|
|
1205
|
+
|
|
1206
|
+
const ifcDelta = ifcOffsetForDirection(direction, bbox);
|
|
1207
|
+
const viewerDelta = viewerDeltaFromIfc(ifcDelta);
|
|
1208
|
+
|
|
1209
|
+
let newId: number;
|
|
1210
|
+
try {
|
|
1211
|
+
const source = resolveDuplicateSource(dataStore, sourceExpressId);
|
|
1212
|
+
const result = duplicateInStore(editor, source, { ...options, offset: ifcDelta });
|
|
1213
|
+
newId = result.newId;
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
return { error: err instanceof Error ? err.message : 'Failed to duplicate' };
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Alias the duplicate to its source for base property / quantity
|
|
1219
|
+
// reads — so the property panel shows the source's psets without
|
|
1220
|
+
// us eagerly cloning them. The duplicate's own override slots
|
|
1221
|
+
// remain scoped to the new id.
|
|
1222
|
+
view.setEntityAlias(newId, sourceExpressId);
|
|
1223
|
+
|
|
1224
|
+
const newGlobalId = toGlobalIdFromModels(state.models, modelId, newId);
|
|
1225
|
+
|
|
1226
|
+
// Mirror the source's meshes into the geometry result with the
|
|
1227
|
+
// offset applied so the duplicate is visible immediately. Without
|
|
1228
|
+
// this the entity exists only in the export overlay — STEP-correct
|
|
1229
|
+
// but invisible — and the user can't tell anything happened.
|
|
1230
|
+
const clonedMeshes = cloneMeshesWithOffset(meshes, sourceGlobalId, newGlobalId, viewerDelta);
|
|
1231
|
+
|
|
1232
|
+
set((s) => {
|
|
1233
|
+
const newUndoStacks = new Map(s.undoStacks);
|
|
1234
|
+
const stack = newUndoStacks.get(modelId) || [];
|
|
1235
|
+
const mutation: Mutation = {
|
|
1236
|
+
id: `mut_dup_${newId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
1237
|
+
type: 'CREATE_ENTITY',
|
|
1238
|
+
timestamp: Date.now(),
|
|
1239
|
+
modelId,
|
|
1240
|
+
entityId: newId,
|
|
1241
|
+
attributeName: 'DUPLICATE',
|
|
1242
|
+
};
|
|
1243
|
+
newUndoStacks.set(modelId, [...stack, mutation]);
|
|
1244
|
+
|
|
1245
|
+
const newRedoStacks = new Map(s.redoStacks);
|
|
1246
|
+
newRedoStacks.set(modelId, []);
|
|
1247
|
+
|
|
1248
|
+
const newDirty = new Set(s.dirtyModels);
|
|
1249
|
+
newDirty.add(modelId);
|
|
1250
|
+
|
|
1251
|
+
return {
|
|
1252
|
+
undoStacks: newUndoStacks,
|
|
1253
|
+
redoStacks: newRedoStacks,
|
|
1254
|
+
dirtyModels: newDirty,
|
|
1255
|
+
mutationVersion: s.mutationVersion + 1,
|
|
1256
|
+
};
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
// Append cloned meshes via the existing data slice action so the
|
|
1260
|
+
// renderer picks them up via its standard tick.
|
|
1261
|
+
if (clonedMeshes.length > 0) {
|
|
1262
|
+
const cross = get() as unknown as {
|
|
1263
|
+
appendGeometryBatch?: (batch: MeshData[]) => void;
|
|
1264
|
+
};
|
|
1265
|
+
cross.appendGeometryBatch?.(clonedMeshes);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
return { expressId: newId, globalId: newGlobalId };
|
|
1269
|
+
},
|
|
1270
|
+
|
|
465
1271
|
// Undo/Redo
|
|
466
1272
|
undo: (modelId) => {
|
|
467
1273
|
const state = get();
|
|
@@ -564,6 +1370,53 @@ export const createMutationSlice: StateCreator<
|
|
|
564
1370
|
view.removeAttributeMutation(mutation.entityId, mutation.attributeName);
|
|
565
1371
|
}
|
|
566
1372
|
}
|
|
1373
|
+
} else if (mutation.type === 'UPDATE_POSITIONAL_ATTRIBUTE') {
|
|
1374
|
+
// Positional attrs encode their index in `@N` since the existing
|
|
1375
|
+
// Mutation shape has no dedicated field for it.
|
|
1376
|
+
const index = positionalIndex(mutation.attributeName);
|
|
1377
|
+
if (index !== null) {
|
|
1378
|
+
if (mutation.oldValue === null || mutation.oldValue === undefined) {
|
|
1379
|
+
view.removePositionalMutation(mutation.entityId, index);
|
|
1380
|
+
} else {
|
|
1381
|
+
view.setPositionalAttribute(mutation.entityId, index, mutation.oldValue as IfcAttributeValue, true);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
} else if (mutation.type === 'CREATE_ENTITY') {
|
|
1385
|
+
// Undo of a create: stash the NewEntity payload so a subsequent redo
|
|
1386
|
+
// can restore it. Without this, redo finds an empty stash and becomes
|
|
1387
|
+
// a no-op for the create-then-undo-then-redo path.
|
|
1388
|
+
const overlay = view.getNewEntity(mutation.entityId);
|
|
1389
|
+
if (overlay) {
|
|
1390
|
+
set((s) => {
|
|
1391
|
+
const next = new Map(s.removedNewEntities);
|
|
1392
|
+
next.set(`${modelId}:${mutation.entityId}`, overlay);
|
|
1393
|
+
return { removedNewEntities: next };
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
// The view's `deleteEntity` returns false if it's already gone, which
|
|
1397
|
+
// is fine for redo to re-establish.
|
|
1398
|
+
view.deleteEntity(mutation.entityId);
|
|
1399
|
+
} else if (mutation.type === 'DELETE_ENTITY') {
|
|
1400
|
+
// Undo of a delete: restore tombstone for source entity, OR replay
|
|
1401
|
+
// the stashed NewEntity record for an overlay-only entity.
|
|
1402
|
+
const stashKey = `${modelId}:${mutation.entityId}`;
|
|
1403
|
+
const stashed = get().removedNewEntities.get(stashKey);
|
|
1404
|
+
if (stashed) {
|
|
1405
|
+
view.restoreNewEntity(stashed);
|
|
1406
|
+
} else {
|
|
1407
|
+
view.restoreFromTombstone(mutation.entityId);
|
|
1408
|
+
}
|
|
1409
|
+
// Also un-hide the rendered mesh — the EntityContextMenu's
|
|
1410
|
+
// delete handler hid it via the visibility system, so undo has
|
|
1411
|
+
// to mirror that to bring the entity back into the scene.
|
|
1412
|
+
const cross = get() as unknown as {
|
|
1413
|
+
toGlobalId?: (modelId: string, expressId: number) => number;
|
|
1414
|
+
showEntity?: (id: number) => void;
|
|
1415
|
+
};
|
|
1416
|
+
if (cross.toGlobalId && cross.showEntity) {
|
|
1417
|
+
const globalId = cross.toGlobalId(modelId, mutation.entityId);
|
|
1418
|
+
cross.showEntity(globalId);
|
|
1419
|
+
}
|
|
567
1420
|
}
|
|
568
1421
|
|
|
569
1422
|
set((s) => {
|
|
@@ -666,6 +1519,48 @@ export const createMutationSlice: StateCreator<
|
|
|
666
1519
|
if (mutation.attributeName && mutation.newValue !== undefined) {
|
|
667
1520
|
view.setAttribute(mutation.entityId, mutation.attributeName, String(mutation.newValue), undefined, true);
|
|
668
1521
|
}
|
|
1522
|
+
} else if (mutation.type === 'UPDATE_POSITIONAL_ATTRIBUTE') {
|
|
1523
|
+
const index = positionalIndex(mutation.attributeName);
|
|
1524
|
+
if (index !== null && mutation.newValue !== undefined) {
|
|
1525
|
+
view.setPositionalAttribute(mutation.entityId, index, mutation.newValue as IfcAttributeValue, true);
|
|
1526
|
+
}
|
|
1527
|
+
} else if (mutation.type === 'CREATE_ENTITY') {
|
|
1528
|
+
// Redo of a create: replay from the stashed NewEntity. Symmetrical to
|
|
1529
|
+
// DELETE_ENTITY's undo — same map, same key.
|
|
1530
|
+
const stashKey = `${modelId}:${mutation.entityId}`;
|
|
1531
|
+
const stashed = get().removedNewEntities.get(stashKey);
|
|
1532
|
+
if (stashed) {
|
|
1533
|
+
view.restoreNewEntity(stashed);
|
|
1534
|
+
} else {
|
|
1535
|
+
// Source-buffer entities have no stash; the editor's deleteEntity
|
|
1536
|
+
// call simply re-tombstoned them — which is exactly what we want
|
|
1537
|
+
// here? No — for CREATE_ENTITY redo we want the entity to come back.
|
|
1538
|
+
// Source-entity creates are not a real path; CREATE_ENTITY in this
|
|
1539
|
+
// codebase only ever fires for overlay-added entities. Nothing to
|
|
1540
|
+
// do if the stash is empty (means the redo is unreachable).
|
|
1541
|
+
}
|
|
1542
|
+
} else if (mutation.type === 'DELETE_ENTITY') {
|
|
1543
|
+
// Redo of a delete: tombstone again. For overlay-only entities we
|
|
1544
|
+
// first stash the NewEntity (it'll be re-fetched for the next undo).
|
|
1545
|
+
const overlay = view.getNewEntity(mutation.entityId);
|
|
1546
|
+
if (overlay) {
|
|
1547
|
+
set((s) => {
|
|
1548
|
+
const next = new Map(s.removedNewEntities);
|
|
1549
|
+
next.set(`${modelId}:${mutation.entityId}`, overlay);
|
|
1550
|
+
return { removedNewEntities: next };
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
view.deleteEntity(mutation.entityId);
|
|
1554
|
+
// Re-hide the mesh — symmetric with the menu's delete handler
|
|
1555
|
+
// and with the undo path above.
|
|
1556
|
+
const cross = get() as unknown as {
|
|
1557
|
+
toGlobalId?: (modelId: string, expressId: number) => number;
|
|
1558
|
+
hideEntity?: (id: number) => void;
|
|
1559
|
+
};
|
|
1560
|
+
if (cross.toGlobalId && cross.hideEntity) {
|
|
1561
|
+
const globalId = cross.toGlobalId(modelId, mutation.entityId);
|
|
1562
|
+
cross.hideEntity(globalId);
|
|
1563
|
+
}
|
|
669
1564
|
}
|
|
670
1565
|
|
|
671
1566
|
set((s) => {
|
|
@@ -758,7 +1653,21 @@ export const createMutationSlice: StateCreator<
|
|
|
758
1653
|
|
|
759
1654
|
// Query
|
|
760
1655
|
hasChanges: (modelId) => {
|
|
761
|
-
|
|
1656
|
+
if (get().dirtyModels.has(modelId)) return true;
|
|
1657
|
+
// Schedule-only case: a generated schedule OR an edited parsed
|
|
1658
|
+
// schedule counts as a pending edit even if the user hasn't touched
|
|
1659
|
+
// any properties.
|
|
1660
|
+
const cross = get() as unknown as {
|
|
1661
|
+
scheduleSourceModelId?: string | null;
|
|
1662
|
+
scheduleIsEdited?: boolean;
|
|
1663
|
+
scheduleData?: { tasks: Array<{ expressId?: number }> } | null;
|
|
1664
|
+
};
|
|
1665
|
+
if (cross.scheduleSourceModelId !== modelId) return false;
|
|
1666
|
+
if (cross.scheduleIsEdited) return true;
|
|
1667
|
+
const tasks = cross.scheduleData?.tasks;
|
|
1668
|
+
if (!tasks) return false;
|
|
1669
|
+
for (const t of tasks) if (!t.expressId || t.expressId <= 0) return true;
|
|
1670
|
+
return false;
|
|
762
1671
|
},
|
|
763
1672
|
|
|
764
1673
|
getMutationsForModel: (modelId) => {
|
|
@@ -779,6 +1688,30 @@ export const createMutationSlice: StateCreator<
|
|
|
779
1688
|
count += 1; // count the model as having modifications
|
|
780
1689
|
}
|
|
781
1690
|
}
|
|
1691
|
+
// Include generated schedule tasks — these are spliced into the STEP
|
|
1692
|
+
// export just like property mutations are, so they belong in the same
|
|
1693
|
+
// "pending changes" count the export badge reads.
|
|
1694
|
+
//
|
|
1695
|
+
// Edited parsed schedules: if the schedule has been edited (any task
|
|
1696
|
+
// renamed / rescheduled / deleted / etc.) count +1 to surface the
|
|
1697
|
+
// badge, even when no generated tasks exist. Users need some signal
|
|
1698
|
+
// that "edits are pending export"; a single +1 keeps the count
|
|
1699
|
+
// honest without inflating for every individual field change.
|
|
1700
|
+
const cross = get() as unknown as {
|
|
1701
|
+
scheduleData?: { tasks: Array<{ expressId?: number }> } | null;
|
|
1702
|
+
scheduleIsEdited?: boolean;
|
|
1703
|
+
};
|
|
1704
|
+
const tasks = cross.scheduleData?.tasks;
|
|
1705
|
+
let hasGenerated = false;
|
|
1706
|
+
if (tasks) {
|
|
1707
|
+
for (const t of tasks) {
|
|
1708
|
+
if (!t.expressId || t.expressId <= 0) {
|
|
1709
|
+
count++;
|
|
1710
|
+
hasGenerated = true;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
if (cross.scheduleIsEdited && !hasGenerated) count++;
|
|
782
1715
|
return count;
|
|
783
1716
|
},
|
|
784
1717
|
|
|
@@ -789,6 +1722,17 @@ export const createMutationSlice: StateCreator<
|
|
|
789
1722
|
view.clear();
|
|
790
1723
|
}
|
|
791
1724
|
|
|
1725
|
+
// Also discard pending schedule edits owned by this model. Done via
|
|
1726
|
+
// the schedule slice's own action so its invariants (range, playback,
|
|
1727
|
+
// expanded rows) stay consistent.
|
|
1728
|
+
const cross = get() as unknown as {
|
|
1729
|
+
scheduleSourceModelId?: string | null;
|
|
1730
|
+
clearGeneratedSchedule?: () => number;
|
|
1731
|
+
};
|
|
1732
|
+
if (cross.scheduleSourceModelId === modelId && cross.clearGeneratedSchedule) {
|
|
1733
|
+
cross.clearGeneratedSchedule();
|
|
1734
|
+
}
|
|
1735
|
+
|
|
792
1736
|
set((state) => {
|
|
793
1737
|
const newUndoStacks = new Map(state.undoStacks);
|
|
794
1738
|
newUndoStacks.delete(modelId);
|
|
@@ -802,11 +1746,22 @@ export const createMutationSlice: StateCreator<
|
|
|
802
1746
|
const newGeorefMuts = new Map(state.georefMutations);
|
|
803
1747
|
newGeorefMuts.delete(modelId);
|
|
804
1748
|
|
|
1749
|
+
const newRemoved = new Map(state.removedNewEntities);
|
|
1750
|
+
const prefix = `${modelId}:`;
|
|
1751
|
+
for (const key of [...newRemoved.keys()]) {
|
|
1752
|
+
if (key.startsWith(prefix)) newRemoved.delete(key);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
const newEditors = new Map(state.storeEditors);
|
|
1756
|
+
newEditors.delete(modelId);
|
|
1757
|
+
|
|
805
1758
|
return {
|
|
806
1759
|
undoStacks: newUndoStacks,
|
|
807
1760
|
redoStacks: newRedoStacks,
|
|
808
1761
|
dirtyModels: newDirty,
|
|
809
1762
|
georefMutations: newGeorefMuts,
|
|
1763
|
+
removedNewEntities: newRemoved,
|
|
1764
|
+
storeEditors: newEditors,
|
|
810
1765
|
mutationVersion: state.mutationVersion + 1,
|
|
811
1766
|
};
|
|
812
1767
|
});
|
|
@@ -817,11 +1772,17 @@ export const createMutationSlice: StateCreator<
|
|
|
817
1772
|
view.clear();
|
|
818
1773
|
}
|
|
819
1774
|
|
|
1775
|
+
// Schedule slice handles its own state transitions.
|
|
1776
|
+
const cross = get() as unknown as { clearGeneratedSchedule?: () => number };
|
|
1777
|
+
cross.clearGeneratedSchedule?.();
|
|
1778
|
+
|
|
820
1779
|
set((state) => ({
|
|
821
1780
|
undoStacks: new Map(),
|
|
822
1781
|
redoStacks: new Map(),
|
|
823
1782
|
dirtyModels: new Set(),
|
|
824
1783
|
georefMutations: new Map(),
|
|
1784
|
+
removedNewEntities: new Map(),
|
|
1785
|
+
storeEditors: new Map(),
|
|
825
1786
|
mutationVersion: state.mutationVersion + 1,
|
|
826
1787
|
}));
|
|
827
1788
|
},
|