@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.
- package/.turbo/turbo-build.log +17 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +513 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +10 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +69 -10
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +11 -1
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/hooks/useIfcLoader.ts +22 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +4 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +70 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store.ts +14 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- 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(
|
|
291
|
-
const shouldUseNativeCache =
|
|
292
|
-
const hugeNativeMode =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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' },
|
package/src/hooks/useSandbox.ts
CHANGED
|
@@ -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
|
|
package/src/lib/llm/types.ts
CHANGED
|
@@ -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';
|
package/src/lib/recent-files.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
39
|
-
const existing = getRecentFiles().filter(f => !
|
|
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 {
|
|
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();
|