@ifc-lite/viewer 1.1.7 → 1.5.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/LICENSE +373 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/Arrow.dom-B0e15b_b.js +20 -0
- package/dist/assets/arrow2-bb-jcVEo.js +2 -0
- package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
- package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
- package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
- package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
- package/dist/assets/event-DIOks52T.js +1 -0
- package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-Dgd6vzw_.js +65252 -0
- package/dist/assets/index-v3mcCUPN.css +1 -0
- package/dist/assets/native-bridge-Ci7NLjlZ.js +111 -0
- package/dist/assets/wasm-bridge-Dc82YpdZ.js +1 -0
- package/dist/favicon-16x16-cropped.png +0 -0
- package/dist/favicon-16x16.png +0 -0
- package/dist/favicon-192x192-cropped.png +0 -0
- package/dist/favicon-192x192.png +0 -0
- package/dist/favicon-32x32-cropped.png +0 -0
- package/dist/favicon-32x32.png +0 -0
- package/dist/favicon-48x48-cropped.png +0 -0
- package/dist/favicon-48x48.png +0 -0
- package/dist/favicon-512x512-cropped.png +0 -0
- package/dist/favicon-512x512.png +0 -0
- package/dist/favicon-64x64-cropped.png +0 -0
- package/dist/favicon-64x64.png +0 -0
- package/dist/favicon-96x96-cropped.png +0 -0
- package/dist/favicon-96x96.png +0 -0
- package/dist/favicon-square-512.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +3 -0
- package/dist/index.html +44 -0
- package/dist/logo.png +0 -0
- package/dist/manifest.json +48 -0
- package/index.html +33 -2
- package/package.json +34 -17
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16-cropped.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-192x192-cropped.png +0 -0
- package/public/favicon-192x192.png +0 -0
- package/public/favicon-32x32-cropped.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon-48x48-cropped.png +0 -0
- package/public/favicon-48x48.png +0 -0
- package/public/favicon-512x512-cropped.png +0 -0
- package/public/favicon-512x512.png +0 -0
- package/public/favicon-64x64-cropped.png +0 -0
- package/public/favicon-64x64.png +0 -0
- package/public/favicon-96x96-cropped.png +0 -0
- package/public/favicon-96x96.png +0 -0
- package/public/favicon-square-512.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +3 -0
- package/public/logo.png +0 -0
- package/public/manifest.json +48 -0
- package/src/App.tsx +2 -0
- package/src/components/ui/alert.tsx +62 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/dialog.tsx +120 -0
- package/src/components/ui/label.tsx +27 -0
- package/src/components/ui/select.tsx +151 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +120 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/BCFPanel.tsx +1164 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
- package/src/components/viewer/DataConnector.tsx +840 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
- package/src/components/viewer/EntityContextMenu.tsx +45 -17
- package/src/components/viewer/ExportChangesButton.tsx +195 -0
- package/src/components/viewer/ExportDialog.tsx +402 -0
- package/src/components/viewer/HierarchyPanel.tsx +1132 -218
- package/src/components/viewer/IDSPanel.tsx +661 -0
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
- package/src/components/viewer/MainToolbar.tsx +418 -94
- package/src/components/viewer/PropertiesPanel.tsx +1355 -91
- package/src/components/viewer/PropertyEditor.tsx +611 -0
- package/src/components/viewer/Section2DPanel.tsx +3313 -0
- package/src/components/viewer/SheetSetupPanel.tsx +502 -0
- package/src/components/viewer/StatusBar.tsx +27 -16
- package/src/components/viewer/TitleBlockEditor.tsx +437 -0
- package/src/components/viewer/ToolOverlays.tsx +935 -127
- package/src/components/viewer/ViewerLayout.tsx +40 -11
- package/src/components/viewer/Viewport.tsx +1276 -336
- package/src/components/viewer/ViewportContainer.tsx +554 -18
- package/src/components/viewer/ViewportOverlays.tsx +24 -7
- package/src/hooks/useBCF.ts +504 -0
- package/src/hooks/useIDS.ts +1065 -0
- package/src/hooks/useIfc.ts +1534 -205
- package/src/hooks/useIfcCache.ts +279 -0
- package/src/hooks/useKeyboardShortcuts.ts +50 -8
- package/src/hooks/useModelSelection.ts +61 -0
- package/src/hooks/useViewerSelectors.ts +218 -0
- package/src/hooks/useWebGPU.ts +80 -0
- package/src/index.css +265 -27
- package/src/lib/platform.ts +23 -0
- package/src/services/cacheService.ts +142 -0
- package/src/services/desktop-cache.ts +143 -0
- package/src/services/fs-cache.ts +212 -0
- package/src/services/ifc-cache.ts +14 -6
- package/src/store/constants.ts +85 -0
- package/src/store/index.ts +214 -0
- package/src/store/slices/bcfSlice.ts +372 -0
- package/src/store/slices/cameraSlice.ts +63 -0
- package/src/store/slices/dataSlice.test.ts +226 -0
- package/src/store/slices/dataSlice.ts +112 -0
- package/src/store/slices/drawing2DSlice.ts +340 -0
- package/src/store/slices/hoverSlice.ts +40 -0
- package/src/store/slices/idsSlice.ts +310 -0
- package/src/store/slices/loadingSlice.ts +33 -0
- package/src/store/slices/measurementSlice.test.ts +217 -0
- package/src/store/slices/measurementSlice.ts +293 -0
- package/src/store/slices/modelSlice.test.ts +271 -0
- package/src/store/slices/modelSlice.ts +211 -0
- package/src/store/slices/mutationSlice.ts +502 -0
- package/src/store/slices/sectionSlice.test.ts +125 -0
- package/src/store/slices/sectionSlice.ts +58 -0
- package/src/store/slices/selectionSlice.test.ts +286 -0
- package/src/store/slices/selectionSlice.ts +263 -0
- package/src/store/slices/sheetSlice.ts +565 -0
- package/src/store/slices/uiSlice.ts +58 -0
- package/src/store/slices/visibilitySlice.test.ts +304 -0
- package/src/store/slices/visibilitySlice.ts +277 -0
- package/src/store/types.test.ts +135 -0
- package/src/store/types.ts +248 -0
- package/src/store.ts +40 -515
- package/src/utils/ifcConfig.ts +82 -0
- package/src/utils/localParsingUtils.ts +287 -0
- package/src/utils/serverDataModel.ts +783 -0
- package/src/utils/spatialHierarchy.ts +283 -0
- package/src/utils/viewportUtils.ts +334 -0
- package/src/vite-env.d.ts +23 -0
- package/src/webgpu-types.d.ts +128 -0
- package/src-tauri/Cargo.toml +29 -0
- package/src-tauri/build.rs +7 -0
- package/src-tauri/capabilities/default.json +18 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/src/lib.rs +21 -0
- package/src-tauri/src/main.rs +10 -0
- package/src-tauri/tauri.conf.json +39 -0
- package/vite.config.ts +174 -26
- package/public/ifc-lite_bg.wasm +0 -0
- package/public/web-ifc.wasm +0 -0
- package/src/components/Viewport.tsx +0 -723
- package/src/components/viewer/BoxSelectionOverlay.tsx +0 -53
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* Model state slice for multi-model federation
|
|
7
|
+
*
|
|
8
|
+
* Uses FederationRegistry for bulletproof ID handling:
|
|
9
|
+
* - Each model gets a unique ID offset at load time
|
|
10
|
+
* - All meshes use globalIds (originalExpressId + offset)
|
|
11
|
+
* - No ID collisions possible between models
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { StateCreator } from 'zustand';
|
|
15
|
+
import type { FederatedModel } from '../types.js';
|
|
16
|
+
import { federationRegistry, type GlobalIdLookup } from '@ifc-lite/renderer';
|
|
17
|
+
|
|
18
|
+
export interface ModelSlice {
|
|
19
|
+
// State
|
|
20
|
+
/** Map of all loaded models by ID */
|
|
21
|
+
models: Map<string, FederatedModel>;
|
|
22
|
+
/** ID of the currently active model (for property panel focus) */
|
|
23
|
+
activeModelId: string | null;
|
|
24
|
+
|
|
25
|
+
// Actions
|
|
26
|
+
/** Add a new model to the federation */
|
|
27
|
+
addModel: (model: FederatedModel) => void;
|
|
28
|
+
/** Remove a model from the federation */
|
|
29
|
+
removeModel: (modelId: string) => void;
|
|
30
|
+
/** Clear all models */
|
|
31
|
+
clearAllModels: () => void;
|
|
32
|
+
/** Set the active model for property panel focus */
|
|
33
|
+
setActiveModel: (modelId: string | null) => void;
|
|
34
|
+
/** Toggle model visibility */
|
|
35
|
+
setModelVisibility: (modelId: string, visible: boolean) => void;
|
|
36
|
+
/** Toggle model collapsed state in hierarchy */
|
|
37
|
+
setModelCollapsed: (modelId: string, collapsed: boolean) => void;
|
|
38
|
+
/** Rename a model */
|
|
39
|
+
setModelName: (modelId: string, name: string) => void;
|
|
40
|
+
/** Get a model by ID */
|
|
41
|
+
getModel: (modelId: string) => FederatedModel | undefined;
|
|
42
|
+
/** Get the currently active model */
|
|
43
|
+
getActiveModel: () => FederatedModel | undefined;
|
|
44
|
+
/** Get all visible models */
|
|
45
|
+
getAllVisibleModels: () => FederatedModel[];
|
|
46
|
+
/** Check if any models are loaded */
|
|
47
|
+
hasModels: () => boolean;
|
|
48
|
+
|
|
49
|
+
// Federation Registry helpers (wraps the singleton for convenience)
|
|
50
|
+
/**
|
|
51
|
+
* Register a model with the federation registry and get its offset
|
|
52
|
+
* Call this BEFORE adding meshes, passing the max expressId in the model
|
|
53
|
+
*/
|
|
54
|
+
registerModelOffset: (modelId: string, maxExpressId: number) => number;
|
|
55
|
+
/** Convert local expressId to globalId */
|
|
56
|
+
toGlobalId: (modelId: string, expressId: number) => number;
|
|
57
|
+
/** Convert globalId back to (modelId, expressId) */
|
|
58
|
+
fromGlobalId: (globalId: number) => GlobalIdLookup | null;
|
|
59
|
+
/** Find which model contains a globalId */
|
|
60
|
+
findModelForGlobalId: (globalId: number) => string | null;
|
|
61
|
+
/** Get the offset for a model */
|
|
62
|
+
getModelOffset: (modelId: string) => number | null;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* BULLETPROOF: Resolve globalId using model store data instead of singleton registry
|
|
66
|
+
* This is more reliable because it uses Zustand state which is always in sync with React
|
|
67
|
+
*/
|
|
68
|
+
resolveGlobalIdFromModels: (globalId: number) => GlobalIdLookup | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const createModelSlice: StateCreator<ModelSlice, [], [], ModelSlice> = (set, get) => ({
|
|
72
|
+
// Initial state
|
|
73
|
+
models: new Map(),
|
|
74
|
+
activeModelId: null,
|
|
75
|
+
|
|
76
|
+
// Actions
|
|
77
|
+
addModel: (model) => set((state) => {
|
|
78
|
+
const newModels = new Map(state.models);
|
|
79
|
+
newModels.set(model.id, model);
|
|
80
|
+
|
|
81
|
+
// If first model, make it active
|
|
82
|
+
// If adding more models, collapse all existing by default
|
|
83
|
+
if (state.models.size === 0) {
|
|
84
|
+
return { models: newModels, activeModelId: model.id };
|
|
85
|
+
} else {
|
|
86
|
+
// Collapse existing models when adding new ones
|
|
87
|
+
for (const [id, m] of newModels) {
|
|
88
|
+
if (id !== model.id) {
|
|
89
|
+
newModels.set(id, { ...m, collapsed: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { models: newModels };
|
|
93
|
+
}
|
|
94
|
+
}),
|
|
95
|
+
|
|
96
|
+
removeModel: (modelId) => set((state) => {
|
|
97
|
+
const newModels = new Map(state.models);
|
|
98
|
+
newModels.delete(modelId);
|
|
99
|
+
|
|
100
|
+
// Unregister from federation registry
|
|
101
|
+
federationRegistry.unregisterModel(modelId);
|
|
102
|
+
|
|
103
|
+
// Update activeModelId if removed model was active
|
|
104
|
+
let newActiveId = state.activeModelId;
|
|
105
|
+
if (state.activeModelId === modelId) {
|
|
106
|
+
const remaining = Array.from(newModels.keys());
|
|
107
|
+
newActiveId = remaining.length > 0 ? remaining[0] : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { models: newModels, activeModelId: newActiveId };
|
|
111
|
+
}),
|
|
112
|
+
|
|
113
|
+
clearAllModels: () => {
|
|
114
|
+
// Clear the federation registry
|
|
115
|
+
federationRegistry.clear();
|
|
116
|
+
return set({
|
|
117
|
+
models: new Map(),
|
|
118
|
+
activeModelId: null,
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
setActiveModel: (modelId) => set({ activeModelId: modelId }),
|
|
123
|
+
|
|
124
|
+
setModelVisibility: (modelId, visible) => set((state) => {
|
|
125
|
+
const model = state.models.get(modelId);
|
|
126
|
+
if (!model) return {};
|
|
127
|
+
|
|
128
|
+
const newModels = new Map(state.models);
|
|
129
|
+
newModels.set(modelId, { ...model, visible });
|
|
130
|
+
return { models: newModels };
|
|
131
|
+
}),
|
|
132
|
+
|
|
133
|
+
setModelCollapsed: (modelId, collapsed) => set((state) => {
|
|
134
|
+
const model = state.models.get(modelId);
|
|
135
|
+
if (!model) return {};
|
|
136
|
+
|
|
137
|
+
const newModels = new Map(state.models);
|
|
138
|
+
newModels.set(modelId, { ...model, collapsed });
|
|
139
|
+
return { models: newModels };
|
|
140
|
+
}),
|
|
141
|
+
|
|
142
|
+
setModelName: (modelId, name) => set((state) => {
|
|
143
|
+
const model = state.models.get(modelId);
|
|
144
|
+
if (!model) return {};
|
|
145
|
+
|
|
146
|
+
const newModels = new Map(state.models);
|
|
147
|
+
newModels.set(modelId, { ...model, name });
|
|
148
|
+
return { models: newModels };
|
|
149
|
+
}),
|
|
150
|
+
|
|
151
|
+
// Getters (synchronous access via get())
|
|
152
|
+
getModel: (modelId) => get().models.get(modelId),
|
|
153
|
+
|
|
154
|
+
getActiveModel: () => {
|
|
155
|
+
const state = get();
|
|
156
|
+
return state.activeModelId ? state.models.get(state.activeModelId) : undefined;
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
getAllVisibleModels: () => {
|
|
160
|
+
return Array.from(get().models.values()).filter(m => m.visible);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
hasModels: () => get().models.size > 0,
|
|
164
|
+
|
|
165
|
+
// Federation Registry helpers
|
|
166
|
+
registerModelOffset: (modelId: string, maxExpressId: number) => {
|
|
167
|
+
return federationRegistry.registerModel(modelId, maxExpressId);
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
toGlobalId: (modelId: string, expressId: number) => {
|
|
171
|
+
return federationRegistry.toGlobalId(modelId, expressId);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
fromGlobalId: (globalId: number) => {
|
|
175
|
+
return federationRegistry.fromGlobalId(globalId);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
findModelForGlobalId: (globalId: number) => {
|
|
179
|
+
return federationRegistry.getModelForGlobalId(globalId);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
getModelOffset: (modelId: string) => {
|
|
183
|
+
return federationRegistry.getOffset(modelId);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* BULLETPROOF: Resolve globalId using model store data instead of singleton registry
|
|
188
|
+
* This iterates through all models and checks if the globalId falls within their range.
|
|
189
|
+
* More reliable than the singleton because it uses Zustand state which is always in sync.
|
|
190
|
+
*/
|
|
191
|
+
resolveGlobalIdFromModels: (globalId: number) => {
|
|
192
|
+
const models = get().models;
|
|
193
|
+
|
|
194
|
+
// Sort models by offset for correct range checking
|
|
195
|
+
const sortedModels = Array.from(models.values()).sort((a, b) => a.idOffset - b.idOffset);
|
|
196
|
+
|
|
197
|
+
// Find the model that contains this globalId
|
|
198
|
+
// A model contains a globalId if: offset <= globalId <= offset + maxExpressId
|
|
199
|
+
for (const model of sortedModels) {
|
|
200
|
+
const localId = globalId - model.idOffset;
|
|
201
|
+
if (localId >= 0 && localId <= model.maxExpressId) {
|
|
202
|
+
return {
|
|
203
|
+
modelId: model.id,
|
|
204
|
+
expressId: localId,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return null;
|
|
210
|
+
},
|
|
211
|
+
});
|
|
@@ -0,0 +1,502 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* Mutation slice - manages property/quantity mutations for IFC export
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type StateCreator } from 'zustand';
|
|
10
|
+
import type { ViewerState } from '../index.js';
|
|
11
|
+
import type { MutablePropertyView } from '@ifc-lite/mutations';
|
|
12
|
+
import type { Mutation, ChangeSet, PropertyValue } from '@ifc-lite/mutations';
|
|
13
|
+
import { PropertyValueType } from '@ifc-lite/data';
|
|
14
|
+
|
|
15
|
+
export interface MutationSlice {
|
|
16
|
+
// State
|
|
17
|
+
/** Mutation views per model */
|
|
18
|
+
mutationViews: Map<string, MutablePropertyView>;
|
|
19
|
+
/** All change sets */
|
|
20
|
+
changeSets: Map<string, ChangeSet>;
|
|
21
|
+
/** Active change set ID */
|
|
22
|
+
activeChangeSetId: string | null;
|
|
23
|
+
/** Undo stack per model */
|
|
24
|
+
undoStacks: Map<string, Mutation[]>;
|
|
25
|
+
/** Redo stack per model */
|
|
26
|
+
redoStacks: Map<string, Mutation[]>;
|
|
27
|
+
/** Models with unsaved changes */
|
|
28
|
+
dirtyModels: Set<string>;
|
|
29
|
+
/** Version counter to trigger re-renders when mutations change */
|
|
30
|
+
mutationVersion: number;
|
|
31
|
+
|
|
32
|
+
// Actions - Mutation View Management
|
|
33
|
+
/** Get or create mutation view for a model */
|
|
34
|
+
getMutationView: (modelId: string) => MutablePropertyView | null;
|
|
35
|
+
/** Register a mutation view for a model */
|
|
36
|
+
registerMutationView: (modelId: string, view: MutablePropertyView) => void;
|
|
37
|
+
/** Clear mutation view for a model */
|
|
38
|
+
clearMutationView: (modelId: string) => void;
|
|
39
|
+
|
|
40
|
+
// Actions - Property Mutations
|
|
41
|
+
/** Set a property value */
|
|
42
|
+
setProperty: (
|
|
43
|
+
modelId: string,
|
|
44
|
+
entityId: number,
|
|
45
|
+
psetName: string,
|
|
46
|
+
propName: string,
|
|
47
|
+
value: PropertyValue,
|
|
48
|
+
valueType?: PropertyValueType
|
|
49
|
+
) => Mutation | null;
|
|
50
|
+
/** Delete a property */
|
|
51
|
+
deleteProperty: (
|
|
52
|
+
modelId: string,
|
|
53
|
+
entityId: number,
|
|
54
|
+
psetName: string,
|
|
55
|
+
propName: string
|
|
56
|
+
) => Mutation | null;
|
|
57
|
+
/** Create a new property set */
|
|
58
|
+
createPropertySet: (
|
|
59
|
+
modelId: string,
|
|
60
|
+
entityId: number,
|
|
61
|
+
psetName: string,
|
|
62
|
+
properties: Array<{ name: string; value: PropertyValue; type?: PropertyValueType }>
|
|
63
|
+
) => Mutation | null;
|
|
64
|
+
/** Delete a property set */
|
|
65
|
+
deletePropertySet: (
|
|
66
|
+
modelId: string,
|
|
67
|
+
entityId: number,
|
|
68
|
+
psetName: string
|
|
69
|
+
) => Mutation | null;
|
|
70
|
+
|
|
71
|
+
// Actions - Undo/Redo
|
|
72
|
+
/** Undo last mutation for a model */
|
|
73
|
+
undo: (modelId: string) => void;
|
|
74
|
+
/** Redo last undone mutation for a model */
|
|
75
|
+
redo: (modelId: string) => void;
|
|
76
|
+
/** Check if undo is available */
|
|
77
|
+
canUndo: (modelId: string) => boolean;
|
|
78
|
+
/** Check if redo is available */
|
|
79
|
+
canRedo: (modelId: string) => boolean;
|
|
80
|
+
|
|
81
|
+
// Actions - Change Sets
|
|
82
|
+
/** Create a new change set */
|
|
83
|
+
createChangeSet: (name: string) => string;
|
|
84
|
+
/** Get active change set */
|
|
85
|
+
getActiveChangeSet: () => ChangeSet | null;
|
|
86
|
+
/** Set active change set */
|
|
87
|
+
setActiveChangeSet: (id: string | null) => void;
|
|
88
|
+
/** Export change set as JSON */
|
|
89
|
+
exportChangeSet: (id: string) => string | null;
|
|
90
|
+
/** Import change set from JSON */
|
|
91
|
+
importChangeSet: (json: string) => void;
|
|
92
|
+
|
|
93
|
+
// Actions - Query
|
|
94
|
+
/** Check if a model has unsaved changes */
|
|
95
|
+
hasChanges: (modelId: string) => boolean;
|
|
96
|
+
/** Get all mutations for a model */
|
|
97
|
+
getMutationsForModel: (modelId: string) => Mutation[];
|
|
98
|
+
/** Get count of modified entities across all models */
|
|
99
|
+
getModifiedEntityCount: () => number;
|
|
100
|
+
|
|
101
|
+
// Actions - Reset
|
|
102
|
+
/** Clear all mutations for a model */
|
|
103
|
+
clearMutations: (modelId: string) => void;
|
|
104
|
+
/** Clear all mutations */
|
|
105
|
+
clearAllMutations: () => void;
|
|
106
|
+
/** Manually bump mutation version (for bulk operations that bypass store) */
|
|
107
|
+
bumpMutationVersion: () => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function generateChangeSetId(): string {
|
|
111
|
+
return `cs_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const createMutationSlice: StateCreator<
|
|
115
|
+
ViewerState,
|
|
116
|
+
[],
|
|
117
|
+
[],
|
|
118
|
+
MutationSlice
|
|
119
|
+
> = (set, get) => ({
|
|
120
|
+
// Initial state
|
|
121
|
+
mutationViews: new Map(),
|
|
122
|
+
changeSets: new Map(),
|
|
123
|
+
activeChangeSetId: null,
|
|
124
|
+
undoStacks: new Map(),
|
|
125
|
+
redoStacks: new Map(),
|
|
126
|
+
dirtyModels: new Set(),
|
|
127
|
+
mutationVersion: 0,
|
|
128
|
+
|
|
129
|
+
// Mutation View Management
|
|
130
|
+
getMutationView: (modelId) => {
|
|
131
|
+
return get().mutationViews.get(modelId) || null;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
registerMutationView: (modelId, view) => {
|
|
135
|
+
set((state) => {
|
|
136
|
+
const newViews = new Map(state.mutationViews);
|
|
137
|
+
newViews.set(modelId, view);
|
|
138
|
+
return { mutationViews: newViews };
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
clearMutationView: (modelId) => {
|
|
143
|
+
set((state) => {
|
|
144
|
+
const newViews = new Map(state.mutationViews);
|
|
145
|
+
newViews.delete(modelId);
|
|
146
|
+
const newDirty = new Set(state.dirtyModels);
|
|
147
|
+
newDirty.delete(modelId);
|
|
148
|
+
return { mutationViews: newViews, dirtyModels: newDirty };
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
// Property Mutations
|
|
153
|
+
setProperty: (modelId, entityId, psetName, propName, value, valueType = PropertyValueType.String) => {
|
|
154
|
+
const view = get().mutationViews.get(modelId);
|
|
155
|
+
if (!view) return null;
|
|
156
|
+
|
|
157
|
+
const mutation = view.setProperty(entityId, psetName, propName, value, valueType);
|
|
158
|
+
|
|
159
|
+
set((state) => {
|
|
160
|
+
// Add to undo stack
|
|
161
|
+
const newUndoStacks = new Map(state.undoStacks);
|
|
162
|
+
const stack = newUndoStacks.get(modelId) || [];
|
|
163
|
+
newUndoStacks.set(modelId, [...stack, mutation]);
|
|
164
|
+
|
|
165
|
+
// Clear redo stack on new mutation
|
|
166
|
+
const newRedoStacks = new Map(state.redoStacks);
|
|
167
|
+
newRedoStacks.set(modelId, []);
|
|
168
|
+
|
|
169
|
+
// Mark model as dirty
|
|
170
|
+
const newDirty = new Set(state.dirtyModels);
|
|
171
|
+
newDirty.add(modelId);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
undoStacks: newUndoStacks,
|
|
175
|
+
redoStacks: newRedoStacks,
|
|
176
|
+
dirtyModels: newDirty,
|
|
177
|
+
mutationVersion: state.mutationVersion + 1,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return mutation;
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
deleteProperty: (modelId, entityId, psetName, propName) => {
|
|
185
|
+
const view = get().mutationViews.get(modelId);
|
|
186
|
+
if (!view) return null;
|
|
187
|
+
|
|
188
|
+
const mutation = view.deleteProperty(entityId, psetName, propName);
|
|
189
|
+
if (!mutation) return null;
|
|
190
|
+
|
|
191
|
+
set((state) => {
|
|
192
|
+
const newUndoStacks = new Map(state.undoStacks);
|
|
193
|
+
const stack = newUndoStacks.get(modelId) || [];
|
|
194
|
+
newUndoStacks.set(modelId, [...stack, mutation]);
|
|
195
|
+
|
|
196
|
+
const newRedoStacks = new Map(state.redoStacks);
|
|
197
|
+
newRedoStacks.set(modelId, []);
|
|
198
|
+
|
|
199
|
+
const newDirty = new Set(state.dirtyModels);
|
|
200
|
+
newDirty.add(modelId);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
undoStacks: newUndoStacks,
|
|
204
|
+
redoStacks: newRedoStacks,
|
|
205
|
+
dirtyModels: newDirty,
|
|
206
|
+
mutationVersion: state.mutationVersion + 1,
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return mutation;
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
createPropertySet: (modelId, entityId, psetName, properties) => {
|
|
214
|
+
const view = get().mutationViews.get(modelId);
|
|
215
|
+
if (!view) return null;
|
|
216
|
+
|
|
217
|
+
const mutation = view.createPropertySet(entityId, psetName, properties);
|
|
218
|
+
|
|
219
|
+
set((state) => {
|
|
220
|
+
const newUndoStacks = new Map(state.undoStacks);
|
|
221
|
+
const stack = newUndoStacks.get(modelId) || [];
|
|
222
|
+
newUndoStacks.set(modelId, [...stack, mutation]);
|
|
223
|
+
|
|
224
|
+
const newRedoStacks = new Map(state.redoStacks);
|
|
225
|
+
newRedoStacks.set(modelId, []);
|
|
226
|
+
|
|
227
|
+
const newDirty = new Set(state.dirtyModels);
|
|
228
|
+
newDirty.add(modelId);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
undoStacks: newUndoStacks,
|
|
232
|
+
redoStacks: newRedoStacks,
|
|
233
|
+
dirtyModels: newDirty,
|
|
234
|
+
mutationVersion: state.mutationVersion + 1,
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return mutation;
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
deletePropertySet: (modelId, entityId, psetName) => {
|
|
242
|
+
const view = get().mutationViews.get(modelId);
|
|
243
|
+
if (!view) return null;
|
|
244
|
+
|
|
245
|
+
const mutation = view.deletePropertySet(entityId, psetName);
|
|
246
|
+
|
|
247
|
+
set((state) => {
|
|
248
|
+
const newUndoStacks = new Map(state.undoStacks);
|
|
249
|
+
const stack = newUndoStacks.get(modelId) || [];
|
|
250
|
+
newUndoStacks.set(modelId, [...stack, mutation]);
|
|
251
|
+
|
|
252
|
+
const newRedoStacks = new Map(state.redoStacks);
|
|
253
|
+
newRedoStacks.set(modelId, []);
|
|
254
|
+
|
|
255
|
+
const newDirty = new Set(state.dirtyModels);
|
|
256
|
+
newDirty.add(modelId);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
undoStacks: newUndoStacks,
|
|
260
|
+
redoStacks: newRedoStacks,
|
|
261
|
+
dirtyModels: newDirty,
|
|
262
|
+
mutationVersion: state.mutationVersion + 1,
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return mutation;
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
// Undo/Redo
|
|
270
|
+
undo: (modelId) => {
|
|
271
|
+
const state = get();
|
|
272
|
+
const undoStack = state.undoStacks.get(modelId) || [];
|
|
273
|
+
if (undoStack.length === 0) return;
|
|
274
|
+
|
|
275
|
+
const mutation = undoStack[undoStack.length - 1];
|
|
276
|
+
const view = state.mutationViews.get(modelId);
|
|
277
|
+
if (!view) return;
|
|
278
|
+
|
|
279
|
+
// Apply inverse mutation (skipHistory=true to avoid polluting mutation history)
|
|
280
|
+
if (mutation.type === 'UPDATE_PROPERTY' || mutation.type === 'CREATE_PROPERTY') {
|
|
281
|
+
if (mutation.oldValue === null && mutation.psetName && mutation.propName) {
|
|
282
|
+
view.deleteProperty(mutation.entityId, mutation.psetName, mutation.propName, true);
|
|
283
|
+
} else if (mutation.psetName && mutation.propName && mutation.oldValue !== undefined) {
|
|
284
|
+
view.setProperty(
|
|
285
|
+
mutation.entityId,
|
|
286
|
+
mutation.psetName,
|
|
287
|
+
mutation.propName,
|
|
288
|
+
mutation.oldValue,
|
|
289
|
+
mutation.valueType,
|
|
290
|
+
undefined,
|
|
291
|
+
true // skipHistory
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
} else if (mutation.type === 'DELETE_PROPERTY') {
|
|
295
|
+
if (mutation.psetName && mutation.propName && mutation.oldValue !== undefined) {
|
|
296
|
+
view.setProperty(
|
|
297
|
+
mutation.entityId,
|
|
298
|
+
mutation.psetName,
|
|
299
|
+
mutation.propName,
|
|
300
|
+
mutation.oldValue,
|
|
301
|
+
mutation.valueType,
|
|
302
|
+
undefined,
|
|
303
|
+
true // skipHistory
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
set((s) => {
|
|
309
|
+
const newUndoStacks = new Map(s.undoStacks);
|
|
310
|
+
newUndoStacks.set(modelId, undoStack.slice(0, -1));
|
|
311
|
+
|
|
312
|
+
const newRedoStacks = new Map(s.redoStacks);
|
|
313
|
+
const redoStack = newRedoStacks.get(modelId) || [];
|
|
314
|
+
newRedoStacks.set(modelId, [...redoStack, mutation]);
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
undoStacks: newUndoStacks,
|
|
318
|
+
redoStacks: newRedoStacks,
|
|
319
|
+
mutationVersion: s.mutationVersion + 1,
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
redo: (modelId) => {
|
|
325
|
+
const state = get();
|
|
326
|
+
const redoStack = state.redoStacks.get(modelId) || [];
|
|
327
|
+
if (redoStack.length === 0) return;
|
|
328
|
+
|
|
329
|
+
const mutation = redoStack[redoStack.length - 1];
|
|
330
|
+
const view = state.mutationViews.get(modelId);
|
|
331
|
+
if (!view) return;
|
|
332
|
+
|
|
333
|
+
// Re-apply mutation (skipHistory=true to avoid polluting mutation history)
|
|
334
|
+
if (mutation.type === 'UPDATE_PROPERTY' || mutation.type === 'CREATE_PROPERTY') {
|
|
335
|
+
if (mutation.psetName && mutation.propName && mutation.newValue !== undefined) {
|
|
336
|
+
view.setProperty(
|
|
337
|
+
mutation.entityId,
|
|
338
|
+
mutation.psetName,
|
|
339
|
+
mutation.propName,
|
|
340
|
+
mutation.newValue,
|
|
341
|
+
mutation.valueType,
|
|
342
|
+
undefined,
|
|
343
|
+
true // skipHistory
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
} else if (mutation.type === 'DELETE_PROPERTY') {
|
|
347
|
+
if (mutation.psetName && mutation.propName) {
|
|
348
|
+
view.deleteProperty(mutation.entityId, mutation.psetName, mutation.propName, true);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
set((s) => {
|
|
353
|
+
const newRedoStacks = new Map(s.redoStacks);
|
|
354
|
+
newRedoStacks.set(modelId, redoStack.slice(0, -1));
|
|
355
|
+
|
|
356
|
+
const newUndoStacks = new Map(s.undoStacks);
|
|
357
|
+
const undoStack = newUndoStacks.get(modelId) || [];
|
|
358
|
+
newUndoStacks.set(modelId, [...undoStack, mutation]);
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
undoStacks: newUndoStacks,
|
|
362
|
+
redoStacks: newRedoStacks,
|
|
363
|
+
mutationVersion: s.mutationVersion + 1,
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
canUndo: (modelId) => {
|
|
369
|
+
const stack = get().undoStacks.get(modelId);
|
|
370
|
+
return stack ? stack.length > 0 : false;
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
canRedo: (modelId) => {
|
|
374
|
+
const stack = get().redoStacks.get(modelId);
|
|
375
|
+
return stack ? stack.length > 0 : false;
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
// Change Sets
|
|
379
|
+
createChangeSet: (name) => {
|
|
380
|
+
const id = generateChangeSetId();
|
|
381
|
+
const changeSet: ChangeSet = {
|
|
382
|
+
id,
|
|
383
|
+
name,
|
|
384
|
+
createdAt: Date.now(),
|
|
385
|
+
mutations: [],
|
|
386
|
+
applied: false,
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
set((state) => {
|
|
390
|
+
const newChangeSets = new Map(state.changeSets);
|
|
391
|
+
newChangeSets.set(id, changeSet);
|
|
392
|
+
return { changeSets: newChangeSets, activeChangeSetId: id };
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
return id;
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
getActiveChangeSet: () => {
|
|
399
|
+
const state = get();
|
|
400
|
+
if (!state.activeChangeSetId) return null;
|
|
401
|
+
return state.changeSets.get(state.activeChangeSetId) || null;
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
setActiveChangeSet: (id) => {
|
|
405
|
+
set({ activeChangeSetId: id });
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
exportChangeSet: (id) => {
|
|
409
|
+
const changeSet = get().changeSets.get(id);
|
|
410
|
+
if (!changeSet) return null;
|
|
411
|
+
|
|
412
|
+
return JSON.stringify({
|
|
413
|
+
version: 1,
|
|
414
|
+
changeSet,
|
|
415
|
+
exportedAt: Date.now(),
|
|
416
|
+
}, null, 2);
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
importChangeSet: (json) => {
|
|
420
|
+
try {
|
|
421
|
+
const data = JSON.parse(json);
|
|
422
|
+
if (!data.changeSet) return;
|
|
423
|
+
|
|
424
|
+
const changeSet: ChangeSet = {
|
|
425
|
+
...data.changeSet,
|
|
426
|
+
id: generateChangeSetId(),
|
|
427
|
+
applied: false,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
set((state) => {
|
|
431
|
+
const newChangeSets = new Map(state.changeSets);
|
|
432
|
+
newChangeSets.set(changeSet.id, changeSet);
|
|
433
|
+
return { changeSets: newChangeSets };
|
|
434
|
+
});
|
|
435
|
+
} catch {
|
|
436
|
+
console.error('Failed to import change set');
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
// Query
|
|
441
|
+
hasChanges: (modelId) => {
|
|
442
|
+
return get().dirtyModels.has(modelId);
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
getMutationsForModel: (modelId) => {
|
|
446
|
+
const view = get().mutationViews.get(modelId);
|
|
447
|
+
return view ? view.getMutations() : [];
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
getModifiedEntityCount: () => {
|
|
451
|
+
let count = 0;
|
|
452
|
+
for (const view of get().mutationViews.values()) {
|
|
453
|
+
count += view.getModifiedEntityCount();
|
|
454
|
+
}
|
|
455
|
+
return count;
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
// Reset
|
|
459
|
+
clearMutations: (modelId) => {
|
|
460
|
+
const view = get().mutationViews.get(modelId);
|
|
461
|
+
if (view) {
|
|
462
|
+
view.clear();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
set((state) => {
|
|
466
|
+
const newUndoStacks = new Map(state.undoStacks);
|
|
467
|
+
newUndoStacks.delete(modelId);
|
|
468
|
+
|
|
469
|
+
const newRedoStacks = new Map(state.redoStacks);
|
|
470
|
+
newRedoStacks.delete(modelId);
|
|
471
|
+
|
|
472
|
+
const newDirty = new Set(state.dirtyModels);
|
|
473
|
+
newDirty.delete(modelId);
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
undoStacks: newUndoStacks,
|
|
477
|
+
redoStacks: newRedoStacks,
|
|
478
|
+
dirtyModels: newDirty,
|
|
479
|
+
mutationVersion: state.mutationVersion + 1,
|
|
480
|
+
};
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
clearAllMutations: () => {
|
|
485
|
+
for (const view of get().mutationViews.values()) {
|
|
486
|
+
view.clear();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
set((state) => ({
|
|
490
|
+
undoStacks: new Map(),
|
|
491
|
+
redoStacks: new Map(),
|
|
492
|
+
dirtyModels: new Set(),
|
|
493
|
+
mutationVersion: state.mutationVersion + 1,
|
|
494
|
+
}));
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
bumpMutationVersion: () => {
|
|
498
|
+
set((state) => ({
|
|
499
|
+
mutationVersion: state.mutationVersion + 1,
|
|
500
|
+
}));
|
|
501
|
+
},
|
|
502
|
+
});
|