@ifc-lite/viewer 1.17.4 → 1.18.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 (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -47,6 +47,14 @@ describe('SectionSlice', () => {
47
47
  assert.strictEqual(state.sectionPlane.axis, 'side');
48
48
  assert.strictEqual(state.sectionPlane.position, 75);
49
49
  });
50
+
51
+ it('should auto-enable the clip so the axis change is immediately visible', () => {
52
+ // Simulate a user who disabled clipping, then picks a new axis — they
53
+ // almost certainly want to see the new cut, not stay in "Clip off".
54
+ state.sectionPlane.enabled = false;
55
+ state.setSectionPlaneAxis('front');
56
+ assert.strictEqual(state.sectionPlane.enabled, true);
57
+ });
50
58
  });
51
59
 
52
60
  describe('setSectionPlanePosition', () => {
@@ -74,6 +82,74 @@ describe('SectionSlice', () => {
74
82
  state.setSectionPlanePosition('50' as any);
75
83
  assert.strictEqual(state.sectionPlane.position, 50);
76
84
  });
85
+
86
+ it('should auto-enable the clip when the slider moves', () => {
87
+ // This is the fix for the "it jitters, doesn't cut" user report: moving
88
+ // the slider implicitly turns on clipping so the user doesn't have to
89
+ // hunt for the toggle.
90
+ state.sectionPlane.enabled = false;
91
+ state.setSectionPlanePosition(42);
92
+ assert.strictEqual(state.sectionPlane.enabled, true);
93
+ assert.strictEqual(state.sectionPlane.position, 42);
94
+ });
95
+ });
96
+
97
+ describe('setSectionPlaneEnabled', () => {
98
+ it('should set enabled to true explicitly', () => {
99
+ state.sectionPlane.enabled = false;
100
+ state.setSectionPlaneEnabled(true);
101
+ assert.strictEqual(state.sectionPlane.enabled, true);
102
+ });
103
+
104
+ it('should set enabled to false explicitly', () => {
105
+ state.setSectionPlaneEnabled(false);
106
+ assert.strictEqual(state.sectionPlane.enabled, false);
107
+ });
108
+ });
109
+
110
+ describe('setSectionShowCap', () => {
111
+ it('should toggle the showCap flag without touching clipping', () => {
112
+ assert.strictEqual(state.sectionPlane.showCap, true);
113
+ state.setSectionShowCap(false);
114
+ assert.strictEqual(state.sectionPlane.showCap, false);
115
+ // Clipping unchanged — cap is a visual-only add-on.
116
+ assert.strictEqual(state.sectionPlane.enabled, true);
117
+ });
118
+ });
119
+
120
+ describe('setSectionShowOutlines', () => {
121
+ it('should toggle the showOutlines flag independently of showCap and clipping', () => {
122
+ assert.strictEqual(state.sectionPlane.showOutlines, true);
123
+ state.setSectionShowOutlines(false);
124
+ assert.strictEqual(state.sectionPlane.showOutlines, false);
125
+ assert.strictEqual(state.sectionPlane.showCap, true);
126
+ assert.strictEqual(state.sectionPlane.enabled, true);
127
+ });
128
+
129
+ it('should set showOutlines back to true', () => {
130
+ state.setSectionShowOutlines(false);
131
+ state.setSectionShowOutlines(true);
132
+ assert.strictEqual(state.sectionPlane.showOutlines, true);
133
+ });
134
+ });
135
+
136
+ describe('setSectionCapStyle', () => {
137
+ it('should partially update the cap style without clobbering other fields', () => {
138
+ const before = state.sectionPlane.capStyle;
139
+ state.setSectionCapStyle({ pattern: 'concrete' });
140
+ assert.strictEqual(state.sectionPlane.capStyle.pattern, 'concrete');
141
+ assert.strictEqual(state.sectionPlane.capStyle.spacingPx, before.spacingPx);
142
+ assert.strictEqual(state.sectionPlane.capStyle.angleRad, before.angleRad);
143
+ });
144
+
145
+ it('should accept custom fill and stroke colours', () => {
146
+ state.setSectionCapStyle({
147
+ fillColor: [0.2, 0.3, 0.4, 1.0],
148
+ strokeColor: [0.9, 0.1, 0.1, 1.0],
149
+ });
150
+ assert.deepStrictEqual(state.sectionPlane.capStyle.fillColor, [0.2, 0.3, 0.4, 1.0]);
151
+ assert.deepStrictEqual(state.sectionPlane.capStyle.strokeColor, [0.9, 0.1, 0.1, 1.0]);
152
+ });
77
153
  });
