@ifc-lite/viewer 1.1.6 → 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,293 @@
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
+ * Measurement state slice
7
+ */
8
+
9
+ import type { StateCreator } from 'zustand';
10
+ import type { SnapTarget } from '@ifc-lite/renderer';
11
+ import type {
12
+ MeasurePoint,
13
+ Measurement,
14
+ ActiveMeasurement,
15
+ EdgeLockState,
16
+ SnapVisualization,
17
+ MeasurementConstraintEdge,
18
+ OrthogonalAxis,
19
+ } from '../types.js';
20
+ import { EDGE_LOCK_DEFAULTS } from '../constants.js';
21
+
22
+ // Monotonic counter to prevent ID collisions under rapid measurement creation
23
+ let measurementCounter = 0;
24
+
25
+ export interface MeasurementSlice {
26
+ // State
27
+ measurements: Measurement[];
28
+ pendingMeasurePoint: MeasurePoint | null;
29
+ activeMeasurement: ActiveMeasurement | null;
30
+ snapTarget: SnapTarget | null;
31
+ snapEnabled: boolean;
32
+ snapVisualization: SnapVisualization | null;
33
+ edgeLockState: EdgeLockState;
34
+ /** Edge constraint for perpendicular measurements (when shift is held) */
35
+ measurementConstraintEdge: MeasurementConstraintEdge | null;
36
+
37
+ // Legacy measurement actions
38
+ addMeasurePoint: (point: MeasurePoint) => void;
39
+ completeMeasurement: (endPoint: MeasurePoint) => void;
40
+
41
+ // Drag-based measurement actions
42
+ startMeasurement: (point: MeasurePoint) => void;
43
+ updateMeasurement: (point: MeasurePoint) => void;
44
+ finalizeMeasurement: () => void;
45
+ cancelMeasurement: () => void;
46
+ deleteMeasurement: (id: string) => void;
47
+ clearMeasurements: () => void;
48
+ updateMeasurementScreenCoords: (
49
+ projectToScreen: (worldPos: { x: number; y: number; z: number }) => { x: number; y: number } | null
50
+ ) => void;
51
+
52
+ // Snap actions
53
+ setSnapTarget: (target: SnapTarget | null) => void;
54
+ setSnapVisualization: (viz: SnapVisualization | null) => void;
55
+ toggleSnap: () => void;
56
+
57
+ // Edge lock actions
58
+ setEdgeLock: (edge: EdgeLockState['edge'], meshExpressId: number | null, edgeT?: number) => void;
59
+ updateEdgeLockPosition: (edgeT: number, isCorner: boolean, cornerValence: number) => void;
60
+ clearEdgeLock: () => void;
61
+ incrementEdgeLockStrength: () => void;
62
+
63
+ // Orthogonal constraint actions (shift+drag)
64
+ setMeasurementConstraintEdge: (edge: MeasurementConstraintEdge | null) => void;
65
+ updateConstraintActiveAxis: (axis: OrthogonalAxis | null) => void;
66
+ clearMeasurementConstraintEdge: () => void;
67
+ }
68
+
69
+ const getDefaultEdgeLockState = (): EdgeLockState => ({
70
+ edge: null,
71
+ meshExpressId: null,
72
+ edgeT: 0,
73
+ lockStrength: 0,
74
+ isCorner: false,
75
+ cornerValence: 0,
76
+ });
77
+
78
+ export const createMeasurementSlice: StateCreator<MeasurementSlice, [], [], MeasurementSlice> = (set, get) => ({
79
+ // Initial state
80
+ measurements: [],
81
+ pendingMeasurePoint: null,
82
+ activeMeasurement: null,
83
+ snapTarget: null,
84
+ snapEnabled: true,
85
+ snapVisualization: null,
86
+ edgeLockState: getDefaultEdgeLockState(),
87
+ measurementConstraintEdge: null,
88
+
89
+ // Legacy measurement actions
90
+ addMeasurePoint: (point) => set({ pendingMeasurePoint: point }),
91
+
92
+ completeMeasurement: (endPoint) => set((state) => {
93
+ if (!state.pendingMeasurePoint) return {};
94
+ const start = state.pendingMeasurePoint;
95
+ const distance = Math.sqrt(
96
+ Math.pow(endPoint.x - start.x, 2) +
97
+ Math.pow(endPoint.y - start.y, 2) +
98
+ Math.pow(endPoint.z - start.z, 2)
99
+ );
100
+ // Use counter combined with timestamp to guarantee unique IDs
101
+ measurementCounter++;
102
+ const measurement: Measurement = {
103
+ id: `m-${Date.now()}-${measurementCounter}`,
104
+ start,
105
+ end: endPoint,
106
+ distance,
107
+ };
108
+ return {
109
+ measurements: [...state.measurements, measurement],
110
+ pendingMeasurePoint: null,
111
+ };
112
+ }),
113
+
114
+ // Drag-based measurement actions
115
+ startMeasurement: (point) => set({
116
+ activeMeasurement: {
117
+ start: point,
118
+ current: point,
119
+ distance: 0,
120
+ },
121
+ }),
122
+
123
+ updateMeasurement: (point) => set((state) => {
124
+ if (!state.activeMeasurement) return {};
125
+ const start = state.activeMeasurement.start;
126
+ const distance = Math.sqrt(
127
+ Math.pow(point.x - start.x, 2) +
128
+ Math.pow(point.y - start.y, 2) +
129
+ Math.pow(point.z - start.z, 2)
130
+ );
131
+ return {
132
+ activeMeasurement: {
133
+ start,
134
+ current: point,
135
+ distance,
136
+ },
137
+ };
138
+ }),
139
+
140
+ finalizeMeasurement: () => set((state) => {
141
+ if (!state.activeMeasurement) return {};
142
+ // Use counter combined with timestamp to guarantee unique IDs
143
+ measurementCounter++;
144
+ const measurement: Measurement = {
145
+ id: `m-${Date.now()}-${measurementCounter}`,
146
+ start: state.activeMeasurement.start,
147
+ end: state.activeMeasurement.current,
148
+ distance: state.activeMeasurement.distance,
149
+ };
150
+ return {
151
+ measurements: [...state.measurements, measurement],
152
+ activeMeasurement: null,
153
+ snapTarget: null,
154
+ measurementConstraintEdge: null,
155
+ };
156
+ }),
157
+
158
+ cancelMeasurement: () => set({
159
+ activeMeasurement: null,
160
+ snapTarget: null,
161
+ measurementConstraintEdge: null,
162
+ }),
163
+
164
+ deleteMeasurement: (id) => set((state) => ({
165
+ measurements: state.measurements.filter((m) => m.id !== id),
166
+ })),
167
+
168
+ clearMeasurements: () => set({
169
+ measurements: [],
170
+ pendingMeasurePoint: null,
171
+ activeMeasurement: null,
172
+ snapTarget: null,
173
+ }),
174
+
175
+ updateMeasurementScreenCoords: (projectToScreen) => {
176
+ const state = get();
177
+ let hasChanges = false;
178
+
179
+ // Check completed measurements for changes
180
+ const updatedMeasurements = state.measurements.map((m) => {
181
+ const startScreen = projectToScreen(m.start);
182
+ const endScreen = projectToScreen(m.end);
183
+
184
+ const newStartX = startScreen?.x ?? m.start.screenX;
185
+ const newStartY = startScreen?.y ?? m.start.screenY;
186
+ const newEndX = endScreen?.x ?? m.end.screenX;
187
+ const newEndY = endScreen?.y ?? m.end.screenY;
188
+
189
+ if (
190
+ newStartX !== m.start.screenX ||
191
+ newStartY !== m.start.screenY ||
192
+ newEndX !== m.end.screenX ||
193
+ newEndY !== m.end.screenY
194
+ ) {
195
+ hasChanges = true;
196
+ }
197
+
198
+ return {
199
+ ...m,
200
+ start: { ...m.start, screenX: newStartX, screenY: newStartY },
201
+ end: { ...m.end, screenX: newEndX, screenY: newEndY },
202
+ };
203
+ });
204
+
205
+ // Check active measurement for changes
206
+ let updatedActiveMeasurement = state.activeMeasurement;
207
+ if (state.activeMeasurement) {
208
+ const startScreen = projectToScreen(state.activeMeasurement.start);
209
+ const currentScreen = projectToScreen(state.activeMeasurement.current);
210
+
211
+ const newStartX = startScreen?.x ?? state.activeMeasurement.start.screenX;
212
+ const newStartY = startScreen?.y ?? state.activeMeasurement.start.screenY;
213
+ const newCurrentX = currentScreen?.x ?? state.activeMeasurement.current.screenX;
214
+ const newCurrentY = currentScreen?.y ?? state.activeMeasurement.current.screenY;
215
+
216
+ if (
217
+ newStartX !== state.activeMeasurement.start.screenX ||
218
+ newStartY !== state.activeMeasurement.start.screenY ||
219
+ newCurrentX !== state.activeMeasurement.current.screenX ||
220
+ newCurrentY !== state.activeMeasurement.current.screenY
221
+ ) {
222
+ hasChanges = true;
223
+ }
224
+
225
+ updatedActiveMeasurement = {
226
+ ...state.activeMeasurement,
227
+ start: { ...state.activeMeasurement.start, screenX: newStartX, screenY: newStartY },
228
+ current: { ...state.activeMeasurement.current, screenX: newCurrentX, screenY: newCurrentY },
229
+ };
230
+ }
231
+
232
+ // Early exit if nothing changed
233
+ if (!hasChanges) {
234
+ return;
235
+ }
236
+
237
+ set({
238
+ measurements: updatedMeasurements,
239
+ activeMeasurement: updatedActiveMeasurement,
240
+ });
241
+ },
242
+
243
+ // Snap actions
244
+ setSnapTarget: (snapTarget) => set({ snapTarget }),
245
+ setSnapVisualization: (snapVisualization) => set({ snapVisualization }),
246
+ toggleSnap: () => set((state) => ({ snapEnabled: !state.snapEnabled })),
247
+
248
+ // Edge lock actions
249
+ setEdgeLock: (edge, meshExpressId, edgeT = EDGE_LOCK_DEFAULTS.INITIAL_T) => set({
250
+ edgeLockState: {
251
+ edge,
252
+ meshExpressId,
253
+ edgeT,
254
+ lockStrength: EDGE_LOCK_DEFAULTS.INITIAL_STRENGTH,
255
+ isCorner: false,
256
+ cornerValence: 0,
257
+ },
258
+ }),
259
+
260
+ updateEdgeLockPosition: (edgeT, isCorner, cornerValence) => set((state) => ({
261
+ edgeLockState: {
262
+ ...state.edgeLockState,
263
+ edgeT,
264
+ isCorner,
265
+ cornerValence,
266
+ },
267
+ })),
268
+
269
+ clearEdgeLock: () => set({ edgeLockState: getDefaultEdgeLockState() }),
270
+
271
+ incrementEdgeLockStrength: () => set((state) => ({
272
+ edgeLockState: {
273
+ ...state.edgeLockState,
274
+ lockStrength: Math.min(
275
+ state.edgeLockState.lockStrength + EDGE_LOCK_DEFAULTS.STRENGTH_INCREMENT,
276
+ EDGE_LOCK_DEFAULTS.MAX_STRENGTH
277
+ ),
278
+ },
279
+ })),
280
+
281
+ // Orthogonal constraint actions
282
+ setMeasurementConstraintEdge: (edge) => set({ measurementConstraintEdge: edge }),
283
+ updateConstraintActiveAxis: (axis) => set((state) => {
284
+ if (!state.measurementConstraintEdge) return {};
285
+ return {
286
+ measurementConstraintEdge: {
287
+ ...state.measurementConstraintEdge,
288
+ activeAxis: axis,
289
+ },
290
+ };
291
+ }),
292
+ clearMeasurementConstraintEdge: () => set({ measurementConstraintEdge: null }),
293
+ });
@@ -0,0 +1,271 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { describe, it, beforeEach } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { createModelSlice, type ModelSlice } from './modelSlice.js';
8
+ import type { FederatedModel } from '../types.js';
9
+
10
+ // Helper to create a mock model
11
+ function createMockModel(id: string, name: string): FederatedModel {
12
+ return {
13
+ id,
14
+ name,
15
+ ifcDataStore: {} as any,
16
+ geometryResult: {} as any,
17
+ visible: true,
18
+ collapsed: false,
19
+ schemaVersion: 'IFC4',
20
+ loadedAt: Date.now(),
21
+ fileSize: 1024,
22
+ };
23
+ }
24
+
25
+ describe('ModelSlice', () => {
26
+ let state: ModelSlice;
27
+ let setState: (partial: Partial<ModelSlice> | ((state: ModelSlice) => Partial<ModelSlice>)) => void;
28
+
29
+ beforeEach(() => {
30
+ // Create a mock set function that updates state
31
+ setState = (partial) => {
32
+ if (typeof partial === 'function') {
33
+ const updates = partial(state);
34
+ state = { ...state, ...updates };
35
+ } else {
36
+ state = { ...state, ...partial };
37
+ }
38
+ };
39
+
40
+ // Create slice with mock set function
41
+ state = createModelSlice(setState, () => state, {} as any);
42
+ });
43
+
44
+ describe('initial state', () => {
45
+ it('should have empty models map', () => {
46
+ assert.strictEqual(state.models.size, 0);
47
+ });
48
+
49
+ it('should have null activeModelId', () => {
50
+ assert.strictEqual(state.activeModelId, null);
51
+ });
52
+
53
+ it('should report hasModels as false', () => {
54
+ assert.strictEqual(state.hasModels(), false);
55
+ });
56
+ });
57
+
58
+ describe('addModel', () => {
59
+ it('should add a model to the map', () => {
60
+ const model = createMockModel('model-1', 'Test Model');
61
+ state.addModel(model);
62
+ assert.strictEqual(state.models.size, 1);
63
+ assert.strictEqual(state.models.get('model-1')?.name, 'Test Model');
64
+ });
65
+
66
+ it('should set first model as active', () => {
67
+ const model = createMockModel('model-1', 'Test Model');
68
+ state.addModel(model);
69
+ assert.strictEqual(state.activeModelId, 'model-1');
70
+ });
71
+
72
+ it('should collapse existing models when adding new ones', () => {
73
+ const model1 = createMockModel('model-1', 'First Model');
74
+ const model2 = createMockModel('model-2', 'Second Model');
75
+
76
+ state.addModel(model1);
77
+ assert.strictEqual(state.models.get('model-1')?.collapsed, false);
78
+
79
+ state.addModel(model2);
80
+ // First model should now be collapsed
81
+ assert.strictEqual(state.models.get('model-1')?.collapsed, true);
82
+ // New model should not be collapsed
83
+ assert.strictEqual(state.models.get('model-2')?.collapsed, false);
84
+ });
85
+
86
+ it('should not change activeModelId when adding subsequent models', () => {
87
+ const model1 = createMockModel('model-1', 'First Model');
88
+ const model2 = createMockModel('model-2', 'Second Model');
89
+
90
+ state.addModel(model1);
91
+ state.addModel(model2);
92
+
93
+ // Active model should still be the first one
94
+ assert.strictEqual(state.activeModelId, 'model-1');
95
+ });
96
+
97
+ it('should report hasModels as true after adding', () => {
98
+ const model = createMockModel('model-1', 'Test Model');
99
+ state.addModel(model);
100
+ assert.strictEqual(state.hasModels(), true);
101
+ });
102
+ });
103
+
104
+ describe('removeModel', () => {
105
+ it('should remove a model from the map', () => {
106
+ const model = createMockModel('model-1', 'Test Model');
107
+ state.addModel(model);
108
+ state.removeModel('model-1');
109
+ assert.strictEqual(state.models.size, 0);
110
+ });
111
+
112
+ it('should update activeModelId if removed model was active', () => {
113
+ const model1 = createMockModel('model-1', 'First Model');
114
+ const model2 = createMockModel('model-2', 'Second Model');
115
+
116
+ state.addModel(model1);
117
+ state.addModel(model2);
118
+ state.setActiveModel('model-1');
119
+
120
+ state.removeModel('model-1');
121
+ // Active model should switch to model-2
122
+ assert.strictEqual(state.activeModelId, 'model-2');
123
+ });
124
+
125
+ it('should set activeModelId to null when last model removed', () => {
126
+ const model = createMockModel('model-1', 'Test Model');
127
+ state.addModel(model);
128
+ state.removeModel('model-1');
129
+ assert.strictEqual(state.activeModelId, null);
130
+ });
131
+
132
+ it('should not affect activeModelId if removed model was not active', () => {
133
+ const model1 = createMockModel('model-1', 'First Model');
134
+ const model2 = createMockModel('model-2', 'Second Model');
135
+
136
+ state.addModel(model1);
137
+ state.addModel(model2);
138
+
139
+ state.removeModel('model-2');
140
+ assert.strictEqual(state.activeModelId, 'model-1');
141
+ });
142
+ });
143
+
144
+ describe('clearAllModels', () => {
145
+ it('should remove all models', () => {
146
+ state.addModel(createMockModel('model-1', 'First'));
147
+ state.addModel(createMockModel('model-2', 'Second'));
148
+
149
+ state.clearAllModels();
150
+
151
+ assert.strictEqual(state.models.size, 0);
152
+ assert.strictEqual(state.activeModelId, null);
153
+ });
154
+ });
155
+
156
+ describe('setActiveModel', () => {
157
+ it('should update activeModelId', () => {
158
+ const model1 = createMockModel('model-1', 'First Model');
159
+ const model2 = createMockModel('model-2', 'Second Model');
160
+
161
+ state.addModel(model1);
162
+ state.addModel(model2);
163
+
164
+ state.setActiveModel('model-2');
165
+ assert.strictEqual(state.activeModelId, 'model-2');
166
+ });
167
+
168
+ it('should allow setting to null', () => {
169
+ const model = createMockModel('model-1', 'Test Model');
170
+ state.addModel(model);
171
+ state.setActiveModel(null);
172
+ assert.strictEqual(state.activeModelId, null);
173
+ });
174
+ });
175
+
176
+ describe('setModelVisibility', () => {
177
+ it('should update model visibility', () => {
178
+ const model = createMockModel('model-1', 'Test Model');
179
+ state.addModel(model);
180
+
181
+ state.setModelVisibility('model-1', false);
182
+ assert.strictEqual(state.models.get('model-1')?.visible, false);
183
+
184
+ state.setModelVisibility('model-1', true);
185
+ assert.strictEqual(state.models.get('model-1')?.visible, true);
186
+ });
187
+
188
+ it('should do nothing for non-existent model', () => {
189
+ state.setModelVisibility('non-existent', false);
190
+ // Should not throw, just return empty update
191
+ assert.strictEqual(state.models.size, 0);
192
+ });
193
+ });
194
+
195
+ describe('setModelCollapsed', () => {
196
+ it('should update model collapsed state', () => {
197
+ const model = createMockModel('model-1', 'Test Model');
198
+ state.addModel(model);
199
+
200
+ state.setModelCollapsed('model-1', true);
201
+ assert.strictEqual(state.models.get('model-1')?.collapsed, true);
202
+
203
+ state.setModelCollapsed('model-1', false);
204
+ assert.strictEqual(state.models.get('model-1')?.collapsed, false);
205
+ });
206
+ });
207
+
208
+ describe('setModelName', () => {
209
+ it('should update model name', () => {
210
+ const model = createMockModel('model-1', 'Original Name');
211
+ state.addModel(model);
212
+
213
+ state.setModelName('model-1', 'New Name');
214
+ assert.strictEqual(state.models.get('model-1')?.name, 'New Name');
215
+ });
216
+ });
217
+
218
+ describe('getModel', () => {
219
+ it('should return model by ID', () => {
220
+ const model = createMockModel('model-1', 'Test Model');
221
+ state.addModel(model);
222
+
223
+ const retrieved = state.getModel('model-1');
224
+ assert.strictEqual(retrieved?.name, 'Test Model');
225
+ });
226
+
227
+ it('should return undefined for non-existent ID', () => {
228
+ const retrieved = state.getModel('non-existent');
229
+ assert.strictEqual(retrieved, undefined);
230
+ });
231
+ });
232
+
233
+ describe('getActiveModel', () => {
234
+ it('should return the active model', () => {
235
+ const model = createMockModel('model-1', 'Test Model');
236
+ state.addModel(model);
237
+
238
+ const active = state.getActiveModel();
239
+ assert.strictEqual(active?.id, 'model-1');
240
+ });
241
+
242
+ it('should return undefined when no active model', () => {
243
+ const active = state.getActiveModel();
244
+ assert.strictEqual(active, undefined);
245
+ });
246
+ });
247
+
248
+ describe('getAllVisibleModels', () => {
249
+ it('should return only visible models', () => {
250
+ state.addModel(createMockModel('model-1', 'First'));
251
+ state.addModel(createMockModel('model-2', 'Second'));
252
+ state.addModel(createMockModel('model-3', 'Third'));
253
+
254
+ state.setModelVisibility('model-2', false);
255
+
256
+ const visible = state.getAllVisibleModels();
257
+ assert.strictEqual(visible.length, 2);
258
+ assert.ok(visible.some(m => m.id === 'model-1'));
259
+ assert.ok(visible.some(m => m.id === 'model-3'));
260
+ assert.ok(!visible.some(m => m.id === 'model-2'));
261
+ });
262
+
263
+ it('should return empty array when all models hidden', () => {
264
+ state.addModel(createMockModel('model-1', 'First'));
265
+ state.setModelVisibility('model-1', false);
266
+
267
+ const visible = state.getAllVisibleModels();
268
+ assert.strictEqual(visible.length, 0);
269
+ });
270
+ });
271
+ });