// Ausschnitte / View-Snapshots — reine Erfassungs-/Wiederherstellungslogik. // // Ein „Ausschnitt" (View-Snapshot, DOSSIER A2) ist eine benannte, gespeicherte // Ansicht, die den kompletten Darstellungszustand einfängt und wiederherstellt. // KERN-PRINZIP: nichts neu erfinden — die Bausteine existieren schon (View- // State, Sichtbarkeits-Snapshots wie `LayerCombo`/`DrawingCombo`, Overrides, // aktives Geschoss) und werden hier nur KOMPONIERT + benannt gebündelt. // // Dieses Modul hält die eigentliche Logik als PURE Funktionen (testbar, ohne // React/Store): Capture aus einem Zustandsobjekt → ViewSnapshot, und Read eines // ViewSnapshots → wiederherstellbarer Kernzustand (den das Wiring in App.tsx auf // die konkreten Setter abbildet). Das Setter-/rAF-Wiring bleibt in App.tsx. // // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). import type { OverrideRule, ViewSnapshot } from "../model/types"; import type { DetailLevel, View3d, ViewType } from "../ui/TopBar"; /** * Der wiederherstellbare KERNZUSTAND eines Ausschnitts — alle Felder, die ein * Snapshot einfängt, OHNE Meta (`id`/`name`/`folder`). Sowohl `captureViewSnapshot` * (Eingang) als auch `readViewSnapshot` (Ausgang) sprechen diese Form; damit ist * ein sauberer Roundtrip (capture → read) trivial prüfbar. */ export interface ViewSnapshotState { viewType: ViewType; view3d: View3d; fov: number; scaleDenominator: number; detail: DetailLevel; activeLevelId: string; layerVisibility: Record; drawingVisibility: Record; /** Ids der aktiven (`enabled`) Override-Regeln zum Erfassungszeitpunkt. */ enabledOverrideRuleIds: string[]; } /** * Baut aus einem Kernzustand einen benannten Ausschnitt. Pure — `id`/`name` * kommen vom Aufrufer (App erzeugt die Id, der Nutzer den Namen); die * Sichtbarkeits-/Overrides-Maps werden defensiv kopiert, damit der Snapshot vom * Live-Zustand entkoppelt ist. `folder` optional (Phase 1: flach). */ export function captureViewSnapshot( id: string, name: string, state: ViewSnapshotState, folder?: string, ): ViewSnapshot { const snap: ViewSnapshot = { id, name, viewType: state.viewType, view3d: state.view3d, fov: state.fov, scaleDenominator: state.scaleDenominator, detail: state.detail, activeLevelId: state.activeLevelId, layerVisibility: { ...state.layerVisibility }, drawingVisibility: { ...state.drawingVisibility }, enabledOverrideRuleIds: [...state.enabledOverrideRuleIds], }; if (folder && folder.trim()) snap.folder = folder.trim(); return snap; } /** * Liest den wiederherstellbaren Kernzustand aus einem Ausschnitt. Fehlende * optionale Felder (aus hand-/altangelegten Snapshots) werden defensiv * aufgefüllt (`fov` → 50, `enabledOverrideRuleIds` → []). Die Sichtbarkeits-Maps * werden kopiert, damit der Aufrufer sie gefahrlos weiterreichen kann. */ export function readViewSnapshot(snap: ViewSnapshot): ViewSnapshotState { return { viewType: snap.viewType, view3d: snap.view3d, fov: snap.fov ?? 50, scaleDenominator: snap.scaleDenominator, detail: snap.detail, activeLevelId: snap.activeLevelId, layerVisibility: { ...snap.layerVisibility }, drawingVisibility: { ...snap.drawingVisibility }, enabledOverrideRuleIds: snap.enabledOverrideRuleIds ? [...snap.enabledOverrideRuleIds] : [], }; } /** Deep-Equal zweier `Record`-Maps: gleiche Keys UND Werte. */ function boolMapEqual( a: Record, b: Record, ): boolean { const ka = Object.keys(a); const kb = Object.keys(b); if (ka.length !== kb.length) return false; for (const k of ka) { if (a[k] !== b[k]) return false; } return true; } /** Mengengleichheit zweier Id-Listen (Reihenfolge egal, Duplikate ignoriert). */ function idSetEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; const sa = new Set(a); for (const id of b) { if (!sa.has(id)) return false; } return sa.size === new Set(b).size; } /** * Prüft, ob ein Ausschnitt-Kernzustand EXAKT dem aktuellen Live-Zustand * entspricht — Grundlage der „angewählt bleibt bis zur Abweichung"-Logik im * Ausschnitte-Panel. Vergleicht ALLE erfassten Felder feldweise (Enums/Zahlen * strikt, Sichtbarkeits-Maps deep-equal, Override-Ids als Menge). Weicht auch * nur EIN Feld ab → `false`. Pure/testbar; der Aufrufer (App.tsx) normalisiert * den gespeicherten Snapshot vorher über `readViewSnapshot`, damit fehlende * optionale Felder (fov/overrides) fair verglichen werden. */ export function snapshotMatchesLiveState( snap: ViewSnapshotState, live: ViewSnapshotState, ): boolean { return ( snap.viewType === live.viewType && snap.view3d === live.view3d && snap.fov === live.fov && snap.scaleDenominator === live.scaleDenominator && snap.detail === live.detail && snap.activeLevelId === live.activeLevelId && boolMapEqual(snap.layerVisibility, live.layerVisibility) && boolMapEqual(snap.drawingVisibility, live.drawingVisibility) && idSetEqual(snap.enabledOverrideRuleIds, live.enabledOverrideRuleIds) ); } /** * Setzt bei jeder Override-Regel `enabled = enabledIds.includes(rule.id)` * (immutabel). Reproduziert die Aktiv-Menge, die ein Ausschnitt gespeichert hat. * Regeln, die es beim Erfassen noch nicht gab, bleiben unberührt deaktiviert; * inzwischen gelöschte Regel-Ids werden schlicht ignoriert. Liefert eine NEUE * Liste nur, wenn sich etwas ändert (sonst dieselbe Referenz → No-Op-freundlich * für den setProject-History-Schutz). */ export function applyOverrideRuleEnabled( rules: OverrideRule[], enabledIds: string[], ): OverrideRule[] { const active = new Set(enabledIds); let changed = false; const next = rules.map((r) => { const enabled = active.has(r.id); if (enabled === r.enabled) return r; changed = true; return { ...r, enabled }; }); return changed ? next : rules; }