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;
/**