@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,125 @@
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 { createSectionSlice, type SectionSlice } from './sectionSlice.js';
8
+ import { SECTION_PLANE_DEFAULTS } from '../constants.js';
9
+
10
+ describe('SectionSlice', () => {
11
+ let state: SectionSlice;
12
+ let setState: (partial: Partial<SectionSlice> | ((state: SectionSlice) => Partial<SectionSlice>)) => void;
13
+
14
+ beforeEach(() => {
15
+ // Create a mock set function that updates state
16
+ setState = (partial) => {
17
+ if (typeof partial === 'function') {
18
+ const updates = partial(state);
19
+ state = { ...state, ...updates };
20
+ } else {
21
+ state = { ...state, ...partial };
22
+ }
23
+ };
24
+
25
+ // Create slice with mock set function
26
+ state = createSectionSlice(setState, () => state, {} as any);
27
+ });
28
+
29
+ describe('initial state', () => {
30
+ it('should have default section plane values', () => {
31
+ assert.strictEqual(state.sectionPlane.axis, SECTION_PLANE_DEFAULTS.AXIS);
32
+ assert.strictEqual(state.sectionPlane.position, SECTION_PLANE_DEFAULTS.POSITION);
33
+ assert.strictEqual(state.sectionPlane.enabled, SECTION_PLANE_DEFAULTS.ENABLED);
34
+ assert.strictEqual(state.sectionPlane.flipped, SECTION_PLANE_DEFAULTS.FLIPPED);
35
+ });
36
+ });
37
+
38
+ describe('setSectionPlaneAxis', () => {
39
+ it('should update the axis', () => {
40
+ state.setSectionPlaneAxis('front');
41
+ assert.strictEqual(state.sectionPlane.axis, 'front');
42
+ });
43
+
44
+ it('should preserve other section plane properties', () => {
45
+ state.sectionPlane.position = 75;
46
+ state.setSectionPlaneAxis('side');
47
+ assert.strictEqual(state.sectionPlane.axis, 'side');
48
+ assert.strictEqual(state.sectionPlane.position, 75);
49
+ });
50
+ });
51
+
52
+ describe('setSectionPlanePosition', () => {
53
+ it('should update the position', () => {
54
+ state.setSectionPlanePosition(75);
55
+ assert.strictEqual(state.sectionPlane.position, 75);
56
+ });
57
+
58
+ it('should clamp position to minimum 0', () => {
59
+ state.setSectionPlanePosition(-10);
60
+ assert.strictEqual(state.sectionPlane.position, 0);
61
+ });
62
+
63
+ it('should clamp position to maximum 100', () => {
64
+ state.setSectionPlanePosition(150);
65
+ assert.strictEqual(state.sectionPlane.position, 100);
66
+ });
67
+
68
+ it('should handle NaN by defaulting to 0', () => {
69
+ state.setSectionPlanePosition(NaN);
70
+ assert.strictEqual(state.sectionPlane.position, 0);
71
+ });
72
+
73
+ it('should coerce string numbers', () => {
74
+ state.setSectionPlanePosition('50' as any);
75
+ assert.strictEqual(state.sectionPlane.position, 50);
76
+ });
77
+ });
78
+
79
+ describe('toggleSectionPlane', () => {
80
+ it('should toggle enabled from true to false', () => {
81
+ assert.strictEqual(state.sectionPlane.enabled, true);
82
+ state.toggleSectionPlane();
83
+ assert.strictEqual(state.sectionPlane.enabled, false);
84
+ });
85
+
86
+ it('should toggle enabled from false to true', () => {
87
+ state.sectionPlane.enabled = false;
88
+ state.toggleSectionPlane();
89
+ assert.strictEqual(state.sectionPlane.enabled, true);
90
+ });
91
+ });
92
+
93
+ describe('flipSectionPlane', () => {
94
+ it('should toggle flipped from false to true', () => {
95
+ assert.strictEqual(state.sectionPlane.flipped, false);
96
+ state.flipSectionPlane();
97
+ assert.strictEqual(state.sectionPlane.flipped, true);
98
+ });
99
+
100
+ it('should toggle flipped from true to false', () => {
101
+ state.sectionPlane.flipped = true;
102
+ state.flipSectionPlane();
103
+ assert.strictEqual(state.sectionPlane.flipped, false);
104
+ });
105
+ });
106
+
107
+ describe('resetSectionPlane', () => {
108
+ it('should reset to default values', () => {
109
+ // Modify state
110
+ state.sectionPlane = {
111
+ axis: 'side',
112
+ position: 25,
113
+ enabled: false,
114
+ flipped: true,
115
+ };
116
+
117
+ state.resetSectionPlane();
118
+
119
+ assert.strictEqual(state.sectionPlane.axis, SECTION_PLANE_DEFAULTS.AXIS);
120
+ assert.strictEqual(state.sectionPlane.position, SECTION_PLANE_DEFAULTS.POSITION);
121
+ assert.strictEqual(state.sectionPlane.enabled, SECTION_PLANE_DEFAULTS.ENABLED);
122
+ assert.strictEqual(state.sectionPlane.flipped, SECTION_PLANE_DEFAULTS.FLIPPED);
123
+ });
124
+ });
125
+ });
@@ -0,0 +1,58 @@
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
+ * Section plane state slice
7
+ */
8
+
9
+ import type { StateCreator } from 'zustand';
10
+ import type { SectionPlane, SectionPlaneAxis } from '../types.js';
11
+ import { SECTION_PLANE_DEFAULTS } from '../constants.js';
12
+
13
+ export interface SectionSlice {
14
+ // State
15
+ sectionPlane: SectionPlane;
16
+
17
+ // Actions
18
+ setSectionPlaneAxis: (axis: SectionPlaneAxis) => void;
19
+ setSectionPlanePosition: (position: number) => void;
20
+ toggleSectionPlane: () => void;
21
+ flipSectionPlane: () => void;
22
+ resetSectionPlane: () => void;
23
+ }
24
+
25
+ const getDefaultSectionPlane = (): SectionPlane => ({
26
+ axis: SECTION_PLANE_DEFAULTS.AXIS,
27
+ position: SECTION_PLANE_DEFAULTS.POSITION,
28
+ enabled: SECTION_PLANE_DEFAULTS.ENABLED,
29
+ flipped: SECTION_PLANE_DEFAULTS.FLIPPED,
30
+ });
31
+
32
+ export const createSectionSlice: StateCreator<SectionSlice, [], [], SectionSlice> = (set) => ({
33
+ // Initial state
34
+ sectionPlane: getDefaultSectionPlane(),
35
+
36
+ // Actions
37
+ setSectionPlaneAxis: (axis) => set((state) => ({
38
+ sectionPlane: { ...state.sectionPlane, axis },
39
+ })),
40
+
41
+ setSectionPlanePosition: (position) => set((state) => {
42
+ // Clamp position to valid range [0, 100]
43
+ const clampedPosition = Math.min(100, Math.max(0, Number(position) || 0));
44
+ return {
45
+ sectionPlane: { ...state.sectionPlane, position: clampedPosition },
46
+ };
47
+ }),
48
+
49
+ toggleSectionPlane: () => set((state) => ({
50
+ sectionPlane: { ...state.sectionPlane, enabled: !state.sectionPlane.enabled },
51
+ })),
52
+
53
+ flipSectionPlane: () => set((state) => ({
54
+ sectionPlane: { ...state.sectionPlane, flipped: !state.sectionPlane.flipped },
55
+ })),
56
+
57
+ resetSectionPlane: () => set({ sectionPlane: getDefaultSectionPlane() }),
58
+ });
@@ -0,0 +1,286 @@
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 { createSelectionSlice, type SelectionSlice } from './selectionSlice.js';
8
+ import type { EntityRef } from '../types.js';
9
+
10
+ describe('SelectionSlice', () => {
11
+ let state: SelectionSlice;
12
+ let setState: (partial: Partial<SelectionSlice> | ((state: SelectionSlice) => Partial<SelectionSlice>)) => void;
13
+
14
+ beforeEach(() => {
15
+ setState = (partial) => {
16
+ if (typeof partial === 'function') {
17
+ const updates = partial(state);
18
+ state = { ...state, ...updates };
19
+ } else {
20
+ state = { ...state, ...partial };
21
+ }
22
+ };
23
+
24
+ state = createSelectionSlice(setState, () => state, {} as any);
25
+ });
26
+
27
+ describe('initial state', () => {
28
+ it('should have null selectedEntity', () => {
29
+ assert.strictEqual(state.selectedEntity, null);
30
+ });
31
+
32
+ it('should have empty selectedEntitiesSet', () => {
33
+ assert.strictEqual(state.selectedEntitiesSet.size, 0);
34
+ });
35
+
36
+ it('should have null selectedEntityId (legacy)', () => {
37
+ assert.strictEqual(state.selectedEntityId, null);
38
+ });
39
+ });
40
+
41
+ describe('multi-model selection: setSelectedEntity', () => {
42
+ it('should set primary selection with model context', () => {
43
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
44
+ state.setSelectedEntity(ref);
45
+
46
+ assert.deepStrictEqual(state.selectedEntity, ref);
47
+ });
48
+
49
+ it('should NOT update selectedEntityId (caller must use setSelectedEntityId for global ID)', () => {
50
+ // NOTE: selectedEntityId holds the GLOBAL ID for renderer highlighting,
51
+ // while selectedEntity.expressId holds the ORIGINAL express ID for property lookup.
52
+ // The caller should use setSelectedEntityId(globalId) separately.
53
+ const ref: EntityRef = { modelId: 'model-1', expressId: 456 };
54
+ state.setSelectedEntity(ref);
55
+
56
+ // selectedEntityId should remain null - caller must set it separately with globalId
57
+ assert.strictEqual(state.selectedEntityId, null);
58
+ });
59
+
60
+ it('should allow clearing selection with null', () => {
61
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
62
+ state.setSelectedEntity(ref);
63
+ state.setSelectedEntity(null);
64
+
65
+ assert.strictEqual(state.selectedEntity, null);
66
+ assert.strictEqual(state.selectedEntityId, null);
67
+ });
68
+ });
69
+
70
+ describe('multi-model selection: addEntityToSelection', () => {
71
+ it('should add entity to selection set', () => {
72
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
73
+ state.addEntityToSelection(ref);
74
+
75
+ assert.strictEqual(state.selectedEntitiesSet.size, 1);
76
+ assert.ok(state.selectedEntitiesSet.has('model-1:123'));
77
+ });
78
+
79
+ it('should update primary selection', () => {
80
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
81
+ state.addEntityToSelection(ref);
82
+
83
+ assert.deepStrictEqual(state.selectedEntity, ref);
84
+ });
85
+
86
+ it('should allow multiple entities from different models', () => {
87
+ const ref1: EntityRef = { modelId: 'model-1', expressId: 123 };
88
+ const ref2: EntityRef = { modelId: 'model-2', expressId: 456 };
89
+
90
+ state.addEntityToSelection(ref1);
91
+ state.addEntityToSelection(ref2);
92
+
93
+ assert.strictEqual(state.selectedEntitiesSet.size, 2);
94
+ assert.ok(state.selectedEntitiesSet.has('model-1:123'));
95
+ assert.ok(state.selectedEntitiesSet.has('model-2:456'));
96
+ });
97
+
98
+ it('should allow multiple entities from same model', () => {
99
+ const ref1: EntityRef = { modelId: 'model-1', expressId: 100 };
100
+ const ref2: EntityRef = { modelId: 'model-1', expressId: 200 };
101
+
102
+ state.addEntityToSelection(ref1);
103
+ state.addEntityToSelection(ref2);
104
+
105
+ assert.strictEqual(state.selectedEntitiesSet.size, 2);
106
+ });
107
+ });
108
+
109
+ describe('multi-model selection: removeEntityFromSelection', () => {
110
+ it('should remove entity from selection set', () => {
111
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
112
+ state.addEntityToSelection(ref);
113
+ state.removeEntityFromSelection(ref);
114
+
115
+ assert.strictEqual(state.selectedEntitiesSet.size, 0);
116
+ });
117
+
118
+ it('should update primary selection when removing primary', () => {
119
+ const ref1: EntityRef = { modelId: 'model-1', expressId: 123 };
120
+ const ref2: EntityRef = { modelId: 'model-2', expressId: 456 };
121
+
122
+ state.addEntityToSelection(ref1);
123
+ state.addEntityToSelection(ref2);
124
+ state.removeEntityFromSelection(ref2);
125
+
126
+ // Primary should update to remaining entity
127
+ assert.strictEqual(state.selectedEntitiesSet.size, 1);
128
+ assert.ok(state.selectedEntitiesSet.has('model-1:123'));
129
+ });
130
+
131
+ it('should clear primary when removing last entity', () => {
132
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
133
+ state.addEntityToSelection(ref);
134
+ state.removeEntityFromSelection(ref);
135
+
136
+ assert.strictEqual(state.selectedEntity, null);
137
+ assert.strictEqual(state.selectedEntityId, null);
138
+ });
139
+ });
140
+
141
+ describe('multi-model selection: toggleEntitySelection', () => {
142
+ it('should add entity if not selected', () => {
143
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
144
+ state.toggleEntitySelection(ref);
145
+
146
+ assert.strictEqual(state.selectedEntitiesSet.size, 1);
147
+ assert.ok(state.selectedEntitiesSet.has('model-1:123'));
148
+ });
149
+
150
+ it('should remove entity if already selected', () => {
151
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
152
+ state.addEntityToSelection(ref);
153
+ state.toggleEntitySelection(ref);
154
+
155
+ assert.strictEqual(state.selectedEntitiesSet.size, 0);
156
+ });
157
+
158
+ it('should update primary selection correctly', () => {
159
+ const ref1: EntityRef = { modelId: 'model-1', expressId: 123 };
160
+ const ref2: EntityRef = { modelId: 'model-1', expressId: 456 };
161
+
162
+ state.toggleEntitySelection(ref1);
163
+ assert.deepStrictEqual(state.selectedEntity, ref1);
164
+
165
+ state.toggleEntitySelection(ref2);
166
+ assert.deepStrictEqual(state.selectedEntity, ref2);
167
+
168
+ state.toggleEntitySelection(ref2);
169
+ // After removing ref2, primary should go back to ref1
170
+ assert.ok(state.selectedEntity?.expressId === 123 || state.selectedEntity === null);
171
+ });
172
+ });
173
+
174
+ describe('multi-model selection: clearEntitySelection', () => {
175
+ it('should clear all multi-model selection state', () => {
176
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
177
+ state.addEntityToSelection(ref);
178
+ state.clearEntitySelection();
179
+
180
+ assert.strictEqual(state.selectedEntity, null);
181
+ assert.strictEqual(state.selectedEntitiesSet.size, 0);
182
+ assert.strictEqual(state.selectedEntityId, null);
183
+ });
184
+
185
+ it('should also clear legacy selection state', () => {
186
+ state.setSelectedEntityIds([1, 2, 3]);
187
+ state.clearEntitySelection();
188
+
189
+ assert.strictEqual(state.selectedEntityIds.size, 0);
190
+ });
191
+ });
192
+
193
+ describe('multi-model selection: isEntitySelected', () => {
194
+ it('should return true for selected entity', () => {
195
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
196
+ state.addEntityToSelection(ref);
197
+
198
+ assert.strictEqual(state.isEntitySelected(ref), true);
199
+ });
200
+
201
+ it('should return false for non-selected entity', () => {
202
+ const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
203
+ assert.strictEqual(state.isEntitySelected(ref), false);
204
+ });
205
+
206
+ it('should distinguish between models', () => {
207
+ const ref1: EntityRef = { modelId: 'model-1', expressId: 123 };
208
+ const ref2: EntityRef = { modelId: 'model-2', expressId: 123 }; // Same expressId, different model
209
+
210
+ state.addEntityToSelection(ref1);
211
+
212
+ assert.strictEqual(state.isEntitySelected(ref1), true);
213
+ assert.strictEqual(state.isEntitySelected(ref2), false);
214
+ });
215
+ });
216
+
217
+ describe('multi-model selection: getSelectedEntitiesForModel', () => {
218
+ it('should return only entities for specified model', () => {
219
+ state.addEntityToSelection({ modelId: 'model-1', expressId: 100 });
220
+ state.addEntityToSelection({ modelId: 'model-1', expressId: 200 });
221
+ state.addEntityToSelection({ modelId: 'model-2', expressId: 300 });
222
+
223
+ const model1Entities = state.getSelectedEntitiesForModel('model-1');
224
+ const model2Entities = state.getSelectedEntitiesForModel('model-2');
225
+
226
+ assert.strictEqual(model1Entities.length, 2);
227
+ assert.ok(model1Entities.includes(100));
228
+ assert.ok(model1Entities.includes(200));
229
+
230
+ assert.strictEqual(model2Entities.length, 1);
231
+ assert.ok(model2Entities.includes(300));
232
+ });
233
+
234
+ it('should return empty array for model with no selections', () => {
235
+ state.addEntityToSelection({ modelId: 'model-1', expressId: 100 });
236
+
237
+ const result = state.getSelectedEntitiesForModel('model-2');
238
+ assert.deepStrictEqual(result, []);
239
+ });
240
+ });
241
+
242
+ describe('legacy selection: setSelectedEntityId', () => {
243
+ it('should set legacy selectedEntityId', () => {
244
+ state.setSelectedEntityId(123);
245
+ assert.strictEqual(state.selectedEntityId, 123);
246
+ });
247
+
248
+ it('should allow clearing with null', () => {
249
+ state.setSelectedEntityId(123);
250
+ state.setSelectedEntityId(null);
251
+ assert.strictEqual(state.selectedEntityId, null);
252
+ });
253
+ });
254
+
255
+ describe('legacy selection: storey selection', () => {
256
+ it('should toggle storey selection', () => {
257
+ state.toggleStoreySelection(1);
258
+ assert.ok(state.selectedStoreys.has(1));
259
+
260
+ state.toggleStoreySelection(1);
261
+ assert.ok(!state.selectedStoreys.has(1));
262
+ });
263
+
264
+ it('should set single storey selection', () => {
265
+ state.setStoreySelection(1);
266
+ state.setStoreySelection(2);
267
+
268
+ assert.strictEqual(state.selectedStoreys.size, 1);
269
+ assert.ok(state.selectedStoreys.has(2));
270
+ });
271
+
272
+ it('should toggle off when selecting already-selected storey', () => {
273
+ state.setStoreySelection(1);
274
+ state.setStoreySelection(1);
275
+
276
+ assert.strictEqual(state.selectedStoreys.size, 0);
277
+ });
278
+
279
+ it('should clear storey selection', () => {
280
+ state.setStoreysSelection([1, 2, 3]);
281
+ state.clearStoreySelection();
282
+
283
+ assert.strictEqual(state.selectedStoreys.size, 0);
284
+ });
285
+ });
286
+ });