78
154
 
79
155
  describe('toggleSectionPlane', () => {
@@ -106,13 +182,13 @@ describe('SectionSlice', () => {
106
182
 
107
183
  describe('resetSectionPlane', () => {
108
184
  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
- };
185
+ state.setSectionPlaneAxis('side');
186
+ state.setSectionPlanePosition(25);
187
+ state.setSectionPlaneEnabled(false);
188
+ state.flipSectionPlane();
189
+ state.setSectionShowCap(false);
190
+ state.setSectionShowOutlines(false);
191
+ state.setSectionCapStyle({ pattern: 'brick' });
116
192
 
117
193
  state.resetSectionPlane();
118
194
 
@@ -120,6 +196,10 @@ describe('SectionSlice', () => {
120
196
  assert.strictEqual(state.sectionPlane.position, SECTION_PLANE_DEFAULTS.POSITION);
121
197
  assert.strictEqual(state.sectionPlane.enabled, SECTION_PLANE_DEFAULTS.ENABLED);
122
198
  assert.strictEqual(state.sectionPlane.flipped, SECTION_PLANE_DEFAULTS.FLIPPED);
199
+ assert.strictEqual(state.sectionPlane.showCap, SECTION_PLANE_DEFAULTS.SHOW_CAP);
200
+ assert.strictEqual(state.sectionPlane.showOutlines, SECTION_PLANE_DEFAULTS.SHOW_OUTLINES);
201
+ // Default cap pattern restored.
202
+ assert.strictEqual(state.sectionPlane.capStyle.pattern, 'diagonal');
123
203
  });
124
204
  });
125
205
  });
@@ -7,8 +7,103 @@
7
7
  */
8
8
 
9
9
  import type { StateCreator } from 'zustand';
10
- import type { SectionPlane, SectionPlaneAxis } from '../types.js';
11
- import { SECTION_PLANE_DEFAULTS } from '../constants.js';
10
+ import type { SectionPlane, SectionPlaneAxis, SectionCapStyle, SectionCapHatchId } from '../types.js';
11
+ import { SECTION_PLANE_DEFAULTS, SECTION_CAP_DEFAULTS } from '../constants.js';
12
+
13
+ // ─── Persistence ─────────────────────────────────────────────────────────
14
+ // Cap appearance (hatch pattern, colours, spacing, angle, whether the cap is
15
+ // shown at all) persists across reloads via localStorage, so the user's
16
+ // preferred cut surface survives closing and re-opening the app. Axis and
17
+ // position are session-scoped because they only make sense relative to a
18
+ // loaded model. See chatSlice.ts for the same direct-localStorage pattern
19
+ // used elsewhere in the store.
20
+ const CAP_STYLE_STORAGE_KEY = 'ifc-lite:section-cap-style';
21
+ const CAP_SHOW_STORAGE_KEY = 'ifc-lite:section-cap-show';
22
+ const OUTLINES_SHOW_STORAGE_KEY = 'ifc-lite:section-outlines-show';
23
+
24
+ const HATCH_IDS: readonly SectionCapHatchId[] = [
25
+ 'solid', 'diagonal', 'crossHatch', 'horizontal',
26
+ 'vertical', 'concrete', 'brick', 'insulation',
27
+ ] as const;
28
+
29
+ function isHatchId(v: unknown): v is SectionCapHatchId {
30
+ return typeof v === 'string' && (HATCH_IDS as readonly string[]).includes(v);
31
+ }
32
+
33
+ function isRgba(v: unknown): v is [number, number, number, number] {
34
+ return Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n));
35
+ }
36
+
37
+ function loadCapStyle(): SectionCapStyle {
38
+ const fallback: SectionCapStyle = {
39
+ fillColor: [...SECTION_CAP_DEFAULTS.FILL_COLOR],
40
+ strokeColor: [...SECTION_CAP_DEFAULTS.STROKE_COLOR],
41
+ pattern: SECTION_CAP_DEFAULTS.PATTERN,
42
+ spacingPx: SECTION_CAP_DEFAULTS.SPACING_PX,
43
+ angleRad: SECTION_CAP_DEFAULTS.ANGLE_RAD,
44
+ widthPx: SECTION_CAP_DEFAULTS.WIDTH_PX,
45
+ secondaryAngleRad: SECTION_CAP_DEFAULTS.SECONDARY_ANGLE_RAD,
46
+ };
47
+ if (typeof window === 'undefined') return fallback;
48
+ try {
49
+ const raw = window.localStorage.getItem(CAP_STYLE_STORAGE_KEY);
50
+ if (!raw) return fallback;
51
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
52
+ return {
53
+ fillColor: isRgba(parsed.fillColor) ? parsed.fillColor : fallback.fillColor,
54
+ strokeColor: isRgba(parsed.strokeColor) ? parsed.strokeColor : fallback.strokeColor,
55
+ pattern: isHatchId(parsed.pattern) ? parsed.pattern : fallback.pattern,
56
+ spacingPx: typeof parsed.spacingPx === 'number' && Number.isFinite(parsed.spacingPx)
57
+ ? Math.max(2, parsed.spacingPx) : fallback.spacingPx,
58
+ angleRad: typeof parsed.angleRad === 'number' && Number.isFinite(parsed.angleRad)
59
+ ? parsed.angleRad : fallback.angleRad,
60
+ widthPx: typeof parsed.widthPx === 'number' && Number.isFinite(parsed.widthPx)
61
+ ? Math.max(1, parsed.widthPx) : fallback.widthPx,
62
+ secondaryAngleRad: typeof parsed.secondaryAngleRad === 'number' && Number.isFinite(parsed.secondaryAngleRad)
63
+ ? parsed.secondaryAngleRad : fallback.secondaryAngleRad,
64
+ };
65
+ } catch (error) {
66
+ console.warn('[section] failed to load cap style from localStorage', error);
67
+ return fallback;
68
+ }
69
+ }
70
+
71
+ function saveCapStyle(style: SectionCapStyle): void {
72
+ if (typeof window === 'undefined') return;
73
+ try {
74
+ window.localStorage.setItem(CAP_STYLE_STORAGE_KEY, JSON.stringify(style));
75
+ } catch (error) {
76
+ // Storage quota, private mode etc. — preference just doesn't persist this
77
+ // session; log so a missing setting is at least diagnosable in devtools.
78
+ console.warn('[section] failed to save cap style to localStorage', error);
79
+ }
80
+ }
81
+
82
+ function loadBoolean(key: string, fallback: boolean): boolean {
83
+ if (typeof window === 'undefined') return fallback;
84
+ try {
85
+ const raw = window.localStorage.getItem(key);
86
+ if (raw === 'true') return true;
87
+ if (raw === 'false') return false;
88
+ } catch (error) {
89
+ console.warn(`[section] failed to load preference '${key}' from localStorage`, error);
90
+ }
91
+ return fallback;
92
+ }
93
+
94
+ function saveBoolean(key: string, value: boolean): void {
95
+ if (typeof window === 'undefined') return;
96
+ try {
97
+ window.localStorage.setItem(key, String(value));
98
+ } catch (error) {
99
+ console.warn(`[section] failed to save preference '${key}' to localStorage`, error);
100
+ }
101
+ }
102
+
103
+ const loadShowCap = () => loadBoolean(CAP_SHOW_STORAGE_KEY, SECTION_PLANE_DEFAULTS.SHOW_CAP);
104
+ const saveShowCap = (v: boolean) => saveBoolean(CAP_SHOW_STORAGE_KEY, v);
105
+ const loadShowOutlines = () => loadBoolean(OUTLINES_SHOW_STORAGE_KEY, SECTION_PLANE_DEFAULTS.SHOW_OUTLINES);
106
+ const saveShowOutlines = (v: boolean) => saveBoolean(OUTLINES_SHOW_STORAGE_KEY, v);
12
107
 
