From d29b91c3a0d73362c073b14cf3e7624a9ae7822a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20Tr=C3=BCmpler?= <78563314+louistrue@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:57:21 +0200 Subject: [PATCH 1/5] feat(viewer): refresh button to reload the open model from disk (#1345) Add a Refresh button that re-reads the currently open model from disk and re-parses it, so a model can be monitored during design without closing and re-opening the same file. A `` File is a frozen snapshot of the bytes at pick time, so re-reading it can never reflect on-disk edits. Only the File System Access API (showOpenFilePicker -> FileSystemFileHandle.getFile()) returns a fresh snapshot. The Open button now prefers that API when available (Chromium, secure context) to capture a live handle, falling back to `` elsewhere. - services/file-system-access.ts: feature-detected open + permission-checked fresh re-read helpers. - types/file-system-access.d.ts: ambient decls for the API surface missing from TS lib.dom (showOpenFilePicker, options, queryPermission/requestPermission). - FederatedModel.sourceHandle: in-memory live handle (never serialized to cache). - loadFile() persists the handle on the primary model record. - MainToolbar: Refresh shown only for a single model that has a live handle (drag-drop / input / cache-restored / federated loads have none). --- .../src/components/viewer/MainToolbar.tsx | 87 ++++++++++++- apps/viewer/src/hooks/useIfcLoader.ts | 2 + .../viewer/src/services/file-system-access.ts | 117 ++++++++++++++++++ apps/viewer/src/store/types.ts | 9 ++ apps/viewer/src/types/file-system-access.d.ts | 56 +++++++++ 5 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 apps/viewer/src/services/file-system-access.ts create mode 100644 apps/viewer/src/types/file-system-access.d.ts diff --git a/apps/viewer/src/components/viewer/MainToolbar.tsx b/apps/viewer/src/components/viewer/MainToolbar.tsx index 1f42a8ea9..9853e06d7 100644 --- a/apps/viewer/src/components/viewer/MainToolbar.tsx +++ b/apps/viewer/src/components/viewer/MainToolbar.tsx @@ -51,6 +51,7 @@ import { Redo2, Boxes, Shapes, + RefreshCw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; @@ -69,7 +70,7 @@ import { DropdownMenuSubContent, } from '@/components/ui/dropdown-menu'; import { Progress } from '@/components/ui/progress'; -import { useViewerStore, isIfcxDataStore } from '@/store'; +import { useViewerStore, isIfcxDataStore, type FederatedModel } from '@/store'; import { goHomeFromStore, resetVisibilityForHomeFromStore } from '@/store/homeView'; import { executeBasketIsolate } from '@/store/basket/basketCommands'; import { useIfc } from '@/hooks/useIfc'; @@ -85,6 +86,11 @@ import { DataConnector } from './DataConnector'; import { ExportChangesButton } from './ExportChangesButton'; import { SearchInline } from './SearchInline'; import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files'; +import { + supportsFileSystemAccess, + openIfcFilesWithHandles, + readFreshFile, +} from '@/services/file-system-access'; import { ThemeSwitch } from './ThemeSwitch'; import { ExtensionToolbarSlot } from '@/components/extensions/ExtensionToolbarSlot'; import { toast } from '@/components/ui/toast'; @@ -498,6 +504,64 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo e.target.value = ''; }, [loadFilesSequentially, addIfcxOverlays, ifcDataStore]); + // Open via the File System Access API when available (Chromium) so we capture + // a live FileSystemFileHandle for each file — that handle is what lets the + // Refresh button re-read the same file from disk later (issue #1345). Browsers + // without the API fall back to the hidden . + const handleOpenClick = useCallback(async () => { + if (!supportsFileSystemAccess()) { + fileInputRef.current?.click(); + return; + } + const opened = await openIfcFilesWithHandles(); + if (!opened) return; // cancelled, unavailable, or picker failed + + const files = opened.map(o => o.file); + recordRecentFiles(files.map(f => ({ name: f.name, size: f.size }))); + cacheFileBlobs(files); + + if (opened.length === 1) { + // Single model: keep the handle so Refresh can re-read it from disk. + void loadFile(opened[0].file, { kind: 'primary' }, { sourceHandle: opened[0].handle }); + } else { + // Multiple files mirror handleFileSelect's branching (no per-model handle). + const allIfcx = files.every(f => f.name.endsWith('.ifcx')); + resetViewerState(); + clearAllModels(); + if (allIfcx) { + loadFederatedIfcx(files); + } else { + loadFilesSequentially(files); + } + } + }, [loadFile, loadFilesSequentially, loadFederatedIfcx, resetViewerState, clearAllModels]); + + // Refresh re-reads the same file from disk and re-parses it. It is only + // possible for a single model opened via the File System Access API (which + // gave us a live handle); drag-drop, , cache-restored, and + // federated loads have no handle, so the button is not offered for them. + const refreshableModel = useMemo(() => { + if (loading || models.size !== 1) return null; + const only = models.values().next().value as FederatedModel | undefined; + return only?.sourceHandle ? only : null; + }, [models, loading]); + + const handleRefresh = useCallback(async () => { + const model = refreshableModel; + if (!model?.sourceHandle) return; + const fresh = await readFreshFile(model.sourceHandle); + if (!fresh) { + toast.error( + `Couldn't re-read "${model.name}". It may have been moved or deleted, or access was denied.`, + ); + return; + } + recordRecentFiles([{ name: fresh.name, size: fresh.size }]); + cacheFileBlobs([fresh]); + void loadFile(fresh, { kind: 'primary' }, { sourceHandle: model.sourceHandle }); + toast.success(`Refreshed "${fresh.name}"`); + }, [refreshableModel, loadFile]); + const hasSelection = selectedEntityId !== null; // Selection chip uses the multi-select size when present; falls back // to the single legacy `selectedEntityId` so the chip still says @@ -788,7 +852,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo onClick={(e) => { // Blur button to close tooltip before opening file dialog (e.currentTarget as HTMLButtonElement).blur(); - fileInputRef.current?.click(); + void handleOpenClick(); }} disabled={loading} > @@ -802,6 +866,25 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo Open IFC File + {refreshableModel && ( + + + + + Refresh model from disk + + )} + {/* Add Model button - only shown when models are loaded */} {hasModelsLoaded && ( diff --git a/apps/viewer/src/hooks/useIfcLoader.ts b/apps/viewer/src/hooks/useIfcLoader.ts index 6dc4dac7f..bf5341fa0 100644 --- a/apps/viewer/src/hooks/useIfcLoader.ts +++ b/apps/viewer/src/hooks/useIfcLoader.ts @@ -186,6 +186,7 @@ export function useIfcLoader() { const loadFile = useCallback(async ( file: File, target: LoadTarget = { kind: 'primary' }, + options?: { sourceHandle?: FileSystemFileHandle }, ) => { const { resetViewerState, clearAllModels } = useViewerStore.getState(); // Only a primary (destructive, replace-everything) load bumps the session. @@ -243,6 +244,7 @@ export function useIfcLoader() { loadedAt: Date.now(), fileSize, sourceFile: file, + sourceHandle: options?.sourceHandle, idOffset: 0, maxExpressId: 0, loadState: 'pending', diff --git a/apps/viewer/src/services/file-system-access.ts b/apps/viewer/src/services/file-system-access.ts new file mode 100644 index 000000000..894e31f39 --- /dev/null +++ b/apps/viewer/src/services/file-system-access.ts @@ -0,0 +1,117 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * File System Access API helpers (Chromium: Chrome / Edge). + * + * Opening a file via `showOpenFilePicker` yields a live `FileSystemFileHandle`. + * Unlike a `` File — a frozen snapshot of the bytes at pick + * time — a handle can be re-read on demand with `getFile()`, which returns a + * fresh File reflecting the current on-disk contents. That is what powers the + * "Refresh" action: re-read the same file the user is editing in their + * authoring tool, without re-picking it (issue #1345). + * + * Browsers without the API (Firefox / Safari) fall back to ``, + * where no handle exists and Refresh is simply not offered. + */ + +/** Accept filter mirroring the `` accept list. */ +const IFC_ACCEPT_TYPES: FilePickerAcceptType[] = [ + { + description: 'BIM models & point clouds', + accept: { + 'application/octet-stream': [ + '.ifc', + '.ifcx', + '.glb', + '.las', + '.laz', + '.ply', + '.pcd', + '.e57', + '.pts', + '.xyz', + ], + }, + }, +]; + +export interface OpenedFile { + file: File; + handle: FileSystemFileHandle; +} + +/** + * Whether this browser exposes the File System Access open API. Requires a + * secure context (https / localhost) and a Chromium engine. + */ +export function supportsFileSystemAccess(): boolean { + return typeof window !== 'undefined' && typeof window.showOpenFilePicker === 'function'; +} + +/** + * Prompt the user to pick one or more BIM files, returning both the File and + * its live handle for each. Returns `null` when the user cancels, the API is + * unavailable, or the picker fails — so callers can fall back to + * ``. + */ +export async function openIfcFilesWithHandles(): Promise { + if (!supportsFileSystemAccess()) return null; + + let handles: FileSystemFileHandle[]; + try { + handles = await window.showOpenFilePicker!({ + multiple: true, + excludeAcceptAllOption: false, + types: IFC_ACCEPT_TYPES, + }); + } catch (err) { + // AbortError = the user dismissed the dialog; not worth surfacing. + if (err instanceof DOMException && err.name === 'AbortError') return null; + console.warn('[file-system-access] showOpenFilePicker failed, falling back', err); + return null; + } + + const opened: OpenedFile[] = []; + for (const handle of handles) { + try { + opened.push({ file: await handle.getFile(), handle }); + } catch (err) { + console.warn(`[file-system-access] could not read "${handle.name}"`, err); + } + } + return opened.length > 0 ? opened : null; +} + +/** + * Ensure we still hold read permission for a handle, prompting if the grant + * lapsed. Resolves true when read access is granted. The request path must run + * inside a user gesture. When the engine lacks the permission probes (some + * Chromium builds expose `showOpenFilePicker` but not `queryPermission`), we + * optimistically return true and let `getFile()` surface the real error. + */ +async function ensureReadPermission(handle: FileSystemFileHandle): Promise { + if (typeof handle.queryPermission !== 'function') return true; + const opts: FileSystemHandlePermissionDescriptor = { mode: 'read' }; + if ((await handle.queryPermission(opts)) === 'granted') return true; + if (typeof handle.requestPermission === 'function') { + return (await handle.requestPermission(opts)) === 'granted'; + } + return false; +} + +/** + * Re-read a handle's current on-disk contents. Returns a fresh File, or `null` + * if permission was denied or the file is no longer reachable (moved/deleted). + * Must be called from a user gesture so the permission prompt can show. + */ +export async function readFreshFile(handle: FileSystemFileHandle): Promise { + if (!(await ensureReadPermission(handle))) return null; + try { + return await handle.getFile(); + } catch (err) { + console.warn(`[file-system-access] refresh read failed for "${handle.name}"`, err); + return null; + } +} diff --git a/apps/viewer/src/store/types.ts b/apps/viewer/src/store/types.ts index 0a7ef60bd..f59f47c6a 100644 --- a/apps/viewer/src/store/types.ts +++ b/apps/viewer/src/store/types.ts @@ -335,6 +335,15 @@ export interface FederatedModel { fileSize: number; /** Original source handle used for explicit reload/reposition operations. */ sourceFile?: ModelSourceFile; + /** + * Live File System Access handle captured when the model was opened via + * `showOpenFilePicker` (Chromium only). Unlike `sourceFile` — a frozen + * snapshot of the bytes at pick time — this can be re-read with `getFile()` + * to pull the current on-disk contents, powering the "Refresh" action + * (issue #1345). Absent for drag-drop, ``, cache-restored, + * and federated loads. Held in memory only; never serialized to cache. + */ + sourceHandle?: FileSystemFileHandle; /** * ID offset for this model (from FederationRegistry) * All mesh expressIds are globalIds = originalExpressId + idOffset diff --git a/apps/viewer/src/types/file-system-access.d.ts b/apps/viewer/src/types/file-system-access.d.ts new file mode 100644 index 000000000..d826b95d7 --- /dev/null +++ b/apps/viewer/src/types/file-system-access.d.ts @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * File System Access API surface that TypeScript's bundled `lib.dom` (TS 6.0.3) + * does not yet declare. `FileSystemHandle` / `FileSystemFileHandle.getFile()` + * already ship in lib.dom, but `Window.showOpenFilePicker`, its options, and the + * Chromium permission probes (`queryPermission` / `requestPermission`) are + * missing. These are global augmentations (no imports/exports → ambient scope), + * so they merge with the existing lib.dom interfaces. + * + * Used by `services/file-system-access.ts` to capture a live file handle on open + * and re-read it on demand (the "Refresh" action). + */ + +interface FileSystemHandlePermissionDescriptor { + mode?: 'read' | 'readwrite'; +} + +interface FileSystemHandle { + /** Chromium-only: current permission state without prompting. */ + queryPermission?( + descriptor?: FileSystemHandlePermissionDescriptor, + ): Promise; + /** Chromium-only: prompt for permission (must run in a user gesture). */ + requestPermission?( + descriptor?: FileSystemHandlePermissionDescriptor, + ): Promise; +} + +interface FilePickerAcceptType { + description?: string; + accept: Record; +} + +interface OpenFilePickerOptions { + multiple?: boolean; + excludeAcceptAllOption?: boolean; + types?: FilePickerAcceptType[]; + id?: string; + startIn?: + | FileSystemHandle + | 'desktop' + | 'documents' + | 'downloads' + | 'music' + | 'pictures' + | 'videos'; +} + +interface Window { + showOpenFilePicker?( + options?: OpenFilePickerOptions, + ): Promise; +} From 906726ceea9ca6f082609742e815b0765293d0bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20Tr=C3=BCmpler?= <78563314+louistrue@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:15:46 +0200 Subject: [PATCH 2/5] feat(viewer): capture handles on every open path + Refresh-all (#1345) Broaden the refresh feature so it works regardless of how a model was opened, and refresh whole federations at once. The first PR only captured a live handle from the toolbar Open button, so a model opened from the empty-state "Open .ifc file" button, drag-drop, or the Add-Model button had no handle and showed no Refresh button. Now every in-session ingestion path on Chromium captures a handle: - ViewportContainer empty-state open buttons -> showOpenFilePicker. - Drag-drop -> DataTransferItem.getAsFileSystemHandle() (kicked off synchronously before any await, since the DataTransferItemList is neutered once the drop handler returns). - Add-Model button + multi-file Open -> picker handles threaded through loadFilesSequentially -> addModel -> the federated finalize. Refresh is now "Refresh all": it re-reads every handle-backed model's bytes, then reloads a single model in place or rebuilds the federation (preserving id, order, visibility, collapsed), reusing the proven reload sequence. Reads happen before anything is cleared, so a failed read never empties the viewer; partial failures are reported per file. The button shows only when every loaded model has a handle (session-only, in memory), so a mixed session never risks dropping handle-less models. No polling/auto-watch and no cross-session persistence, per the agreed scope. --- .../src/components/viewer/MainToolbar.tsx | 150 ++++++++++++------ .../components/viewer/ViewportContainer.tsx | 113 ++++++++----- apps/viewer/src/hooks/useIfcFederation.ts | 19 ++- apps/viewer/src/hooks/useIfcLoader.ts | 1 + .../viewer/src/services/file-system-access.ts | 42 +++++ apps/viewer/src/types/file-system-access.d.ts | 5 + 6 files changed, 238 insertions(+), 92 deletions(-) diff --git a/apps/viewer/src/components/viewer/MainToolbar.tsx b/apps/viewer/src/components/viewer/MainToolbar.tsx index 9853e06d7..1695935ce 100644 --- a/apps/viewer/src/components/viewer/MainToolbar.tsx +++ b/apps/viewer/src/components/viewer/MainToolbar.tsx @@ -288,6 +288,14 @@ function ActionButton({ icon: Icon, label, onClick, shortcut, disabled }: Action } // #endregion +/** Extensions the viewer can ingest (IFC / IFCX / GLB / point clouds). */ +function isSupportedModelFile(f: File): boolean { + const n = f.name.toLowerCase(); + return n.endsWith('.ifc') || n.endsWith('.ifcx') || n.endsWith('.glb') + || n.endsWith('.las') || n.endsWith('.laz') || n.endsWith('.ply') || n.endsWith('.pcd') + || n.endsWith('.e57') || n.endsWith('.pts') || n.endsWith('.xyz'); +} + interface MainToolbarProps { onShowShortcuts?: () => void; } @@ -434,11 +442,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo const files = e.target.files; if (!files || files.length === 0) return; - // Filter to supported files (IFC, IFCX, GLB) - const supportedFiles = Array.from(files).filter( - f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb') - || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz') - ); + // Filter to supported files (IFC, IFCX, GLB, point clouds) + const supportedFiles = Array.from(files).filter(isSupportedModelFile); if (supportedFiles.length === 0) return; @@ -471,38 +476,53 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo e.target.value = ''; }, [loadFile, loadFilesSequentially, loadFederatedIfcx, resetViewerState, clearAllModels]); - const handleAddModelSelect = useCallback((e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - - // Filter to supported files (IFC, IFCX, GLB) - const supportedFiles = Array.from(files).filter( - f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb') - || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz') - ); - + // Shared Add-Model routing. `handles` is positionally aligned with + // `supportedFiles`, carrying a live FS Access handle per file (Chromium) so + // each added model stays part of a refreshable federation. + const addSupportedFiles = useCallback(( + supportedFiles: File[], + handles?: (FileSystemFileHandle | undefined)[], + ) => { if (supportedFiles.length === 0) return; - - // Check if adding IFCX files const newFilesAreIfcx = supportedFiles.every(f => f.name.endsWith('.ifcx')); const existingIsIfcx = isIfcxDataStore(ifcDataStore); if (newFilesAreIfcx && existingIsIfcx) { // Adding IFCX overlay(s) to existing IFCX model - re-compose with new layers console.log(`[MainToolbar] Adding ${supportedFiles.length} IFCX overlay(s) to existing IFCX model - re-composing`); - addIfcxOverlays(supportedFiles); + void addIfcxOverlays(supportedFiles); } else if (newFilesAreIfcx && !existingIsIfcx && ifcDataStore) { // User trying to add IFCX to IFC4 model - won't work console.warn('[MainToolbar] Cannot add IFCX files to non-IFCX model'); alert(`IFCX overlay files cannot be added to IFC4 models.\n\nPlease load IFCX files separately.`); } else { // Standard case - add as independent models (IFC4, GLB, or mixed) - loadFilesSequentially(supportedFiles); + void loadFilesSequentially(supportedFiles, handles); } + }, [loadFilesSequentially, addIfcxOverlays, ifcDataStore]); + const handleAddModelSelect = useCallback((e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + // yields no live handle, so models added this way aren't refreshable. + const supportedFiles = Array.from(files).filter(isSupportedModelFile); + addSupportedFiles(supportedFiles); // Reset input so same files can be selected again e.target.value = ''; - }, [loadFilesSequentially, addIfcxOverlays, ifcDataStore]); + }, [addSupportedFiles]); + + // Preferred Add-Model path: the picker captures a handle per file so the + // resulting federation can be refreshed. Falls back to the hidden . + const handleAddModelClick = useCallback(async () => { + if (!supportsFileSystemAccess()) { + addModelInputRef.current?.click(); + return; + } + const opened = await openIfcFilesWithHandles(); + if (!opened) return; + const supported = opened.filter(o => isSupportedModelFile(o.file)); + addSupportedFiles(supported.map(o => o.file), supported.map(o => o.handle)); + }, [addSupportedFiles]); // Open via the File System Access API when available (Chromium) so we capture // a live FileSystemFileHandle for each file — that handle is what lets the @@ -518,49 +538,85 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo const files = opened.map(o => o.file); recordRecentFiles(files.map(f => ({ name: f.name, size: f.size }))); - cacheFileBlobs(files); + void cacheFileBlobs(files); if (opened.length === 1) { // Single model: keep the handle so Refresh can re-read it from disk. void loadFile(opened[0].file, { kind: 'primary' }, { sourceHandle: opened[0].handle }); } else { - // Multiple files mirror handleFileSelect's branching (no per-model handle). + // Multiple files mirror handleFileSelect's branching. const allIfcx = files.every(f => f.name.endsWith('.ifcx')); resetViewerState(); clearAllModels(); if (allIfcx) { - loadFederatedIfcx(files); + // IFCX layers compose into one shared store — no per-file handle. + void loadFederatedIfcx(files); } else { - loadFilesSequentially(files); + // Carry each file's handle so the whole federation stays refreshable. + void loadFilesSequentially(files, opened.map(o => o.handle)); } } }, [loadFile, loadFilesSequentially, loadFederatedIfcx, resetViewerState, clearAllModels]); - // Refresh re-reads the same file from disk and re-parses it. It is only - // possible for a single model opened via the File System Access API (which - // gave us a live handle); drag-drop, , cache-restored, and - // federated loads have no handle, so the button is not offered for them. - const refreshableModel = useMemo(() => { - if (loading || models.size !== 1) return null; - const only = models.values().next().value as FederatedModel | undefined; - return only?.sourceHandle ? only : null; + // Refresh re-reads files from disk and re-parses them. Offered when EVERY + // loaded model has a live FS Access handle (a single model, or a federation + // fully opened via the picker/drag this session). Drag-drop on non-Chromium, + // , cache-restored, and IFCX-composed models have no + // handle, so a mixed session hides the button rather than risk dropping the + // handle-less models during the rebuild. + const canRefresh = useMemo(() => { + if (loading || models.size === 0) return false; + return Array.from(models.values()).every(m => m.sourceHandle); }, [models, loading]); const handleRefresh = useCallback(async () => { - const model = refreshableModel; - if (!model?.sourceHandle) return; - const fresh = await readFreshFile(model.sourceHandle); - if (!fresh) { - toast.error( - `Couldn't re-read "${model.name}". It may have been moved or deleted, or access was denied.`, - ); + const targets = (Array.from(useViewerStore.getState().models.values()) as FederatedModel[]) + .filter((m): m is FederatedModel & { sourceHandle: FileSystemFileHandle } => Boolean(m.sourceHandle)) + .sort((a, b) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0)); + if (targets.length === 0) return; + + // Re-read every handle BEFORE clearing anything, so a failed read never + // leaves the viewer empty. + const reads = await Promise.all( + targets.map(async (m) => ({ model: m, fresh: await readFreshFile(m.sourceHandle) })), + ); + const ok = reads.filter((r) => r.fresh) as { model: typeof targets[number]; fresh: File }[]; + const failedNames = reads.filter((r) => !r.fresh).map((r) => `"${r.model.name}"`); + + if (ok.length === 0) { + toast.error(`Couldn't re-read ${failedNames.join(', ')}. Files may have moved, been deleted, or access was denied.`); return; } - recordRecentFiles([{ name: fresh.name, size: fresh.size }]); - cacheFileBlobs([fresh]); - void loadFile(fresh, { kind: 'primary' }, { sourceHandle: model.sourceHandle }); - toast.success(`Refreshed "${fresh.name}"`); - }, [refreshableModel, loadFile]); + + recordRecentFiles(ok.map((r) => ({ name: r.fresh.name, size: r.fresh.size }))); + void cacheFileBlobs(ok.map((r) => r.fresh)); + + if (targets.length === 1) { + void loadFile(ok[0].fresh, { kind: 'primary' }, { sourceHandle: ok[0].model.sourceHandle }); + } else { + // Rebuild the federation from fresh bytes, preserving id + order + state. + clearAllModels(); + for (const r of ok) { + const reloadedId = await addModel(r.fresh, { + name: r.model.name, + modelId: r.model.id, + loadedAt: r.model.loadedAt, + visible: r.model.visible, + collapsed: r.model.collapsed, + sourceHandle: r.model.sourceHandle, + }); + if (reloadedId && r.model.visible === false) { + useViewerStore.getState().setModelVisibility(r.model.id, false); + } + } + } + + if (failedNames.length > 0) { + toast.error(`Refreshed ${ok.length}; couldn't re-read ${failedNames.join(', ')}.`); + } else { + toast.success(ok.length === 1 ? `Refreshed "${ok[0].fresh.name}"` : `Refreshed ${ok.length} models`); + } + }, [loadFile, addModel, clearAllModels]); const hasSelection = selectedEntityId !== null; // Selection chip uses the multi-select size when present; falls back @@ -866,7 +922,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo Open IFC File - {refreshableModel && ( + {canRefresh && ( - Refresh model from disk + {models.size > 1 ? 'Refresh models from disk' : 'Refresh model from disk'} )} @@ -894,7 +950,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo size="icon-sm" onClick={(e) => { (e.currentTarget as HTMLButtonElement).blur(); - addModelInputRef.current?.click(); + void handleAddModelClick(); }} disabled={loading} className="text-[#9ece6a] hover:text-[#9ece6a] hover:bg-[#9ece6a]/10" diff --git a/apps/viewer/src/components/viewer/ViewportContainer.tsx b/apps/viewer/src/components/viewer/ViewportContainer.tsx index 7ed65cdcc..7c3f000ae 100644 --- a/apps/viewer/src/components/viewer/ViewportContainer.tsx +++ b/apps/viewer/src/components/viewer/ViewportContainer.tsx @@ -31,6 +31,11 @@ import type { AggregationRelationships } from '@/utils/aggregation'; import { useIfc } from '@/hooks/useIfc'; import { useWebGPU } from '@/hooks/useWebGPU'; import { cacheFileBlobs, formatFileSize, getCachedFile, getRecentFiles, recordRecentFiles, type RecentFileEntry } from '@/lib/recent-files'; +import { + supportsFileSystemAccess, + openIfcFilesWithHandles, + handlesFromDataTransfer, +} from '@/services/file-system-access'; import { toast } from '@/components/ui/toast'; import { describeUnsupportedFormat } from '@/hooks/ingest/pointCloudIngest'; import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3, Sparkles, ArrowUpRight, PackagePlus } from 'lucide-react'; @@ -440,6 +445,35 @@ export function ViewportContainer() { applyDragEvent('leave'); }, [applyDragEvent]); + const isSupportedFile = useCallback((f: File) => { + const n = f.name.toLowerCase(); + return n.endsWith('.ifc') || n.endsWith('.ifcx') || n.endsWith('.glb') + || n.endsWith('.las') || n.endsWith('.laz') || n.endsWith('.ply') || n.endsWith('.pcd') + || n.endsWith('.e57') || n.endsWith('.pts') || n.endsWith('.xyz'); + }, []); + + // Single routing point for every ingestion path (picker / drop / input). The + // optional `handles` array is positionally aligned with `files` and carries a + // live FS Access handle per file when one was captured (Chromium) so the model + // stays refreshable; entries are `undefined` otherwise. + const routeLoad = useCallback(( + files: File[], + handles?: (FileSystemFileHandle | undefined)[], + ) => { + if (hasModelsLoaded) { + // Models already loaded - add new files sequentially (federate). + void loadFilesSequentially(files, handles); + } else if (files.length === 1) { + // Single file, no models loaded - primary single-model load. + void loadFile(files[0], { kind: 'primary' }, { sourceHandle: handles?.[0] }); + } else { + // Multiple files, no models loaded - start a fresh federation. + resetViewerState(); + clearAllModels(); + void loadFilesSequentially(files, handles); + } + }, [loadFile, loadFilesSequentially, resetViewerState, clearAllModels, hasModelsLoaded]); + const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -450,12 +484,13 @@ export function ViewportContainer() { return; } + // Capture live handles synchronously — the DataTransferItemList is neutered + // once this handler returns, so this must run before any await. + const handlesPromise = handlesFromDataTransfer(e.dataTransfer); + // Filter to supported files (IFC, IFCX, GLB, point clouds) const allDropped = Array.from(e.dataTransfer.files); - const supportedFiles = allDropped.filter( - f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb') - || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz') - ); + const supportedFiles = allDropped.filter(isSupportedFile); if (supportedFiles.length === 0) { // Tell the user *why* — common case is a Recap project / SketchUp @@ -471,19 +506,11 @@ export function ViewportContainer() { void cacheFileBlobs(supportedFiles); setRecentFiles(getRecentFiles().slice(0, 3)); - if (hasModelsLoaded) { - // Models already loaded - add new files sequentially - loadFilesSequentially(supportedFiles); - } else if (supportedFiles.length === 1) { - // Single file, no models loaded - use loadFile - loadFile(supportedFiles[0]); - } else { - // Multiple files, no models loaded - use federation - resetViewerState(); - clearAllModels(); - loadFilesSequentially(supportedFiles); - } - }, [loadFile, loadFilesSequentially, resetViewerState, clearAllModels, webgpu.supported, hasModelsLoaded]); + void handlesPromise.then((opened) => { + const handleByName = new Map((opened ?? []).map((o) => [o.file.name, o.handle])); + routeLoad(supportedFiles, supportedFiles.map((f) => handleByName.get(f.name))); + }); + }, [routeLoad, applyDragEvent, isSupportedFile, webgpu.supported]); const handleFileSelect = useCallback((e: React.ChangeEvent) => { // Block file loading if WebGPU not supported @@ -494,11 +521,9 @@ export function ViewportContainer() { const files = e.target.files; if (!files || files.length === 0) return; - // Filter to supported files (IFC, IFCX, GLB) - const supportedFiles = Array.from(files).filter( - f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb') - || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz') - ); + // Filter to supported files (IFC, IFCX, GLB). The path yields no + // live handle, so these models are not refreshable. + const supportedFiles = Array.from(files).filter(isSupportedFile); if (supportedFiles.length === 0) return; @@ -506,20 +531,33 @@ export function ViewportContainer() { void cacheFileBlobs(supportedFiles); setRecentFiles(getRecentFiles().slice(0, 3)); - if (supportedFiles.length === 1) { - // Single file - use loadFile (simpler single-model path) - loadFile(supportedFiles[0]); - } else { - // Multiple files selected - use federation from the start - // Clear everything and start fresh, then load sequentially - resetViewerState(); - clearAllModels(); - loadFilesSequentially(supportedFiles); - } + routeLoad(supportedFiles); // Reset input so same file can be selected again e.target.value = ''; - }, [loadFile, loadFilesSequentially, resetViewerState, clearAllModels, webgpu.supported]); + }, [routeLoad, isSupportedFile, webgpu.supported]); + + // Preferred open path: the File System Access picker (Chromium) captures a + // live handle per file so the model can be refreshed from disk. Falls back to + // the hidden on browsers without the API. + const handleOpenClick = useCallback(async () => { + if (!webgpu.supported) return; + if (!supportsFileSystemAccess()) { + fileInputRef.current?.click(); + return; + } + const opened = await openIfcFilesWithHandles(); + if (!opened) return; + const supported = opened.filter((o) => isSupportedFile(o.file)); + if (supported.length === 0) return; + + const files = supported.map((o) => o.file); + recordRecentFiles(files.map((f) => ({ name: f.name, size: f.size }))); + void cacheFileBlobs(files); + setRecentFiles(getRecentFiles().slice(0, 3)); + + routeLoad(files, supported.map((o) => o.handle)); + }, [routeLoad, isSupportedFile, webgpu.supported]); const handleStartBlank = useCallback(async () => { if (!webgpu.supported) return; @@ -1016,12 +1054,7 @@ export function ViewportContainer() { */} {/* Track 1 — open / drag */} diff --git a/apps/viewer/src/components/viewer/ViewportContainer.tsx b/apps/viewer/src/components/viewer/ViewportContainer.tsx index 7c3f000ae..deedd35dc 100644 --- a/apps/viewer/src/components/viewer/ViewportContainer.tsx +++ b/apps/viewer/src/components/viewer/ViewportContainer.tsx @@ -502,13 +502,20 @@ export function ViewportContainer() { return; } - recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size }))); - void cacheFileBlobs(supportedFiles); - setRecentFiles(getRecentFiles().slice(0, 3)); - void handlesPromise.then((opened) => { - const handleByName = new Map((opened ?? []).map((o) => [o.file.name, o.handle])); - routeLoad(supportedFiles, supportedFiles.map((f) => handleByName.get(f.name))); + // Prefer the handle-paired files (Chromium): each file + handle comes from + // the same dropped item, so no filename matching is needed. Fall back to + // the plain dropped files when no handles were captured (Firefox/Safari). + const supportedOpened = (opened ?? []).filter((o) => isSupportedFile(o.file)); + const useHandles = supportedOpened.length > 0; + const files = useHandles ? supportedOpened.map((o) => o.file) : supportedFiles; + const handles = useHandles ? supportedOpened.map((o) => o.handle) : undefined; + + recordRecentFiles(files.map((file) => ({ name: file.name, size: file.size }))); + void cacheFileBlobs(files); + setRecentFiles(getRecentFiles().slice(0, 3)); + + routeLoad(files, handles); }); }, [routeLoad, applyDragEvent, isSupportedFile, webgpu.supported]); diff --git a/apps/viewer/src/lib/recent-files.ts b/apps/viewer/src/lib/recent-files.ts index eefb026e8..92eff2904 100644 --- a/apps/viewer/src/lib/recent-files.ts +++ b/apps/viewer/src/lib/recent-files.ts @@ -86,6 +86,12 @@ function openDB(): Promise { /** Cache file blobs in IndexedDB for instant reload from palette. */ export async function cacheFileBlobs(files: File[]): Promise { try { + // Only stage up to the cache capacity. The store keeps at most + // MAX_CACHED_FILES entries, so reading every blob of a large multi-file drop + // into memory just to evict most of them afterwards is wasteful — keep the + // last-selected ones (the eviction below keeps newest by timestamp anyway). + const eligible = files.filter((f) => f.size <= MAX_CACHE_SIZE).slice(-MAX_CACHED_FILES); + // Read every blob FIRST. An IndexedDB transaction auto-commits as soon as // control returns to the event loop with no pending request, so awaiting // file.arrayBuffer() *inside* the transaction would inactivate it and make @@ -93,8 +99,7 @@ export async function cacheFileBlobs(files: File[]): Promise { // nothing cached). Do all the async reads up front, then write in one // synchronous burst. const records: { name: string; blob: ArrayBuffer; size: number; type: string; timestamp: number }[] = []; - for (const file of files) { - if (file.size > MAX_CACHE_SIZE) continue; // skip oversized files + for (const file of eligible) { records.push({ name: file.name, blob: await file.arrayBuffer(), diff --git a/apps/viewer/src/services/file-system-access.ts b/apps/viewer/src/services/file-system-access.ts index 412bce111..4c5e95447 100644 --- a/apps/viewer/src/services/file-system-access.ts +++ b/apps/viewer/src/services/file-system-access.ts @@ -136,7 +136,8 @@ export function handlesFromDataTransfer(dataTransfer: DataTransfer): Promise { try { return item.getAsFileSystemHandle?.() ?? Promise.resolve(null); - } catch { + } catch (err) { + console.warn('[file-system-access] getAsFileSystemHandle threw for a dropped item', err); return Promise.resolve(null); } }); diff --git a/apps/viewer/src/store/types.ts b/apps/viewer/src/store/types.ts index f59f47c6a..2235e0751 100644 --- a/apps/viewer/src/store/types.ts +++ b/apps/viewer/src/store/types.ts @@ -336,12 +336,15 @@ export interface FederatedModel { /** Original source handle used for explicit reload/reposition operations. */ sourceFile?: ModelSourceFile; /** - * Live File System Access handle captured when the model was opened via - * `showOpenFilePicker` (Chromium only). Unlike `sourceFile` — a frozen - * snapshot of the bytes at pick time — this can be re-read with `getFile()` - * to pull the current on-disk contents, powering the "Refresh" action - * (issue #1345). Absent for drag-drop, ``, cache-restored, - * and federated loads. Held in memory only; never serialized to cache. + * Live File System Access handle captured when the model was opened on a + * Chromium browser, via the picker (`showOpenFilePicker`) or by drag-drop + * (`DataTransferItem.getAsFileSystemHandle`), through the toolbar, the + * empty-state open, the command palette, or Add Model. Unlike `sourceFile` + * (a frozen snapshot of the bytes at pick time), this can be re-read with + * `getFile()` to pull the current on-disk contents, powering the "Refresh" + * action (issue #1345). Absent for the `` fallback + * (Firefox/Safari/insecure context), cache-restored models, and IFCX-composed + * layers. Held in memory only; never serialized to cache. */ sourceHandle?: FileSystemFileHandle; /**