Skip to content

Commit aaa6253

Browse files
authored
feat(viewer): save and reopen a portable federation setup (#3945)
Closes #3930. Save which models make up a federation (load order, visibility, alignment anchor) to a small, portable JSON file that references source files by name/size/content-fingerprint rather than embedding bytes or paths. Reopening matches saved slots to freshly picked local files, replays alignment through the existing realignFederation pipeline against the restored anchor, and always reports restored/missing/mismatched counts instead of silently accepting a partial restore. Reachable via the command palette ("Save Federation Setup" / "Open Federation Setup"). Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436
1 parent bb123e3 commit aaa6253

7 files changed

Lines changed: 856 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ifc-lite/viewer": minor
3+
---
4+
5+
Add a portable federation setup file: save which models make up a federation (load order, visibility, and the alignment anchor) and reopen it later by matching saved slots back to local files by content fingerprint. The file references source files by name, size, and a content fingerprint — it never embeds file bytes, paths, or handles. Reopening replays the existing alignment pipeline against the restored anchor rather than storing baked transforms, and always reports how many models were restored versus missing or mismatched instead of silently accepting a partial restore. Reachable via the command palette ("Save Federation Setup" / "Open Federation Setup").

apps/viewer/src/components/viewer/CommandPalette.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,9 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
287287
action: () => {
288288
window.dispatchEvent(new CustomEvent('ifc-lite:open-files'));
289289
} },
290+
// #3930 portable federation setup — handlers live in `FederationSetupControls` (mounted from `useFileCommands`, ShareDialog's pattern).
291+
{ id: 'file:save-federation-setup', label: 'Save Federation Setup', keywords: 'federation setup save export portable models order alignment anchor', category: 'File', icon: Save, action: () => { window.dispatchEvent(new CustomEvent('ifc-lite:save-federation-setup')); } },
292+
{ id: 'file:open-federation-setup', label: 'Open Federation Setup', keywords: 'federation setup restore reopen import portable models order alignment anchor', category: 'File', icon: FolderOpen, immediate: true, action: () => { window.dispatchEvent(new CustomEvent('ifc-lite:open-federation-setup')); } },
290293
);
291294
for (const rf of recentFiles) {
292295
const fileName = rf.name;
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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+
* Save/reopen a portable federation setup (#3930).
7+
*
8+
* Mounted once from `useFileCommands` (same pattern as `ShareDialog`), so it
9+
* is reachable from the command palette regardless of which toolbar style is
10+
* active. Two hidden `<input type="file">`s drive the two-step "open" flow
11+
* (pick the saved `.federation.json`, then pick the local model files to
12+
* match against it); the review step never applies anything silently — every
13+
* slot is shown as matched, mismatched (same name, different content/size),
14+
* or missing before the user confirms.
15+
*/
16+
17+
import { useCallback, useEffect, useRef, useState } from 'react';
18+
import { AlertTriangle, Anchor, Check, FileWarning } from 'lucide-react';
19+
import {
20+
Dialog,
21+
DialogContent,
22+
DialogDescription,
23+
DialogFooter,
24+
DialogHeader,
25+
DialogTitle,
26+
} from '@/components/ui/dialog';
27+
import { Button } from '@/components/ui/button';
28+
import { toast } from '@/components/ui/toast';
29+
import { useFederationSetup } from '@/hooks/useFederationSetup';
30+
import {
31+
parseFederationSetupFile,
32+
type FederationSetupFile,
33+
type FederationSetupSlotMatch,
34+
} from '@/lib/federation/federationSetupFile';
35+
36+
const EVENT_SAVE = 'ifc-lite:save-federation-setup';
37+
const EVENT_OPEN = 'ifc-lite:open-federation-setup';
38+
39+
function confidenceBadge(match: FederationSetupSlotMatch): { label: string; icon: typeof Check; tone: string } {
40+
switch (match.confidence) {
41+
case 'content':
42+
return { label: 'Matched', icon: Check, tone: 'text-emerald-600 dark:text-emerald-400' };
43+
case 'name-size':
44+
return { label: 'Matched (by name)', icon: Check, tone: 'text-emerald-600 dark:text-emerald-400' };
45+
case 'name-only':
46+
return { label: 'Same name, different file', icon: FileWarning, tone: 'text-amber-600 dark:text-amber-400' };
47+
case 'none':
48+
return { label: 'Missing', icon: AlertTriangle, tone: 'text-red-600 dark:text-red-400' };
49+
}
50+
}
51+
52+
export function FederationSetupControls() {
53+
const { exportFederationSetup, matchFederationSetup, applyFederationSetup } = useFederationSetup();
54+
55+
const setupFileInputRef = useRef<HTMLInputElement>(null);
56+
const modelFilesInputRef = useRef<HTMLInputElement>(null);
57+
const [pendingSetup, setPendingSetup] = useState<FederationSetupFile | null>(null);
58+
const [matches, setMatches] = useState<FederationSetupSlotMatch[] | null>(null);
59+
const [applying, setApplying] = useState(false);
60+
61+
const handleSave = useCallback(() => {
62+
void exportFederationSetup().then((result) => {
63+
if (!result.ok) toast.error(result.error);
64+
else toast.success('Federation setup saved.');
65+
});
66+
}, [exportFederationSetup]);
67+
68+
useEffect(() => {
69+
const onSave = () => handleSave();
70+
const onOpen = () => setupFileInputRef.current?.click();
71+
window.addEventListener(EVENT_SAVE, onSave);
72+
window.addEventListener(EVENT_OPEN, onOpen);
73+
return () => {
74+
window.removeEventListener(EVENT_SAVE, onSave);
75+
window.removeEventListener(EVENT_OPEN, onOpen);
76+
};
77+
}, [handleSave]);
78+
79+
const handleSetupFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
80+
const file = e.target.files?.[0];
81+
e.target.value = '';
82+
if (!file) return;
83+
void file.text().then((text) => {
84+
const result = parseFederationSetupFile(text);
85+
if (!result.ok) {
86+
toast.error(`Not a valid federation setup file: ${result.error}`);
87+
return;
88+
}
89+
setPendingSetup(result.setup);
90+
toast.info(
91+
`Loaded a setup with ${result.setup.slots.length} model slot${result.setup.slots.length === 1 ? '' : 's'} — now pick the model file(s) to match against it.`,
92+
);
93+
modelFilesInputRef.current?.click();
94+
});
95+
}, []);
96+
97+
const handleModelFilesSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
98+
const files = Array.from(e.target.files ?? []);
99+
e.target.value = '';
100+
if (!pendingSetup || files.length === 0) return;
101+
void matchFederationSetup(pendingSetup, files).then(setMatches);
102+
}, [pendingSetup, matchFederationSetup]);
103+
104+
const closeReview = useCallback(() => {
105+
setMatches(null);
106+
setPendingSetup(null);
107+
}, []);
108+
109+
const handleApply = useCallback(() => {
110+
if (!matches) return;
111+
setApplying(true);
112+
void applyFederationSetup(matches).then((result) => {
113+
setApplying(false);
114+
closeReview();
115+
if (result.outcome === 'failed') {
116+
toast.error('Could not restore the federation setup — none of the saved models were found.');
117+
return;
118+
}
119+
const parts: string[] = [`Restored ${result.restoredCount}/${result.totalSlots} model(s)`];
120+
if (result.missingSlots.length > 0) parts.push(`missing: ${result.missingSlots.join(', ')}`);
121+
if (result.mismatchedSlots.length > 0) parts.push(`same name, different content: ${result.mismatchedSlots.join(', ')}`);
122+
if (result.anchorMissing) parts.push('alignment anchor could not be restored');
123+
const message = parts.join(' — ');
124+
if (result.outcome === 'restored' && !result.anchorMissing) toast.success(message);
125+
else toast.error(message);
126+
});
127+
}, [matches, applyFederationSetup, closeReview]);
128+
129+
return (
130+
<>
131+
<input
132+
ref={setupFileInputRef}
133+
type="file"
134+
accept=".json,application/json"
135+
className="hidden"
136+
onChange={handleSetupFileSelected}
137+
/>
138+
<input
139+
ref={modelFilesInputRef}
140+
type="file"
141+
multiple
142+
className="hidden"
143+
onChange={handleModelFilesSelected}
144+
/>
145+
<Dialog open={matches !== null} onOpenChange={(open) => { if (!open) closeReview(); }}>
146+
<DialogContent>
147+
<DialogHeader>
148+
<DialogTitle>Reopen federation setup</DialogTitle>
149+
<DialogDescription>
150+
Review how each saved model slot matched your local files before restoring.
151+
</DialogDescription>
152+
</DialogHeader>
153+
<div className="max-h-80 overflow-y-auto space-y-1">
154+
{matches?.map((match) => {
155+
const badge = confidenceBadge(match);
156+
const Icon = badge.icon;
157+
return (
158+
<div
159+
key={match.slotIndex}
160+
className="flex items-center gap-2 px-2 py-1.5 border border-zinc-200 dark:border-zinc-800 rounded text-sm"
161+
>
162+
{match.slot.anchor && <Anchor className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 shrink-0" />}
163+
<span className="truncate flex-1">{match.slot.name}</span>
164+
<span className={`inline-flex items-center gap-1 text-xs ${badge.tone}`}>
165+
<Icon className="h-3.5 w-3.5" />
166+
{badge.label}
167+
</span>
168+
</div>
169+
);
170+
})}
171+
</div>
172+
<DialogFooter>
173+
<Button variant="outline" onClick={closeReview} disabled={applying}>Cancel</Button>
174+
<Button
175+
onClick={handleApply}
176+
disabled={applying || !matches?.some((m) => m.file !== null)}
177+
>
178+
{applying ? 'Restoring…' : 'Restore federation'}
179+
</Button>
180+
</DialogFooter>
181+
</DialogContent>
182+
</Dialog>
183+
</>
184+
);
185+
}
186+
187+
export default FederationSetupControls;