13
108
  export interface SectionSlice {
14
109
  // State
@@ -18,15 +113,28 @@ export interface SectionSlice {
18
113
  setSectionPlaneAxis: (axis: SectionPlaneAxis) => void;
19
114
  setSectionPlanePosition: (position: number) => void;
20
115
  toggleSectionPlane: () => void;
116
+ setSectionPlaneEnabled: (enabled: boolean) => void;
21
117
  flipSectionPlane: () => void;
118
+ setSectionShowCap: (show: boolean) => void;
119
+ setSectionShowOutlines: (show: boolean) => void;
120
+ setSectionCapStyle: (style: Partial<SectionCapStyle>) => void;
22
121
  resetSectionPlane: () => void;
23
122
  }
24
123
 
124
+ const getDefaultCapStyle = (): SectionCapStyle => loadCapStyle();
125
+
25
126
  const getDefaultSectionPlane = (): SectionPlane => ({
26
127
  axis: SECTION_PLANE_DEFAULTS.AXIS,
27
128
  position: SECTION_PLANE_DEFAULTS.POSITION,
28
129
  enabled: SECTION_PLANE_DEFAULTS.ENABLED,
29
130
  flipped: SECTION_PLANE_DEFAULTS.FLIPPED,
131
+ // showCap + showOutlines + capStyle come from localStorage so the
132
+ // user's preferred cut-surface appearance survives reloads; the axis,
133
+ // position, and enabled fields stay session-scoped because they only
134
+ // make sense for the currently loaded model.
135
+ showCap: loadShowCap(),
136
+ showOutlines: loadShowOutlines(),
137
+ capStyle: getDefaultCapStyle(),
30
138
  });
31
139
 
32
140
  export const createSectionSlice: StateCreator<SectionSlice, [], [], SectionSlice> = (set) => ({
@@ -35,14 +143,19 @@ export const createSectionSlice: StateCreator<SectionSlice, [], [], SectionSlice
35
143
 
36
144
  // Actions
37
145
  setSectionPlaneAxis: (axis) => set((state) => ({
38
- sectionPlane: { ...state.sectionPlane, axis },
146
+ // Changing the axis implicitly means "I want to cut now" — enable the clip
147
+ // so users don't get stuck in a confusing no-op preview.
148
+ sectionPlane: { ...state.sectionPlane, axis, enabled: true },
39
149
  })),
40
150
 
41
151
  setSectionPlanePosition: (position) => set((state) => {
42
152
  // Clamp position to valid range [0, 100]
43
153
  const clampedPosition = Math.min(100, Math.max(0, Number(position) || 0));
44
154
  return {
45
- sectionPlane: { ...state.sectionPlane, position: clampedPosition },
155
+ // Moving the slider also enables the cut — previously you had to press
156
+ // "Cutting" separately, which led to the "it just jitters, doesn't cut"
157
+ // feedback from users.
158
+ sectionPlane: { ...state.sectionPlane, position: clampedPosition, enabled: true },
46
159
  };
47
160
  }),
48
161
 
@@ -50,9 +163,42 @@ export const createSectionSlice: StateCreator<SectionSlice, [], [], SectionSlice
50
163
  sectionPlane: { ...state.sectionPlane, enabled: !state.sectionPlane.enabled },
51
164
  })),
