@ifc-lite/viewer 1.11.5 → 1.14.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 (36) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/assets/{Arrow.dom-HFGUoQyp.js → Arrow.dom-CNguvlQi.js} +1 -1
  3. package/dist/assets/{browser-CllJKxsx.js → browser-D6lgLpkA.js} +1 -1
  4. package/dist/assets/{index-CQd80vMv.js → index-BMwpw264.js} +4 -4
  5. package/dist/assets/index-Qp8stcGO.css +1 -0
  6. package/dist/assets/{index-B69WAU-m.js → index-UaDsJsCR.js} +26434 -23023
  7. package/dist/assets/{native-bridge-Bu4SptAa.js → native-bridge-DqELq4X0.js} +1 -1
  8. package/dist/assets/{wasm-bridge-CR2KvcQN.js → wasm-bridge-CVWvHlfH.js} +1 -1
  9. package/dist/index.html +2 -2
  10. package/package.json +19 -19
  11. package/src/App.tsx +2 -0
  12. package/src/components/ui/toast.tsx +121 -0
  13. package/src/components/viewer/BulkPropertyEditor.tsx +8 -1
  14. package/src/components/viewer/DataConnector.tsx +8 -1
  15. package/src/components/viewer/ExportChangesButton.tsx +11 -2
  16. package/src/components/viewer/ExportDialog.tsx +224 -132
  17. package/src/components/viewer/MainToolbar.tsx +9 -2
  18. package/src/components/viewer/PropertiesPanel.tsx +300 -15
  19. package/src/components/viewer/properties/BsddCard.tsx +507 -0
  20. package/src/components/viewer/properties/QuantitySetCard.tsx +1 -0
  21. package/src/components/viewer/useGeometryStreaming.ts +4 -4
  22. package/src/index.css +7 -0
  23. package/src/lib/scripts/templates/bim-globals.d.ts +33 -0
  24. package/src/lib/scripts/templates/create-building.ts +491 -0
  25. package/src/lib/scripts/templates.ts +8 -0
  26. package/src/sdk/adapters/export-adapter.ts +84 -0
  27. package/src/sdk/adapters/lens-adapter.ts +1 -1
  28. package/src/sdk/adapters/model-adapter.ts +8 -0
  29. package/src/sdk/adapters/viewer-adapter.ts +1 -1
  30. package/src/services/bsdd.ts +262 -0
  31. package/src/store/index.ts +2 -2
  32. package/src/store/slices/measurementSlice.test.ts +22 -22
  33. package/src/store/slices/modelSlice.test.ts +2 -0
  34. package/src/store/slices/mutationSlice.ts +155 -1
  35. package/vite.config.ts +7 -0
  36. package/dist/assets/index-BoYyWYAu.css +0 -1
