@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.
Files changed (164) hide show
  1. package/LICENSE +373 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/Arrow.dom-B0e15b_b.js +20 -0
  4. package/dist/assets/arrow2-bb-jcVEo.js +2 -0
  5. package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
  6. package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
  7. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  8. package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
  9. package/dist/assets/event-DIOks52T.js +1 -0
  10. package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
  11. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  12. package/dist/assets/index-Dgd6vzw_.js +65252 -0
  13. package/dist/assets/index-v3mcCUPN.css +1 -0
  14. package/dist/assets/native-bridge-Ci7NLjlZ.js +111 -0
  15. package/dist/assets/wasm-bridge-Dc82YpdZ.js +1 -0
  16. package/dist/favicon-16x16-cropped.png +0 -0
  17. package/dist/favicon-16x16.png +0 -0
  18. package/dist/favicon-192x192-cropped.png +0 -0
  19. package/dist/favicon-192x192.png +0 -0
  20. package/dist/favicon-32x32-cropped.png +0 -0
  21. package/dist/favicon-32x32.png +0 -0
  22. package/dist/favicon-48x48-cropped.png +0 -0
  23. package/dist/favicon-48x48.png +0 -0
  24. package/dist/favicon-512x512-cropped.png +0 -0
  25. package/dist/favicon-512x512.png +0 -0
  26. package/dist/favicon-64x64-cropped.png +0 -0
  27. package/dist/favicon-64x64.png +0 -0
  28. package/dist/favicon-96x96-cropped.png +0 -0
  29. package/dist/favicon-96x96.png +0 -0
  30. package/dist/favicon-square-512.png +0 -0
  31. package/dist/favicon.ico +0 -0
  32. package/dist/favicon.png +0 -0
  33. package/dist/favicon.svg +3 -0
  34. package/dist/index.html +44 -0
  35. package/dist/logo.png +0 -0
  36. package/dist/manifest.json +48 -0
  37. package/index.html +33 -2
  38. package/package.json +34 -17
  39. package/public/apple-touch-icon.png +0 -0
  40. package/public/favicon-16x16-cropped.png +0 -0
  41. package/public/favicon-16x16.png +0 -0
  42. package/public/favicon-192x192-cropped.png +0 -0
  43. package/public/favicon-192x192.png +0 -0
  44. package/public/favicon-32x32-cropped.png +0 -0
  45. package/public/favicon-32x32.png +0 -0
  46. package/public/favicon-48x48-cropped.png +0 -0
  47. package/public/favicon-48x48.png +0 -0
  48. package/public/favicon-512x512-cropped.png +0 -0
  49. package/public/favicon-512x512.png +0 -0
  50. package/public/favicon-64x64-cropped.png +0 -0
  51. package/public/favicon-64x64.png +0 -0
  52. package/public/favicon-96x96-cropped.png +0 -0
  53. package/public/favicon-96x96.png +0 -0
  54. package/public/favicon-square-512.png +0 -0
  55. package/public/favicon.ico +0 -0
  56. package/public/favicon.png +0 -0
  57. package/public/favicon.svg +3 -0
  58. package/public/logo.png +0 -0
  59. package/public/manifest.json +48 -0
  60. package/src/App.tsx +2 -0
  61. package/src/components/ui/alert.tsx +62 -0
  62. package/src/components/ui/badge.tsx +39 -0
  63. package/src/components/ui/dialog.tsx +120 -0
  64. package/src/components/ui/label.tsx +27 -0
  65. package/src/components/ui/select.tsx +151 -0
  66. package/src/components/ui/switch.tsx +30 -0
  67. package/src/components/ui/table.tsx +120 -0
  68. package/src/components/ui/tabs.tsx +1 -1
  69. package/src/components/viewer/BCFPanel.tsx +1164 -0
  70. package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
  71. package/src/components/viewer/DataConnector.tsx +840 -0
  72. package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
  73. package/src/components/viewer/EntityContextMenu.tsx +45 -17
  74. package/src/components/viewer/ExportChangesButton.tsx +195 -0
  75. package/src/components/viewer/ExportDialog.tsx +402 -0
  76. package/src/components/viewer/HierarchyPanel.tsx +1132 -218
  77. package/src/components/viewer/IDSPanel.tsx +661 -0
  78. package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
  79. package/src/components/viewer/MainToolbar.tsx +418 -94
  80. package/src/components/viewer/PropertiesPanel.tsx +1355 -91
  81. package/src/components/viewer/PropertyEditor.tsx +611 -0
  82. package/src/components/viewer/Section2DPanel.tsx +3313 -0
  83. package/src/components/viewer/SheetSetupPanel.tsx +502 -0
  84. package/src/components/viewer/StatusBar.tsx +27 -16
  85. package/src/components/viewer/TitleBlockEditor.tsx +437 -0
  86. package/src/components/viewer/ToolOverlays.tsx +935 -127
  87. package/src/components/viewer/ViewerLayout.tsx +40 -11
  88. package/src/components/viewer/Viewport.tsx +1276 -336
  89. package/src/components/viewer/ViewportContainer.tsx +554 -18
  90. package/src/components/viewer/ViewportOverlays.tsx +24 -7
  91. package/src/hooks/useBCF.ts +504 -0
  92. package/src/hooks/useIDS.ts +1065 -0
  93. package/src/hooks/useIfc.ts +1534 -205
  94. package/src/hooks/useIfcCache.ts +279 -0
  95. package/src/hooks/useKeyboardShortcuts.ts +50 -8
  96. package/src/hooks/useModelSelection.ts +61 -0
  97. package/src/hooks/useViewerSelectors.ts +218 -0
  98. package/src/hooks/useWebGPU.ts +80 -0
  99. package/src/index.css +265 -27
  100. package/src/lib/platform.ts +23 -0
  101. package/src/services/cacheService.ts +142 -0
  102. package/src/services/desktop-cache.ts +143 -0
  103. package/src/services/fs-cache.ts +212 -0
  104. package/src/services/ifc-cache.ts +14 -6
  105. package/src/store/constants.ts +85 -0
  106. package/src/store/index.ts +214 -0
  107. package/src/store/slices/bcfSlice.ts +372 -0
  108. package/src/store/slices/cameraSlice.ts +63 -0
  109. package/src/store/slices/dataSlice.test.ts +226 -0
  110. package/src/store/slices/dataSlice.ts +112 -0
  111. package/src/store/slices/drawing2DSlice.ts +340 -0
  112. package/src/store/slices/hoverSlice.ts +40 -0
  113. package/src/store/slices/idsSlice.ts +310 -0
  114. package/src/store/slices/loadingSlice.ts +33 -0
  115. package/src/store/slices/measurementSlice.test.ts +217 -0
  116. package/src/store/slices/measurementSlice.ts +293 -0
  117. package/src/store/slices/modelSlice.test.ts +271 -0
  118. package/src/store/slices/modelSlice.ts +211 -0
  119. package/src/store/slices/mutationSlice.ts +502 -0
  120. package/src/store/slices/sectionSlice.test.ts +125 -0
  121. package/src/store/slices/sectionSlice.ts +58 -0
  122. package/src/store/slices/selectionSlice.test.ts +286 -0
  123. package/src/store/slices/selectionSlice.ts +263 -0
  124. package/src/store/slices/sheetSlice.ts +565 -0
  125. package/src/store/slices/uiSlice.ts +58 -0
  126. package/src/store/slices/visibilitySlice.test.ts +304 -0
  127. package/src/store/slices/visibilitySlice.ts +277 -0
  128. package/src/store/types.test.ts +135 -0
  129. package/src/store/types.ts +248 -0
  130. package/src/store.ts +40 -515
  131. package/src/utils/ifcConfig.ts +82 -0
  132. package/src/utils/localParsingUtils.ts +287 -0
  133. package/src/utils/serverDataModel.ts +783 -0
  134. package/src/utils/spatialHierarchy.ts +283 -0
  135. package/src/utils/viewportUtils.ts +334 -0
  136. package/src/vite-env.d.ts +23 -0
  137. package/src/webgpu-types.d.ts +128 -0
  138. package/src-tauri/Cargo.toml +29 -0
  139. package/src-tauri/build.rs +7 -0
  140. package/src-tauri/capabilities/default.json +18 -0
  141. package/src-tauri/icons/128x128.png +0 -0
  142. package/src-tauri/icons/128x128@2x.png +0 -0
  143. package/src-tauri/icons/32x32.png +0 -0
  144. package/src-tauri/icons/Square107x107Logo.png +0 -0
  145. package/src-tauri/icons/Square142x142Logo.png +0 -0
  146. package/src-tauri/icons/Square150x150Logo.png +0 -0
  147. package/src-tauri/icons/Square284x284Logo.png +0 -0
  148. package/src-tauri/icons/Square30x30Logo.png +0 -0
  149. package/src-tauri/icons/Square310x310Logo.png +0 -0
  150. package/src-tauri/icons/Square44x44Logo.png +0 -0
  151. package/src-tauri/icons/Square71x71Logo.png +0 -0
  152. package/src-tauri/icons/Square89x89Logo.png +0 -0
  153. package/src-tauri/icons/StoreLogo.png +0 -0
  154. package/src-tauri/icons/icon.icns +0 -0
  155. package/src-tauri/icons/icon.ico +0 -0
  156. package/src-tauri/icons/icon.png +0 -0
  157. package/src-tauri/src/lib.rs +21 -0
  158. package/src-tauri/src/main.rs +10 -0
  159. package/src-tauri/tauri.conf.json +39 -0
  160. package/vite.config.ts +174 -26
  161. package/public/ifc-lite_bg.wasm +0 -0
  162. package/public/web-ifc.wasm +0 -0
  163. package/src/components/Viewport.tsx +0 -723
  164. 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
+ });