/* 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/. */ /** * Federation alignment controls — surfaces per-model alignment status, anchor * selection, and an explicit "Re-align federation" action. * * Lives inside GeoreferencingPanel (one instance per model). When only one * model is loaded the controls are hidden — alignment is a federation concept. */ import { useCallback, useMemo, useState } from 'react'; import { Anchor, RefreshCw, AlertTriangle, Check } from 'lucide-react'; import { useViewerStore } from '@/store'; import { useIfc } from '@/hooks/useIfc'; import { toast } from '@/components/ui/toast'; import { getEffectiveGeoreference } from '@/lib/geo/effective-georef'; import type { IfcDataStore } from '@ifc-lite/parser'; import type { FederatedModel } from '@/store/types'; export interface FederationAlignmentControlsProps { modelId: string; } function statusLabel(status: FederatedModel['federationAlignmentStatus']): { text: string; tone: 'anchor' | 'ok' | 'warn' | 'neutral'; icon: typeof Check; } { switch (status) { case 'anchor': return { text: 'Federation anchor', tone: 'anchor', icon: Anchor }; case 'same-crs': return { text: 'Aligned (same CRS)', tone: 'ok', icon: Check }; case 'reprojected': return { text: 'Reprojected to anchor CRS', tone: 'ok', icon: Check }; case 'identity': return { text: 'Aligned (identity)', tone: 'ok', icon: Check }; case 'failed': return { text: 'Alignment failed', tone: 'warn', icon: AlertTriangle }; case 'none': case undefined: return { text: 'Not aligned', tone: 'neutral', icon: Anchor }; } } const toneClasses = { anchor: 'border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/40 text-amber-800 dark:text-amber-200', ok: 'border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-200', warn: 'border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950/40 text-red-800 dark:text-red-200', neutral: 'border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900 text-zinc-600 dark:text-zinc-400', } as const; export function FederationAlignmentControls({ modelId }: FederationAlignmentControlsProps) { const models = useViewerStore((s) => s.models); const anchorModelIdOverride = useViewerStore((s) => s.anchorModelIdOverride); const setAnchorModelIdOverride = useViewerStore((s) => s.setAnchorModelIdOverride); const georefMutations = useViewerStore((s) => s.georefMutations); useViewerStore((s) => s.mutationVersion); const { realignFederation } = useIfc(); const [busy, setBusy] = useState(false); // The "effective anchor" matches findReferenceGeorefModel in useIfcFederation: // honour the override if it points to a model with a valid (non-site) georef, // otherwise pick the earliest-loaded model with one. Federation status alone // is not enough — a model can be loaded standalone with status='none' and // would otherwise show up as a fake anchor in the badge. const hasValidGeoref = useCallback( (model: FederatedModel | undefined): boolean => { if (!model?.ifcDataStore) return false; const eff = getEffectiveGeoreference( model.ifcDataStore as IfcDataStore, model.geometryResult?.coordinateInfo, georefMutations.get(model.id), ); return Boolean( eff?.projectedCRS?.name && eff.mapConversion && eff.source !== 'siteLocation', ); }, [georefMutations], ); const effectiveAnchorId = useMemo(() => { if (anchorModelIdOverride && hasValidGeoref(models.get(anchorModelIdOverride))) { return anchorModelIdOverride; } const sorted = Array.from(models.entries()).sort( ([, a], [, b]) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0), ); for (const [id, model] of sorted) { if (hasValidGeoref(model)) return id; } return null; }, [models, anchorModelIdOverride, hasValidGeoref]); const thisModel = models.get(modelId); if (!thisModel) return null; if (models.size < 2) return null; const isAnchor = effectiveAnchorId === modelId; const status: FederatedModel['federationAlignmentStatus'] = isAnchor ? 'anchor' : thisModel.federationAlignmentStatus ?? 'none'; const badge = statusLabel(status); const Icon = badge.icon; const handleSetAnchor = useCallback(() => { setAnchorModelIdOverride(modelId); }, [modelId, setAnchorModelIdOverride]); const handleClearAnchor = useCallback(() => { setAnchorModelIdOverride(null); }, [setAnchorModelIdOverride]); const handleRealign = useCallback(async () => { setBusy(true); try { await realignFederation(); } catch (error) { // realignFederation's happy path uses toast for per-model status; if // the orchestrator itself throws (e.g. proj4 grid loader rejects), the // async click handler would otherwise surface an unhandled rejection // with no user feedback. Catch + log + toast so the failure mode is // visible and the spinner clears. console.error('[FederationAlignmentControls] re-align failed:', error); toast.error( error instanceof Error ? `Re-align failed: ${error.message}` : 'Re-align failed.', ); } finally { setBusy(false); } }, [realignFederation]); return (
{badge.text}
{!isAnchor && ( )} {isAnchor && anchorModelIdOverride === modelId && ( )}
); }