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:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+11 -3
View File
@@ -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>),
+61
View File
@@ -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);
});
});
+58
View File
@@ -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),
})),
};
}
+12 -4
View File
@@ -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;
}),
};
+59
View File
@@ -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();
});
});
+184
View File
@@ -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
View File
@@ -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);
+12
View File
@@ -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: [],
}),
};
}
+158
View File
@@ -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);
});
});
+183
View File
@@ -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;
}
+196
View File
@@ -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);
});
});
+160
View File
@@ -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;
}