Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.
Enthalten (Details in PENDENZEN.md ✅-Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
Prioritäts-Verschneidung (section_boolean.rs), einstellbare
Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
+11
-3
@@ -19,6 +19,8 @@ import { createLayoutSlice } from "./layoutSlice";
|
||||
import type { LayoutSlice } from "./layoutSlice";
|
||||
import { createSiteSlice } from "./siteSlice";
|
||||
import type { SiteSlice } from "./siteSlice";
|
||||
import { createNotifySlice } from "./notifySlice";
|
||||
import type { NotifySlice } from "./notifySlice";
|
||||
|
||||
/** Gesamtzustand: Vereinigung aller Slice-Felder + Actions. */
|
||||
export type RootState = ProjectSlice &
|
||||
@@ -26,17 +28,23 @@ export type RootState = ProjectSlice &
|
||||
SelectionSlice &
|
||||
ViewSlice &
|
||||
LayoutSlice &
|
||||
SiteSlice;
|
||||
SiteSlice &
|
||||
NotifySlice;
|
||||
|
||||
const store = createStore<RootState>((api) => ({
|
||||
// Jede Slice-Factory bekommt die volle RootState-API. Die Slices tippen nur
|
||||
// ihren eigenen Ausschnitt; die Cast auf die enge Slice-API ist sicher, weil
|
||||
// `set/get` immer über den Gesamt-RootState laufen. ProjectSlice bringt die
|
||||
// History (undo/redo) gleich mit (setProject ist der einzige Durchlauf-
|
||||
// punkt jeder Projekt-Mutation, siehe projectSlice.ts).
|
||||
// punkt jeder Projekt-Mutation, siehe projectSlice.ts) und braucht `notify`
|
||||
// (Cross-Slice) für die Lösch-Schutz-Warnungen (statt window.alert, siehe
|
||||
// notifySlice.ts).
|
||||
...createProjectSlice(
|
||||
api as unknown as StoreApi<ProjectSlice & HistorySlice & { activeLevelId: string }>,
|
||||
api as unknown as StoreApi<
|
||||
ProjectSlice & HistorySlice & { activeLevelId: string } & Pick<NotifySlice, "notify">
|
||||
>,
|
||||
),
|
||||
...createNotifySlice(api as unknown as StoreApi<NotifySlice>),
|
||||
...createSelectionSlice(api as unknown as StoreApi<SelectionSlice>),
|
||||
...createViewSlice(api as unknown as StoreApi<ViewSlice>),
|
||||
...createLayoutSlice(api as unknown as StoreApi<LayoutSlice>),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Notify-Slice (`src/state/notifySlice.ts`) — nicht-blockierende
|
||||
* Kurzmeldungen (Ersatz für `window.alert`, das im Tauri-WKWebView deaktiviert
|
||||
* ist). Getestet wird nur der reine State (Hinzufügen/Entfernen); der
|
||||
* Auto-Dismiss-Timer lebt bewusst in der Toast-Komponente (src/ui/Toast.tsx),
|
||||
* nicht in der Slice, und ist hier daher kein Thema.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { getState, setState } from "./appStore";
|
||||
|
||||
beforeEach(() => {
|
||||
setState({ notifications: [] });
|
||||
});
|
||||
|
||||
describe("notifySlice", () => {
|
||||
it("notify fügt eine Meldung hinzu (Default-Art: info)", () => {
|
||||
getState().notify("Hallo");
|
||||
|
||||
const notes = getState().notifications;
|
||||
expect(notes.length).toBe(1);
|
||||
expect(notes[0].message).toBe("Hallo");
|
||||
expect(notes[0].kind).toBe("info");
|
||||
expect(notes[0].id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("notify übernimmt die übergebene Art", () => {
|
||||
getState().notify("Achtung", "warning");
|
||||
expect(getState().notifications[0].kind).toBe("warning");
|
||||
});
|
||||
|
||||
it("mehrere notify()-Aufrufe erzeugen unterschiedliche ids, in Reihenfolge angehängt", () => {
|
||||
getState().notify("A");
|
||||
getState().notify("B");
|
||||
|
||||
const notes = getState().notifications;
|
||||
expect(notes.map((n) => n.message)).toEqual(["A", "B"]);
|
||||
expect(new Set(notes.map((n) => n.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("dismissNotify entfernt genau die Meldung mit der gegebenen id", () => {
|
||||
getState().notify("A");
|
||||
getState().notify("B");
|
||||
const [first, second] = getState().notifications;
|
||||
|
||||
getState().dismissNotify(first.id);
|
||||
|
||||
const remaining = getState().notifications;
|
||||
expect(remaining.length).toBe(1);
|
||||
expect(remaining[0].id).toBe(second.id);
|
||||
});
|
||||
|
||||
it("dismissNotify bei unbekannter id ist ein No-Op", () => {
|
||||
getState().notify("A");
|
||||
const before = getState().notifications;
|
||||
|
||||
getState().dismissNotify("unbekannte-id");
|
||||
|
||||
expect(getState().notifications).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Notify-Slice: nicht-blockierende Kurzmeldungen (Toasts). Ersetzt
|
||||
// `window.alert`, das im Tauri-WKWebView deaktiviert ist (tut nichts) und
|
||||
// Nutzer-Warnungen (z. B. „X wird noch verwendet") lautlos verschluckte.
|
||||
//
|
||||
// Reiner Zustand + zwei Actions — das Rendern (gestapelter Container) UND der
|
||||
// Auto-Dismiss-Timer leben bewusst in der Toast-Komponente
|
||||
// (src/ui/Toast.tsx), nicht hier: ein Timer pro GEMOUNTETER Meldung ist
|
||||
// robuster als einer pro `notify()`-Aufruf (kein doppeltes Aufräumen bei
|
||||
// Store-Reload/Tests, kein Timer, der eine Komponente überlebt, die es nie zu
|
||||
// sehen bekam). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { StoreApi } from "./store";
|
||||
|
||||
/** Art der Meldung — steuert nur den Farbakzent im Toast. */
|
||||
export type NotifyKind = "info" | "warning" | "error";
|
||||
|
||||
/** Eine einzelne Toast-Meldung. */
|
||||
export interface Notification {
|
||||
id: string;
|
||||
message: string;
|
||||
kind: NotifyKind;
|
||||
}
|
||||
|
||||
/** Felder/Actions, die diese Slice in den RootState beisteuert. */
|
||||
export interface NotifySlice {
|
||||
notifications: Notification[];
|
||||
/** Fügt eine Meldung mit eindeutiger id hinzu (Default-Art: "info"). */
|
||||
notify: (message: string, kind?: NotifyKind) => void;
|
||||
/** Entfernt eine Meldung per id (No-op bei unbekannter id). */
|
||||
dismissNotify: (id: string) => void;
|
||||
}
|
||||
|
||||
// Monoton wachsender Zähler zusätzlich zum Zeitstempel: verhindert doppelte
|
||||
// ids, falls mehrere `notify()`-Aufrufe innerhalb derselben Millisekunde
|
||||
// passieren (z. B. mehrere Löschversuche in einer Schleife).
|
||||
let counter = 0;
|
||||
function nextId(): string {
|
||||
counter += 1;
|
||||
return `notify-${Date.now()}-${counter}`;
|
||||
}
|
||||
|
||||
export function createNotifySlice(api: StoreApi<NotifySlice>): NotifySlice {
|
||||
const { set } = api;
|
||||
|
||||
return {
|
||||
notifications: [],
|
||||
|
||||
notify: (message, kind = "info") =>
|
||||
set((s) => ({
|
||||
notifications: [...s.notifications, { id: nextId(), message, kind }],
|
||||
})),
|
||||
|
||||
dismissNotify: (id) =>
|
||||
set((s) => ({
|
||||
notifications: s.notifications.filter((n) => n.id !== id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { t } from "../i18n";
|
||||
import type { StoreApi } from "./store";
|
||||
import { createHistorySlice, pushHistoryPatch } from "./historySlice";
|
||||
import type { HistorySlice } from "./historySlice";
|
||||
import type { NotifyKind } from "./notifySlice";
|
||||
|
||||
/** Felder, die diese Slice in den RootState beisteuert. */
|
||||
export interface ProjectSlice {
|
||||
@@ -286,6 +287,9 @@ export interface ProjectSlice {
|
||||
*/
|
||||
type ProjectSliceDeps = {
|
||||
activeLevelId: string;
|
||||
/** Nicht-blockierende Kurzmeldung (Toast) — Ersatz für `window.alert`, das
|
||||
* im Tauri-WKWebView deaktiviert ist (siehe notifySlice.ts). */
|
||||
notify: (message: string, kind?: NotifyKind) => void;
|
||||
} & HistorySlice;
|
||||
|
||||
export function createProjectSlice(
|
||||
@@ -579,7 +583,7 @@ export function createProjectSlice(
|
||||
codes.has(o.categoryCode),
|
||||
);
|
||||
if (usedByWall || usedByDoor || usedByOpening) {
|
||||
window.alert(t("alert.layerInUse", { code: src.code, name: src.name }));
|
||||
get().notify(t("alert.layerInUse", { code: src.code, name: src.name }), "warning");
|
||||
return p;
|
||||
}
|
||||
return { ...p, layers: removeByCode(p.layers, code) };
|
||||
@@ -664,7 +668,7 @@ export function createProjectSlice(
|
||||
);
|
||||
if (used) {
|
||||
const name = p.components.find((c) => c.id === id)?.name ?? id;
|
||||
window.alert(t("alert.componentInUse", { name }));
|
||||
get().notify(t("alert.componentInUse", { name }), "warning");
|
||||
return p;
|
||||
}
|
||||
return { ...p, components: p.components.filter((c) => c.id !== id) };
|
||||
@@ -695,7 +699,7 @@ export function createProjectSlice(
|
||||
const used = p.components.some((c) => c.hatchId === id);
|
||||
if (used) {
|
||||
const name = p.hatches.find((h) => h.id === id)?.name ?? id;
|
||||
window.alert(t("alert.hatchInUse", { name }));
|
||||
get().notify(t("alert.hatchInUse", { name }), "warning");
|
||||
return p;
|
||||
}
|
||||
return { ...p, hatches: p.hatches.filter((h) => h.id !== id) };
|
||||
@@ -726,7 +730,7 @@ export function createProjectSlice(
|
||||
const used = p.hatches.some((h) => h.lineStyleId === id);
|
||||
if (used) {
|
||||
const name = p.lineStyles.find((l) => l.id === id)?.name ?? id;
|
||||
window.alert(t("alert.lineStyleInUse", { name }));
|
||||
get().notify(t("alert.lineStyleInUse", { name }), "warning");
|
||||
return p;
|
||||
}
|
||||
return { ...p, lineStyles: p.lineStyles.filter((l) => l.id !== id) };
|
||||
@@ -1373,6 +1377,8 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
|
||||
{ x: g.min.x, y: g.max.y },
|
||||
];
|
||||
}
|
||||
// Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip).
|
||||
if (g.shape === "text") return [g.at];
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1412,6 +1418,8 @@ function moveGrip(
|
||||
const max = { x: Math.max(pt.x, opp.x), y: Math.max(pt.y, opp.y) };
|
||||
return { ...d, geom: { ...g, min, max } };
|
||||
}
|
||||
// Text: der einzige Griff (Index 0) sitzt am Ankerpunkt → verschiebt ihn.
|
||||
if (g.shape === "text") return { ...d, geom: { ...g, at: pt } };
|
||||
return d;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Unit-Tests für projectStore.ts — Guard-Verhalten ohne IndexedDB.
|
||||
//
|
||||
// vitest läuft mit environment: "node" (siehe vitest.config.ts); dort
|
||||
// existiert `indexedDB` nicht, und `fake-indexeddb` ist keine devDependency
|
||||
// dieses Repos. Getestet wird daher primär, dass die API auch ohne
|
||||
// IndexedDB sauber degradiert (siehe CONVENTIONS/Auftrag): `loadProject`
|
||||
// liefert `null`, `listProjects` liefert `[]`, `saveProject`/`deleteProject`
|
||||
// werfen nicht.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { saveProject, loadProject, listProjects, deleteProject } from "./projectStore";
|
||||
import type { Project } from "../model/types";
|
||||
|
||||
/** Minimalprojekt (analog model/wall.test.ts) — genügt der Project-Form. */
|
||||
function makeProject(overrides?: Partial<Project>): Project {
|
||||
return {
|
||||
id: "t",
|
||||
name: "T",
|
||||
lineStyles: [],
|
||||
hatches: [],
|
||||
components: [],
|
||||
wallTypes: [],
|
||||
drawingLevels: [],
|
||||
layers: [],
|
||||
walls: [],
|
||||
doors: [],
|
||||
...overrides,
|
||||
} as Project;
|
||||
}
|
||||
|
||||
describe("projectStore — Guard ohne IndexedDB", () => {
|
||||
it("loadProject liefert null", async () => {
|
||||
await expect(loadProject("irgendein-projekt")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("listProjects liefert leere Liste", async () => {
|
||||
await expect(listProjects()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("saveProject wirft nicht (no-op)", async () => {
|
||||
await expect(saveProject("p1", makeProject())).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("deleteProject wirft nicht (no-op)", async () => {
|
||||
await expect(deleteProject("p1")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("loadProject mit leerem Namen liefert null, ohne indexedDB zu berühren", async () => {
|
||||
await expect(loadProject("")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("saveProject mit leerem Namen ist ein No-op", async () => {
|
||||
await expect(saveProject("", makeProject())).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("deleteProject mit leerem Namen ist ein No-op", async () => {
|
||||
await expect(deleteProject("")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// Projekt-Persistenz — IndexedDB (Browser-nativer Speicher-Kern).
|
||||
//
|
||||
// Speichert vollständige Projekte (inkl. Meshes/grosser JSON-Nutzlast) lokal
|
||||
// im Browser. Analog zu visibilitySets.ts: reine Speicher-Helfer OHNE React-
|
||||
// Abhängigkeit, sämtlicher Zugriff defensiv (SSR/Tests/Privatmodus) — hier
|
||||
// mit `indexedDB` statt `localStorage`, da localStorage-Kontingente für
|
||||
// Projekt-JSON (Wände, Meshes, …) zu klein sind.
|
||||
//
|
||||
// Hinweis: Dies ist nur der Speicher-KERN (CRUD-API). UI-Verdrahtung
|
||||
// (Speichern/Öffnen-Menü, zuletzt-geöffnet-Liste, Auto-Save) ist NICHT Teil
|
||||
// dieses Moduls — folgt als separates Item.
|
||||
|
||||
import type { Project } from "../model/types";
|
||||
|
||||
/** Name der IndexedDB-Datenbank. */
|
||||
const DB_NAME = "dossier";
|
||||
/** Name des ObjectStores (keyPath „name"). */
|
||||
const STORE_NAME = "projects";
|
||||
/** DB-Schemaversion. */
|
||||
const DB_VERSION = 1;
|
||||
|
||||
/** Ein gespeicherter Datensatz je Projekt. */
|
||||
interface ProjectRecord {
|
||||
name: string;
|
||||
savedAt: number;
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/** `true`, sobald die Fehlmeldung „IndexedDB nicht verfügbar" geloggt wurde (nur einmal). */
|
||||
let warnedUnavailable = false;
|
||||
|
||||
/** Warnt einmalig auf der Konsole, dass IndexedDB fehlt (Tests/SSR/Privatmodus). */
|
||||
function warnUnavailableOnce(): void {
|
||||
if (warnedUnavailable) return;
|
||||
warnedUnavailable = true;
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[projectStore] IndexedDB nicht verfügbar — Projekt-Persistenz deaktiviert.",
|
||||
);
|
||||
} catch {
|
||||
// Konsole kann in exotischen Umgebungen fehlen — ignorieren.
|
||||
}
|
||||
}
|
||||
|
||||
/** Liefert `true`, wenn `indexedDB` in dieser Umgebung nutzbar ist. */
|
||||
function isAvailable(): boolean {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
} catch {
|
||||
// Zugriff kann werfen (z. B. strikte SSR-Sandboxen).
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Gecachtes Öffnen-Promise, damit `indexedDB.open` nur einmal läuft. */
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
/** Öffnet (bzw. erstellt) die Datenbank und liefert die geöffnete Verbindung. */
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
let request: IDBOpenDBRequest;
|
||||
try {
|
||||
request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
} catch (err) {
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: "name" });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("indexedDB.open fehlgeschlagen"));
|
||||
});
|
||||
// Bei Fehlschlag den Cache zurücksetzen, damit ein späterer Aufruf erneut
|
||||
// versuchen kann (statt dauerhaft am fehlgeschlagenen Promise zu hängen).
|
||||
dbPromise.catch(() => {
|
||||
dbPromise = null;
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert ein Projekt unter `name` (überschreibt einen vorhandenen
|
||||
* Datensatz gleichen Namens). Ohne IndexedDB: no-op (wirft nicht).
|
||||
*/
|
||||
export async function saveProject(name: string, project: Project): Promise<void> {
|
||||
if (!name) return;
|
||||
if (!isAvailable()) {
|
||||
warnUnavailableOnce();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const db = await openDb();
|
||||
const record: ProjectRecord = { name, savedAt: Date.now(), project };
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
tx.objectStore(STORE_NAME).put(record);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error("Transaktion fehlgeschlagen"));
|
||||
tx.onabort = () => reject(tx.error ?? new Error("Transaktion abgebrochen"));
|
||||
});
|
||||
} catch {
|
||||
// Kontingent überschritten, Zugriff verweigert o. Ä. — still ignorieren
|
||||
// (analog writeJson in visibilitySets.ts).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt ein Projekt nach Namen. Liefert `null`, wenn es nicht existiert, die
|
||||
* Umgebung IndexedDB nicht unterstützt oder der Zugriff fehlschlägt.
|
||||
*/
|
||||
export async function loadProject(name: string): Promise<Project | null> {
|
||||
if (!name) return null;
|
||||
if (!isAvailable()) {
|
||||
warnUnavailableOnce();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const db = await openDb();
|
||||
const record = await new Promise<ProjectRecord | undefined>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const request = tx.objectStore(STORE_NAME).get(name);
|
||||
request.onsuccess = () => resolve(request.result as ProjectRecord | undefined);
|
||||
request.onerror = () => reject(request.error ?? new Error("Lesen fehlgeschlagen"));
|
||||
});
|
||||
return record?.project ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listet alle gespeicherten Projekte (Name + Speicherzeitpunkt), alphabetisch
|
||||
* nach Name sortiert. Ohne IndexedDB oder bei Fehler: leere Liste.
|
||||
*/
|
||||
export async function listProjects(): Promise<{ name: string; savedAt: number }[]> {
|
||||
if (!isAvailable()) {
|
||||
warnUnavailableOnce();
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const db = await openDb();
|
||||
const records = await new Promise<ProjectRecord[]>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const request = tx.objectStore(STORE_NAME).getAll();
|
||||
request.onsuccess = () => resolve((request.result as ProjectRecord[]) ?? []);
|
||||
request.onerror = () => reject(request.error ?? new Error("Lesen fehlgeschlagen"));
|
||||
});
|
||||
return records
|
||||
.map((r) => ({ name: r.name, savedAt: r.savedAt }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein gespeichertes Projekt. Ohne IndexedDB oder bei Fehler: no-op
|
||||
* (wirft nicht).
|
||||
*/
|
||||
export async function deleteProject(name: string): Promise<void> {
|
||||
if (!name) return;
|
||||
if (!isAvailable()) {
|
||||
warnUnavailableOnce();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
tx.objectStore(STORE_NAME).delete(name);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error("Transaktion fehlgeschlagen"));
|
||||
tx.onabort = () => reject(tx.error ?? new Error("Transaktion abgebrochen"));
|
||||
});
|
||||
} catch {
|
||||
// still ignorieren — analog removeKey in visibilitySets.ts.
|
||||
}
|
||||
}
|
||||
+180
-2
@@ -21,20 +21,26 @@ import {
|
||||
import type {
|
||||
AttributeSource,
|
||||
Ceiling,
|
||||
Column,
|
||||
ColumnProfile,
|
||||
Drawing2D,
|
||||
Drawing2DGeom,
|
||||
ExtrudedSolid,
|
||||
LayerCategory,
|
||||
Opening,
|
||||
Project,
|
||||
Room,
|
||||
SiaCategory,
|
||||
SliceTermination,
|
||||
Stair,
|
||||
StairShape,
|
||||
VerticalAnchor,
|
||||
Wall,
|
||||
WallReferenceLine,
|
||||
} from "../model/types";
|
||||
import { evaluateRoom } from "../geometry/roomArea";
|
||||
import { columnVerticalExtent } from "../model/wall";
|
||||
import { columnFootprint } from "../geometry/column";
|
||||
import { evaluateRoom, polygonArea } from "../geometry/roomArea";
|
||||
import {
|
||||
ceilingVerticalExtent,
|
||||
nextFloorAbove,
|
||||
@@ -70,6 +76,8 @@ export interface FloorChoice {
|
||||
*/
|
||||
export interface WallInfo {
|
||||
referenceLine: WallReferenceLine;
|
||||
/** Terminierungs-Regel am Deckenanschluss (Default "both" = heutiges Verhalten). */
|
||||
sliceTermination: SliceTermination;
|
||||
wallTypeId: string;
|
||||
/** Aktuelle Gesamtdicke (Meter). */
|
||||
thickness: number;
|
||||
@@ -91,6 +99,14 @@ export interface WallInfo {
|
||||
/** Aufgelöste absolute UK/OK (Meter) + abgeleitete Höhe. */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
/** Achslänge der Wand (Meter). */
|
||||
length: number;
|
||||
/** Bruttovolumen = Länge × Dicke × Höhe (m³, ohne Öffnungsabzug). */
|
||||
grossVolume: number;
|
||||
/** Summe der Öffnungsvolumina (Fenster/Türen) dieser Wand (m³). */
|
||||
openingVolume: number;
|
||||
/** Nettovolumen = Brutto − Öffnungen (m³, ≥ 0). */
|
||||
netVolume: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,6 +122,8 @@ export interface CeilingInfo {
|
||||
singleLayer: boolean;
|
||||
/** Grundfläche der Decke (m²). */
|
||||
area: number;
|
||||
/** Volumen = Fläche × Dicke (m³). */
|
||||
volume: number;
|
||||
/** Verfügbare Deckentyp-Presets (dediziert, `project.ceilingTypes`). */
|
||||
ceilingTypes: WallTypeChoice[];
|
||||
/** Geschoss, dem die Decke zugeordnet ist. */
|
||||
@@ -115,6 +133,8 @@ export interface CeilingInfo {
|
||||
floors: FloorChoice[];
|
||||
/** OK-Bindung (undefined = Geschoss-Oberkante). */
|
||||
top?: VerticalAnchor;
|
||||
/** UK-Bindung (undefined = OK − Dicke). */
|
||||
bottom?: VerticalAnchor;
|
||||
/** Aufgelöste absolute UK/OK (Meter). */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
@@ -146,6 +166,8 @@ export interface OpeningInfo {
|
||||
doorType?: "normal" | "wandoeffnung";
|
||||
/** Nur Tür: Sturzlinien (keine/innen/aussen/beide). Fehlt → "beide". */
|
||||
lintelLines?: "keine" | "innen" | "aussen" | "beide";
|
||||
/** Zugewiesener Tür-/Fenstertyp (Bibliothek, `Opening.typeId`) oder undefined. */
|
||||
typeId?: string;
|
||||
/** Aufgelöste absolute UK/OK (Meter). */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
@@ -171,6 +193,8 @@ export interface StairInfo {
|
||||
up: boolean;
|
||||
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
|
||||
referenz?: "links" | "mitte" | "rechts";
|
||||
/** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */
|
||||
typeId?: string;
|
||||
/** Geschoss, dem die Treppe zugeordnet ist. */
|
||||
floorId: string;
|
||||
floorName: string;
|
||||
@@ -198,6 +222,41 @@ export interface RoomInfo {
|
||||
floorName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrusions-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl
|
||||
* genau eines extrudierten Körpers, truck-Integration, gesetzt).
|
||||
*/
|
||||
export interface ExtrudedSolidInfo {
|
||||
/** Extrusionshöhe in Metern (editierbar). */
|
||||
height: number;
|
||||
/** Verjüngung 0 (Prisma) … 1 (Spitze), editierbar. */
|
||||
taper: number;
|
||||
/** Grundfläche des Profils (m²). */
|
||||
area: number;
|
||||
/** Geschoss, dessen `baseElevation` die UK des Körpers bestimmt. */
|
||||
floorId: string;
|
||||
floorName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stützen-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl genau
|
||||
* einer Stütze gesetzt). Anzeigefertige Werte für die Panel-Felder.
|
||||
*/
|
||||
export interface ColumnInfo {
|
||||
/** Profil (Rechteck/Kreis) — bestimmt, welche Masse editierbar sind. */
|
||||
profile: ColumnProfile;
|
||||
/** Höhe (Meter, editierbar). */
|
||||
height: number;
|
||||
/** Drehung (Grad, editierbar) — intern in Radiant gespeichert. */
|
||||
rotationDeg: number;
|
||||
/** Geschoss, dem die Stütze zugeordnet ist. */
|
||||
floorId: string;
|
||||
floorName: string;
|
||||
/** Aufgelöste absolute UK/OK (Meter). */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
}
|
||||
|
||||
/** Achsparallele Bounding-Box eines Elements in Modell-Metern. */
|
||||
export interface SelectionBBox {
|
||||
minX: number;
|
||||
@@ -212,7 +271,16 @@ export interface SelectionBBox {
|
||||
* gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll.
|
||||
*/
|
||||
export interface Selection {
|
||||
kind: "wall" | "ceiling" | "opening" | "stair" | "room" | "drawing2d" | "door";
|
||||
kind:
|
||||
| "wall"
|
||||
| "ceiling"
|
||||
| "opening"
|
||||
| "stair"
|
||||
| "room"
|
||||
| "drawing2d"
|
||||
| "door"
|
||||
| "extrudedSolid"
|
||||
| "column";
|
||||
id: string;
|
||||
/** Grafik-Kategorie (Ebene) des Elements. */
|
||||
categoryCode: string;
|
||||
@@ -276,6 +344,10 @@ export interface Selection {
|
||||
stair?: StairInfo;
|
||||
/** Raum-Attribute (nur bei `kind === "room"`). */
|
||||
room?: RoomInfo;
|
||||
/** Extrusions-Attribute (nur bei `kind === "extrudedSolid"`). */
|
||||
extrudedSolid?: ExtrudedSolidInfo;
|
||||
/** Stützen-Attribute (nur bei `kind === "column"`). */
|
||||
column?: ColumnInfo;
|
||||
}
|
||||
|
||||
const WALL_FALLBACK_MM = 0.18;
|
||||
@@ -354,8 +426,18 @@ function wallSelection(project: Project, wall: Wall): Selection {
|
||||
const floor = project.drawingLevels.find((z) => z.id === wall.floorId);
|
||||
const { zBottom, zTop } = wallVerticalExtent(project, wall);
|
||||
const above = nextFloorAbove(project, wall);
|
||||
// Volumen aus Achslänge × Gesamtdicke × Höhe, abzüglich der Öffnungen (jede
|
||||
// Öffnung durchbricht die volle Wanddicke: Breite × Höhe × Dicke).
|
||||
const length = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
|
||||
const wallHeight = Math.max(0, zTop - zBottom);
|
||||
const grossVolume = length * thickness * wallHeight;
|
||||
const openingVolume = (project.openings ?? [])
|
||||
.filter((o) => o.hostWallId === wall.id)
|
||||
.reduce((sum, o) => sum + o.width * o.height * thickness, 0);
|
||||
const netVolume = Math.max(0, grossVolume - openingVolume);
|
||||
const wallInfo: WallInfo = {
|
||||
referenceLine: wall.referenceLine ?? "center",
|
||||
sliceTermination: wall.sliceTermination ?? "both",
|
||||
wallTypeId: wall.wallTypeId,
|
||||
thickness,
|
||||
singleLayer: wt.layers.length <= 1,
|
||||
@@ -376,6 +458,10 @@ function wallSelection(project: Project, wall: Wall): Selection {
|
||||
top: wall.top,
|
||||
zBottom,
|
||||
zTop,
|
||||
length,
|
||||
grossVolume,
|
||||
openingVolume,
|
||||
netVolume,
|
||||
};
|
||||
// bbox aus der Wandachse (start/end); die Schichtdicke bleibt unberücksichtigt,
|
||||
// damit die Box der Achs-Geometrie folgt (genügt für die Paletten).
|
||||
@@ -415,6 +501,7 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
|
||||
thickness,
|
||||
singleLayer: wt.layers.length <= 1,
|
||||
area: ceilingArea(ceiling.outline),
|
||||
volume: ceilingArea(ceiling.outline) * thickness,
|
||||
ceilingTypes: (project.ceilingTypes ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
@@ -428,6 +515,7 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
|
||||
.filter((z) => z.kind === "floor")
|
||||
.map((z) => ({ id: z.id, name: z.name })),
|
||||
top: ceiling.top,
|
||||
bottom: ceiling.bottom,
|
||||
zBottom,
|
||||
zTop,
|
||||
};
|
||||
@@ -494,6 +582,7 @@ function openingSelection(project: Project, o: Opening): Selection {
|
||||
wingCount: o.wingCount,
|
||||
doorType: o.doorType,
|
||||
lintelLines: o.lintelLines,
|
||||
typeId: o.typeId,
|
||||
zBottom: ext.zBottom,
|
||||
zTop: ext.zTop,
|
||||
};
|
||||
@@ -530,6 +619,7 @@ function stairSelection(project: Project, s: Stair): Selection {
|
||||
treadDepth: geo.treadDepth,
|
||||
up: s.up !== false,
|
||||
referenz: s.referenz,
|
||||
typeId: s.typeId,
|
||||
floorId: s.floorId,
|
||||
floorName: floor?.name ?? s.floorId,
|
||||
zBottom,
|
||||
@@ -589,6 +679,84 @@ function roomSelection(project: Project, r: Room): Selection {
|
||||
};
|
||||
}
|
||||
|
||||
/** Auswahl-Farbe der Extrusions-Footprints (identisch zum Grundriss-Umriss/3D-Orange). */
|
||||
const EXTRUSION_COLOR = "#d98c40";
|
||||
|
||||
/** Selektion für einen extrudierten Körper ableiten (truck-Integration). */
|
||||
function extrudedSolidSelection(project: Project, s: ExtrudedSolid): Selection {
|
||||
const floor = project.drawingLevels.find((z) => z.id === s.levelId);
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const p of s.points) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
if (!isFinite(minX)) minX = minY = maxX = maxY = 0;
|
||||
const info: ExtrudedSolidInfo = {
|
||||
height: s.height,
|
||||
taper: s.taper ?? 0,
|
||||
area: polygonArea(s.points),
|
||||
floorId: s.levelId,
|
||||
floorName: floor?.name ?? s.levelId,
|
||||
};
|
||||
return {
|
||||
kind: "extrudedSolid",
|
||||
id: s.id,
|
||||
categoryCode: "",
|
||||
color: EXTRUSION_COLOR,
|
||||
weightMm: WALL_FALLBACK_MM,
|
||||
fillHatchId: null,
|
||||
fillColor: null,
|
||||
closed: true,
|
||||
bbox: { minX, minY, maxX, maxY },
|
||||
extrudedSolid: info,
|
||||
};
|
||||
}
|
||||
|
||||
/** Umriss-/Auswahlfarbe der Stützen-Poché (neutrales Tragwerks-Grau). */
|
||||
const COLUMN_COLOR = "#3a3f47";
|
||||
|
||||
/** Selektion für eine Stütze (Column) ableiten. */
|
||||
function columnSelection(project: Project, col: Column): Selection {
|
||||
const floor = project.drawingLevels.find((z) => z.id === col.floorId);
|
||||
const category = findCategory(project.layers, col.categoryCode);
|
||||
const pts = columnFootprint(col);
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const p of pts) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
if (!isFinite(minX)) minX = minY = maxX = maxY = 0;
|
||||
const { zBottom, zTop } = columnVerticalExtent(project, col);
|
||||
const info: ColumnInfo = {
|
||||
profile: col.profile,
|
||||
height: col.height,
|
||||
rotationDeg: ((col.rotation ?? 0) * 180) / Math.PI,
|
||||
floorId: col.floorId,
|
||||
floorName: floor?.name ?? col.floorId,
|
||||
zBottom,
|
||||
zTop,
|
||||
};
|
||||
return {
|
||||
kind: "column",
|
||||
id: col.id,
|
||||
categoryCode: col.categoryCode,
|
||||
color: col.color ?? category?.color ?? COLUMN_COLOR,
|
||||
weightMm: WALL_FALLBACK_MM,
|
||||
bbox: { minX, minY, maxX, maxY },
|
||||
column: info,
|
||||
};
|
||||
}
|
||||
|
||||
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
|
||||
function drawingSelection(project: Project, d: Drawing2D): Selection {
|
||||
const ls = d.lineStyleId
|
||||
@@ -640,11 +808,21 @@ export function deriveSelection(
|
||||
selectedOpeningId: string | null = null,
|
||||
selectedStairId: string | null = null,
|
||||
selectedRoomId: string | null = null,
|
||||
selectedExtrudedSolidId: string | null = null,
|
||||
selectedColumnId: string | null = null,
|
||||
): Selection | null {
|
||||
if (selectedDrawingId) {
|
||||
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
|
||||
return d ? drawingSelection(project, d) : null;
|
||||
}
|
||||
if (selectedExtrudedSolidId) {
|
||||
const s = (project.extrudedSolids ?? []).find((x) => x.id === selectedExtrudedSolidId);
|
||||
return s ? extrudedSolidSelection(project, s) : null;
|
||||
}
|
||||
if (selectedColumnId) {
|
||||
const c = (project.columns ?? []).find((x) => x.id === selectedColumnId);
|
||||
return c ? columnSelection(project, c) : null;
|
||||
}
|
||||
if (selectedOpeningId) {
|
||||
const o = (project.openings ?? []).find((x) => x.id === selectedOpeningId);
|
||||
if (o) return openingSelection(project, o);
|
||||
|
||||
@@ -19,6 +19,10 @@ export interface SelectionSlice {
|
||||
selectedStairIds: string[];
|
||||
// Gewählte Räume als MENGE von IDs — getrennt von den übrigen Kanälen.
|
||||
selectedRoomIds: string[];
|
||||
// Gewählte extrudierte Körper (truck-Integration) als MENGE von IDs — getrennt.
|
||||
selectedExtrudedSolidIds: string[];
|
||||
// Gewählte Stützen (Tragwerk) als MENGE von IDs — getrennt von den übrigen Kanälen.
|
||||
selectedColumnIds: string[];
|
||||
|
||||
setSelectedWallIds: (ids: string[]) => void;
|
||||
setSelectedDrawingIds: (ids: string[]) => void;
|
||||
@@ -26,6 +30,8 @@ export interface SelectionSlice {
|
||||
setSelectedOpeningIds: (ids: string[]) => void;
|
||||
setSelectedStairIds: (ids: string[]) => void;
|
||||
setSelectedRoomIds: (ids: string[]) => void;
|
||||
setSelectedExtrudedSolidIds: (ids: string[]) => void;
|
||||
setSelectedColumnIds: (ids: string[]) => void;
|
||||
/** Auswahl (Wände + Decken + Öffnungen + Treppen + 2D-Elemente) leeren. */
|
||||
clearSelection: () => void;
|
||||
}
|
||||
@@ -41,12 +47,16 @@ export function createSelectionSlice(
|
||||
selectedOpeningIds: [],
|
||||
selectedStairIds: [],
|
||||
selectedRoomIds: [],
|
||||
selectedExtrudedSolidIds: [],
|
||||
selectedColumnIds: [],
|
||||
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
|
||||
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
|
||||
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
|
||||
setSelectedOpeningIds: (ids) => set({ selectedOpeningIds: ids }),
|
||||
setSelectedStairIds: (ids) => set({ selectedStairIds: ids }),
|
||||
setSelectedRoomIds: (ids) => set({ selectedRoomIds: ids }),
|
||||
setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }),
|
||||
setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }),
|
||||
clearSelection: () =>
|
||||
set({
|
||||
selectedWallIds: [],
|
||||
@@ -55,6 +65,8 @@ export function createSelectionSlice(
|
||||
selectedOpeningIds: [],
|
||||
selectedStairIds: [],
|
||||
selectedRoomIds: [],
|
||||
selectedExtrudedSolidIds: [],
|
||||
selectedColumnIds: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Pure Tests für den Ausschnitte-Baum (state/viewSnapshotFolders.ts):
|
||||
// Aufbau, Reparent, Zyklus-Schutz, verwaiste Referenzen, Löschen ohne
|
||||
// Datenverlust. Kein React/DOM.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { ViewSnapshot, ViewSnapshotFolder } from "../model/types";
|
||||
import {
|
||||
buildViewSnapshotTree,
|
||||
createViewSnapshotFolder,
|
||||
deleteFolder,
|
||||
isDescendant,
|
||||
moveFolder,
|
||||
moveSnapshotToFolder,
|
||||
} from "./viewSnapshotFolders";
|
||||
|
||||
/** Minimaler ViewSnapshot-Stub (nur id/name/folderId sind hier relevant). */
|
||||
function snap(id: string, folderId?: string): ViewSnapshot {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
...(folderId ? { folderId } : {}),
|
||||
viewType: "grundriss",
|
||||
view3d: "top",
|
||||
scaleDenominator: 100,
|
||||
detail: "mittel",
|
||||
activeLevelId: "L0",
|
||||
layerVisibility: {},
|
||||
drawingVisibility: {},
|
||||
} as ViewSnapshot;
|
||||
}
|
||||
|
||||
function folder(id: string, parentId?: string): ViewSnapshotFolder {
|
||||
return createViewSnapshotFolder(id, id, parentId);
|
||||
}
|
||||
|
||||
describe("createViewSnapshotFolder", () => {
|
||||
it("lässt parentId weg, wenn nicht gesetzt (Wurzelebene)", () => {
|
||||
const f = createViewSnapshotFolder("f1", "Ordner");
|
||||
expect(f).toEqual({ id: "f1", name: "Ordner" });
|
||||
expect("parentId" in f).toBe(false);
|
||||
});
|
||||
it("setzt parentId, wenn gegeben", () => {
|
||||
expect(createViewSnapshotFolder("f2", "Sub", "f1").parentId).toBe("f1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildViewSnapshotTree", () => {
|
||||
it("verschachtelt Ordner + Ausschnitte", () => {
|
||||
const folders = [folder("f1"), folder("f2", "f1")];
|
||||
const snaps = [snap("s0"), snap("s1", "f1"), snap("s2", "f2")];
|
||||
const tree = buildViewSnapshotTree(folders, snaps);
|
||||
expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s0"]);
|
||||
expect(tree.rootFolders).toHaveLength(1);
|
||||
const f1 = tree.rootFolders[0];
|
||||
expect(f1.folder.id).toBe("f1");
|
||||
expect(f1.snapshots.map((s) => s.id)).toEqual(["s1"]);
|
||||
expect(f1.subfolders).toHaveLength(1);
|
||||
expect(f1.subfolders[0].folder.id).toBe("f2");
|
||||
expect(f1.subfolders[0].snapshots.map((s) => s.id)).toEqual(["s2"]);
|
||||
});
|
||||
|
||||
it("hebt Ausschnitt mit unbekanntem folderId auf die Wurzelebene (verwaist)", () => {
|
||||
const tree = buildViewSnapshotTree([folder("f1")], [snap("s1", "ghost")]);
|
||||
expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s1"]);
|
||||
expect(tree.rootFolders[0].snapshots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hebt Ordner mit unbekanntem parentId auf die Wurzelebene (verwaist)", () => {
|
||||
const tree = buildViewSnapshotTree([folder("f2", "ghost")], []);
|
||||
expect(tree.rootFolders.map((n) => n.folder.id)).toEqual(["f2"]);
|
||||
});
|
||||
|
||||
it("bewahrt die Eingabe-Reihenfolge je Ebene", () => {
|
||||
const folders = [folder("b"), folder("a")];
|
||||
const snaps = [snap("s2"), snap("s1")];
|
||||
const tree = buildViewSnapshotTree(folders, snaps);
|
||||
expect(tree.rootFolders.map((n) => n.folder.id)).toEqual(["b", "a"]);
|
||||
expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s2", "s1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDescendant", () => {
|
||||
const folders = [folder("f1"), folder("f2", "f1"), folder("f3", "f2")];
|
||||
it("erkennt sich selbst", () => {
|
||||
expect(isDescendant(folders, "f1", "f1")).toBe(true);
|
||||
});
|
||||
it("erkennt echten Nachfahren (transitiv)", () => {
|
||||
expect(isDescendant(folders, "f3", "f1")).toBe(true);
|
||||
});
|
||||
it("verneint Nicht-Nachfahren", () => {
|
||||
expect(isDescendant(folders, "f1", "f3")).toBe(false);
|
||||
});
|
||||
it("bricht bei kaputter parentId-Kette ab (kein Absturz)", () => {
|
||||
expect(isDescendant([folder("x", "ghost")], "x", "f1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveSnapshotToFolder", () => {
|
||||
it("setzt folderId", () => {
|
||||
const out = moveSnapshotToFolder([snap("s1")], "s1", "f1");
|
||||
expect(out[0].folderId).toBe("f1");
|
||||
});
|
||||
it("entfernt folderId bei null (Wurzelebene)", () => {
|
||||
const out = moveSnapshotToFolder([snap("s1", "f1")], "s1", null);
|
||||
expect("folderId" in out[0]).toBe(false);
|
||||
});
|
||||
it("no-op bei unbekannter Id", () => {
|
||||
const input = [snap("s1", "f1")];
|
||||
expect(moveSnapshotToFolder(input, "ghost", null)).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveFolder", () => {
|
||||
const folders = [folder("f1"), folder("f2", "f1"), folder("f3")];
|
||||
it("hängt Ordner unter neuen Elternordner", () => {
|
||||
const out = moveFolder(folders, "f3", "f1");
|
||||
expect(out.find((f) => f.id === "f3")!.parentId).toBe("f1");
|
||||
});
|
||||
it("verschiebt auf Wurzelebene bei null", () => {
|
||||
const out = moveFolder(folders, "f2", null);
|
||||
expect("parentId" in out.find((f) => f.id === "f2")!).toBe(false);
|
||||
});
|
||||
it("verhindert Zyklus: Ordner in eigenen Nachfahren (No-op)", () => {
|
||||
expect(moveFolder(folders, "f1", "f2")).toEqual(folders);
|
||||
});
|
||||
it("verhindert Ordner in sich selbst (No-op)", () => {
|
||||
expect(moveFolder(folders, "f1", "f1")).toEqual(folders);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteFolder", () => {
|
||||
it("zieht Unterordner + Ausschnitte auf die Elternebene (kein Datenverlust)", () => {
|
||||
// f1 > f2 > f3; s1 in f2. f2 löschen → f3 & s1 hängen an f1.
|
||||
const folders = [folder("f1"), folder("f2", "f1"), folder("f3", "f2")];
|
||||
const snaps = [snap("s1", "f2")];
|
||||
const out = deleteFolder("f2", folders, snaps);
|
||||
expect(out.folders.map((f) => f.id).sort()).toEqual(["f1", "f3"]);
|
||||
expect(out.folders.find((f) => f.id === "f3")!.parentId).toBe("f1");
|
||||
expect(out.snapshots[0].folderId).toBe("f1");
|
||||
});
|
||||
|
||||
it("zieht auf die Wurzelebene, wenn der gelöschte Ordner an der Wurzel lag", () => {
|
||||
const folders = [folder("f1"), folder("f2", "f1")];
|
||||
const snaps = [snap("s1", "f1")];
|
||||
const out = deleteFolder("f1", folders, snaps);
|
||||
expect(out.folders).toHaveLength(1);
|
||||
expect("parentId" in out.folders[0]).toBe(false); // f2 an Wurzel
|
||||
expect("folderId" in out.snapshots[0]).toBe(false); // s1 an Wurzel
|
||||
});
|
||||
|
||||
it("no-op bei unbekannter Ordner-Id", () => {
|
||||
const folders = [folder("f1")];
|
||||
const snaps = [snap("s1", "f1")];
|
||||
const out = deleteFolder("ghost", folders, snaps);
|
||||
expect(out.folders).toEqual(folders);
|
||||
expect(out.snapshots).toEqual(snaps);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
// Ausschnitte-Baum (Ordner + Ausschnitte) — reine, testbare Logik (DOSSIER A2).
|
||||
//
|
||||
// Enthält KEINE React-/DOM-Abhängigkeit: nur immutable CRUD auf ViewSnapshot-
|
||||
// Ordnern und die Auflösung der flachen Listen zum verschachtelten Baum. Das
|
||||
// UI-Wiring (App.tsx setProject, ViewSnapshotsPanel) baut hierauf auf.
|
||||
//
|
||||
// Spiegelbildlich zu `panels/layoutModel.ts` (Layouts-Baum), aber bewusst OHNE
|
||||
// Import aus dort — Ausschnitte-spezifisch und entkoppelt gehalten.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { ViewSnapshot, ViewSnapshotFolder } from "../model/types";
|
||||
|
||||
// ── Fabrik ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Neuer, leerer Ausschnitte-Ordner (optional in `parentId`). */
|
||||
export function createViewSnapshotFolder(
|
||||
id: string,
|
||||
name: string,
|
||||
parentId?: string,
|
||||
): ViewSnapshotFolder {
|
||||
return { id, name, ...(parentId ? { parentId } : {}) };
|
||||
}
|
||||
|
||||
// ── Baum-Aufbau ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Ein Ordner-Knoten: der Ordner, seine Unterordner und direkten Ausschnitte. */
|
||||
export interface ViewSnapshotFolderNode {
|
||||
folder: ViewSnapshotFolder;
|
||||
subfolders: ViewSnapshotFolderNode[];
|
||||
snapshots: ViewSnapshot[];
|
||||
}
|
||||
|
||||
/** Der aufgebaute Baum: Wurzel-Ausschnitte (ohne Ordner) + Wurzel-Ordner. */
|
||||
export interface ViewSnapshotTree {
|
||||
/** Ausschnitte ohne (gültigen) `folderId` — auf Wurzelebene. */
|
||||
rootSnapshots: ViewSnapshot[];
|
||||
/** Ordner ohne (gültigen) `parentId` — auf Wurzelebene, je mit Unterbaum. */
|
||||
rootFolders: ViewSnapshotFolderNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus der flachen Ordner-/Ausschnitt-Liste den verschachtelten Baum. Robust
|
||||
* gegen verwaiste Referenzen: ein Ausschnitt mit unbekanntem `folderId` bzw. ein
|
||||
* Ordner mit unbekanntem `parentId` landet auf der Wurzelebene (so bleiben
|
||||
* Alt-/Teilprojekte sichtbar). Die Eingabe-Reihenfolge bleibt je Ebene erhalten.
|
||||
*/
|
||||
export function buildViewSnapshotTree(
|
||||
folders: ViewSnapshotFolder[],
|
||||
snapshots: ViewSnapshot[],
|
||||
): ViewSnapshotTree {
|
||||
const folderIds = new Set(folders.map((f) => f.id));
|
||||
const nodes = new Map<string, ViewSnapshotFolderNode>();
|
||||
for (const f of folders) {
|
||||
nodes.set(f.id, { folder: f, subfolders: [], snapshots: [] });
|
||||
}
|
||||
|
||||
const rootFolders: ViewSnapshotFolderNode[] = [];
|
||||
for (const f of folders) {
|
||||
const node = nodes.get(f.id)!;
|
||||
const parent =
|
||||
f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined;
|
||||
if (parent) parent.subfolders.push(node);
|
||||
else rootFolders.push(node);
|
||||
}
|
||||
|
||||
const rootSnapshots: ViewSnapshot[] = [];
|
||||
for (const s of snapshots) {
|
||||
const node =
|
||||
s.folderId && folderIds.has(s.folderId) ? nodes.get(s.folderId) : undefined;
|
||||
if (node) node.snapshots.push(s);
|
||||
else rootSnapshots.push(s);
|
||||
}
|
||||
|
||||
return { rootSnapshots, rootFolders };
|
||||
}
|
||||
|
||||
// ── Baum-Umsortieren (Drag & Drop) + Löschen ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ist `candidateId` gleich `rootId` oder ein Nachfahre von `rootId`? Für den
|
||||
* Zyklus-Schutz beim Ordner-in-Ordner-Verschieben: ein Ordner darf NICHT in sich
|
||||
* selbst oder einen seiner Nachfahren wandern (sonst löst sich der Ast vom Baum).
|
||||
* Robust gegen kaputte `parentId`-Ketten (bricht bei unbekannter Referenz ab).
|
||||
*/
|
||||
export function isDescendant(
|
||||
folders: ViewSnapshotFolder[],
|
||||
candidateId: string,
|
||||
rootId: string,
|
||||
): boolean {
|
||||
if (candidateId === rootId) return true;
|
||||
const byId = new Map(folders.map((f) => [f.id, f]));
|
||||
const seen = new Set<string>();
|
||||
let cur = byId.get(candidateId);
|
||||
while (cur && cur.parentId && !seen.has(cur.id)) {
|
||||
if (cur.parentId === rootId) return true;
|
||||
seen.add(cur.id);
|
||||
cur = byId.get(cur.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt einen Ausschnitt in einen anderen Ordner (immutabel). `folderId =
|
||||
* null` → zurück auf die Wurzelebene. No-op, wenn die Ausschnitt-Id fehlt.
|
||||
*/
|
||||
export function moveSnapshotToFolder(
|
||||
snapshots: ViewSnapshot[],
|
||||
snapshotId: string,
|
||||
folderId: string | null,
|
||||
): ViewSnapshot[] {
|
||||
return snapshots.map((s) => {
|
||||
if (s.id !== snapshotId) return s;
|
||||
if (folderId) return { ...s, folderId };
|
||||
const next = { ...s };
|
||||
delete next.folderId;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt einen Ordner unter einen neuen Elternordner (immutabel).
|
||||
* `parentId = null` → Wurzelebene. Verhindert Zyklen: ein Ordner kann nicht in
|
||||
* sich selbst oder einen seiner Nachfahren wandern (dann No-op — Original zurück).
|
||||
*/
|
||||
export function moveFolder(
|
||||
folders: ViewSnapshotFolder[],
|
||||
folderId: string,
|
||||
parentId: string | null,
|
||||
): ViewSnapshotFolder[] {
|
||||
if (parentId && isDescendant(folders, parentId, folderId)) return folders;
|
||||
return folders.map((f) => {
|
||||
if (f.id !== folderId) return f;
|
||||
if (parentId) return { ...f, parentId };
|
||||
return stripParentId(f);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Ordner (immutabel). Unterordner und Ausschnitte des Ordners
|
||||
* werden auf die Ebene des gelöschten Ordners (dessen `parentId`) hochgezogen,
|
||||
* statt mitgelöscht zu werden — es gehen keine Ausschnitte verloren. Liefert die
|
||||
* neuen `folders`/`snapshots`-Listen.
|
||||
*/
|
||||
export function deleteFolder(
|
||||
folderId: string,
|
||||
folders: ViewSnapshotFolder[],
|
||||
snapshots: ViewSnapshot[],
|
||||
): { folders: ViewSnapshotFolder[]; snapshots: ViewSnapshot[] } {
|
||||
const target = folders.find((f) => f.id === folderId);
|
||||
const grandParentId = target?.parentId;
|
||||
const nextFolders = folders
|
||||
.filter((f) => f.id !== folderId)
|
||||
.map((f) =>
|
||||
f.parentId === folderId
|
||||
? grandParentId
|
||||
? { ...f, parentId: grandParentId }
|
||||
: stripParentId(f)
|
||||
: f,
|
||||
);
|
||||
const nextSnapshots = snapshots.map((s) =>
|
||||
s.folderId === folderId
|
||||
? grandParentId
|
||||
? { ...s, folderId: grandParentId }
|
||||
: stripFolderId(s)
|
||||
: s,
|
||||
);
|
||||
return { folders: nextFolders, snapshots: nextSnapshots };
|
||||
}
|
||||
|
||||
/** Ordner auf die Wurzelebene setzen (parentId entfernen). */
|
||||
function stripParentId(folder: ViewSnapshotFolder): ViewSnapshotFolder {
|
||||
const next = { ...folder };
|
||||
delete next.parentId;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Ausschnitt auf die Wurzelebene setzen (folderId entfernen). */
|
||||
function stripFolderId(snap: ViewSnapshot): ViewSnapshot {
|
||||
const next = { ...snap };
|
||||
delete next.folderId;
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Unit-Tests der reinen Ausschnitt-Logik (src/state/viewSnapshots.ts):
|
||||
// Capture aus einem Zustand → ViewSnapshot mit allen Feldern; Read →
|
||||
// Kernzustand; Roundtrip capture→read; Override-Aktiv-Menge wiederherstellen;
|
||||
// Robustheit gegen fehlende optionale Felder (Alt-/Fremd-Snapshots).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { OverrideRule, ViewSnapshot } from "../model/types";
|
||||
import {
|
||||
captureViewSnapshot,
|
||||
readViewSnapshot,
|
||||
applyOverrideRuleEnabled,
|
||||
snapshotMatchesLiveState,
|
||||
type ViewSnapshotState,
|
||||
} from "./viewSnapshots";
|
||||
|
||||
/** Ein voll besetzter Beispiel-Kernzustand. */
|
||||
function sampleState(): ViewSnapshotState {
|
||||
return {
|
||||
viewType: "perspektive",
|
||||
view3d: "iso",
|
||||
fov: 42,
|
||||
scaleDenominator: 50,
|
||||
detail: "fein",
|
||||
activeLevelId: "floor-2",
|
||||
layerVisibility: { "20": true, "30": false },
|
||||
drawingVisibility: { "floor-1": true, "floor-2": false },
|
||||
enabledOverrideRuleIds: ["r1", "r3"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("viewSnapshots — captureViewSnapshot", () => {
|
||||
it("erfasst alle Felder + Meta (id/name)", () => {
|
||||
const snap = captureViewSnapshot("vs-1", "Nord EG", sampleState());
|
||||
expect(snap).toMatchObject({
|
||||
id: "vs-1",
|
||||
name: "Nord EG",
|
||||
viewType: "perspektive",
|
||||
view3d: "iso",
|
||||
fov: 42,
|
||||
scaleDenominator: 50,
|
||||
detail: "fein",
|
||||
activeLevelId: "floor-2",
|
||||
layerVisibility: { "20": true, "30": false },
|
||||
drawingVisibility: { "floor-1": true, "floor-2": false },
|
||||
enabledOverrideRuleIds: ["r1", "r3"],
|
||||
});
|
||||
expect(snap.folder).toBeUndefined();
|
||||
});
|
||||
|
||||
it("übernimmt einen (getrimmten) Ordner, ignoriert Leerstrings", () => {
|
||||
expect(captureViewSnapshot("a", "n", sampleState(), " Pläne ").folder).toBe(
|
||||
"Pläne",
|
||||
);
|
||||
expect(captureViewSnapshot("a", "n", sampleState(), " ").folder).toBeUndefined();
|
||||
});
|
||||
|
||||
it("koppelt die Maps/Arrays vom Live-Zustand ab (Kopie, keine Referenz)", () => {
|
||||
const st = sampleState();
|
||||
const snap = captureViewSnapshot("a", "n", st);
|
||||
st.layerVisibility["20"] = false;
|
||||
st.drawingVisibility["floor-1"] = false;
|
||||
st.enabledOverrideRuleIds.push("r9");
|
||||
expect(snap.layerVisibility["20"]).toBe(true);
|
||||
expect(snap.drawingVisibility["floor-1"]).toBe(true);
|
||||
expect(snap.enabledOverrideRuleIds).toEqual(["r1", "r3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewSnapshots — readViewSnapshot", () => {
|
||||
it("Roundtrip: capture → read ergibt denselben Kernzustand", () => {
|
||||
const st = sampleState();
|
||||
const snap = captureViewSnapshot("vs-1", "Nord EG", st);
|
||||
expect(readViewSnapshot(snap)).toEqual(st);
|
||||
});
|
||||
|
||||
it("füllt fehlende optionale Felder defensiv auf (Alt-Snapshot ohne fov/overrides)", () => {
|
||||
const legacy: ViewSnapshot = {
|
||||
id: "old",
|
||||
name: "Alt",
|
||||
viewType: "grundriss",
|
||||
view3d: "perspective",
|
||||
scaleDenominator: 100,
|
||||
detail: "mittel",
|
||||
activeLevelId: "floor-1",
|
||||
layerVisibility: {},
|
||||
drawingVisibility: {},
|
||||
// fov + enabledOverrideRuleIds absichtlich weggelassen
|
||||
};
|
||||
const st = readViewSnapshot(legacy);
|
||||
expect(st.fov).toBe(50);
|
||||
expect(st.enabledOverrideRuleIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewSnapshots — applyOverrideRuleEnabled", () => {
|
||||
const mk = (id: string, enabled: boolean): OverrideRule => ({
|
||||
id,
|
||||
name: id,
|
||||
enabled,
|
||||
condition: { type: "layer_name", operator: "equals", value: id },
|
||||
actions: {},
|
||||
});
|
||||
|
||||
it("setzt enabled genau auf die gespeicherte Aktiv-Menge", () => {
|
||||
const rules = [mk("r1", false), mk("r2", true), mk("r3", false)];
|
||||
const next = applyOverrideRuleEnabled(rules, ["r1", "r3"]);
|
||||
expect(next.map((r) => [r.id, r.enabled])).toEqual([
|
||||
["r1", true],
|
||||
["r2", false],
|
||||
["r3", true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignoriert unbekannte (inzwischen gelöschte) Ids", () => {
|
||||
const rules = [mk("r1", false)];
|
||||
const next = applyOverrideRuleEnabled(rules, ["r1", "ghost"]);
|
||||
expect(next[0].enabled).toBe(true);
|
||||
expect(next).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("liefert dieselbe Referenz zurück, wenn nichts zu ändern ist (No-Op)", () => {
|
||||
const rules = [mk("r1", true), mk("r2", false)];
|
||||
expect(applyOverrideRuleEnabled(rules, ["r1"])).toBe(rules);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewSnapshots — snapshotMatchesLiveState", () => {
|
||||
it("identischer Zustand → true", () => {
|
||||
expect(snapshotMatchesLiveState(sampleState(), sampleState())).toBe(true);
|
||||
});
|
||||
|
||||
it("Override-Ids in anderer Reihenfolge → weiterhin true (Menge zählt)", () => {
|
||||
const live = { ...sampleState(), enabledOverrideRuleIds: ["r3", "r1"] };
|
||||
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(true);
|
||||
});
|
||||
|
||||
// Jedes einzelne abweichende Feld muss zu false führen.
|
||||
it("viewType weicht ab → false", () => {
|
||||
expect(
|
||||
snapshotMatchesLiveState(sampleState(), { ...sampleState(), viewType: "grundriss" }),
|
||||
).toBe(false);
|
||||
});
|
||||
it("view3d weicht ab → false", () => {
|
||||
expect(
|
||||
snapshotMatchesLiveState(sampleState(), { ...sampleState(), view3d: "perspective" }),
|
||||
).toBe(false);
|
||||
});
|
||||
it("fov weicht ab → false", () => {
|
||||
expect(
|
||||
snapshotMatchesLiveState(sampleState(), { ...sampleState(), fov: 43 }),
|
||||
).toBe(false);
|
||||
});
|
||||
it("scaleDenominator weicht ab → false", () => {
|
||||
expect(
|
||||
snapshotMatchesLiveState(sampleState(), { ...sampleState(), scaleDenominator: 100 }),
|
||||
).toBe(false);
|
||||
});
|
||||
it("detail weicht ab → false", () => {
|
||||
expect(
|
||||
snapshotMatchesLiveState(sampleState(), { ...sampleState(), detail: "grob" }),
|
||||
).toBe(false);
|
||||
});
|
||||
it("activeLevelId weicht ab → false", () => {
|
||||
expect(
|
||||
snapshotMatchesLiveState(sampleState(), { ...sampleState(), activeLevelId: "floor-9" }),
|
||||
).toBe(false);
|
||||
});
|
||||
it("layerVisibility-Wert weicht ab → false", () => {
|
||||
const live = { ...sampleState(), layerVisibility: { "20": true, "30": true } };
|
||||
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false);
|
||||
});
|
||||
it("layerVisibility-Key fehlt/kommt hinzu → false", () => {
|
||||
const live = { ...sampleState(), layerVisibility: { "20": true } };
|
||||
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false);
|
||||
const live2 = {
|
||||
...sampleState(),
|
||||
layerVisibility: { "20": true, "30": false, "40": true },
|
||||
};
|
||||
expect(snapshotMatchesLiveState(sampleState(), live2)).toBe(false);
|
||||
});
|
||||
it("drawingVisibility weicht ab → false", () => {
|
||||
const live = {
|
||||
...sampleState(),
|
||||
drawingVisibility: { "floor-1": true, "floor-2": true },
|
||||
};
|
||||
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false);
|
||||
});
|
||||
it("enabledOverrideRuleIds weicht ab (eine Regel mehr) → false", () => {
|
||||
const live = { ...sampleState(), enabledOverrideRuleIds: ["r1", "r3", "r4"] };
|
||||
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false);
|
||||
});
|
||||
it("enabledOverrideRuleIds weicht ab (andere Regel) → false", () => {
|
||||
const live = { ...sampleState(), enabledOverrideRuleIds: ["r1", "r2"] };
|
||||
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// 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<string, boolean>;
|
||||
drawingVisibility: Record<string, boolean>;
|
||||
/** 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<string, boolean>`-Maps: gleiche Keys UND Werte. */
|
||||
function boolMapEqual(
|
||||
a: Record<string, boolean>,
|
||||
b: Record<string, boolean>,
|
||||
): 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;
|
||||
}
|
||||
Reference in New Issue
Block a user