52
165
 
166
+ setSectionPlaneEnabled: (enabled) => set((state) => ({
167
+ sectionPlane: { ...state.sectionPlane, enabled },
168
+ })),
169
+
53
170
  flipSectionPlane: () => set((state) => ({
54
171
  sectionPlane: { ...state.sectionPlane, flipped: !state.sectionPlane.flipped },
55
172
  })),
56
173
 
57
- resetSectionPlane: () => set({ sectionPlane: getDefaultSectionPlane() }),
174
+ setSectionShowCap: (showCap) => set((state) => {
175
+ saveShowCap(showCap);
176
+ return { sectionPlane: { ...state.sectionPlane, showCap } };
177
+ }),
178
+
179
+ setSectionShowOutlines: (showOutlines) => set((state) => {
180
+ saveShowOutlines(showOutlines);
181
+ return { sectionPlane: { ...state.sectionPlane, showOutlines } };
182
+ }),
183
+
184
+ setSectionCapStyle: (style) => set((state) => {
185
+ const capStyle: SectionCapStyle = { ...state.sectionPlane.capStyle, ...style };
186
+ saveCapStyle(capStyle);
187
+ return { sectionPlane: { ...state.sectionPlane, capStyle } };
188
+ }),
189
+
190
+ resetSectionPlane: () => set(() => {
191
+ // Reset clears persisted cap style too — users asking for defaults expect
192
+ // the defaults to stick on the next reload.
193
+ try {
194
+ if (typeof window !== 'undefined') {
195
+ window.localStorage.removeItem(CAP_STYLE_STORAGE_KEY);
196
+ window.localStorage.removeItem(CAP_SHOW_STORAGE_KEY);
197
+ window.localStorage.removeItem(OUTLINES_SHOW_STORAGE_KEY);
198
+ }
199
+ } catch (error) {
200
+ console.warn('[section] failed to clear persisted cap preferences', error);
201
+ }
202
+ return { sectionPlane: getDefaultSectionPlane() };
203
+ }),
58
204
  });