@@ -0,0 +1,262 @@
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
+ * bSDD (buildingSMART Data Dictionary) API client.
7
+ *
8
+ * Fetches IFC class definitions, property sets, and properties from the
9
+ * bSDD REST API so that users can discover schema-conform properties
10
+ * for a selected IFC entity type and add them in one click.
11
+ *
12
+ * API docs: https://app.swaggerhub.com/apis/buildingSMART/Dictionaries/v1
13
+ */
14
+
15
+ // Proxy through our own origin to avoid CORS issues.
16
+ // In dev Vite proxies /api/bsdd → https://api.bsdd.buildingsmart.org,
17
+ // in production Vercel rewrites do the same.
18
+ const BSDD_API = '/api/bsdd';
19
+ const IFC_DICTIONARY_URI =
20
+ 'https://identifier.buildingsmart.org/uri/buildingsmart/ifc/4.3';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface BsddClassProperty {
27
+ /** Property name, e.g. "IsExternal" */
28
+ name: string;
29
+ /** URI of the property definition */
30
+ uri: string;
31
+ /** Human-readable description */
32
+ description: string | null;
33
+ /** bSDD data type, e.g. "Boolean", "Real", "String" */
34
+ dataType: string | null;
35
+ /** Name of the property set this property belongs to */
36
+ propertySet: string | null;
37
+ /** Allowed values (enum constraints) */
38
+ allowedValues: Array<{ uri?: string; value: string; description?: string }> | null;
39
+ /** Units */
40
+ units: string[] | null;
41
+ /** Whether this is from the IFC standard dictionary */
42
+ isIfcStandard: boolean;
43
+ }
44
+
45
+ export interface BsddClassInfo {
46
+ /** Class URI */
47
+ uri: string;
48
+ /** IFC entity code, e.g. "IfcWall" */
49
+ code: string;
50
+ /** Human-readable name */
51
+ name: string;
52
+ /** Description / definition */
53
+ definition: string | null;
54
+ /** Parent class URI */
55
+ parentClassUri: string | null;
56
+ /** Properties defined for this class */
57
+ classProperties: BsddClassProperty[];
58
+ /** Related IFC entity names */
59
+ relatedIfcEntityNames: string[] | null;
60
+ }
61
+
62
+ export interface BsddSearchResult {
63
+ uri: string;
64
+ code: string;
65
+ name: string;
66
+ definition: string | null;
67
+ dictionaryUri: string;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // In-memory cache (keyed by class URI)
72
+ // ---------------------------------------------------------------------------
73
+
74
+ const classCache = new Map<string, { data: BsddClassInfo; ts: number }>();
75
+ const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
76
+
77
+ function getCached(key: string): BsddClassInfo | null {
78
+ const entry = classCache.get(key);
79
+ if (entry && Date.now() - entry.ts < CACHE_TTL_MS) return entry.data;
80
+ if (entry) classCache.delete(key);
81
+ return null;
82
+ }
83
+
84
+ function setCache(key: string, data: BsddClassInfo) {
85
+ classCache.set(key, { data, ts: Date.now() });
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // API helpers
90
+ // ---------------------------------------------------------------------------
91
+
92
+ async function fetchJson<T>(url: string): Promise<T> {
93
+ const res = await fetch(url, {
94
+ headers: { Accept: 'application/json' },
95
+ });
96
+ if (!res.ok) {
97
+ throw new Error(`bSDD API ${res.status}: ${res.statusText}`);
98
+ }
99
+ return res.json() as Promise<T>;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Public API
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Build the bSDD class URI for an IFC entity type.
108
+ * e.g. "IfcWall" -> "https://identifier.buildingsmart.org/uri/buildingsmart/ifc/4.3/class/IfcWall"
109
+ */
110
+ export function ifcClassUri(ifcType: string): string {
111
+ // Use the type name as-is. IFC parsers typically produce PascalCase
112
+ // names (e.g. "IfcWall") which match the bSDD URI scheme directly.
113
+ // Previous best-effort lowercasing corrupted multi-word names like
114
+ // IFCWALLSTANDARDCASE → "IfcWallstandardcase", so we no longer attempt
115
+ // case normalisation — the bSDD API will simply 404 for unknown names
116
+ // and we handle that gracefully.
117
+ return `${IFC_DICTIONARY_URI}/class/${ifcType}`;
118
+ }
119
+
120
+ /**
121
+ * Fetch full class info (including properties) for an IFC entity type.
122
+ *
123
+ * Uses the `/api/Class/v1` endpoint with `IncludeClassProperties=true`
124
+ * (PascalCase parameter names per the bSDD OpenAPI spec).
125
+ * Falls back to the paginated `/api/Class/Properties/v1` endpoint when
126
+ * the inline property list comes back empty.
127
+ */
128
+ export async function fetchClassInfo(
129
+ ifcType: string,
130
+ ): Promise<BsddClassInfo | null> {
131
+ const uri = ifcClassUri(ifcType);
132
+ const cached = getCached(uri);
133
+ if (cached) return cached;
134
+
135
+ try {
136
+ // Parameter names must be PascalCase per the bSDD OpenAPI spec
137
+ const raw = await fetchJson<Record<string, unknown>>(
138
+ `${BSDD_API}/api/Class/v1?Uri=${encodeURIComponent(uri)}&IncludeClassProperties=true&IncludeClassRelations=true`,
139
+ );
140
+
141
+ let info = mapClassResponse(raw, true);
142
+
143
+ // Fallback: if inline classProperties came back empty, try the
144
+ // dedicated paginated properties endpoint
145
+ if (info.classProperties.length === 0) {
146
+ const propsRaw = await fetchJson<Record<string, unknown>>(
147
+ `${BSDD_API}/api/Class/Properties/v1?ClassUri=${encodeURIComponent(uri)}`,
148
+ ).catch(() => null);
149
+
150
+ if (propsRaw) {
151
+ const propsList = propsRaw.classProperties as Array<Record<string, unknown>> | undefined;
152
+ if (propsList && propsList.length > 0) {
153
+ info = {
154
+ ...info,
155
+ classProperties: propsList.map((p) => ({
156
+ name: String(p.name ?? p.propertyCode ?? ''),
157
+ uri: String(p.propertyUri ?? p.uri ?? ''),
158
+ description: p.description ? String(p.description) : null,
159
+ dataType: p.dataType ? String(p.dataType) : null,
160
+ propertySet: p.propertySet ? String(p.propertySet) : null,
161
+ allowedValues: Array.isArray(p.allowedValues)
162
+ ? p.allowedValues.map((v: Record<string, unknown>) => ({
163
+ uri: v.uri ? String(v.uri) : undefined,
164
+ value: String(v.value ?? ''),
165
+ description: v.description ? String(v.description) : undefined,
166
+ }))
167
+ : null,
168
+ units: Array.isArray(p.units) ? (p.units as string[]) : null,
169
+ isIfcStandard: true,
170
+ })),
171
+ };
172
+ }
173
+ }
174
+ }
175
+
176
+ setCache(uri, info);
177
+ return info;
178
+ } catch {
179
+ // Silently return null – bSDD may not have data for every type
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Search bSDD for classes related to a given IFC entity type across all
186
+ * dictionaries (not just the IFC dictionary).
187
+ *
188
+ * Uses `/api/Class/Search/v1` with a RelatedIfcEntities filter.
189
+ * Returns lightweight results. Call `fetchClassInfo` on a specific result
190
+ * to get full properties.
191
+ */
192
+ export async function searchRelatedClasses(
193
+ ifcType: string,
194
+ ): Promise<BsddSearchResult[]> {
195
+ try {
196
+ const raw = await fetchJson<{
197
+ classes?: Array<Record<string, unknown>>;
198
+ }>(
199
+ `${BSDD_API}/api/Class/Search/v1?SearchText=${encodeURIComponent(ifcType)}&RelatedIfcEntities=${encodeURIComponent(ifcType)}`,
200
+ );
201
+ return (raw.classes ?? []).map((c) => ({
202
+ uri: String(c.uri ?? ''),
203
+ code: String(c.code ?? c.name ?? ''),
204
+ name: String(c.name ?? ''),
205
+ definition: c.definition ? String(c.definition) : null,
206
+ dictionaryUri: String(c.dictionaryUri ?? ''),
207
+ }));
208
+ } catch {
209
+ return [];
210
+ }
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Response mapping
215
+ // ---------------------------------------------------------------------------
216
+
217
+ function mapClassResponse(
218
+ raw: Record<string, unknown>,
219
+ isIfcStandard: boolean,
220
+ ): BsddClassInfo {
221
+ const props = raw.classProperties as Array<Record<string, unknown>> | undefined;
222
+
223
+ return {
224
+ uri: String(raw.uri ?? ''),
225
+ code: String(raw.code ?? raw.name ?? ''),
226
+ name: String(raw.name ?? ''),
227
+ definition: raw.definition ? String(raw.definition) : null,
228
+ parentClassUri: raw.parentClassReference
229
+ ? String((raw.parentClassReference as Record<string, unknown>).uri ?? '')
230
+ : null,
231
+ relatedIfcEntityNames: raw.relatedIfcEntityNames as string[] | null,
232
+ classProperties: (props ?? []).map((p) => ({
233
+ name: String(p.name ?? p.propertyCode ?? ''),
234
+ uri: String(p.propertyUri ?? p.uri ?? ''),
235
+ description: p.description ? String(p.description) : null,
236
+ dataType: p.dataType ? String(p.dataType) : null,
237
+ propertySet: p.propertySet ? String(p.propertySet) : null,
238
+ allowedValues: Array.isArray(p.allowedValues)
239
+ ? p.allowedValues.map((v: Record<string, unknown>) => ({
240
+ uri: v.uri ? String(v.uri) : undefined,
241
+ value: String(v.value ?? ''),
242
+ description: v.description ? String(v.description) : undefined,
243
+ }))
244
+ : null,
245
+ units: Array.isArray(p.units) ? (p.units as string[]) : null,
246
+ isIfcStandard,
247
+ })),
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Map bSDD dataType string to a human-friendly label.
253
+ */
254
+ export function bsddDataTypeLabel(dt: string | null): string {
255
+ if (!dt) return 'String';
256
+ const lower = dt.toLowerCase();
257
+ if (lower === 'boolean') return 'Boolean';
258
+ if (lower === 'real' || lower === 'number') return 'Real';
259
+ if (lower === 'integer') return 'Integer';
260
+ if (lower === 'string' || lower === 'character') return 'String';
261
+ return dt;
262
+ }
@@ -275,8 +275,8 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
275
275
  basketPresentationVisible: false,
276
276
  hierarchyBasketSelection: new Set<string>(),
277
277
 
278
- // Script - reset execution state but keep saved scripts and editor content
279
- scriptPanelVisible: false,
278
+ // Script - reset execution state but keep saved scripts, editor content, and panel visibility
279
+ // (scripts that create-and-load a model should not close the panel)
280
280
  scriptExecutionState: 'idle' as const,
281
281
  scriptLastResult: null,
282
282
  scriptLastError: null,
@@ -47,7 +47,7 @@ describe('MeasurementSlice', () => {
47
47
 
48
48
  describe('addMeasurePoint', () => {
49
49
  it('should set pending measure point', () => {
50
- const point = { x: 1, y: 2, z: 3 };
50
+ const point = { x: 1, y: 2, z: 3, screenX: 0, screenY: 0 };
51
51
  state.addMeasurePoint(point);
52
52
  assert.deepStrictEqual(state.pendingMeasurePoint, point);
53
53
  });
@@ -55,8 +55,8 @@ describe('MeasurementSlice', () => {
55
55
 
56
56
  describe('completeMeasurement', () => {
57
57
  it('should create measurement when pending point exists', () => {
58
- const startPoint = { x: 0, y: 0, z: 0 };
59
- const endPoint = { x: 3, y: 4, z: 0 };
58
+ const startPoint = { x: 0, y: 0, z: 0, screenX: 0, screenY: 0 };
59
+ const endPoint = { x: 3, y: 4, z: 0, screenX: 0, screenY: 0 };
60
60
 
61
61
  state.addMeasurePoint(startPoint);
62
62
  state.completeMeasurement(endPoint);
@@ -69,14 +69,14 @@ describe('MeasurementSlice', () => {
69
69
  });
70
70
 
71
71
  it('should not create measurement when no pending point', () => {
72
- const endPoint = { x: 1, y: 1, z: 1 };
72
+ const endPoint = { x: 1, y: 1, z: 1, screenX: 0, screenY: 0 };
73
73
  state.completeMeasurement(endPoint);
74
74
  assert.strictEqual(state.measurements.length, 0);
75
75
  });
76
76
 
77
77
  it('should generate unique IDs for rapid measurements', () => {
78
- const point1 = { x: 0, y: 0, z: 0 };
79
- const point2 = { x: 1, y: 0, z: 0 };
78
+ const point1 = { x: 0, y: 0, z: 0, screenX: 0, screenY: 0 };
79
+ const point2 = { x: 1, y: 0, z: 0, screenX: 0, screenY: 0 };
80
80
 
81
81
  state.addMeasurePoint(point1);
82
82
  state.completeMeasurement(point2);
@@ -91,7 +91,7 @@ describe('MeasurementSlice', () => {
91
91
 
92
92
  describe('startMeasurement', () => {
93
93
  it('should initialize active measurement', () => {
94
- const point = { x: 1, y: 2, z: 3 };
94
+ const point = { x: 1, y: 2, z: 3, screenX: 0, screenY: 0 };
95
95
  state.startMeasurement(point);
96
96
 
97
97
  assert.deepStrictEqual(state.activeMeasurement?.start, point);
@@ -102,8 +102,8 @@ describe('MeasurementSlice', () => {
102
102
 
103
103
  describe('updateMeasurement', () => {
104
104
  it('should update current point and distance', () => {
105
- const startPoint = { x: 0, y: 0, z: 0 };
106
- const currentPoint = { x: 3, y: 4, z: 0 };
105
+ const startPoint = { x: 0, y: 0, z: 0, screenX: 0, screenY: 0 };
106
+ const currentPoint = { x: 3, y: 4, z: 0, screenX: 0, screenY: 0 };
107
107
 
108
108
  state.startMeasurement(startPoint);
109
109
  state.updateMeasurement(currentPoint);
@@ -114,7 +114,7 @@ describe('MeasurementSlice', () => {
114
114
  });
115
115
 
116
116
  it('should not update when no active measurement', () => {
117
- const point = { x: 1, y: 1, z: 1 };
117
+ const point = { x: 1, y: 1, z: 1, screenX: 0, screenY: 0 };
118
118
  state.updateMeasurement(point);
119
119
  assert.strictEqual(state.activeMeasurement, null);
120
120
  });
@@ -122,8 +122,8 @@ describe('MeasurementSlice', () => {
122
122
 
123
123
  describe('finalizeMeasurement', () => {
124
124
  it('should add completed measurement to list', () => {
125
- const startPoint = { x: 0, y: 0, z: 0 };
126
- const endPoint = { x: 1, y: 0, z: 0 };
125
+ const startPoint = { x: 0, y: 0, z: 0, screenX: 0, screenY: 0 };
126
+ const endPoint = { x: 1, y: 0, z: 0, screenX: 0, screenY: 0 };
127
127
 
128
128
  state.startMeasurement(startPoint);
129
129
  state.updateMeasurement(endPoint);
@@ -143,7 +143,7 @@ describe('MeasurementSlice', () => {
143
143
 
144
144
  describe('cancelMeasurement', () => {
145
145
  it('should clear active measurement', () => {
146
- state.startMeasurement({ x: 0, y: 0, z: 0 });
146
+ state.startMeasurement({ x: 0, y: 0, z: 0, screenX: 0, screenY: 0 });
147
147
  state.cancelMeasurement();
148
148
  assert.strictEqual(state.activeMeasurement, null);
149
149
  });
@@ -157,8 +157,8 @@ describe('MeasurementSlice', () => {
157
157
 
158
158
  describe('deleteMeasurement', () => {
159
159
  it('should remove measurement by id', () => {
160
- state.startMeasurement({ x: 0, y: 0, z: 0 });
161
- state.updateMeasurement({ x: 1, y: 0, z: 0 });
160
+ state.startMeasurement({ x: 0, y: 0, z: 0, screenX: 0, screenY: 0 });
161
+ state.updateMeasurement({ x: 1, y: 0, z: 0, screenX: 0, screenY: 0 });
162
162
  state.finalizeMeasurement();
163
163
 
164
164
  const id = state.measurements[0].id;
@@ -169,12 +169,12 @@ describe('MeasurementSlice', () => {
169
169
 
170
170
  it('should not affect other measurements', () => {
171
171
  // Create two measurements
172
- state.startMeasurement({ x: 0, y: 0, z: 0 });
173
- state.updateMeasurement({ x: 1, y: 0, z: 0 });
172
+ state.startMeasurement({ x: 0, y: 0, z: 0, screenX: 0, screenY: 0 });
173
+ state.updateMeasurement({ x: 1, y: 0, z: 0, screenX: 0, screenY: 0 });
174
174
  state.finalizeMeasurement();
175
175
 
176
- state.startMeasurement({ x: 0, y: 0, z: 0 });
177
- state.updateMeasurement({ x: 2, y: 0, z: 0 });
176
+ state.startMeasurement({ x: 0, y: 0, z: 0, screenX: 0, screenY: 0 });
177
+ state.updateMeasurement({ x: 2, y: 0, z: 0, screenX: 0, screenY: 0 });
178
178
  state.finalizeMeasurement();
179
179
 
180
180
  const firstId = state.measurements[0].id;
@@ -187,11 +187,11 @@ describe('MeasurementSlice', () => {
187
187
 
188
188
  describe('clearMeasurements', () => {
189
189
  it('should clear all measurements and state', () => {
190
- state.startMeasurement({ x: 0, y: 0, z: 0 });
191
- state.updateMeasurement({ x: 1, y: 0, z: 0 });
190
+ state.startMeasurement({ x: 0, y: 0, z: 0, screenX: 0, screenY: 0 });
191
+ state.updateMeasurement({ x: 1, y: 0, z: 0, screenX: 0, screenY: 0 });
192
192
  state.finalizeMeasurement();
193
193
 
194
- state.addMeasurePoint({ x: 5, y: 5, z: 5 });
194
+ state.addMeasurePoint({ x: 5, y: 5, z: 5, screenX: 0, screenY: 0 });
195
195
 
196
196
  state.clearMeasurements();
197
197
 
@@ -19,6 +19,8 @@ function createMockModel(id: string, name: string): FederatedModel {
19
19
  schemaVersion: 'IFC4',
20
20
  loadedAt: Date.now(),
21
21
  fileSize: 1024,
22
+ idOffset: 0,
23
+ maxExpressId: 0,
22
24
  };
23
25
  }
24
26
 
@@ -10,7 +10,7 @@ import { type StateCreator } from 'zustand';
10
10
  import type { ViewerState } from '../index.js';
11
11
  import type { MutablePropertyView } from '@ifc-lite/mutations';
12
12
  import type { Mutation, ChangeSet, PropertyValue } from '@ifc-lite/mutations';
13
- import { PropertyValueType } from '@ifc-lite/data';
13
+ import { PropertyValueType, QuantityType } from '@ifc-lite/data';
14
14
 
15
15
  export interface MutationSlice {
16
16
  // State
@@ -68,6 +68,35 @@ export interface MutationSlice {
68
68
  psetName: string
69
69
  ) => Mutation | null;
70
70
 
71
+ // Actions - Quantity Mutations
72
+ /** Set a quantity value */
73
+ setQuantity: (
74
+ modelId: string,
75
+ entityId: number,
76
+ qsetName: string,
77
+ quantName: string,
78
+ value: number,
79
+ quantityType?: QuantityType,
80
+ unit?: string
81
+ ) => Mutation | null;
82
+ /** Create a new quantity set */
83
+ createQuantitySet: (
84
+ modelId: string,
85
+ entityId: number,
86
+ qsetName: string,
87
+ quantities: Array<{ name: string; value: number; quantityType: QuantityType; unit?: string }>
88
+ ) => Mutation | null;
89
+
90
+ // Actions - Attribute Mutations
91
+ /** Set an entity attribute value */
92
+ setAttribute: (
93
+ modelId: string,
94
+ entityId: number,
95
+ attrName: string,
96
+ value: string,
97
+ oldValue?: string
98
+ ) => Mutation | null;
99
+
71
100
  // Actions - Undo/Redo
72
101
  /** Undo last mutation for a model */
73
102
  undo: (modelId: string) => void;
@@ -266,6 +295,92 @@ export const createMutationSlice: StateCreator<
266
295
  return mutation;
267
296
  },
268
297
 
298
+ // Quantity Mutations
299
+ setQuantity: (modelId, entityId, qsetName, quantName, value, quantityType = QuantityType.Count, unit) => {
300
+ const view = get().mutationViews.get(modelId);
301
+ if (!view) return null;
302
+
303
+ const mutation = view.setQuantity(entityId, qsetName, quantName, value, quantityType, unit);
304
+
305
+ set((state) => {
306
+ const newUndoStacks = new Map(state.undoStacks);
307
+ const stack = newUndoStacks.get(modelId) || [];
308
+ newUndoStacks.set(modelId, [...stack, mutation]);
309
+
310
+ const newRedoStacks = new Map(state.redoStacks);
311
+ newRedoStacks.set(modelId, []);
312
+
313
+ const newDirty = new Set(state.dirtyModels);
314
+ newDirty.add(modelId);
315
+
316
+ return {
317
+ undoStacks: newUndoStacks,
318
+ redoStacks: newRedoStacks,
319
+ dirtyModels: newDirty,
320
+ mutationVersion: state.mutationVersion + 1,
321
+ };
322
+ });
323
+
324
+ return mutation;
325
+ },
326
+
327
+ createQuantitySet: (modelId, entityId, qsetName, quantities) => {
328
+ const view = get().mutationViews.get(modelId);
329
+ if (!view) return null;
330
+
331
+ const mutation = view.createQuantitySet(entityId, qsetName, quantities);
332
+
333
+ set((state) => {
334
+ const newUndoStacks = new Map(state.undoStacks);
335
+ const stack = newUndoStacks.get(modelId) || [];
336
+ newUndoStacks.set(modelId, [...stack, mutation]);
337
+
338
+ const newRedoStacks = new Map(state.redoStacks);
339
+ newRedoStacks.set(modelId, []);
340
+
341
+ const newDirty = new Set(state.dirtyModels);
342
+ newDirty.add(modelId);
343
+
344
+ return {
345
+ undoStacks: newUndoStacks,
346
+ redoStacks: newRedoStacks,
347
+ dirtyModels: newDirty,
348
+ mutationVersion: state.mutationVersion + 1,
349
+ };
350
+ });
351
+
352
+ return mutation;
353
+ },
354
+
355
+ // Attribute Mutations
356
+ setAttribute: (modelId, entityId, attrName, value, oldValue) => {
357
+ const view = get().mutationViews.get(modelId);
358
+ if (!view) return null;
359
+
360
+ const mutation = view.setAttribute(entityId, attrName, value, oldValue);
361
+
362
+ set((state) => {
363
+ const newUndoStacks = new Map(state.undoStacks);
364
+ const stack = newUndoStacks.get(modelId) || [];
365
+ newUndoStacks.set(modelId, [...stack, mutation]);
366
+
367
+ const newRedoStacks = new Map(state.redoStacks);
368
+ newRedoStacks.set(modelId, []);
369
+
370
+ const newDirty = new Set(state.dirtyModels);
371
+ newDirty.add(modelId);
372
+
373
+ return {
374
+ undoStacks: newUndoStacks,
375
+ redoStacks: newRedoStacks,
376
+ dirtyModels: newDirty,
377
+ mutationVersion: state.mutationVersion + 1,
378
+ };
379
+ });
380
+
381
+ return mutation;
382
+ },
383
+
269
384
  // Undo/Redo
270
385
  undo: (modelId) => {
271
386
  const state = get();
@@ -303,6 +418,29 @@ export const createMutationSlice: StateCreator<
303
418
  true // skipHistory
304
419
  );
305
420
  }
421
+ } else if (mutation.type === 'CREATE_QUANTITY') {
422
+ // Undo creation: remove the quantity mutation
423
+ view.removeQuantityMutation(mutation.entityId, mutation.psetName!, mutation.propName);
424
+ } else if (mutation.type === 'UPDATE_QUANTITY') {
425
+ if (mutation.psetName && mutation.propName && mutation.oldValue !== undefined && mutation.oldValue !== null) {
426
+ view.setQuantity(
427
+ mutation.entityId,
428
+ mutation.psetName,
429
+ mutation.propName,
430
+ Number(mutation.oldValue),
431
+ undefined,
432
+ undefined,
433
+ true // skipHistory
434
+ );
435
+ }
436
+ } else if (mutation.type === 'UPDATE_ATTRIBUTE') {
437
+ if (mutation.attributeName) {
438
+ if (mutation.oldValue !== undefined && mutation.oldValue !== null) {
439
+ view.setAttribute(mutation.entityId, mutation.attributeName, String(mutation.oldValue), undefined, true);
440
+ } else {
441
+ view.removeAttributeMutation(mutation.entityId, mutation.attributeName);
442
+ }
443
+ }
306
444
  }
307
445
 
308
446
  set((s) => {
@@ -347,6 +485,22 @@ export const createMutationSlice: StateCreator<
347
485
  if (mutation.psetName && mutation.propName) {
348
486
  view.deleteProperty(mutation.entityId, mutation.psetName, mutation.propName, true);
349
487
  }
488
+ } else if (mutation.type === 'CREATE_QUANTITY' || mutation.type === 'UPDATE_QUANTITY') {
489
+ if (mutation.psetName && mutation.propName && mutation.newValue !== undefined) {
490
+ view.setQuantity(
491
+ mutation.entityId,
492
+ mutation.psetName,
493
+ mutation.propName,
494
+ Number(mutation.newValue),
495
+ undefined,
496
+ undefined,
497
+ true // skipHistory
498
+ );
499
+ }
500
+ } else if (mutation.type === 'UPDATE_ATTRIBUTE') {
501
+ if (mutation.attributeName && mutation.newValue !== undefined) {
502
+ view.setAttribute(mutation.entityId, mutation.attributeName, String(mutation.newValue), undefined, true);
503
+ }
350
504
  }
351
505
 
352
506
  set((s) => {
package/vite.config.ts CHANGED
@@ -201,6 +201,13 @@ export default defineConfig({
201
201
  fs: {
202
202
  allow: ['../..'],
203
203
  },
204
+ proxy: {
205
+ '/api/bsdd': {
206
+ target: 'https://api.bsdd.buildingsmart.org',
207
+ changeOrigin: true,
208
+ rewrite: (p) => p.replace(/^\/api\/bsdd/, ''),
209
+ },
210
+ },
204
211
  },
205
212
  build: {
206
213
  target: 'esnext',