68a0459d0e
- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht, unsichtbare Ebenen blenden ihre Führungslinie aus - Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/ Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste (AppMenuBar) neben der Wortmarke - Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert, flackerfrei vor erstem Render gesetzt - SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene - UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access- Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/ Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt - Neues Dachziegel-Material (RoofingTiles013A)
1738 lines
62 KiB
TypeScript
1738 lines
62 KiB
TypeScript
// Projekt-Slice: das semantische Modell (`project`) plus ALLE Mutationen, die
|
||
// heute in App.tsx als Closures über setProject existieren. Reiner Umzug — die
|
||
// Logik ist 1:1 übernommen (inkl. recomputeFloorElevations, refs-aware delete,
|
||
// Tree-Helfern). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||
//
|
||
// Cross-Slice-Zugriffe: einige Aktionen lesen/schreiben View-Felder
|
||
// (activeLevelId) — das geht, weil `get()`/`set` über den Gesamt-RootState
|
||
// laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert
|
||
// RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu.
|
||
|
||
import { emptyProject } from "../model/sampleProject";
|
||
import {
|
||
flattenCategories,
|
||
recomputeFloorElevations,
|
||
} from "../model/types";
|
||
import type {
|
||
Component,
|
||
Drawing2D,
|
||
DrawingLevel,
|
||
HatchStyle,
|
||
LayerCategory,
|
||
LineStyle,
|
||
Project,
|
||
Vec2,
|
||
} from "../model/types";
|
||
import { commitTransform } from "../tools/transform";
|
||
import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform";
|
||
import { centroid as roomCentroid } from "../geometry/roomArea";
|
||
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 {
|
||
project: Project;
|
||
|
||
// ── Projekt direkt setzen (für Werkzeuge/Transformationen aus App) ────────
|
||
/** Ersetzt das Projekt (Teil-/Updater wie React-setState). */
|
||
setProject: (next: Project | ((p: Project) => Project)) => void;
|
||
|
||
// ── Zeichnungsebenen (Levels/Geschosse) ──────────────────────────────────
|
||
toggleLevelVisible: (id: string) => void;
|
||
patchActiveFloor: (patch: Partial<DrawingLevel>) => void;
|
||
addFloor: () => void;
|
||
addDrawing: () => void;
|
||
/** Legt eine neue Schnitt-Zeichnungsebene (kind:"section") als Platzhalter an. */
|
||
addSection: () => void;
|
||
/** Legt eine neue Ansichts-Zeichnungsebene (kind:"elevation") als Platzhalter an. */
|
||
addElevation: () => void;
|
||
/**
|
||
* Legt eine neue freie Zeichnungsebene (kind:"drawing") mit dem gegebenen
|
||
* Namen an und liefert ihre ID zurück (z. B. als Ziel eines DXF-Imports).
|
||
*/
|
||
addDrawingLevel: (name: string) => string;
|
||
/** Hängt fertige Drawing2D-Elemente an (z. B. aus einem DXF-Import). */
|
||
importDrawings: (drawings: import("../model/types").Drawing2D[]) => void;
|
||
patchLevel: (id: string, patch: Partial<DrawingLevel>) => void;
|
||
duplicateLevel: (id: string) => void;
|
||
deleteLevel: (id: string) => void;
|
||
|
||
// ── Ebenen (Kategorien) ───────────────────────────────────────────────────
|
||
toggleCategoryVisible: (code: string) => void;
|
||
addCategory: () => void;
|
||
patchCategory: (code: string, patch: Partial<LayerCategory>) => void;
|
||
addSubCategory: (parentCode: string) => void;
|
||
duplicateCategory: (code: string) => void;
|
||
deleteCategory: (code: string) => void;
|
||
assignWallCategory: (wallId: string, code: string) => void;
|
||
|
||
// ── Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungs-Komb.) ────────────────
|
||
/** Liest je Kategorie-`code` → `visible` aus dem Layer-Baum (rekursiv). */
|
||
snapshotLayerVisibility: () => Record<string, boolean>;
|
||
/**
|
||
* Setzt im Layer-Baum jede Kategorie, deren `code` in `codes` vorkommt, auf
|
||
* den gespeicherten Wert (rekursiv, immutabel). Unbekannte Codes werden
|
||
* ignoriert; nicht enthaltene Kategorien bleiben unverändert.
|
||
*/
|
||
applyLayerVisibility: (codes: Record<string, boolean>) => void;
|
||
/** Liest je DrawingLevel-`id` → `visible`. */
|
||
snapshotDrawingVisibility: () => Record<string, boolean>;
|
||
/** Setzt `drawingLevels[].visible` gemäß `ids` (unbekannte Ids ignorieren). */
|
||
applyDrawingVisibility: (ids: Record<string, boolean>) => void;
|
||
|
||
// ── Ressourcen (Components / Hatches / LineStyles) ────────────────────────
|
||
patchComponent: (id: string, patch: Partial<Component>) => void;
|
||
addComponent: () => void;
|
||
deleteComponent: (id: string) => void;
|
||
patchHatch: (id: string, patch: Partial<HatchStyle>) => void;
|
||
addHatch: () => void;
|
||
deleteHatch: (id: string) => void;
|
||
patchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
|
||
addLineStyle: () => void;
|
||
deleteLineStyle: (id: string) => void;
|
||
importLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
|
||
importHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
|
||
|
||
// ── Editier-Griffe + Body-Move des selektierten Elements ──────────────────
|
||
moveGripOf: (
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
index: number,
|
||
pt: Vec2,
|
||
) => void;
|
||
moveElementByOf: (
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
delta: Vec2,
|
||
) => void;
|
||
/**
|
||
* Verschiebt eine SEITE (Kante) des selektierten Elements: BEIDE Vertices der
|
||
* Kante (`aIndex`/`bIndex`) um denselben `delta` (immutabel). Beim Rechteck
|
||
* werden min/max anschließend neu normalisiert; bei der Wand bewegt sich
|
||
* `start`+`end` gemeinsam (ganze Wand senkrecht).
|
||
*/
|
||
moveEdgeOf: (
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
aIndex: number,
|
||
bIndex: number,
|
||
delta: Vec2,
|
||
) => void;
|
||
|
||
// ── Selektions-Attribute (für Attributes-/Object-Info-Paletten) ───────────
|
||
/** Setzt die (Strich-)Farbe eines Drawing2D bzw. einer Wand (Override). */
|
||
setElementColor: (
|
||
kind: "wall" | "drawing2d",
|
||
id: string,
|
||
color: string,
|
||
) => void;
|
||
/** Setzt die direkte Strichstärke (mm) — NUR Drawing2D (Wände haben keine). */
|
||
setElementWeight: (id: string, weightMm: number) => void;
|
||
/** Setzt/entfernt die Füllschraffur — NUR Drawing2D (geschlossene Formen). */
|
||
setElementFill: (id: string, hatchId: string | null) => void;
|
||
/**
|
||
* Setzt/entfernt die Vollton-Füllfarbe — NUR Drawing2D (geschlossene Formen).
|
||
* `null` entfernt das Feld (Fläche wieder transparent).
|
||
*/
|
||
setElementFillColor: (id: string, fillColor: string | null) => void;
|
||
/**
|
||
* Setzt/entfernt den Vordergrund-Override (Muster-/Schraffurfarbe) — Wand,
|
||
* Decke oder Drawing2D. `null` = „Nach System" (Feld entfernen → erben).
|
||
*/
|
||
setElementForeground: (
|
||
kind: "wall" | "ceiling" | "drawing2d",
|
||
id: string,
|
||
value: string | null,
|
||
) => void;
|
||
/**
|
||
* Setzt/entfernt den Hintergrund-Override (Füllfarbe/Poché) — Wand, Decke
|
||
* oder Drawing2D. `null` = „Nach System" (Feld entfernen → erben).
|
||
*/
|
||
setElementBackground: (
|
||
kind: "wall" | "ceiling" | "drawing2d",
|
||
id: string,
|
||
value: string | null,
|
||
) => void;
|
||
/**
|
||
* Generischer immutabler Patch auf ein Drawing2D (per ID) — analog zu
|
||
* `updateWall`/`updateCeiling`. Trägt u. a. die By-Layer/By-Object-
|
||
* Quellenfelder (`foregroundSource`/`backgroundSource`/`strokeWeightSource`/
|
||
* `hatchSource`), die kein eigenes Setter-Paar haben.
|
||
*/
|
||
updateDrawing2D: (id: string, patch: Partial<Drawing2D>) => void;
|
||
/**
|
||
* Sortiert die gewählten 2D-Zeichnungselemente in `drawings2d` um (z-Anordnen
|
||
* im Grundriss). Die Array-Reihenfolge IST die Zeichenreihenfolge — spätere
|
||
* Position = weiter oben/vorn (siehe `generatePlan.ts`). Unbekannte IDs
|
||
* werden ignoriert; die relative Reihenfolge der Auswahl bleibt erhalten.
|
||
* - "front"/"back": Auswahl ans Ende/an den Anfang des Arrays.
|
||
* - "forward"/"backward": Auswahl je einen Schritt Richtung Ende/Anfang.
|
||
*/
|
||
reorderDrawings: (
|
||
ids: string[],
|
||
op: "front" | "back" | "forward" | "backward",
|
||
) => void;
|
||
/**
|
||
* Skaliert die Geometrie eines Elements auf Zielbreite×-höhe (Meter) um einen
|
||
* Ankerpunkt (fx/fy ∈ [0,1] relativ zur bbox; 0,0 = oben-links).
|
||
*/
|
||
resizeElement: (
|
||
kind: "wall" | "drawing2d",
|
||
id: string,
|
||
w: number,
|
||
h: number,
|
||
anchor: { fx: number; fy: number },
|
||
) => void;
|
||
|
||
// ── Transformation (Bewegen/Spiegeln/Drehen) committen ────────────────────
|
||
commitTransformOn: (
|
||
sel: TransformSelection,
|
||
op: TransformOp,
|
||
points: Vec2[],
|
||
copyMode: CopyMode,
|
||
count: number,
|
||
) => void;
|
||
|
||
// ── Wand-Attribute (Object-Info-Panel) ────────────────────────────────────
|
||
/** Generischer immutabler Patch auf eine Wand (per ID). */
|
||
updateWall: (id: string, patch: Partial<import("../model/types").Wall>) => void;
|
||
/**
|
||
* Setzt die Gesamtdicke einer EINSCHICHTIGEN Wand. Ansatz: editiert die
|
||
* Schichtdicke des Wandtyps. Ist der Wandtyp mehrschichtig oder von mehr als
|
||
* dieser einen Wand genutzt, wird ein dedizierter einschichtiger Wandtyp
|
||
* (Klon der ersten Schicht) erzeugt und der Wand zugewiesen — so wirkt die
|
||
* Dicke nur auf diese Wand und teilt keine Presets versehentlich.
|
||
*/
|
||
setWallThickness: (wallId: string, thickness: number) => void;
|
||
|
||
// ── Decken-Editieren ──────────────────────────────────────────────────────
|
||
/** Verschiebt EINEN Umriss-Vertex (per Index) einer Decke. */
|
||
moveCeilingGrip: (ceilingId: string, index: number, pt: Vec2) => void;
|
||
/** Verschiebt eine Decke (ganzer Umriss) um `delta`. */
|
||
moveCeilingBy: (ceilingId: string, delta: Vec2) => void;
|
||
/** Verschiebt eine Umriss-KANTE (beide Vertices) einer Decke um `delta`. */
|
||
moveCeilingEdge: (
|
||
ceilingId: string,
|
||
aIndex: number,
|
||
bIndex: number,
|
||
delta: Vec2,
|
||
) => void;
|
||
/** Generischer immutabler Patch auf eine Decke (per ID). */
|
||
updateCeiling: (
|
||
id: string,
|
||
patch: Partial<import("../model/types").Ceiling>,
|
||
) => void;
|
||
/** Setzt die Gesamtdicke einer Decke (thickness-Übersteuerung, Meter). */
|
||
setCeilingThickness: (ceilingId: string, thickness: number) => void;
|
||
|
||
// ── Öffnungs-Editieren (Object-Info-Panel) ────────────────────────────────
|
||
/** Generischer immutabler Patch auf eine Öffnung (per ID). */
|
||
updateOpening: (
|
||
id: string,
|
||
patch: Partial<import("../model/types").Opening>,
|
||
) => void;
|
||
|
||
/** Immutable Änderung eines Dachs (Form/Neigung/Überstand/Firstrichtung/Dicke). */
|
||
updateRoof: (
|
||
id: string,
|
||
patch: Partial<import("../model/types").Roof>,
|
||
) => void;
|
||
/** Dach-Eckgriff (3D): Umriss-Ecke `index` auf `pt`, Gegenecke fix (Rechteck). */
|
||
moveRoofGrip: (roofId: string, index: number, pt: import("../model/types").Vec2) => void;
|
||
/** Dach als Ganzes um `delta` verschieben (Verschiebe-Griff). */
|
||
moveRoofBy: (roofId: string, delta: import("../model/types").Vec2) => void;
|
||
|
||
// ── Treppen-Editieren ──────────────────────────────────────────────────────
|
||
/** Verschiebt eine Treppe (Antritt) um `delta`. */
|
||
moveStairBy: (stairId: string, delta: Vec2) => void;
|
||
/** Generischer immutabler Patch auf eine Treppe (per ID). */
|
||
updateStair: (
|
||
id: string,
|
||
patch: Partial<import("../model/types").Stair>,
|
||
) => void;
|
||
|
||
// ── Raum-Editieren ──────────────────────────────────────────────────────────
|
||
/** Verschiebt einen Raum (ganzer Umriss + optionaler Stempel-Anker) um `delta`. */
|
||
moveRoomBy: (roomId: string, delta: Vec2) => void;
|
||
/** Verschiebt EINEN Umriss-Vertex (per Index) eines Raums. */
|
||
moveRoomGrip: (roomId: string, index: number, pt: Vec2) => void;
|
||
/** Verschiebt eine Umriss-KANTE (beide Vertices) eines Raums um `delta`. */
|
||
moveRoomEdge: (
|
||
roomId: string,
|
||
aIndex: number,
|
||
bIndex: number,
|
||
delta: Vec2,
|
||
) => void;
|
||
/** Generischer immutabler Patch auf einen Raum (per ID). */
|
||
updateRoom: (
|
||
id: string,
|
||
patch: Partial<import("../model/types").Room>,
|
||
) => void;
|
||
/** Verschiebt NUR den Stempel-Anker eines Raums um `delta` (Kontur bleibt). */
|
||
moveRoomStamp: (roomId: string, delta: Vec2) => void;
|
||
/** Setzt den Stempel-Anker eines Raums absolut. */
|
||
setRoomStampAnchor: (roomId: string, anchor: Vec2) => void;
|
||
/** Setzt den Rich-Text des Raum-Stempels. */
|
||
setRoomStampDoc: (
|
||
roomId: string,
|
||
doc: import("../model/types").RichTextDoc,
|
||
) => void;
|
||
/** Setzt den strukturierten Raum-Stempel (Feldmodell). */
|
||
setRoomStamp: (
|
||
roomId: string,
|
||
stamp: import("../model/types").RoomStamp,
|
||
) => void;
|
||
}
|
||
|
||
/**
|
||
* Minimaler View-Ausschnitt, den Projekt-Aktionen lesen/schreiben dürfen
|
||
* (Cross-Slice). Der Store komponiert ProjectSlice mit dieser Sicht, sodass
|
||
* `get()`/`set` typsicher auf `activeLevelId` zugreifen können. Die
|
||
* History-Felder gehören ebenfalls dazu: `setProject` (unten) ist der EINZIGE
|
||
* Durchlaufpunkt jeder Projekt-Mutation und pusht dort bei jeder ECHTEN
|
||
* Änderung auf `undoStack`.
|
||
*/
|
||
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(
|
||
api: StoreApi<ProjectSlice & ProjectSliceDeps>,
|
||
): ProjectSlice & HistorySlice {
|
||
const { set, get } = api;
|
||
|
||
// Koaleszenz-Buchführung für Drag-Serien (Griff ziehen, Element verschieben,
|
||
// Kante ziehen, …): diese Aktionen rufen `setProject` bei JEDEM
|
||
// Maus-Move-Schritt auf (kein separates Draft/Preview + Commit-Muster wie
|
||
// bei den Zeichenwerkzeugen — dort läuft die Vorschau über lokalen
|
||
// `draft`-State in App.tsx, und `setProject` wird erst einmal beim Commit
|
||
// aufgerufen, siehe `applyToolResult`/`onToolCommit` in App.tsx). Ohne
|
||
// Koaleszenz würde EIN Drag zig Undo-Schritte erzeugen. Ephemer (wie ein
|
||
// Ref) — kein reaktiver State nötig, wird bei jedem `setProject`-Aufruf neu
|
||
// bewertet und bei `undo`/`redo` zurückgesetzt (siehe `onAfterJump` unten).
|
||
let lastCoalesceKey: string | undefined;
|
||
let lastCoalesceAt = 0;
|
||
const COALESCE_WINDOW_MS = 400;
|
||
|
||
const history = createHistorySlice(
|
||
api as unknown as StoreApi<HistorySlice & { project: Project }>,
|
||
() => {
|
||
// Nach einem Sprung (undo/redo) muss die NÄCHSTE Aktion garantiert
|
||
// pushen, auch wenn sie zufällig denselben coalesceKey trägt wie die
|
||
// Serie vor dem Sprung — sonst würde sie mit der alten Serie
|
||
// verschmolzen und verschwände spurlos (kein Undo-Eintrag).
|
||
lastCoalesceKey = undefined;
|
||
},
|
||
);
|
||
|
||
// Bequemer immutabler Projekt-Updater (wie setProject in App). Zweiter,
|
||
// optionaler Parameter `coalesceKey`: NUR von den paar bekannten
|
||
// Hochfrequenz-Aktionen (Grip-Drag & Co., s. u.) gesetzt — alle anderen ~50
|
||
// Aktionen in dieser Datei rufen `setProject(fn)` ohne Key auf und pushen
|
||
// damit IMMER diskret (kein Koaleszieren zwischen unterschiedlichen
|
||
// Aktionen möglich, auch nicht bei zufällig identischem Timing in Tests).
|
||
const setProject = (
|
||
next: Project | ((p: Project) => Project),
|
||
coalesceKey?: string,
|
||
) =>
|
||
set((s) => {
|
||
const nextProject =
|
||
typeof next === "function" ? (next as (p: Project) => Project)(s.project) : next;
|
||
// No-Op-Schutz: echte Immutable-Updates liefern bei tatsächlicher
|
||
// Änderung immer eine NEUE Referenz — bleibt sie gleich, gab es keine
|
||
// Änderung, also kein History-Eintrag (und `set` selbst benachrichtigt
|
||
// dann ebenfalls nicht, da der Patch leer ist).
|
||
if (nextProject === s.project) return {};
|
||
const now = Date.now();
|
||
const coalesced =
|
||
coalesceKey !== undefined &&
|
||
coalesceKey === lastCoalesceKey &&
|
||
now - lastCoalesceAt < COALESCE_WINDOW_MS;
|
||
lastCoalesceKey = coalesceKey;
|
||
lastCoalesceAt = now;
|
||
if (coalesced) return { project: nextProject };
|
||
return { project: nextProject, ...pushHistoryPatch(s.undoStack, s.project) };
|
||
});
|
||
|
||
return {
|
||
...history,
|
||
|
||
// TEMPORÄR für manuellen Test (Nutzer-Wunsch): leeres Projekt statt Demo-
|
||
// Haus. NICHT committen — vor dem nächsten echten Commit zurück auf
|
||
// `sampleProject` stellen.
|
||
project: emptyProject,
|
||
|
||
setProject,
|
||
|
||
// ── Zeichnungsebenen ───────────────────────────────────────────────────
|
||
toggleLevelVisible: (id) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawingLevels: p.drawingLevels.map((z) =>
|
||
z.id === id ? { ...z, visible: !z.visible } : z,
|
||
),
|
||
})),
|
||
|
||
patchActiveFloor: (patch) => {
|
||
const activeLevelId = get().activeLevelId;
|
||
setProject((p) => ({
|
||
...p,
|
||
drawingLevels: recomputeFloorElevations(
|
||
p.drawingLevels.map((z) =>
|
||
z.id === activeLevelId ? { ...z, ...patch } : z,
|
||
),
|
||
),
|
||
}));
|
||
},
|
||
|
||
addFloor: () =>
|
||
setProject((p) => {
|
||
const n = p.drawingLevels.filter((z) => z.kind === "floor").length + 1;
|
||
const floor: DrawingLevel = {
|
||
id: `floor-${Date.now()}`,
|
||
name: t("default.floorName", { n }),
|
||
kind: "floor",
|
||
visible: true,
|
||
locked: false,
|
||
floorHeight: 2.6,
|
||
cutHeight: 1.0,
|
||
};
|
||
return {
|
||
...p,
|
||
drawingLevels: recomputeFloorElevations([...p.drawingLevels, floor]),
|
||
};
|
||
}),
|
||
|
||
addDrawing: () =>
|
||
setProject((p) => {
|
||
const n = p.drawingLevels.filter((z) => z.kind === "drawing").length + 1;
|
||
const drawing: DrawingLevel = {
|
||
id: `drawing-${Date.now()}`,
|
||
name: t("default.drawingName", { n }),
|
||
kind: "drawing",
|
||
visible: true,
|
||
locked: false,
|
||
};
|
||
return { ...p, drawingLevels: [...p.drawingLevels, drawing] };
|
||
}),
|
||
|
||
// Schnitt/Ansicht sind vorerst Platzhalter ohne Schnittlinie (linePoints
|
||
// bleibt undefined, bis die Schnitt-UI existiert — siehe toSection.ts).
|
||
addSection: () =>
|
||
setProject((p) => {
|
||
const n = p.drawingLevels.filter((z) => z.kind === "section").length + 1;
|
||
const section: DrawingLevel = {
|
||
id: `section-${Date.now()}`,
|
||
name: t("default.sectionName", { n }),
|
||
kind: "section",
|
||
visible: true,
|
||
locked: false,
|
||
};
|
||
return { ...p, drawingLevels: [...p.drawingLevels, section] };
|
||
}),
|
||
|
||
addElevation: () =>
|
||
setProject((p) => {
|
||
const n = p.drawingLevels.filter((z) => z.kind === "elevation").length + 1;
|
||
const elevation: DrawingLevel = {
|
||
id: `elevation-${Date.now()}`,
|
||
name: t("default.elevationName", { n }),
|
||
kind: "elevation",
|
||
visible: true,
|
||
locked: false,
|
||
};
|
||
return { ...p, drawingLevels: [...p.drawingLevels, elevation] };
|
||
}),
|
||
|
||
// Neue freie Zeichnungsebene mit explizitem Namen anlegen und ihre ID
|
||
// zurückgeben (Ziel-Ebene eines DXF-Imports). Der Name kommt vom Aufrufer
|
||
// (Dialog); leere Eingabe fällt auf den Default-Namen zurück.
|
||
addDrawingLevel: (name) => {
|
||
const id = `drawing-${Date.now()}`;
|
||
setProject((p) => {
|
||
const n = p.drawingLevels.filter((z) => z.kind === "drawing").length + 1;
|
||
const drawing: DrawingLevel = {
|
||
id,
|
||
name: name.trim() || t("default.drawingName", { n }),
|
||
kind: "drawing",
|
||
visible: true,
|
||
locked: false,
|
||
};
|
||
return { ...p, drawingLevels: [...p.drawingLevels, drawing] };
|
||
});
|
||
return id;
|
||
},
|
||
|
||
importDrawings: (drawings) =>
|
||
setProject((p) =>
|
||
drawings.length
|
||
? { ...p, drawings2d: [...p.drawings2d, ...drawings] }
|
||
: p,
|
||
),
|
||
|
||
patchLevel: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawingLevels: recomputeFloorElevations(
|
||
p.drawingLevels.map((z) => (z.id === id ? { ...z, ...patch } : z)),
|
||
),
|
||
})),
|
||
|
||
duplicateLevel: (id) =>
|
||
setProject((p) => {
|
||
const i = p.drawingLevels.findIndex((z) => z.id === id);
|
||
if (i < 0) return p;
|
||
const src = p.drawingLevels[i];
|
||
const clone: DrawingLevel = {
|
||
...src,
|
||
id: `${src.kind}-${Date.now()}`,
|
||
name: t("default.copySuffixLevel", { name: src.name }),
|
||
};
|
||
const next = [...p.drawingLevels];
|
||
next.splice(i + 1, 0, clone);
|
||
return { ...p, drawingLevels: recomputeFloorElevations(next) };
|
||
}),
|
||
|
||
// Zeichnungsebene löschen (≥1 behalten). War es das aktive Geschoss, auf
|
||
// die erste verbleibende Ebene wechseln (View-Feld, Cross-Slice).
|
||
deleteLevel: (id) => {
|
||
const { project, activeLevelId } = get();
|
||
if (project.drawingLevels.length <= 1) return; // letzte behalten
|
||
if (!project.drawingLevels.some((z) => z.id === id)) return;
|
||
if (activeLevelId === id) {
|
||
const fallback = project.drawingLevels.find((z) => z.id !== id);
|
||
if (fallback) set({ activeLevelId: fallback.id });
|
||
}
|
||
setProject((p) => {
|
||
const removedWallIds = new Set(
|
||
p.walls.filter((w) => w.floorId === id).map((w) => w.id),
|
||
);
|
||
return {
|
||
...p,
|
||
drawingLevels: recomputeFloorElevations(
|
||
p.drawingLevels.filter((z) => z.id !== id),
|
||
),
|
||
walls: p.walls.filter((w) => w.floorId !== id),
|
||
ceilings: (p.ceilings ?? []).filter((c) => c.floorId !== id),
|
||
stairs: (p.stairs ?? []).filter((s) => s.floorId !== id),
|
||
rooms: (p.rooms ?? []).filter((r) => r.floorId !== id),
|
||
doors: p.doors.filter((d) => !removedWallIds.has(d.hostWallId)),
|
||
openings: (p.openings ?? []).filter(
|
||
(o) => !removedWallIds.has(o.hostWallId),
|
||
),
|
||
};
|
||
});
|
||
},
|
||
|
||
// ── Ebenen (Kategorien) ────────────────────────────────────────────────
|
||
toggleCategoryVisible: (code) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
layers: toggleCategoryByCode(p.layers, code),
|
||
})),
|
||
|
||
addCategory: () =>
|
||
setProject((p) => {
|
||
const code = nextFreeCode(p.layers);
|
||
const category: LayerCategory = {
|
||
code,
|
||
name: t("default.categoryName"),
|
||
color: "#888888",
|
||
lw: 0.18,
|
||
visible: true,
|
||
locked: false,
|
||
};
|
||
return { ...p, layers: [...p.layers, category] };
|
||
}),
|
||
|
||
patchCategory: (code, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
layers: patchCategoryByCode(p.layers, code, patch),
|
||
})),
|
||
|
||
addSubCategory: (parentCode) =>
|
||
setProject((p) => {
|
||
const code = nextFreeCode(p.layers);
|
||
const child: LayerCategory = {
|
||
code,
|
||
name: t("default.subCategoryName"),
|
||
color: "#888888",
|
||
lw: 0.18,
|
||
visible: true,
|
||
locked: false,
|
||
};
|
||
return { ...p, layers: addChildByCode(p.layers, parentCode, child) };
|
||
}),
|
||
|
||
duplicateCategory: (code) =>
|
||
setProject((p) => {
|
||
const src = findCategory(p.layers, code);
|
||
if (!src) return p;
|
||
const clone: LayerCategory = {
|
||
...src,
|
||
code: nextFreeCode(p.layers),
|
||
name: t("default.copySuffixLayer", { name: src.name }),
|
||
children: undefined,
|
||
};
|
||
return { ...p, layers: insertAfterCode(p.layers, code, clone) };
|
||
}),
|
||
|
||
deleteCategory: (code) =>
|
||
setProject((p) => {
|
||
if (flattenCategories(p.layers).length <= 1) return p; // letzte behalten
|
||
const src = findCategory(p.layers, code);
|
||
if (!src) return p;
|
||
const codes = new Set(flattenCategories([src]).map((c) => c.code));
|
||
const usedByWall = p.walls.some((w) => codes.has(w.categoryCode));
|
||
const usedByDoor = p.doors.some((d) => codes.has(d.categoryCode));
|
||
const usedByOpening = (p.openings ?? []).some((o) =>
|
||
codes.has(o.categoryCode),
|
||
);
|
||
if (usedByWall || usedByDoor || usedByOpening) {
|
||
get().notify(t("alert.layerInUse", { code: src.code, name: src.name }), "warning");
|
||
return p;
|
||
}
|
||
return { ...p, layers: removeByCode(p.layers, code) };
|
||
}),
|
||
|
||
assignWallCategory: (wallId, code) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
walls: p.walls.map((w) =>
|
||
w.id === wallId ? { ...w, categoryCode: code } : w,
|
||
),
|
||
})),
|
||
|
||
// ── Sichtbarkeits-Kombinationen ────────────────────────────────────────
|
||
snapshotLayerVisibility: () => {
|
||
const out: Record<string, boolean> = {};
|
||
for (const c of flattenCategories(get().project.layers)) {
|
||
out[c.code] = c.visible;
|
||
}
|
||
return out;
|
||
},
|
||
|
||
applyLayerVisibility: (codes) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
layers: applyVisibilityByCode(p.layers, codes),
|
||
})),
|
||
|
||
snapshotDrawingVisibility: () => {
|
||
const out: Record<string, boolean> = {};
|
||
for (const z of get().project.drawingLevels) out[z.id] = z.visible;
|
||
return out;
|
||
},
|
||
|
||
applyDrawingVisibility: (ids) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawingLevels: p.drawingLevels.map((z) =>
|
||
z.id in ids ? { ...z, visible: ids[z.id] } : z,
|
||
),
|
||
})),
|
||
|
||
// ── Ressourcen ─────────────────────────────────────────────────────────
|
||
patchComponent: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
components: p.components.map((c) =>
|
||
c.id === id ? { ...c, ...patch } : c,
|
||
),
|
||
})),
|
||
|
||
addComponent: () =>
|
||
setProject((p) => {
|
||
let hatches = p.hatches;
|
||
let hatchId = hatches[0]?.id;
|
||
if (!hatchId) {
|
||
const fallback: HatchStyle = {
|
||
id: `hatch-${Date.now()}`,
|
||
name: t("default.hatchNone"),
|
||
pattern: "none",
|
||
scale: 1,
|
||
angle: 0,
|
||
color: "#2b3039",
|
||
};
|
||
hatches = [fallback];
|
||
hatchId = fallback.id;
|
||
}
|
||
const component: Component = {
|
||
id: `component-${Date.now()}`,
|
||
name: t("default.componentName"),
|
||
color: "#9aa0a6",
|
||
hatchId,
|
||
joinPriority: 50,
|
||
};
|
||
return { ...p, hatches, components: [...p.components, component] };
|
||
}),
|
||
|
||
deleteComponent: (id) =>
|
||
setProject((p) => {
|
||
const used = p.wallTypes.some((wt) =>
|
||
wt.layers.some((l) => l.componentId === id),
|
||
);
|
||
if (used) {
|
||
const name = p.components.find((c) => c.id === id)?.name ?? id;
|
||
get().notify(t("alert.componentInUse", { name }), "warning");
|
||
return p;
|
||
}
|
||
return { ...p, components: p.components.filter((c) => c.id !== id) };
|
||
}),
|
||
|
||
patchHatch: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
hatches: p.hatches.map((h) => (h.id === id ? { ...h, ...patch } : h)),
|
||
})),
|
||
|
||
addHatch: () =>
|
||
setProject((p) => {
|
||
const hatch: HatchStyle = {
|
||
id: `hatch-${Date.now()}`,
|
||
name: t("default.hatchName"),
|
||
pattern: "diagonal",
|
||
scale: 1,
|
||
angle: 45,
|
||
color: "#2b3039",
|
||
lineStyleId: p.lineStyles[0]?.id,
|
||
};
|
||
return { ...p, hatches: [...p.hatches, hatch] };
|
||
}),
|
||
|
||
deleteHatch: (id) =>
|
||
setProject((p) => {
|
||
const used = p.components.some((c) => c.hatchId === id);
|
||
if (used) {
|
||
const name = p.hatches.find((h) => h.id === id)?.name ?? id;
|
||
get().notify(t("alert.hatchInUse", { name }), "warning");
|
||
return p;
|
||
}
|
||
return { ...p, hatches: p.hatches.filter((h) => h.id !== id) };
|
||
}),
|
||
|
||
patchLineStyle: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
lineStyles: p.lineStyles.map((l) =>
|
||
l.id === id ? { ...l, ...patch } : l,
|
||
),
|
||
})),
|
||
|
||
addLineStyle: () =>
|
||
setProject((p) => {
|
||
const lineStyle: LineStyle = {
|
||
id: `line-${Date.now()}`,
|
||
name: t("default.lineStyleName"),
|
||
weight: 0.18,
|
||
color: "#2b3039",
|
||
dash: null,
|
||
};
|
||
return { ...p, lineStyles: [...p.lineStyles, lineStyle] };
|
||
}),
|
||
|
||
deleteLineStyle: (id) =>
|
||
setProject((p) => {
|
||
const used = p.hatches.some((h) => h.lineStyleId === id);
|
||
if (used) {
|
||
const name = p.lineStyles.find((l) => l.id === id)?.name ?? id;
|
||
get().notify(t("alert.lineStyleInUse", { name }), "warning");
|
||
return p;
|
||
}
|
||
return { ...p, lineStyles: p.lineStyles.filter((l) => l.id !== id) };
|
||
}),
|
||
|
||
importLineStyles: (styles) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
lineStyles: [
|
||
...p.lineStyles,
|
||
...styles.map((s, i) => ({ ...s, id: `ls-${Date.now()}-${i}` })),
|
||
],
|
||
})),
|
||
|
||
importHatches: (hatches) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
hatches: [
|
||
...p.hatches,
|
||
...hatches.map((h, i) => ({ ...h, id: `ht-${Date.now()}-${i}` })),
|
||
],
|
||
})),
|
||
|
||
// ── Editier-Griffe ─────────────────────────────────────────────────────
|
||
// Diese Aktionen laufen AD-HOC bei jedem Maus-Move-Schritt eines Drags
|
||
// (kein Draft/Commit-Muster wie bei den Zeichenwerkzeugen) — daher mit
|
||
// coalesceKey, damit EIN Drag EINEN Undo-Schritt erzeugt statt vieler.
|
||
moveGripOf: (drawingId, wallId, index, pt) =>
|
||
setProject(
|
||
(p) => moveGrip(p, drawingId, wallId, index, pt),
|
||
`moveGripOf:${drawingId ?? ""}:${wallId ?? ""}:${index}`,
|
||
),
|
||
|
||
moveElementByOf: (drawingId, wallId, delta) =>
|
||
setProject(
|
||
(p) => moveElementBy(p, drawingId, wallId, delta),
|
||
`moveElementByOf:${drawingId ?? ""}:${wallId ?? ""}`,
|
||
),
|
||
|
||
moveEdgeOf: (drawingId, wallId, aIndex, bIndex, delta) =>
|
||
setProject(
|
||
(p) => moveEdge(p, drawingId, wallId, aIndex, bIndex, delta),
|
||
`moveEdgeOf:${drawingId ?? ""}:${wallId ?? ""}:${aIndex}:${bIndex}`,
|
||
),
|
||
|
||
// ── Selektions-Attribute ───────────────────────────────────────────────
|
||
setElementColor: (kind, id, color) =>
|
||
setProject((p) => {
|
||
if (kind === "drawing2d") {
|
||
return {
|
||
...p,
|
||
drawings2d: p.drawings2d.map((d) =>
|
||
d.id === id ? { ...d, color } : d,
|
||
),
|
||
};
|
||
}
|
||
return {
|
||
...p,
|
||
walls: p.walls.map((w) => (w.id === id ? { ...w, color } : w)),
|
||
};
|
||
}),
|
||
|
||
// Strichstärke gibt es als direktes Feld NUR auf Drawing2D; bei unbekannter
|
||
// ID bleibt das Projekt unverändert (No-op).
|
||
setElementWeight: (id, weightMm) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawings2d: p.drawings2d.map((d) =>
|
||
d.id === id ? { ...d, weightMm } : d,
|
||
),
|
||
})),
|
||
|
||
// Füllschraffur NUR für Drawing2D; `null` entfernt die Schraffur (Feld weg).
|
||
setElementFill: (id, hatchId) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawings2d: p.drawings2d.map((d) => {
|
||
if (d.id !== id) return d;
|
||
if (hatchId === null) {
|
||
const { hatchId: _omit, ...rest } = d;
|
||
return rest;
|
||
}
|
||
return { ...d, hatchId };
|
||
}),
|
||
})),
|
||
|
||
// Vollton-Füllfarbe NUR für Drawing2D; `null` entfernt das Feld.
|
||
setElementFillColor: (id, fillColor) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawings2d: p.drawings2d.map((d) => {
|
||
if (d.id !== id) return d;
|
||
if (fillColor === null) {
|
||
const { fillColor: _omit, ...rest } = d;
|
||
return rest;
|
||
}
|
||
return { ...d, fillColor };
|
||
}),
|
||
})),
|
||
|
||
// Vordergrund-/Hintergrund-Override (Wand/Decke/Drawing2D); `null` löscht
|
||
// das Feld (→ „Nach System", erbt wieder vom Bauteil/System).
|
||
setElementForeground: (kind, id, value) =>
|
||
setProject((p) => setOverrideField(p, kind, id, "foreground", value)),
|
||
setElementBackground: (kind, id, value) =>
|
||
setProject((p) => setOverrideField(p, kind, id, "background", value)),
|
||
|
||
updateDrawing2D: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawings2d: p.drawings2d.map((d) => (d.id === id ? { ...d, ...patch } : d)),
|
||
})),
|
||
|
||
reorderDrawings: (ids, op) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
drawings2d: reorderDrawings2D(p.drawings2d, ids, op),
|
||
})),
|
||
|
||
// ── Größe (Resize um Anker) ────────────────────────────────────────────
|
||
resizeElement: (kind, id, w, h, anchor) =>
|
||
setProject((p) => resizeElement(p, kind, id, w, h, anchor)),
|
||
|
||
// ── Transformation ─────────────────────────────────────────────────────
|
||
commitTransformOn: (sel, op, points, copyMode, count) =>
|
||
setProject((p) => commitTransform(p, sel, op, points, copyMode, count)),
|
||
|
||
// ── Wand-Attribute ─────────────────────────────────────────────────────
|
||
updateWall: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
walls: p.walls.map((w) => (w.id === id ? { ...w, ...patch } : w)),
|
||
})),
|
||
|
||
setWallThickness: (wallId, thickness) =>
|
||
setProject((p) => setWallThickness(p, wallId, thickness)),
|
||
|
||
// ── Decken-Editieren ──────────────────────────────────────────────────
|
||
// (coalesceKey: siehe Kommentar bei moveGripOf — auch hier läuft die
|
||
// Mutation bei jedem Maus-Move-Schritt eines Drags.)
|
||
moveCeilingGrip: (ceilingId, index, pt) =>
|
||
setProject(
|
||
(p) => mapCeiling(p, ceilingId, (c) => ({
|
||
...c,
|
||
outline: c.outline.map((v, i) => (i === index ? pt : v)),
|
||
})),
|
||
`moveCeilingGrip:${ceilingId}:${index}`,
|
||
),
|
||
|
||
moveCeilingBy: (ceilingId, delta) =>
|
||
setProject(
|
||
(p) => mapCeiling(p, ceilingId, (c) => ({
|
||
...c,
|
||
outline: c.outline.map((v) => ({ x: v.x + delta.x, y: v.y + delta.y })),
|
||
})),
|
||
`moveCeilingBy:${ceilingId}`,
|
||
),
|
||
|
||
moveCeilingEdge: (ceilingId, aIndex, bIndex, delta) =>
|
||
setProject(
|
||
(p) => mapCeiling(p, ceilingId, (c) => ({
|
||
...c,
|
||
outline: c.outline.map((v, i) =>
|
||
i === aIndex || i === bIndex ? { x: v.x + delta.x, y: v.y + delta.y } : v,
|
||
),
|
||
})),
|
||
`moveCeilingEdge:${ceilingId}:${aIndex}:${bIndex}`,
|
||
),
|
||
|
||
updateCeiling: (id, patch) =>
|
||
setProject((p) => mapCeiling(p, id, (c) => ({ ...c, ...patch }))),
|
||
|
||
setCeilingThickness: (ceilingId, thickness) =>
|
||
setProject((p) => setCeilingThickness(p, ceilingId, thickness)),
|
||
|
||
// ── Öffnungs-Editieren ────────────────────────────────────────────────
|
||
updateOpening: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
openings: (p.openings ?? []).map((o) => (o.id === id ? { ...o, ...patch } : o)),
|
||
})),
|
||
|
||
// ── Dach-Editieren ─────────────────────────────────────────────────────
|
||
updateRoof: (id, patch) =>
|
||
setProject((p) => ({
|
||
...p,
|
||
roofs: (p.roofs ?? []).map((r) => (r.id === id ? { ...r, ...patch } : r)),
|
||
})),
|
||
|
||
// Dach-Eckgriff (3D): der gezogene Umriss-Eckpunkt `index` wandert auf `pt`;
|
||
// die GEGENÜBERLIEGENDE Ecke bleibt fix → der Umriss bleibt ein achsparalleles
|
||
// Rechteck (die Dach-Geometrie rechnet ohnehin auf der Bounding-Box).
|
||
moveRoofGrip: (roofId, index, pt) =>
|
||
setProject(
|
||
(p) => ({
|
||
...p,
|
||
roofs: (p.roofs ?? []).map((r) => {
|
||
if (r.id !== roofId || r.outline.length < 4) return r;
|
||
const opp = r.outline[(index + 2) % r.outline.length];
|
||
const x0 = Math.min(pt.x, opp.x);
|
||
const x1 = Math.max(pt.x, opp.x);
|
||
const y0 = Math.min(pt.y, opp.y);
|
||
const y1 = Math.max(pt.y, opp.y);
|
||
return {
|
||
...r,
|
||
outline: [
|
||
{ x: x0, y: y0 },
|
||
{ x: x1, y: y0 },
|
||
{ x: x1, y: y1 },
|
||
{ x: x0, y: y1 },
|
||
],
|
||
};
|
||
}),
|
||
}),
|
||
`moveRoofGrip:${roofId}`,
|
||
),
|
||
|
||
// Dach als Ganzes verschieben (Verschiebe-Griff).
|
||
moveRoofBy: (roofId, delta) =>
|
||
setProject(
|
||
(p) => ({
|
||
...p,
|
||
roofs: (p.roofs ?? []).map((r) =>
|
||
r.id === roofId
|
||
? { ...r, outline: r.outline.map((v) => ({ x: v.x + delta.x, y: v.y + delta.y })) }
|
||
: r,
|
||
),
|
||
}),
|
||
`moveRoofBy:${roofId}`,
|
||
),
|
||
|
||
// ── Treppen-Editieren ──────────────────────────────────────────────────
|
||
// (coalesceKey: siehe Kommentar bei moveGripOf.)
|
||
moveStairBy: (stairId, delta) =>
|
||
setProject(
|
||
(p) =>
|
||
mapStair(p, stairId, (s) => ({
|
||
...s,
|
||
start: { x: s.start.x + delta.x, y: s.start.y + delta.y },
|
||
...(s.center
|
||
? { center: { x: s.center.x + delta.x, y: s.center.y + delta.y } }
|
||
: {}),
|
||
})),
|
||
`moveStairBy:${stairId}`,
|
||
),
|
||
|
||
updateStair: (id, patch) =>
|
||
setProject((p) => mapStair(p, id, (s) => ({ ...s, ...patch }))),
|
||
|
||
// ── Raum-Editieren ──────────────────────────────────────────────────────
|
||
// (coalesceKey: siehe Kommentar bei moveGripOf.)
|
||
moveRoomBy: (roomId, delta) =>
|
||
setProject(
|
||
(p) =>
|
||
mapRoom(p, roomId, (r) => ({
|
||
...r,
|
||
boundary: r.boundary.map((v) => ({ x: v.x + delta.x, y: v.y + delta.y })),
|
||
...(r.stampAnchor
|
||
? {
|
||
stampAnchor: {
|
||
x: r.stampAnchor.x + delta.x,
|
||
y: r.stampAnchor.y + delta.y,
|
||
},
|
||
}
|
||
: {}),
|
||
})),
|
||
`moveRoomBy:${roomId}`,
|
||
),
|
||
|
||
moveRoomGrip: (roomId, index, pt) =>
|
||
setProject(
|
||
(p) =>
|
||
mapRoom(p, roomId, (r) => ({
|
||
...r,
|
||
boundary: r.boundary.map((v, i) => (i === index ? pt : v)),
|
||
})),
|
||
`moveRoomGrip:${roomId}:${index}`,
|
||
),
|
||
|
||
moveRoomEdge: (roomId, aIndex, bIndex, delta) =>
|
||
setProject(
|
||
(p) =>
|
||
mapRoom(p, roomId, (r) => ({
|
||
...r,
|
||
boundary: r.boundary.map((v, i) =>
|
||
i === aIndex || i === bIndex
|
||
? { x: v.x + delta.x, y: v.y + delta.y }
|
||
: v,
|
||
),
|
||
})),
|
||
`moveRoomEdge:${roomId}:${aIndex}:${bIndex}`,
|
||
),
|
||
|
||
updateRoom: (id, patch) =>
|
||
setProject((p) => mapRoom(p, id, (r) => ({ ...r, ...patch }))),
|
||
|
||
moveRoomStamp: (roomId, delta) =>
|
||
setProject((p) =>
|
||
mapRoom(p, roomId, (r) => {
|
||
const base = r.stampAnchor ?? roomCentroid(r.boundary);
|
||
return {
|
||
...r,
|
||
stampAnchor: { x: base.x + delta.x, y: base.y + delta.y },
|
||
};
|
||
}),
|
||
),
|
||
|
||
setRoomStampAnchor: (roomId, anchor) =>
|
||
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stampAnchor: anchor }))),
|
||
|
||
setRoomStampDoc: (roomId, doc) =>
|
||
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stampDoc: doc }))),
|
||
|
||
setRoomStamp: (roomId, stamp) =>
|
||
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stamp }))),
|
||
};
|
||
}
|
||
|
||
/** Immutabler Map über einen Raum (per ID); No-op ohne Treffer. */
|
||
function mapRoom(
|
||
project: Project,
|
||
roomId: string,
|
||
fn: (r: import("../model/types").Room) => import("../model/types").Room,
|
||
): Project {
|
||
const rooms = project.rooms ?? [];
|
||
return {
|
||
...project,
|
||
rooms: rooms.map((r) => (r.id === roomId ? fn(r) : r)),
|
||
};
|
||
}
|
||
|
||
/** Immutabler Map über eine Treppe (per ID); No-op ohne Treffer. */
|
||
function mapStair(
|
||
project: Project,
|
||
stairId: string,
|
||
fn: (s: import("../model/types").Stair) => import("../model/types").Stair,
|
||
): Project {
|
||
const stairs = project.stairs ?? [];
|
||
return {
|
||
...project,
|
||
stairs: stairs.map((s) => (s.id === stairId ? fn(s) : s)),
|
||
};
|
||
}
|
||
|
||
/** Immutabler Map über eine Decke (per ID); No-op ohne Treffer. */
|
||
/**
|
||
* Setzt/entfernt einen optionalen Farb-Override (`foreground`/`background`) an
|
||
* einem Element (Wand, Decke oder Drawing2D). `value === null` entfernt das Feld
|
||
* (→ „Nach System", erbt wieder). Immutable; unbekannte IDs sind ein No-op.
|
||
*/
|
||
function setOverrideField(
|
||
project: Project,
|
||
kind: "wall" | "ceiling" | "drawing2d",
|
||
id: string,
|
||
field: "foreground" | "background",
|
||
value: string | null,
|
||
): Project {
|
||
const apply = <T extends { id: string }>(item: T): T => {
|
||
if (item.id !== id) return item;
|
||
if (value === null) {
|
||
const { [field]: _omit, ...rest } = item as Record<string, unknown>;
|
||
return rest as unknown as T;
|
||
}
|
||
return { ...item, [field]: value };
|
||
};
|
||
if (kind === "wall") return { ...project, walls: project.walls.map(apply) };
|
||
if (kind === "ceiling")
|
||
return { ...project, ceilings: (project.ceilings ?? []).map(apply) };
|
||
return { ...project, drawings2d: project.drawings2d.map(apply) };
|
||
}
|
||
|
||
/**
|
||
* Z-Anordnen im Grundriss: `drawings2d` IST die Zeichenreihenfolge (spätere
|
||
* Position = weiter oben, siehe `generatePlan.ts`). Unbekannte IDs werden
|
||
* ignoriert; reine, immutable Umsortierung.
|
||
*
|
||
* - "front"/"back": die gewählten Elemente wandern gesammelt ans Ende/an den
|
||
* Anfang des Arrays, ihre relative Reihenfolge untereinander bleibt erhalten.
|
||
* - "forward"/"backward": jedes gewählte Element rückt einzeln um eine
|
||
* Position Richtung Ende/Anfang vor. Bei "forward" von hinten nach vorn
|
||
* iteriert (sonst würde ein vorderes Element ein gerade verschobenes
|
||
* sofort wieder überholen); bei "backward" entsprechend von vorn nach
|
||
* hinten. Über die Array-Grenzen hinaus passiert nichts (kein Wrap).
|
||
*/
|
||
function reorderDrawings2D(
|
||
drawings: Drawing2D[],
|
||
ids: string[],
|
||
op: "front" | "back" | "forward" | "backward",
|
||
): Drawing2D[] {
|
||
const selected = new Set(ids.filter((id) => drawings.some((d) => d.id === id)));
|
||
if (selected.size === 0) return drawings;
|
||
|
||
if (op === "front" || op === "back") {
|
||
const picked = drawings.filter((d) => selected.has(d.id));
|
||
const rest = drawings.filter((d) => !selected.has(d.id));
|
||
return op === "front" ? [...rest, ...picked] : [...picked, ...rest];
|
||
}
|
||
|
||
// "forward" / "backward": jedes gewählte Element um genau eine Position
|
||
// tauschen — Iterationsrichtung verhindert, dass mehrfach-selektierte
|
||
// Nachbarn sich gegenseitig überholen.
|
||
const arr = drawings.slice();
|
||
if (op === "forward") {
|
||
for (let i = arr.length - 2; i >= 0; i--) {
|
||
if (selected.has(arr[i].id) && !selected.has(arr[i + 1].id)) {
|
||
[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
|
||
}
|
||
}
|
||
} else {
|
||
for (let i = 1; i < arr.length; i++) {
|
||
if (selected.has(arr[i].id) && !selected.has(arr[i - 1].id)) {
|
||
[arr[i], arr[i - 1]] = [arr[i - 1], arr[i]];
|
||
}
|
||
}
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
function mapCeiling(
|
||
project: Project,
|
||
ceilingId: string,
|
||
fn: (c: import("../model/types").Ceiling) => import("../model/types").Ceiling,
|
||
): Project {
|
||
const ceilings = project.ceilings ?? [];
|
||
return {
|
||
...project,
|
||
ceilings: ceilings.map((c) => (c.id === ceilingId ? fn(c) : c)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Setzt die Gesamtdicke einer einschichtigen Wand über ihre Wandtyp-Schicht.
|
||
* Wenn der Wandtyp einschichtig ist UND nur von dieser Wand genutzt wird, wird
|
||
* er direkt editiert. Sonst wird ein dedizierter einschichtiger Wandtyp (Klon
|
||
* der ersten Schicht des aktuellen Typs) erzeugt und der Wand zugewiesen.
|
||
*/
|
||
function setWallThickness(
|
||
project: Project,
|
||
wallId: string,
|
||
thickness: number,
|
||
): Project {
|
||
if (!isFinite(thickness) || thickness <= 0) return project;
|
||
const wall = project.walls.find((w) => w.id === wallId);
|
||
if (!wall) return project;
|
||
const wt = project.wallTypes.find((t) => t.id === wall.wallTypeId);
|
||
if (!wt || wt.layers.length === 0) return project;
|
||
|
||
const single = wt.layers.length === 1;
|
||
const usedByOthers = project.walls.some(
|
||
(w) => w.id !== wallId && w.wallTypeId === wt.id,
|
||
);
|
||
|
||
if (single && !usedByOthers) {
|
||
// Direkt die Schichtdicke des (exklusiven, einschichtigen) Wandtyps setzen.
|
||
return {
|
||
...project,
|
||
wallTypes: project.wallTypes.map((t) =>
|
||
t.id === wt.id
|
||
? { ...t, layers: [{ ...t.layers[0], thickness }] }
|
||
: t,
|
||
),
|
||
};
|
||
}
|
||
|
||
// Dedizierten einschichtigen Wandtyp erzeugen (erste Schicht klonen) und der
|
||
// Wand zuweisen — damit die Dicke nur diese Wand betrifft.
|
||
const id = `wt-${Date.now()}`;
|
||
const clone = {
|
||
id,
|
||
name: t("default.wallTypeSingle", { thickness: thickness.toFixed(2) }),
|
||
layers: [{ ...wt.layers[0], thickness }],
|
||
};
|
||
return {
|
||
...project,
|
||
wallTypes: [...project.wallTypes, clone],
|
||
walls: project.walls.map((w) =>
|
||
w.id === wallId ? { ...w, wallTypeId: id } : w,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Setzt die Gesamtdicke einer einschichtigen Decke über ihre Deckentyp-Schicht
|
||
* — analog `setWallThickness`. Wenn der aktive Aufbau-Typ einschichtig UND ein
|
||
* DEDIZIERTER Deckentyp (`ceilingTypeId`) ist, der nur von dieser Decke genutzt
|
||
* wird, wird er direkt editiert. Sonst wird ein neuer, dedizierter
|
||
* einschichtiger Deckentyp (Klon der ersten Schicht des aktuellen Aufbaus)
|
||
* erzeugt und der Decke zugewiesen. Der geteilte LEGACY-Wandtyp
|
||
* (`Ceiling.wallTypeId`) wird dabei NIE mutiert — er gehört den Wänden.
|
||
*/
|
||
function setCeilingThickness(
|
||
project: Project,
|
||
ceilingId: string,
|
||
thickness: number,
|
||
): Project {
|
||
if (!isFinite(thickness) || thickness <= 0) return project;
|
||
const ceiling = (project.ceilings ?? []).find((c) => c.id === ceilingId);
|
||
if (!ceiling) return project;
|
||
const activeId = ceiling.ceilingTypeId ?? ceiling.wallTypeId;
|
||
const ct =
|
||
(project.ceilingTypes ?? []).find((t) => t.id === ceiling.ceilingTypeId) ??
|
||
project.wallTypes.find((t) => t.id === ceiling.wallTypeId);
|
||
if (!ct || ct.layers.length === 0) return project;
|
||
|
||
const single = ct.layers.length === 1;
|
||
const usedByOthers = (project.ceilings ?? []).some(
|
||
(c) => c.id !== ceilingId && (c.ceilingTypeId ?? c.wallTypeId) === activeId,
|
||
);
|
||
|
||
if (single && !usedByOthers && ceiling.ceilingTypeId) {
|
||
// Direkt die Schichtdicke des (exklusiven, einschichtigen) Deckentyps setzen.
|
||
return {
|
||
...project,
|
||
ceilingTypes: (project.ceilingTypes ?? []).map((t) =>
|
||
t.id === ct.id ? { ...t, layers: [{ ...t.layers[0], thickness }] } : t,
|
||
),
|
||
};
|
||
}
|
||
|
||
// Dedizierten einschichtigen Deckentyp erzeugen (erste Schicht klonen) und
|
||
// der Decke zuweisen — damit die Dicke nur diese Decke betrifft.
|
||
const id = `ct-${Date.now()}`;
|
||
const clone = {
|
||
id,
|
||
name: t("default.ceilingTypeSingle", { thickness: thickness.toFixed(2) }),
|
||
layers: [{ ...ct.layers[0], thickness }],
|
||
};
|
||
return {
|
||
...project,
|
||
ceilingTypes: [...(project.ceilingTypes ?? []), clone],
|
||
ceilings: (project.ceilings ?? []).map((c) =>
|
||
c.id === ceilingId ? { ...c, ceilingTypeId: id, thickness: undefined } : c,
|
||
),
|
||
};
|
||
}
|
||
|
||
// ── Resize (Skalierung auf Zielmaß um einen Anker) ───────────────────────────
|
||
|
||
/**
|
||
* Skaliert ein Element auf Zielbreite `w` × -höhe `h` (Meter) um einen
|
||
* Ankerpunkt. `anchor.fx/fy ∈ [0,1]` liegen relativ zur aktuellen bbox
|
||
* (0,0 = oben-links, 1,1 = unten-rechts). Der Ankerpunkt bleibt fix; alle
|
||
* übrigen Punkte werden um die Skalierungsfaktoren um ihn herum gestreckt.
|
||
*/
|
||
function resizeElement(
|
||
project: Project,
|
||
kind: "wall" | "drawing2d",
|
||
id: string,
|
||
w: number,
|
||
h: number,
|
||
anchor: { fx: number; fy: number },
|
||
): Project {
|
||
if (kind === "drawing2d") {
|
||
return {
|
||
...project,
|
||
drawings2d: project.drawings2d.map((d) =>
|
||
d.id === id ? { ...d, geom: resizeGeom(d.geom, w, h, anchor) } : d,
|
||
),
|
||
};
|
||
}
|
||
return {
|
||
...project,
|
||
walls: project.walls.map((wall) => {
|
||
if (wall.id !== id) return wall;
|
||
const box = bbox2([wall.start, wall.end]);
|
||
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
|
||
const apply = (p: Vec2): Vec2 => ({
|
||
x: ax + (p.x - ax) * sx,
|
||
y: ay + (p.y - ay) * sy,
|
||
});
|
||
return { ...wall, start: apply(wall.start), end: apply(wall.end) };
|
||
}),
|
||
};
|
||
}
|
||
|
||
/** bbox einer Punktliste. */
|
||
function bbox2(pts: Vec2[]): { minX: number; minY: number; maxX: number; maxY: number } {
|
||
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)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||
return { minX, minY, maxX, maxY };
|
||
}
|
||
|
||
/**
|
||
* Liefert Ankerpunkt (ax,ay) und Skalierungsfaktoren (sx,sy), um eine bbox auf
|
||
* Zielbreite `w` × -höhe `h` zu bringen. Bei Null-Ausdehnung in einer Achse
|
||
* bleibt der Faktor 1 (keine Division durch 0, keine Verschiebung).
|
||
*/
|
||
function scaleAround(
|
||
box: { minX: number; minY: number; maxX: number; maxY: number },
|
||
w: number,
|
||
h: number,
|
||
anchor: { fx: number; fy: number },
|
||
): { ax: number; ay: number; sx: number; sy: number } {
|
||
const bw = box.maxX - box.minX;
|
||
const bh = box.maxY - box.minY;
|
||
const ax = box.minX + anchor.fx * bw;
|
||
const ay = box.minY + anchor.fy * bh;
|
||
const sx = bw > 1e-9 ? w / bw : 1;
|
||
const sy = bh > 1e-9 ? h / bh : 1;
|
||
return { ax, ay, sx, sy };
|
||
}
|
||
|
||
/**
|
||
* Skaliert eine 2D-Form auf Zielmaß um den Anker.
|
||
* • line/polyline/rect: jeder Punkt um den Anker skaliert (B/H aus bbox).
|
||
* • circle: Radius = halbe Zielbreite (min/Mittelwert wäre auch denkbar; Breite
|
||
* ist eindeutig), Mittelpunkt um den Anker verschoben.
|
||
* • arc: wie circle (Radius aus Zielbreite), Winkel unverändert.
|
||
* • text: nur die Einfügeposition wird um den Anker verschoben; die Schrifthöhe
|
||
* bleibt (Text skaliert über `height`, nicht über bbox — bewusst unverändert).
|
||
*/
|
||
function resizeGeom(
|
||
g: import("../model/types").Drawing2DGeom,
|
||
w: number,
|
||
h: number,
|
||
anchor: { fx: number; fy: number },
|
||
): import("../model/types").Drawing2DGeom {
|
||
switch (g.shape) {
|
||
case "line": {
|
||
const { ax, ay, sx, sy } = scaleAround(bbox2([g.a, g.b]), w, h, anchor);
|
||
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
|
||
return { ...g, a: f(g.a), b: f(g.b) };
|
||
}
|
||
case "polyline": {
|
||
const { ax, ay, sx, sy } = scaleAround(bbox2(g.pts), w, h, anchor);
|
||
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
|
||
return { ...g, pts: g.pts.map(f) };
|
||
}
|
||
case "rect": {
|
||
const box = bbox2([g.min, g.max]);
|
||
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
|
||
const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy });
|
||
const a = f(g.min);
|
||
const b = f(g.max);
|
||
return {
|
||
...g,
|
||
min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) },
|
||
max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) },
|
||
};
|
||
}
|
||
case "circle":
|
||
case "arc": {
|
||
// bbox eines Kreises ist 2r×2r; der Anker liegt relativ dazu.
|
||
const box = {
|
||
minX: g.center.x - g.r,
|
||
minY: g.center.y - g.r,
|
||
maxX: g.center.x + g.r,
|
||
maxY: g.center.y + g.r,
|
||
};
|
||
const ax = box.minX + anchor.fx * (2 * g.r);
|
||
const ay = box.minY + anchor.fy * (2 * g.r);
|
||
const r = Math.max(w, h) / 2 > 0 ? Math.max(w, h) / 2 : g.r;
|
||
const sx = g.r > 1e-9 ? (w / 2) / g.r : 1;
|
||
const sy = g.r > 1e-9 ? (h / 2) / g.r : 1;
|
||
// Mittelpunkt anhand der nicht-uniformen Skalierung um den Anker schieben,
|
||
// den Radius aber als skalaren Kreis (max der Zielmaße/2) setzen.
|
||
const center: Vec2 = {
|
||
x: ax + (g.center.x - ax) * sx,
|
||
y: ay + (g.center.y - ay) * sy,
|
||
};
|
||
return { ...g, center, r };
|
||
}
|
||
case "text": {
|
||
// Text: nur die Einfügeposition um den Anker schieben (Höhe bleibt).
|
||
const box = { minX: g.at.x, minY: g.at.y, maxX: g.at.x, maxY: g.at.y };
|
||
const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor);
|
||
return { ...g, at: { x: ax + (g.at.x - ax) * sx, y: ay + (g.at.y - ay) * sy } };
|
||
}
|
||
default:
|
||
return g;
|
||
}
|
||
}
|
||
|
||
// ── Editier-Griffe (verschoben aus App.tsx, 1:1) ─────────────────────────────
|
||
|
||
/**
|
||
* Editier-Griffe (Eckpunkte) eines 2D-Zeichenelements in Modell-Metern. Phase 4
|
||
* unterstützt line/polyline/rect; circle/arc/text bekommen (noch) keine Griffe.
|
||
*/
|
||
export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
|
||
const g = d.geom;
|
||
if (g.shape === "line") return [g.a, g.b];
|
||
if (g.shape === "polyline") return [...g.pts];
|
||
if (g.shape === "rect") {
|
||
return [
|
||
g.min,
|
||
{ x: g.max.x, y: g.min.y },
|
||
g.max,
|
||
{ 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 [];
|
||
}
|
||
|
||
/**
|
||
* Verschiebt den Griff `index` des selektierten Elements (2D-Element ODER Wand)
|
||
* auf `pt` (immutabel). Beim Rechteck bleibt die Achsparallelität erhalten.
|
||
*/
|
||
function moveGrip(
|
||
project: Project,
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
index: number,
|
||
pt: Vec2,
|
||
): Project {
|
||
if (drawingId) {
|
||
return {
|
||
...project,
|
||
drawings2d: project.drawings2d.map((d) => {
|
||
if (d.id !== drawingId) return d;
|
||
const g = d.geom;
|
||
if (g.shape === "line") {
|
||
return { ...d, geom: index === 0 ? { ...g, a: pt } : { ...g, b: pt } };
|
||
}
|
||
if (g.shape === "polyline") {
|
||
const pts = g.pts.map((p, i) => (i === index ? pt : p));
|
||
return { ...d, geom: { ...g, pts } };
|
||
}
|
||
if (g.shape === "rect") {
|
||
const corners = [
|
||
g.min,
|
||
{ x: g.max.x, y: g.min.y },
|
||
g.max,
|
||
{ x: g.min.x, y: g.max.y },
|
||
];
|
||
const opp = corners[(index + 2) % 4];
|
||
const min = { x: Math.min(pt.x, opp.x), y: Math.min(pt.y, opp.y) };
|
||
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;
|
||
}),
|
||
};
|
||
}
|
||
if (wallId) {
|
||
return {
|
||
...project,
|
||
walls: project.walls.map((w) =>
|
||
w.id === wallId ? { ...w, ...(index === 0 ? { start: pt } : { end: pt }) } : w,
|
||
),
|
||
};
|
||
}
|
||
return project;
|
||
}
|
||
|
||
/** Verschiebt das selektierte Element (2D-Element ODER Wand) um `delta` (parallel). */
|
||
function moveElementBy(
|
||
project: Project,
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
delta: Vec2,
|
||
): Project {
|
||
const mv = (p: Vec2): Vec2 => ({ x: p.x + delta.x, y: p.y + delta.y });
|
||
if (drawingId) {
|
||
return {
|
||
...project,
|
||
drawings2d: project.drawings2d.map((d) => {
|
||
if (d.id !== drawingId) return d;
|
||
const g = d.geom;
|
||
switch (g.shape) {
|
||
case "line":
|
||
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
|
||
case "polyline":
|
||
return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
|
||
case "rect":
|
||
return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
||
case "circle":
|
||
case "arc":
|
||
return { ...d, geom: { ...g, center: mv(g.center) } };
|
||
case "text":
|
||
return { ...d, geom: { ...g, at: mv(g.at) } };
|
||
default:
|
||
return d;
|
||
}
|
||
}),
|
||
};
|
||
}
|
||
if (wallId) {
|
||
return {
|
||
...project,
|
||
walls: project.walls.map((w) =>
|
||
w.id === wallId ? { ...w, start: mv(w.start), end: mv(w.end) } : w,
|
||
),
|
||
};
|
||
}
|
||
return project;
|
||
}
|
||
|
||
/**
|
||
* Verschiebt eine SEITE (Kante) des selektierten Elements: beide Vertices der
|
||
* Kante um `delta` (immutabel). Für 2D-Elemente:
|
||
* • rect: die zwei betroffenen Ecken (per Index) verschieben und min/max neu
|
||
* normalisieren — so wächst/schrumpft das Rechteck an genau dieser Seite.
|
||
* • polyline: die zwei Vertices `aIndex`/`bIndex` um `delta` verschieben.
|
||
* • line: beide Endpunkte verschieben (das gesamte Segment senkrecht).
|
||
* Für die Wand: start+end gemeinsam (die ganze Wand senkrecht zur Achse).
|
||
*/
|
||
function moveEdge(
|
||
project: Project,
|
||
drawingId: string | null,
|
||
wallId: string | null,
|
||
aIndex: number,
|
||
bIndex: number,
|
||
delta: Vec2,
|
||
): Project {
|
||
const mv = (p: Vec2): Vec2 => ({ x: p.x + delta.x, y: p.y + delta.y });
|
||
if (drawingId) {
|
||
return {
|
||
...project,
|
||
drawings2d: project.drawings2d.map((d) => {
|
||
if (d.id !== drawingId) return d;
|
||
const g = d.geom;
|
||
if (g.shape === "line") {
|
||
// Linie hat nur eine Kante [0,1] → das ganze Segment verschieben.
|
||
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
|
||
}
|
||
if (g.shape === "polyline") {
|
||
const pts = g.pts.map((p, i) => (i === aIndex || i === bIndex ? mv(p) : p));
|
||
return { ...d, geom: { ...g, pts } };
|
||
}
|
||
if (g.shape === "rect") {
|
||
// Ecken in derselben Reihenfolge wie drawingVertices, die zwei Kanten-
|
||
// Ecken verschieben, dann bbox neu bilden (achsparallel halten).
|
||
const corners = [
|
||
g.min,
|
||
{ x: g.max.x, y: g.min.y },
|
||
g.max,
|
||
{ x: g.min.x, y: g.max.y },
|
||
];
|
||
const moved = corners.map((c, i) =>
|
||
i === aIndex || i === bIndex ? mv(c) : c,
|
||
);
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const c of moved) {
|
||
minX = Math.min(minX, c.x); minY = Math.min(minY, c.y);
|
||
maxX = Math.max(maxX, c.x); maxY = Math.max(maxY, c.y);
|
||
}
|
||
return { ...d, geom: { ...g, min: { x: minX, y: minY }, max: { x: maxX, y: maxY } } };
|
||
}
|
||
return d;
|
||
}),
|
||
};
|
||
}
|
||
if (wallId) {
|
||
// Wand: beide Enden gemeinsam → ganze Wand senkrecht verschieben.
|
||
return {
|
||
...project,
|
||
walls: project.walls.map((w) =>
|
||
w.id === wallId ? { ...w, start: mv(w.start), end: mv(w.end) } : w,
|
||
),
|
||
};
|
||
}
|
||
return project;
|
||
}
|
||
|
||
// ── Immutable Tree-Helfer (verschoben aus App.tsx, 1:1) ──────────────────────
|
||
|
||
/** Schaltet die Sichtbarkeit der Kategorie mit `code` im Baum um (immutabel). */
|
||
export function toggleCategoryByCode(
|
||
cats: LayerCategory[],
|
||
code: string,
|
||
): LayerCategory[] {
|
||
return cats.map((c) => {
|
||
if (c.code === code) return { ...c, visible: !c.visible };
|
||
if (c.children)
|
||
return { ...c, children: toggleCategoryByCode(c.children, code) };
|
||
return c;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Setzt im Baum die `visible`-Flags gemäß `codes` (Code → sichtbar?, immutabel).
|
||
* Kategorien, deren Code nicht in der Map steht, bleiben unverändert; Codes der
|
||
* Map, die es nicht mehr gibt, werden stillschweigend ignoriert.
|
||
*/
|
||
export function applyVisibilityByCode(
|
||
cats: LayerCategory[],
|
||
codes: Record<string, boolean>,
|
||
): LayerCategory[] {
|
||
return cats.map((c) => {
|
||
const next = c.code in codes ? { ...c, visible: codes[c.code] } : c;
|
||
if (next.children)
|
||
return { ...next, children: applyVisibilityByCode(next.children, codes) };
|
||
return next;
|
||
});
|
||
}
|
||
|
||
/** Nächster freier zweistelliger Code über den ganzen Baum hinweg. */
|
||
export function nextFreeCode(cats: LayerCategory[]): string {
|
||
const used = new Set<string>();
|
||
const walk = (list: LayerCategory[]) => {
|
||
for (const c of list) {
|
||
used.add(c.code);
|
||
if (c.children) walk(c.children);
|
||
}
|
||
};
|
||
walk(cats);
|
||
const nums = [...used].map((c) => parseInt(c, 10)).filter((n) => !isNaN(n));
|
||
const start = nums.length ? Math.max(...nums) : 0;
|
||
for (let i = start + 1; i < 1000; i++) {
|
||
const c = String(i).padStart(2, "0");
|
||
if (!used.has(c)) return c;
|
||
}
|
||
return String(Date.now());
|
||
}
|
||
|
||
/** Findet eine Kategorie (per Code) im Baum oder `undefined`. */
|
||
export function findCategory(
|
||
cats: LayerCategory[],
|
||
code: string,
|
||
): LayerCategory | undefined {
|
||
for (const c of cats) {
|
||
if (c.code === code) return c;
|
||
if (c.children) {
|
||
const hit = findCategory(c.children, code);
|
||
if (hit) return hit;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/** Patcht die Kategorie mit `code` im Baum (immutabel). */
|
||
function patchCategoryByCode(
|
||
cats: LayerCategory[],
|
||
code: string,
|
||
patch: Partial<LayerCategory>,
|
||
): LayerCategory[] {
|
||
return cats.map((c) => {
|
||
if (c.code === code) return { ...c, ...patch };
|
||
if (c.children)
|
||
return { ...c, children: patchCategoryByCode(c.children, code, patch) };
|
||
return c;
|
||
});
|
||
}
|
||
|
||
/** Hängt `child` als letztes Kind an die Kategorie mit `parentCode` (immutabel). */
|
||
function addChildByCode(
|
||
cats: LayerCategory[],
|
||
parentCode: string,
|
||
child: LayerCategory,
|
||
): LayerCategory[] {
|
||
return cats.map((c) => {
|
||
if (c.code === parentCode)
|
||
return { ...c, children: [...(c.children ?? []), child] };
|
||
if (c.children)
|
||
return { ...c, children: addChildByCode(c.children, parentCode, child) };
|
||
return c;
|
||
});
|
||
}
|
||
|
||
/** Fügt `clone` direkt nach der Kategorie mit `code` auf derselben Ebene ein. */
|
||
function insertAfterCode(
|
||
cats: LayerCategory[],
|
||
code: string,
|
||
clone: LayerCategory,
|
||
): LayerCategory[] {
|
||
const out: LayerCategory[] = [];
|
||
for (const c of cats) {
|
||
const next =
|
||
c.children && !c.children.some((k) => k.code === code)
|
||
? { ...c, children: insertAfterCode(c.children, code, clone) }
|
||
: c;
|
||
out.push(next);
|
||
if (c.code === code) out.push(clone);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Entfernt die Kategorie mit `code` (samt Kindern) aus dem Baum (immutabel). */
|
||
function removeByCode(cats: LayerCategory[], code: string): LayerCategory[] {
|
||
const out: LayerCategory[] = [];
|
||
for (const c of cats) {
|
||
if (c.code === code) continue;
|
||
out.push(c.children ? { ...c, children: removeByCode(c.children, code) } : c);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Beschriftung einer Kategorie für die Status-Leiste: „Code Name". */
|
||
export function categoryLabel(cats: LayerCategory[], code: string): string {
|
||
const c = findCategory(cats, code);
|
||
return c ? `${c.code} ${c.name}` : code;
|
||
}
|