apps/viewer/src/components/viewer/toolbar/useFileCommands.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { toast } from '@/components/ui/toast';
2424
import { isCollabEnabled } from '@/lib/collab/config';
2525
import { ingestDxfFiles, splitDxfFiles } from '@/hooks/ingest/dxfIngest';
2626
import { ShareDialog } from '../ShareDialog';
27+
import { FederationSetupControls } from '../FederationSetupControls';
2728

2829
import { FILE_ACCEPT, isSupportedModelFile } from '@/services/supported-model-files';
2930

@@ -374,6 +375,7 @@ export function useFileCommands(): FileCommands {
374375
className="hidden"
375376
/>
376377
{collabEnabled && <ShareDialog open={shareDialogOpen} onOpenChange={setShareDialogOpen} />}
378+
<FederationSetupControls />
377379
</>
378380
);
379381

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
* Save and reopen a portable federation setup (issue #3930).
7+
*
8+
* Thin glue between the pure logic in `lib/federation/federationSetupFile.ts`
9+
* and the live viewer store / canonical load path (`useIfcFederation.addModel`,
10+
* `realignFederation`). See that module's header for what the saved file
11+
* references versus embeds, and how alignment is replayed rather than baked in.
12+
*/
13+
14+
import { useCallback } from 'react';
15+
import { useShallow } from 'zustand/react/shallow';
16+
import { useViewerStore, type FederatedModel } from '../store/index.js';
17+
import { useIfc } from './useIfc.js';
18+
import { findReferenceGeorefModel } from './ingest/federationAlign.js';
19+
import {
20+
buildFederationSetupFile,
21+
serializeFederationSetupFile,
22+
matchFederationSetupSlots,
23+
summarizeFederationSetupMatches,
24+
type FederationSetupFile,
25+
type FederationSetupSlotMatch,
26+
} from '../lib/federation/federationSetupFile.js';
27+
import { downloadFile, sanitizeFilename } from '../lib/export/download.js';
28+
29+
/** Result of applying a federation setup — always distinguishes full vs. partial vs. failed restore. */
30+
export interface FederationSetupApplyResult {
31+
outcome: 'restored' | 'partial' | 'failed';
32+
/** Models actually (re-)loaded, in the order they were loaded. */
33+
restoredCount: number;
34+
totalSlots: number;
35+
/** Slots for which no local file could be found. */
36+
missingSlots: string[];
37+
/** Slots matched only by filename, with different size/content than saved. */
38+
mismatchedSlots: string[];
39+
/** True when the saved anchor's model was restored and re-alignment ran. */
40+
anchorRestored: boolean;
41+
/** True when the setup had a saved anchor but that model could not be restored. */
42+
anchorMissing: boolean;
43+
}
44+
45+
export function useFederationSetup() {
46+
const { addModel, realignFederation } = useIfc();
47+
const { anchorModelIdOverride, setAnchorModelIdOverride } = useViewerStore(
48+
useShallow((s) => ({
49+
anchorModelIdOverride: s.anchorModelIdOverride,
50+
setAnchorModelIdOverride: s.setAnchorModelIdOverride,
51+
})),
52+
);
53+
54+
/** Build and download the current federation as a portable setup file. Read-only — never mutates the store. */
55+
const exportFederationSetup = useCallback(async (): Promise<{ ok: true } | { ok: false; error: string }> => {
56+
const state = useViewerStore.getState();
57+
const models = Array.from(state.models.values()) as FederatedModel[]; // Map insertion order = load order.
58+
if (models.length === 0) {
59+
return { ok: false, error: 'No models loaded — nothing to save.' };
60+
}
61+
const reference = findReferenceGeorefModel();
62+
const anchorModelId = reference?.modelId ?? anchorModelIdOverride ?? null;
63+
64+
const setup = await buildFederationSetupFile(models, anchorModelId);
65+
const json = serializeFederationSetupFile(setup);
66+
const stem = models.length === 1
67+
? sanitizeFilename(models[0].name, { fallback: 'federation' })
68+
: `federation-setup-${models.length}-models`;
69+
downloadFile(json, `${sanitizeFilename(stem, { fallback: 'federation' })}.federation.json`, 'application/json;charset=utf-8;');
70+
return { ok: true };
71+
}, [anchorModelIdOverride]);
72+
73+
/** Match saved slots to freshly-picked local files, without applying anything yet (for the review step). */
74+
const matchFederationSetup = useCallback(
75+
(setup: FederationSetupFile, files: readonly File[]): Promise<FederationSetupSlotMatch[]> =>
76+
matchFederationSetupSlots(setup, files),
77+
[],
78+
);
79+
80+
/**
81+
* Load every resolvable (matched or name-only) slot through the ONE canonical
82+
* load path (`addModel` -> `loadFile`), in saved order, then restore the
83+
* anchor and re-run alignment. Never silently drops a slot: the return value
84+
* always states exactly how many restored, which were missing, and whether
85+
* the anchor could be restored.
86+
*/
87+
const applyFederationSetup = useCallback(
88+
async (matches: readonly FederationSetupSlotMatch[]): Promise<FederationSetupApplyResult> => {
89+
const summary = summarizeFederationSetupMatches(matches);
90+
const loadable = matches.filter((m) => m.file !== null);
91+
92+
let restoredCount = 0;
93+
let restoredAnchorModelId: string | null = null;
94+
const hadAnchorSlot = matches.some((m) => m.slot.anchor);
95+
96+
// Sequential, in saved order — mirrors loadFilesSequentially (the WASM
97+
// parser isn't thread-safe) and preserves the saved federation's
98+
// relative model order even when some slots are missing.
99+
for (const match of loadable) {
100+
if (!match.file) continue;
101+
const modelId = await addModel(match.file, {
102+
name: match.slot.name,
103+
visible: match.slot.visible,
104+
collapsed: match.slot.collapsed,
105+
});
106+
if (modelId) {
107+
restoredCount += 1;
108+
if (match.slot.anchor) restoredAnchorModelId = modelId;
109+
}
110+
}
111+
112+
let anchorRestored = false;
113+
if (restoredAnchorModelId) {
114+
setAnchorModelIdOverride(restoredAnchorModelId);
115+
await realignFederation();
116+
anchorRestored = true;
117+
}
118+
119+
const outcome: FederationSetupApplyResult['outcome'] =
120+
restoredCount === 0 ? 'failed' : restoredCount === matches.length ? 'restored' : 'partial';
121+
122+
return {
123+
outcome,
124+
restoredCount,
125+
totalSlots: matches.length,
126+
missingSlots: summary.missing.map((m) => m.slot.name),
127+
mismatchedSlots: summary.mismatched.map((m) => m.slot.name),
128+
anchorRestored,
129+
anchorMissing: hadAnchorSlot && !anchorRestored,
130+
};
131+
},
132+
[addModel, realignFederation, setAnchorModelIdOverride],
133+
);
134+
135+
return { exportFederationSetup, matchFederationSetup, applyFederationSetup };
136+
}
137+
138+
export default useFederationSetup;

0 commit comments

Comments
 (0)