@@ -106,6 +106,52 @@ describe('SelectionSlice', () => {
106
106
  });
107
107
  });
108
108
 
109
+ describe('multi-model selection: addEntitiesToSelection (batch)', () => {
110
+ it('should add every ref in one set call', () => {
111
+ const refs: EntityRef[] = [
112
+ { modelId: 'model-1', expressId: 1 },
113
+ { modelId: 'model-1', expressId: 2 },
114
+ { modelId: 'model-2', expressId: 3 },
115
+ ];
116
+ state.addEntitiesToSelection(refs);
117
+ assert.strictEqual(state.selectedEntitiesSet.size, 3);
118
+ });
119
+
120
+ it('should set primary selection to the LAST ref (matches single-add convention)', () => {
121
+ const refs: EntityRef[] = [
122
+ { modelId: 'model-1', expressId: 1 },
123
+ { modelId: 'model-2', expressId: 99 },
124
+ ];
125
+ state.addEntitiesToSelection(refs);
126
+ assert.deepStrictEqual(state.selectedEntity, { modelId: 'model-2', expressId: 99 });
127
+ });
128
+
129
+ it('should be a no-op for empty input', () => {
130
+ const before = state.selectedEntitiesSet;
131
+ state.addEntitiesToSelection([]);
132
+ assert.strictEqual(state.selectedEntitiesSet, before, 'state ref unchanged');
133
+ });
134
+
135
+ it('should compose with prior single-adds without losing entries', () => {
136
+ const ref0: EntityRef = { modelId: 'model-1', expressId: 0 };
137
+ state.addEntityToSelection(ref0);
138
+ state.addEntitiesToSelection([
139
+ { modelId: 'model-1', expressId: 1 },
140
+ { modelId: 'model-1', expressId: 2 },
141
+ ]);
142
+ assert.strictEqual(state.selectedEntitiesSet.size, 3);
143
+ });
144
+
145
+ it('should dedupe overlapping refs without changing the set size beyond the union', () => {
146
+ state.addEntityToSelection({ modelId: 'm', expressId: 7 });
147
+ state.addEntitiesToSelection([
148
+ { modelId: 'm', expressId: 7 }, // duplicate
149
+ { modelId: 'm', expressId: 8 },
150
+ ]);
151
+ assert.strictEqual(state.selectedEntitiesSet.size, 2);
152
+ });
153
+ });
154
+
109
155
  describe('multi-model selection: removeEntityFromSelection', () => {
110
156
  it('should remove entity from selection set', () => {
111
157
  const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
@@ -46,6 +46,13 @@ export interface SelectionSlice {
46
46
  setSelectedEntity: (ref: EntityRef | null) => void;
47
47
  /** Add entity to multi-selection */
48
48
  addEntityToSelection: (ref: EntityRef) => void;
49
+ /**
50
+ * Batch-add multiple entities to multi-selection in a single Zustand
51
+ * `set`. Use for bulk paths like "Select all visible results" — the
52
+ * naïve loop over `addEntityToSelection` re-renders every subscriber
53
+ * O(N) times for an N-row select. Empty input is a no-op.
54
+ */
55
+ addEntitiesToSelection: (refs: ReadonlyArray<EntityRef>) => void;
49
56
  /** Remove entity from multi-selection */
50
57
  removeEntityFromSelection: (ref: EntityRef) => void;
51
58
  /** Toggle entity in multi-selection */
@@ -170,6 +177,19 @@ export const createSelectionSlice: StateCreator<SelectionSlice, [], [], Selectio
170
177
  };
171
178
  }),
172
179
 
180
+ addEntitiesToSelection: (refs) => set((state) => {
181
+ if (refs.length === 0) return {};
182
+ const newSet = new Set(state.selectedEntitiesSet);
183
+ for (const ref of refs) newSet.add(entityRefToString(ref));
184
+ // Primary selection becomes the LAST ref in the input — matches the
185
+ // existing addEntityToSelection convention where the most recent
186
+ // add is treated as primary.
187
+ return {
188
+ selectedEntitiesSet: newSet,
189
+ selectedEntity: refs[refs.length - 1],
190
+ };
191
+ }),
192
+
173
193
  removeEntityFromSelection: (ref) => set((state) => {
174
194
  const key = entityRefToString(ref);
175
195
  const newSet = new Set(state.selectedEntitiesSet);
@@ -10,12 +10,14 @@ import type { StateCreator } from 'zustand';
10
10
  import { UI_DEFAULTS } from '../constants.js';
11
11
  import type { ContactShadingQuality, SeparationLinesQuality } from '@ifc-lite/renderer';
12
12
 
13
+ export type ThemeMode = 'light' | 'dark' | 'colorful';
14
+
13
15
  export interface UISlice {
14
16
  // State
15
17
  leftPanelCollapsed: boolean;
16
18
  rightPanelCollapsed: boolean;
17
19
  activeTool: string;
18
- theme: 'light' | 'dark';
20
+ theme: ThemeMode;
19
21
  isMobile: boolean;
20
22
  hoverTooltipsEnabled: boolean;
21
23
  visualEnhancementsEnabled: boolean;
@@ -33,8 +35,10 @@ export interface UISlice {
33
35
  setLeftPanelCollapsed: (collapsed: boolean) => void;
34
36
  setRightPanelCollapsed: (collapsed: boolean) => void;
35
37
  setActiveTool: (tool: string) => void;
36
- setTheme: (theme: 'light' | 'dark') => void;
38
+ setTheme: (theme: ThemeMode) => void;
37
39
  toggleTheme: () => void;
40
+ /** Shift+click secret: toggle colorful mode on/off */
41
+ toggleColorful: () => void;
38
42
  setIsMobile: (isMobile: boolean) => void;
39
43
  toggleHoverTooltips: () => void;
40
44
  setVisualEnhancementsEnabled: (enabled: boolean) => void;
@@ -49,6 +53,13 @@ export interface UISlice {
49
53
  setSeparationLinesRadius: (radius: number) => void;
50
54
  }
51
55
 
56
+ /** Apply the correct CSS classes on <html> for the given theme */
57
+ function applyThemeClasses(theme: ThemeMode) {
58
+ const el = document.documentElement;
59
+ el.classList.toggle('dark', theme === 'dark');
60
+ el.classList.toggle('colorful', theme === 'colorful');
61
+ }
62
+
52
63
  export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get) => ({
53
64
  // Initial state
54
65
  leftPanelCollapsed: false,
@@ -74,14 +85,26 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
74
85
  setActiveTool: (activeTool) => set({ activeTool }),
75
86
 
76
87
  setTheme: (theme) => {
77
- document.documentElement.classList.toggle('dark', theme === 'dark');
88
+ applyThemeClasses(theme);
78
89
  localStorage.setItem('ifc-lite-theme', theme);
79
90
  set({ theme });
80
91
  },
81
92
 
82
93
  toggleTheme: () => {
83
- const newTheme = get().theme === 'dark' ? 'light' : 'dark';
84
- document.documentElement.classList.toggle('dark', newTheme === 'dark');
94
+ // Normal toggle: dark ↔ light. If currently colorful, drop to dark.
95
+ const current = get().theme;
96
+ const newTheme = current === 'dark' ? 'light' : 'dark';
97
+ applyThemeClasses(newTheme);
98
+ localStorage.setItem('ifc-lite-theme', newTheme);
99
+ set({ theme: newTheme });
100
+ },
101
+
102
+ toggleColorful: () => {
103
+ // Shift+click secret: toggle colorful on/off
104
+ // Into colorful from any state. Out of colorful → light (the storm clears).
105
+ const current = get().theme;
106
+ const newTheme: ThemeMode = current === 'colorful' ? 'light' : 'colorful';
107
+ applyThemeClasses(newTheme);
85
108
  localStorage.setItem('ifc-lite-theme', newTheme);
86
109
  set({ theme: newTheme });
87
110
  },
@@ -86,6 +86,12 @@ export interface EdgeLockState {
86
86
  /** Semantic axis names: down (Y), front (Z), side (X) for intuitive user experience */
87
87
  export type SectionPlaneAxis = 'down' | 'front' | 'side';
88
88
 
89
+ // Re-export the renderer's canonical cap-styling types so the viewer store and
90
+ // the WebGPU renderer share a single source of truth. Adding a new hatch
91
+ // pattern only requires editing `packages/renderer/src/section-cap-style.ts`.
92
+ export type { HatchPatternId as SectionCapHatchId, SectionCapStyle } from '@ifc-lite/renderer';
93
+ import type { SectionCapStyle } from '@ifc-lite/renderer';
94
+
89
95
  export interface SectionPlane {
90
96
  axis: SectionPlaneAxis;
91
97
  /** 0-100 percentage of model bounds */
@@ -93,6 +99,17 @@ export interface SectionPlane {
93
99
  enabled: boolean;
94
100
  /** If true, show the opposite side of the cut */
95
101
  flipped: boolean;
102
+ /** Whether to render the filled, hatched cap surface at the plane. Defaults to true. */
103
+ showCap: boolean;
104
+ /**
105
+ * Whether to draw polygon outlines on top of the cut (the crisp black
106
+ * line the architect expects around each sliced element). Independent
107
+ * from `showCap` so users can have a hatched fill without outlines,
108
+ * or vice versa. Defaults to true.
109
+ */
110
+ showOutlines: boolean;
111
+ /** User-defined colour + hatch for the cut surface. */
112
+ capStyle: SectionCapStyle;
96
113
  }
97
114
 
98
115
  // ============================================================================
@@ -272,6 +289,13 @@ export interface NativeMetadataSnapshot {
272
289
  spatialTree: NativeMetadataSpatialNode | null;
273
290
  }
274
291
 
292
+ export type ModelSourceFile = File | {
293
+ path: string;
294
+ name: string;
295
+ size: number;
296
+ modifiedMs?: number | null;
297
+ };
298
+
275
299
  /** Complete model container for federation */
276
300
  export interface FederatedModel {
277
301
  /** Unique identifier (UUID generated on load) */
@@ -292,6 +316,8 @@ export interface FederatedModel {
292
316
  loadedAt: number;
293
317
  /** Original file size in bytes */
294
318
  fileSize: number;
319
+ /** Original source handle used for explicit reload/reposition operations. */
320
+ sourceFile?: ModelSourceFile;
295
321
  /**
296
322
  * ID offset for this model (from FederationRegistry)
297
323
  * All mesh expressIds are globalIds = originalExpressId + idOffset
package/src/store.ts CHANGED
@@ -48,3 +48,17 @@ export { entityRefToString, stringToEntityRef, entityRefEquals, isIfcxDataStore
48
48
 
49
49
  // Re-export single source of truth for globalId → EntityRef resolution
50
50
  export { resolveEntityRef } from './store/resolveEntityRef.js';
51
+ export { toGlobalIdFromModels, fromGlobalIdFromModels, toGlobalIdForRef } from './store/globalId.js';
52
+ export type { ForwardModelMapLike } from './store/globalId.js';
53
+
54
+ // Re-export Schedule (4D) types + helpers
55
+ export type { ScheduleSlice, ScheduleTimeRange, GanttTimeScale } from './store/slices/scheduleSlice.js';
56
+ export {
57
+ computeScheduleRange,
58
+ computeHiddenProductIds,
59
+ computeActiveProductIds,
60
+ countGeneratedTasks,
61
+ taskStartEpoch,
62
+ taskFinishEpoch,
63
+ parseIsoDate,
64
+ } from './store/slices/scheduleSlice.js';
@@ -4,6 +4,7 @@
4
4
 
5
5
  import {
6
6
  IfcTypeEnumFromString,
7
+ IfcTypeEnumToString,
7
8
  isBuildingLikeSpatialType,
8
9
  isStoreyLikeSpatialType,
9
10
  type SpatialHierarchy,
@@ -103,7 +104,9 @@ function buildEntityLookup() {
103
104
  return typeNameById.get(expressId) ?? 'Unknown';
104
105
  },
105
106
  getByType(type: string | number) {
106
- const key = typeof type === 'string' ? type.toUpperCase() : String(type);
107
+ const key = typeof type === 'string'
108
+ ? type.toUpperCase()
109
+ : IfcTypeEnumToString(type).toUpperCase();
107
110
  return byType.get(key) ?? [];
108
111
  },
109
112
  };
@@ -301,13 +301,18 @@ export function getRenderThrottleMs(meshCount: number): number {
301
301
 
302
302
  /**
303
303
  * Get clear color based on theme
304
- * @param theme - 'light' or 'dark'
304
+ * @param theme - 'light', 'dark', or 'colorful'
305
305
  * @returns RGBA clear color tuple
306
306
  */
307
- export function getThemeClearColor(theme: 'light' | 'dark'): [number, number, number, number] {
307
+ export function getThemeClearColor(theme: 'light' | 'dark' | 'colorful'): [number, number, number, number] {
308
308
  if (theme === 'light') {
309
309
  return [0.96, 0.96, 0.97, 1]; // Light gray
310
310
  }
311
+ if (theme === 'colorful') {
312
+ // Transparent — the CSS gradient on the canvas element shows through.
313
+ // alphaMode:'premultiplied' + fragment alpha=1 keeps models fully opaque.
314
+ return [0, 0, 0, 0];
315
+ }
311
316
  return [0.102, 0.106, 0.149, 1]; // Tokyo Night storm (#1a1b26)
312
317
  }
313
318
 
package/src/vite-env.d.ts CHANGED
@@ -13,10 +13,6 @@ interface ImportMetaEnv {
13
13
  readonly VITE_USE_SERVER?: string;
14
14
  /** Comma-separated free-tier model IDs */
15
15
  readonly VITE_LLM_FREE_MODELS?: string;
16
- /** Comma-separated pro model IDs grouped by relative cost */
17
- readonly VITE_LLM_PRO_MODELS_LOW?: string;
18
- readonly VITE_LLM_PRO_MODELS_MEDIUM?: string;
19
- readonly VITE_LLM_PRO_MODELS_HIGH?: string;
20
16
  /** Comma-separated model IDs that support image inputs */
21
17
  readonly VITE_LLM_IMAGE_MODELS?: string;
22
18
  /** Comma-separated model IDs that support file attachment context */