@ifc-lite/viewer 1.17.6 → 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 (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -281,15 +281,23 @@ export function useIfcLoader() {
281
281
  return 'IFC2X3';
282
282
  };
283
283
 
284
+ // Native renderer streaming path is currently disabled — the
285
+ // `huge native file` block further down handles real desktop
286
+ // streaming. This branch is retained as a scaffold for the future
287
+ // always-on native renderer integration.
288
+ const NATIVE_RENDERER_PATH_ENABLED = false as boolean;
284
289
  if (
290
+ NATIVE_RENDERER_PATH_ENABLED &&
285
291
  isNativeFileHandle(file) &&
286
- fileName.toLowerCase().endsWith('.ifc') &&
287
- false
292
+ fileName.toLowerCase().endsWith('.ifc')
288
293
  ) {
294
+ // Re-narrow `file` for the body — TS occasionally drops the
295
+ // type-predicate result inside a dead branch.
296
+ const nativeFile: NativeFileHandle = file;
289
297
  const harnessRequest = getActiveHarnessRequest();
290
- const nativeCacheKey = computeNativeCacheKey(file);
291
- const shouldUseNativeCache = file.size >= CACHE_SIZE_THRESHOLD;
292
- const hugeNativeMode = file.size >= HUGE_NATIVE_FILE_THRESHOLD;
298
+ const nativeCacheKey = computeNativeCacheKey(nativeFile);
299
+ const shouldUseNativeCache = nativeFile.size >= CACHE_SIZE_THRESHOLD;
300
+ const hugeNativeMode = nativeFile.size >= HUGE_NATIVE_FILE_THRESHOLD;
293
301
  let firstBatchWaitMs: number | null = null;
294
302
  let firstVisibleGeometryMs: number | null = null;
295
303
  let modelOpenMs: number | null = null;
@@ -312,7 +320,7 @@ export function useIfcLoader() {
312
320
  let nativeGeometryCacheHit = false;
313
321
  let nativeMetadataSnapshotHit = false;
314
322
  let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
315
- let nativeMetadataStartGate: 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete' = 'immediate';
323
+ let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
316
324
  let finalCoordinateInfo: CoordinateInfo | null = null;
317
325
 
318
326
  console.log(`[useIfc] Native renderer load: ${fileName}, size: ${fileSizeMB.toFixed(2)}MB`);
@@ -727,7 +735,7 @@ export function useIfcLoader() {
727
735
  let fullNativeDataStore: IfcDataStore | null = null;
728
736
  let nativeLoadStage: 'open' | 'streamGeometry' | 'finalizeGeometry' | 'hydrateMetadata' | 'complete' = 'open';
729
737
  let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
730
- let nativeMetadataStartGate: 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete' = 'immediate';
738
+ let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
731
739
 
732
740
  setGeometryResult(null);
733
741
 
@@ -1638,8 +1646,10 @@ export function useIfcLoader() {
1638
1646
  }
1639
1647
 
1640
1648
  // Try server parsing first (enabled by default for multi-core performance)
1641
- // Only for IFC4 STEP files (server doesn't support IFCX)
1642
- if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '') {
1649
+ // Only for IFC4 STEP files (server doesn't support IFCX). Native
1650
+ // file handles (Tauri) don't have an HTTP-uploadable body, so skip
1651
+ // the server path and fall through to the WASM loader.
1652
+ if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '' && !isNativeFileHandle(file)) {
1643
1653
  // Pass buffer directly - server uses File object for parsing, buffer is only for size checks
1644
1654
  const serverSuccess = await loadFromServer(file, buffer, () => loadSessionRef.current !== currentSession);
1645
1655
  if (serverSuccess) {
@@ -1792,7 +1802,9 @@ export function useIfcLoader() {
1792
1802
  if (geometryIteratorClosed || typeof geometryIterator.return !== 'function') return;
1793
1803
  geometryIteratorClosed = true;
1794
1804
  try {
1795
- await geometryIterator.return();
1805
+ // `AsyncIterator.return()` is signed as taking a value in
1806
+ // current TS libs; callers conventionally pass `undefined`.
1807
+ await geometryIterator.return(undefined);
1796
1808
  } catch {
1797
1809
  // Ignore iterator shutdown failures during recovery.
1798
1810
  }
@@ -88,6 +88,10 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
88
88
  e.preventDefault();
89
89
  setActiveTool('section');
90
90
  }
91
+ if (key === 'p' && !ctrl && !shift) {
92
+ e.preventDefault();
93
+ setActiveTool('annotate');
94
+ }
91
95
 
92
96
  // Basket controls (automatic context source)
93
97
  // I = Isolate from current context
@@ -150,6 +154,26 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
150
154
  resetVisibilityForHomeFromStore();
151
155
  }
152
156
 
157
+ // Add-element tool shortcuts — Enter commits an in-progress slab
158
+ // polygon; Esc clears any pending points before falling through to
159
+ // the global Esc handler (which exits the tool).
160
+ if (activeTool === 'addElement') {
161
+ const state = useViewerStore.getState();
162
+ const polygonable = ['slab', 'roof', 'plate', 'space'].includes(state.addElementType);
163
+ if (key === 'enter' && polygonable && state.addElementSlabMode === 'polygon') {
164
+ e.preventDefault();
165
+ // Lazy import keeps this module out of the keyboard hook's
166
+ // synchronous bundle (the close handler pulls in toast).
167
+ import('@/components/viewer/selectionHandlers').then((mod) => mod.commitAddElementSlabPolygon());
168
+ return;
169
+ }
170
+ if (key === 'escape' && state.addElementPendingPoints.length > 0) {
171
+ e.preventDefault();
172
+ state.clearAddElementPending();
173
+ return;
174
+ }
175
+ }
176
+
153
177
  // Measure tool shortcuts
154
178
  if (activeTool === 'measure') {
155
179
  // Cancel active measurement with ESC
@@ -243,6 +267,7 @@ export const KEYBOARD_SHORTCUTS = [
243
267
  { key: 'V', description: 'Select tool', category: 'Tools' },
244
268
  { key: 'C', description: 'Walk mode', category: 'Tools' },
245
269
  { key: 'M', description: 'Measure tool', category: 'Tools' },
270
+ { key: 'P', description: 'Annotate tool — drop a pin with a note', category: 'Tools' },
246
271
  { key: 'X', description: 'Section tool', category: 'Tools' },
247
272
  { key: 'S', description: 'Toggle snapping (Measure tool)', category: 'Tools' },
248
273
  { key: 'Esc', description: 'Cancel measurement (Measure tool)', category: 'Tools' },
@@ -165,7 +165,7 @@ export function useSandbox(config?: SandboxConfig) {
165
165
  // Create a fresh sandbox for every execution — full isolation
166
166
  const { createSandbox } = await import('@ifc-lite/sandbox');
167
167
  sandbox = await createSandbox(bim, {
168
- permissions: { model: true, query: true, viewer: true, mutate: true, lens: true, export: true, files: true, ...config?.permissions },
168
+ permissions: { model: true, query: true, viewer: true, mutate: true, store: true, lens: true, export: true, files: true, ...config?.permissions },
169
169
  limits: { timeoutMs: 30_000, ...config?.limits },
170
170
  });
171
171
  activeSandboxRef.current = sandbox;
@@ -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
+ /**
6
+ * useSearchIndex — lazy builder for the Tier-1 search index.
7
+ *
8
+ * Mount once near the root of the viewer shell (currently `SearchInline`,
9
+ * since it's always rendered once the toolbar is up). The hook watches
10
+ * the federated `models` map; for each model with a populated
11
+ * `ifcDataStore` that doesn't yet have a Tier-1 record, it spawns a
12
+ * chunked build. Models that disappear get their index record dropped.
13
+ *
14
+ * Load-perf guarantee: the build NEVER runs during the actual IFC load
15
+ * because `ifcDataStore` is non-null only after the parser reports the
16
+ * model is ready (`onSpatialReady` + geometry). The build itself yields
17
+ * to the event loop every `DEFAULT_CHUNK_SIZE` rows so a 4M-entity
18
+ * index doesn't hog the main thread.
19
+ */
20
+
21
+ import { useEffect, useRef } from 'react';
22
+ import { useShallow } from 'zustand/react/shallow';
23
+ import { useViewerStore } from '@/store';
24
+ import { buildTier1Index } from '@/lib/search/tier1-index';
25
+
26
+ export function useSearchIndex(): void {
27
+ const {
28
+ models,
29
+ searchIndexes,
30
+ setSearchIndexRecord,
31
+ removeSearchIndexRecord,
32
+ searchFilterSchema,
33
+ removeFilterSchema,
34
+ } = useViewerStore(
35
+ useShallow((s) => ({
36
+ models: s.models,
37
+ searchIndexes: s.searchIndexes,
38
+ setSearchIndexRecord: s.setSearchIndexRecord,
39
+ removeSearchIndexRecord: s.removeSearchIndexRecord,
40
+ searchFilterSchema: s.searchFilterSchema,
41
+ removeFilterSchema: s.removeFilterSchema,
42
+ })),
43
+ );
44
+
45
+ // One AbortController per in-flight build. Lets us cancel cleanly when a
46
+ // model is removed mid-build or when the component unmounts.
47
+ const controllersRef = useRef<Map<string, AbortController>>(new Map());
48
+
49
+ useEffect(() => {
50
+ const controllers = controllersRef.current;
51
+
52
+ // Drop records / abort builds for models that no longer exist.
53
+ for (const modelId of Array.from(searchIndexes.keys())) {
54
+ if (!models.has(modelId)) {
55
+ controllers.get(modelId)?.abort();
56
+ controllers.delete(modelId);
57
+ removeSearchIndexRecord(modelId);
58
+ }
59
+ }
60
+
61
+ // Drop the filter-schema cache for departed models too. Stale entries
62
+ // would surface in the chip dropdowns the next time a model with the
63
+ // same id loaded (e.g. user reopens a different file as model_0).
64
+ for (const modelId of Array.from(searchFilterSchema.keys())) {
65
+ if (!models.has(modelId)) removeFilterSchema(modelId);
66
+ }
67
+
68
+ // Kick off builds for models that are loaded but not yet indexed.
69
+ for (const [modelId, model] of models) {
70
+ if (!model.ifcDataStore) continue;
71
+ const existing = searchIndexes.get(modelId);
72
+ if (existing && existing.status !== 'pending') continue;
73
+ if (controllers.has(modelId)) continue;
74
+
75
+ const controller = new AbortController();
76
+ controllers.set(modelId, controller);
77
+
78
+ setSearchIndexRecord(modelId, { status: 'building', progress: 0 });
79
+
80
+ // Fire-and-forget — the build is cancellable via the controller, and
81
+ // the completion handlers update the store without needing a ref.
82
+ void buildTier1Index(modelId, model.ifcDataStore, {
83
+ signal: controller.signal,
84
+ onProgress: (done, total) => {
85
+ if (controller.signal.aborted) return;
86
+ const progress = total > 0 ? done / total : 1;
87
+ setSearchIndexRecord(modelId, { status: 'building', progress });
88
+ },
89
+ })
90
+ .then((index) => {
91
+ if (controller.signal.aborted) return;
92
+ controllers.delete(modelId);
93
+ setSearchIndexRecord(modelId, { status: 'ready', index, progress: 1 });
94
+ })
95
+ .catch((err: unknown) => {
96
+ controllers.delete(modelId);
97
+ if (err instanceof DOMException && err.name === 'AbortError') return;
98
+ const message = err instanceof Error ? err.message : String(err);
99
+ // Don't set a 'ready' record — Tier-0 fallback stays live.
100
+ console.warn(`[useSearchIndex] build failed for ${modelId}:`, message);
101
+ setSearchIndexRecord(modelId, { status: 'error', error: message });
102
+ });
103
+ }
104
+
105
+ // On unmount OR next effect pass, abort everything. The effect re-runs
106
+ // only when `models` / `searchIndexes` changes, so steady-state
107
+ // incurs no abort — the `controllers.has(modelId)` guard above makes
108
+ // re-entry idempotent.
109
+ return () => {
110
+ // Intentionally NOT aborting everything on every re-render — only
111
+ // models that went missing got aborted above. The real cleanup is
112
+ // the component-unmount pass below.
113
+ };
114
+ }, [models, searchIndexes, setSearchIndexRecord, removeSearchIndexRecord, searchFilterSchema, removeFilterSchema]);
115
+
116
+ // Abort any in-flight builds when the consumer unmounts. Separate effect
117
+ // so it only fires on unmount (no deps).
118
+ useEffect(() => {
119
+ const controllers = controllersRef.current;
120
+ return () => {
121
+ for (const c of controllers.values()) c.abort();
122
+ controllers.clear();
123
+ };
124
+ }, []);
125
+ }
package/src/index.css CHANGED
@@ -379,6 +379,43 @@ body {
379
379
  color: var(--color-primary) !important;
380
380
  }
381
381
 
382
+ /* Raw STEP tab — terminal-flavoured dev affordance.
383
+ Compact (icon-only </>), separates visually from the three "human"
384
+ tabs with a left divider and a green active state that nods to a
385
+ shell cursor. Stays width-stable at narrow panel widths because
386
+ it explicitly opts out of `flex: 1` (uses an `auto`-ish width
387
+ driven by the </> glyph plus padding). */
388
+ .properties-tab-trigger.raw-step-tab-trigger {
389
+ flex: 0 0 auto;
390
+ min-width: 2.25rem;
391
+ padding-left: 0.5rem;
392
+ padding-right: 0.5rem;
393
+ border-left: 1px solid var(--tabs-border);
394
+ letter-spacing: 0;
395
+ color: color-mix(in srgb, var(--tab-text) 70%, transparent) !important;
396
+ }
397
+
398
+ .properties-tab-trigger.raw-step-tab-trigger:hover {
399
+ background-color: color-mix(in srgb, #10b981 16%, var(--tabs-bg)) !important;
400
+ color: #047857 !important;
401
+ }
402
+
403
+ .dark .properties-tab-trigger.raw-step-tab-trigger:hover {
404
+ background-color: color-mix(in srgb, #10b981 22%, var(--tabs-bg)) !important;
405
+ color: #34d399 !important;
406
+ }
407
+
408
+ .properties-tab-trigger.raw-step-tab-trigger[data-state="active"] {
409
+ background-color: color-mix(in srgb, #10b981 14%, var(--tab-active-bg)) !important;
410
+ color: #047857 !important;
411
+ border-top-color: #10b981 !important;
412
+ }
413
+
414
+ .dark .properties-tab-trigger.raw-step-tab-trigger[data-state="active"] {
415
+ color: #34d399 !important;
416
+ border-top-color: #34d399 !important;
417
+ }
418
+
382
419
  /* Quantity cards - cyan accent */
383
420
  .dark .border-blue-200,
384
421
  .dark .border-blue-800,
@@ -997,6 +1034,35 @@ body {
997
1034
  cursor: not-allowed;
998
1035
  }
999
1036
 
1037
+ /* Annotation pin idle introduction — fires once on mount, then settles.
1038
+ Driven by a `prefers-reduced-motion` guard so accessibility users
1039
+ don't get hit by the pulse. */
1040
+ @keyframes annotation-pin-idle {
1041
+ 0% {
1042
+ transform: scale(0.7);
1043
+ box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55);
1044
+ }
1045
+ 60% {
1046
+ transform: scale(1.08);
1047
+ box-shadow: 0 0 0 8px rgba(245, 158, 11, 0);
1048
+ }
1049
+ 100% {
1050
+ transform: scale(1);
1051
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35),
1052
+ 0 0 0 1px rgba(0, 0, 0, 0.15);
1053
+ }
1054
+ }
1055
+
1056
+ .annotation-pin-idle {
1057
+ animation: annotation-pin-idle 360ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
1058
+ }
1059
+
1060
+ @media (prefers-reduced-motion: reduce) {
1061
+ .annotation-pin-idle {
1062
+ animation: none;
1063
+ }
1064
+ }
1065
+
1000
1066
  /* Loading skeleton animation */
1001
1067
  @keyframes skeleton-pulse {
1002
1068
  0%, 100% {
@@ -91,6 +91,20 @@ test('system prompt includes selected entity IFC context when provided', () => {
91
91
  assert.match(prompt, /Selected entities: Tower: IfcCurtainWall "Facade Panel A", kind=occurrence, storey=Level 10@31.5m, psets=Pset_CurtainWallCommon, typePsets=Pset_CurtainWallTypeCommon, qsets=Qto_CurtainWallBaseQuantities, material=Aluminium, classifications=A-123 \| Tower: IfcWallType "Exterior Wall Type", kind=type, psets=Pset_WallCommon, classifications=A-WALL/);
92
92
  });
93
93
 
94
+ test('system prompt includes the BIM.STORE cheat sheet and routing rule', () => {
95
+ const prompt = buildSystemPrompt();
96
+
97
+ // Section heading + the three method examples
98
+ assert.match(prompt, /## BIM\.STORE CHEAT SHEET/);
99
+ assert.match(prompt, /bim\.store\.setPositionalAttribute\(profile, 3, 0\.6\)/);
100
+ assert.match(prompt, /bim\.store\.addEntity\("arch"/);
101
+ assert.match(prompt, /bim\.store\.removeEntity\(unwantedRef\)/);
102
+
103
+ // Routing rule disambiguating store / mutate / create
104
+ assert.match(prompt, /bim\.store\.setPositionalAttribute\(entity, index, value\)`? for positional STEP-argument edits/);
105
+ assert.match(prompt, /Do NOT use `bim\.create` for these/);
106
+ });
107
+
94
108
  test('system prompt includes method-specific create contract guidance', () => {
95
109
  const prompt = buildSystemPrompt();
96
110
  assert.match(prompt, /BIM\.CREATE CONTRACT CHEAT SHEET/);
@@ -100,6 +100,69 @@ function buildIntentMethodSection(intent: LlmTaskIntent): string {
100
100
  return lines.join('\n');
101
101
  }
102
102
 
103
+ function buildStoreCheatSheet(): string {
104
+ const storeNamespace = NAMESPACE_SCHEMAS.find((schema) => schema.name === 'store');
105
+ if (!storeNamespace) return '';
106
+
107
+ return [
108
+ '## BIM.STORE CHEAT SHEET',
109
+ '`bim.store.*` edits a parsed model in place — use it when the user already has',
110
+ 'a model loaded and wants raw STEP-level edits, NOT when building a new model from',
111
+ 'scratch (that\'s `bim.create`).',
112
+ '',
113
+ '- `addEntity(modelId, { type, attributes })` — inject a STEP entity. `attributes`',
114
+ ' follows EntityExtractor output: numbers → REAL/integer, `"#42"` → ref, `".AREA."` → enum,',
115
+ ' `null` → `$`, arrays → STEP list. Returns `{ modelId, expressId }`.',
116
+ '- `removeEntity(entity)` — tombstones existing source entities or forgets overlay-only ones.',
117
+ '- `setPositionalAttribute(entity, index, value)` — edit a non-IfcRoot attribute by',
118
+ ' zero-based STEP argument index. Use this for `IfcRectangleProfileDef.XDim` (index 3),',
119
+ ' `YDim` (index 4), `IfcCartesianPoint.Coordinates` (index 0), etc. Use `bim.mutate.setAttribute`',
120
+ ' for IfcRoot attributes (Name, Description, ObjectType, Tag).',
121
+ '- High-level builders anchor a new element to an existing IfcBuildingStorey:',
122
+ ' `addColumn(modelId, storeyId, { Position, Width, Depth, Height })`',
123
+ ' `addWall(modelId, storeyId, { Start, End, Thickness, Height })`',
124
+ ' `addBeam(modelId, storeyId, { Start, End, Width, Height })`',
125
+ ' `addSlab(modelId, storeyId, { Position, Width, Depth, Thickness })` // rectangle',
126
+ ' `addSlab(modelId, storeyId, { Profile: "polygon", OuterCurve: [[x,y],…], Thickness })`',
127
+ ' `addRoof(modelId, storeyId, { … same shape as addSlab — emits .FLAT_ROOF. })`',
128
+ ' `addPlate(modelId, storeyId, { … same shape as addSlab — IfcPlate, PredefinedType? })`',
129
+ ' `addSpace(modelId, storeyId, { Position, Width, Depth, Height, LongName? })` // room rectangle',
130
+ ' `addSpace(modelId, storeyId, { Profile: "polygon", OuterCurve, Height })` // room polygon',
131
+ ' `addDoor(modelId, storeyId, { Position, Width, Height, FrameThickness?, OperationType? })`',
132
+ ' `addWindow(modelId, storeyId, { Position, Width, Height, FrameThickness?, PartitioningType? })`',
133
+ ' `addMember(modelId, storeyId, { Start, End, Width, Height, PredefinedType? })` // brace/post/strut',
134
+ ' Each emits ~12 STEP entities (placement chain → profile → solid → representation +',
135
+ ' IfcRelContainedInSpatialStructure, except `addSpace` which uses IfcRelAggregates).',
136
+ ' Coords are storey-local metres. Polygon outlines need ≥3 points; the polyline is auto-closed.',
137
+ '- Edits accumulate in an overlay; they show up after `bim.export.ifc(bim.query.all())`',
138
+ ' or when the viewer next renders. Use `bim.mutate.undo(modelId)` to roll back.',
139
+ '',
140
+ 'Canonical examples:',
141
+ '```js',
142
+ '// Resize a rectangular profile from 0.3×0.4 to 0.6×0.4',
143
+ 'const profile = bim.query.byId("arch", 35);',
144
+ 'bim.store.setPositionalAttribute(profile, 3, 0.6); // XDim',
145
+ '',
146
+ '// Drop a wall on the first storey',
147
+ 'const storeyId = bim.query.byType("IfcBuildingStorey")[0].ref.expressId;',
148
+ 'bim.store.addWall("arch", storeyId, {',
149
+ ' Start: [0, 0, 0], End: [5, 0, 0],',
150
+ ' Thickness: 0.2, Height: 3, Name: "North Wall",',
151
+ '});',
152
+ '',
153
+ '// Add a custom IfcCartesianPoint, then reference it from another entity',
154
+ 'const pt = bim.store.addEntity("arch", {',
155
+ ' type: "IfcCartesianPoint",',
156
+ ' attributes: [[1.0, 2.0, 0.0]],',
157
+ '});',
158
+ 'console.log("Allocated", pt.expressId);',
159
+ '',
160
+ '// Drop an entity entirely',
161
+ 'bim.store.removeEntity(unwantedRef);',
162
+ '```',
163
+ ].join('\n');
164
+ }
165
+
103
166
  function buildCreateContractCheatSheet(): string {
104
167
  const createNamespace = NAMESPACE_SCHEMAS.find((schema) => schema.name === 'create');
105
168
  if (!createNamespace) return '## BIM.CREATE CONTRACT CHEAT SHEET';
@@ -280,6 +343,7 @@ export function buildSystemPrompt(
280
343
  const intent = inferPromptIntent(task);
281
344
  const intentSection = buildIntentMethodSection(intent);
282
345
  const createContractCheatSheet = buildCreateContractCheatSheet();
346
+ const storeCheatSheet = buildStoreCheatSheet();
283
347
  const placementSemantics = buildPlacementSemanticsSection();
284
348
  const inspectionGuidance = buildInspectionGuidance();
285
349
 
@@ -326,9 +390,11 @@ ${intentSection}
326
390
  4. Keep scripts concise — avoid unnecessary abstractions
327
391
  5. Coordinates are in meters. Z is up. Do NOT assume every create method is storey-relative — use the method-specific placement rules below.
328
392
  6. For create or explicit rewrite turns, wrap runnable code in a \`\`\`js\`\`\` fence. For repair turns, return exactly one \`\`\`ifc-script-edits\`\`\` fence containing SEARCH/REPLACE blocks and no \`\`\`js\`\`\` fence.
329
- 7. If the user asks to modify existing data, use \`bim.mutate\` or \`bim.query\` — NOT \`bim.create\`
393
+ 7. If the user asks to modify existing data, use \`bim.mutate\`, \`bim.store\`, or \`bim.query\` — NOT \`bim.create\`
330
394
  - Use \`bim.mutate.setAttribute(entity, "Description", "...")\` for root IFC attributes like \`Name\`, \`Description\`, \`ObjectType\`, or \`Tag\`
331
395
  - Use \`bim.mutate.setProperty(entity, "Pset_Name", "PropName", value)\` only for IfcPropertySet or quantity data
396
+ - Use \`bim.store.setPositionalAttribute(entity, index, value)\` for positional STEP-argument edits (profile dimensions, \`IfcCartesianPoint.Coordinates\`, and other index-addressed attributes — even when they have a symbolic name) — see BIM.STORE CHEAT SHEET
397
+ - Use \`bim.store.addEntity\` / \`bim.store.removeEntity\` to inject or drop raw STEP entities in an already-loaded model. Do NOT use \`bim.create\` for these — \`bim.create\` builds a fresh project
332
398
  - Distinguish occurrence vs type edits: occurrence/entity-specific changes belong on the occurrence; shared defaults and inherited type properties belong on the related \`Ifc...Type\` entity
333
399
  - If CURRENT MODEL STATE marks a selection as \`kind=type\`, treat it as a type object and avoid describing it as one physical placed occurrence
334
400
  - When an occurrence is selected, inspect \`bim.query.typeProperties(entity)\` before editing inherited values; mutate the type entity when the intent is to change all occurrences that share that type
@@ -353,6 +419,8 @@ ${intentSection}
353
419
 
354
420
  ${createContractCheatSheet}
355
421
 
422
+ ${storeCheatSheet}
423
+
356
424
  ${placementSemantics}
357
425
  - \`addIfcDoor\` and \`addIfcWindow\` do not infer host-wall orientation. If you place them next to angled walls, they will stay world-aligned unless you build the wall void another way.
358
426
  - For storey-relative methods, \`Z=0\` usually means floor level of that storey.
@@ -393,6 +461,39 @@ for (let i = 0; i < storeyCount; i++) {
393
461
  - If repeated elements appear only at one level, you probably reused one storey reference instead of iterating over the intended storeys.
394
462
  - If repeated world-placement elements stack at the base level, first check whether their Z coordinates include the current storey elevation.
395
463
 
464
+ ## SCHEDULING / 4D (IfcTask, IfcWorkSchedule, IfcRelSequence)
465
+ - ifc-lite ships a Gantt panel in the lower workspace that plays a construction-sequence animation driven by IfcTask dates and the products each task controls.
466
+ - Creating a schedule from scratch:
467
+ \`\`\`js
468
+ const h = bim.create.project({ Name: "Demo" });
469
+ const storey = bim.create.addIfcBuildingStorey(h, { Name: "Ground", Elevation: 0 });
470
+ const wallA = bim.create.addIfcWall(h, storey, { Start: [0,0,0], End: [5,0,0], Thickness: 0.2, Height: 3 });
471
+
472
+ const schedule = bim.create.addIfcWorkSchedule(h, {
473
+ Name: "Construction schedule",
474
+ StartTime: "2024-05-01T08:00:00",
475
+ FinishTime: "2024-06-30T17:00:00",
476
+ PredefinedType: "PLANNED",
477
+ });
478
+ const task = bim.create.addIfcTask(h, {
479
+ Name: "Install wall A",
480
+ PredefinedType: "INSTALLATION",
481
+ ScheduleStart: "2024-05-06T08:00:00",
482
+ ScheduleFinish: "2024-05-10T17:00:00",
483
+ ScheduleDuration: "P5D",
484
+ });
485
+ bim.create.assignTasksToWorkSchedule(h, schedule, [task]);
486
+ bim.create.assignProductsToTask(h, task, [wallA]); // products reveal in the 4D animation
487
+ // bim.create.addIfcRelSequence(h, prevTask, task, { SequenceType: "FINISH_START", TimeLag: "P2D" });
488
+ // bim.create.nestTasks(h, summaryTask, [task]); // WBS hierarchy
489
+ \`\`\`
490
+ - Dates are ISO 8601 (\`2024-05-01T08:00:00\`). Durations are ISO 8601 (\`P5D\`, \`PT8H\`).
491
+ - IfcTask.PredefinedType is an enum — prefer CONSTRUCTION, INSTALLATION, DEMOLITION, RENOVATION over free strings.
492
+ - For milestones (e.g. "handover"), set \`IsMilestone: true\` and omit or equate start/finish.
493
+ - \`assignProductsToTask\` is the bridge that lets the 4D Gantt animation reveal/hide elements as time advances. Always bind tasks to the elements they construct when the user wants the viewport to animate.
494
+ - Reading an existing schedule: \`bim.schedule.data()\` returns { workSchedules, tasks, sequences }. Use it to inspect or validate a construction plan.
495
+ - **CSV / Excel / PDF → schedule workflow:** when the user attaches a spreadsheet or PDF with activities and dates, parse it with \`bim.files.csv(name)\` (for CSV) or \`bim.files.text(name)\` (for text-extracted PDF/Excel content converted upstream). Map each row to an \`addIfcTask(...)\` call and resolve the \`products\` column — an IFC type like \`IfcWall\` expands to every matching entity, a globalId maps to one specific entity — into \`expressId\`s to feed \`assignProductsToTask\`. The \`Construction schedule (4D)\` script template is a ready-made starting point.
496
+
396
497
  ## API REFERENCE
397
498
  ${apiRef}
398
499
 
@@ -124,6 +124,12 @@ export interface FileAttachment {
124
124
  imageBase64?: string;
125
125
  /** Whether this is an image attachment */
126
126
  isImage?: boolean;
127
+ /** Base64-encoded PDF data (for PDF attachments — Anthropic native document blocks) */
128
+ pdfBase64?: string;
129
+ /** Whether this is a PDF attachment */
130
+ isPdf?: boolean;
131
+ /** Whether this is a binary spreadsheet (xlsx/xls/ods) that we can't parse here yet. */
132
+ isSpreadsheetBinary?: boolean;
127
133
  }
128
134
 
129
135
  export type ModelTier = 'free' | 'byok';
@@ -24,8 +24,21 @@ export interface RecentFileEntry {
24
24
  name: string;
25
25
  size: number;
26
26
  timestamp: number;
27
+ /** Native filesystem path (Tauri only) — enables direct re-open from disk. */
28
+ path?: string;
29
+ /** Last-modified epoch in ms when known (Tauri stat). */
30
+ modifiedMs?: number | null;
27
31
  }
28
32
 
33
+ // Input shape for `recordRecentFiles` — accepts the optional native fields
34
+ // so callers can persist a path / modifiedMs without lying about the type.
35
+ export type RecentFileInput = {
36
+ name: string;
37
+ size: number;
38
+ path?: string;
39
+ modifiedMs?: number | null;
40
+ };
41
+
29
42
  // ── localStorage (metadata) ─────────────────────────────────────────────
30
43
 
31
44
  export function getRecentFiles(): RecentFileEntry[] {
@@ -33,17 +46,30 @@ export function getRecentFiles(): RecentFileEntry[] {
33
46
  catch { return []; }
34
47
  }
35
48
 
36
- export function recordRecentFiles(files: { name: string; size: number }[]) {
49
+ // Path-aware dedup key: when a native filesystem path is available it
50
+ // uniquely identifies the file (so `A/model.ifc` and `B/model.ifc` are
51
+ // kept separate); otherwise fall back to the name (browser uploads
52
+ // don't expose paths).
53
+ function recentKey(f: { name: string; path?: string }): string {
54
+ return f.path ? `path:${f.path}` : `name:${f.name}`;
55
+ }
56
+
57
+ export function recordRecentFiles(files: RecentFileInput[]) {
37
58
  try {
38
- const names = new Set(files.map(f => f.name));
39
- const existing = getRecentFiles().filter(f => !names.has(f.name));
59
+ const incomingKeys = new Set(files.map(recentKey));
60
+ const existing = getRecentFiles().filter(f => !incomingKeys.has(recentKey(f)));
40
61
  const entries: RecentFileEntry[] = files.map(f => ({
41
62
  name: f.name,
42
63
  size: f.size,
43
64
  timestamp: Date.now(),
65
+ path: f.path,
66
+ modifiedMs: f.modifiedMs ?? null,
44
67
  }));
45
68
  localStorage.setItem(KEY, JSON.stringify([...entries, ...existing].slice(0, 10)));
46
- } catch { /* noop */ }
69
+ } catch (err) {
70
+ // eslint-disable-next-line no-console
71
+ console.warn('[recent-files] failed to persist recent files metadata', err);
72
+ }
47
73
  }
48
74
 
49
75
  /** Format bytes into human-readable size */
@@ -104,6 +130,14 @@ export async function cacheFileBlobs(files: File[]): Promise<void> {
104
130
 
105
131
  /** Retrieve a cached file blob and reconstruct a File object. */
106
132
  export async function getCachedFile(target: string | RecentFileEntry): Promise<File | null> {
133
+ // Path-bearing entries (Tauri filesystem) are uniquely keyed by path
134
+ // in the recents list, but the IndexedDB cache is name-keyed. A
135
+ // name-only hit could resolve `A/model.ifc` to the cached blob from
136
+ // `B/model.ifc`, opening the wrong file silently. Defer to the
137
+ // caller's native re-open path instead.
138
+ if (typeof target !== 'string' && target.path) {
139
+ return null;
140
+ }
107
141
  const name = typeof target === 'string' ? target : target.name;
108
142
  try {
109
143
  const db = await openDB();