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 9b33a89318
commit 1d939a939a
129 changed files with 26324 additions and 842 deletions
+4
View File
@@ -39,6 +39,8 @@ import {
OpeningSection,
StairSection,
RoomSection,
ExtrudedSolidSection,
ColumnSection,
} from "./ObjectInfoPanel";
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
@@ -315,6 +317,8 @@ export function AttributesPanel() {
{sel.opening && <OpeningSection opening={sel.opening} host={host} />}
{sel.stair && <StairSection stair={sel.stair} host={host} />}
{sel.room && <RoomSection room={sel.room} roomId={sel.id} host={host} />}
{sel.extrudedSolid && <ExtrudedSolidSection solid={sel.extrudedSolid} host={host} />}
{sel.column && <ColumnSection column={sel.column} host={host} />}
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
// Inhalts-Panel „Elemente" — BIM-Baum Geschoss → Bauteilklasse → Element
// (ROADMAP §11 „Element-Übersicht", Phase 1).
//
// Datenquelle: `scheduleRows()` (export/exportSchedule.ts) — dieselbe
// Auswertung wie die Bauteilliste (CSV-Export), hier aber live zu einem
// klick-/zoombaren Baum gruppiert statt exportiert. Legacy-„Tür"-Datensätze
// (project.doors) werden ausgeblendet: dafür existiert aktuell KEIN
// Auswahl-Kanal (nichts legt sie mehr an, siehe evalDoor-Kommentar) — echte
// Türen laufen als Öffnung (`Opening.kind === "door"`) und erscheinen hier
// als Bauteilklasse „Öffnung".
//
// Klick selektiert das Element (leert alle anderen Auswahl-Kanäle, wechselt
// bei Bedarf das Geschoss); Shift-Klick oder Doppelklick selektiert UND passt
// die Plan-Ansicht ein. Auf-/zuklappbare Geschoss- und Klassen-Knoten, ein
// Textfilter oben durchsucht Name/Typ/Klasse.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useMemo, useState } from "react";
import { usePanelHost } from "./host";
import type { ScheduleKind, ScheduleRow } from "./host";
import { scheduleRows } from "../export/exportSchedule";
import { t } from "../i18n";
/** Bauteilklassen, die im Baum erscheinen (Reihenfolge = Anzeigereihenfolge). */
const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
"Wand",
"Decke",
"Fenster",
"Öffnung",
"Treppe",
"Extrusion",
];
/** i18n-Key des Klassen-Labels je Bauteilklasse. */
const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = {
Wand: "elements.kind.wall",
Decke: "elements.kind.ceiling",
Fenster: "elements.kind.window",
Öffnung: "elements.kind.opening",
Treppe: "elements.kind.stair",
Extrusion: "elements.kind.extrusion",
};
/** Kurzform einer ID für die Anzeige (voller Wert bleibt im Tooltip). */
function shortId(id: string): string {
return id.length <= 14 ? id : `${id.slice(0, 4)}…${id.slice(-6)}`;
}
/** Ein Geschoss-Knoten des Baums (auch für Elemente ohne bekanntes Geschoss). */
interface FloorGroup {
/** Gruppierungsschlüssel: die Geschoss-ID, oder ein Namens-Fallback. */
key: string;
/** Anzeigename (Geschoss-Name bzw. bereits aufgelöster Fallback aus row.floor). */
name: string;
rows: ScheduleRow[];
}
/** Gruppiert Zeilen nach Geschoss, in der Reihenfolge der Zeichnungsebenen (oben→unten). */
function buildFloorGroups(
rows: ScheduleRow[],
levels: { id: string; name: string }[],
): FloorGroup[] {
const byKey = new Map<string, ScheduleRow[]>();
for (const r of rows) {
const key = r.floorId ?? `name:${r.floor}`;
const list = byKey.get(key);
if (list) list.push(r);
else byKey.set(key, [r]);
}
// Oberstes Geschoss zuoberst, wie im Zeichnungsebenen-Panel.
const levelsTopDown = [...levels].reverse();
const groups: FloorGroup[] = [];
const used = new Set<string>();
for (const lvl of levelsTopDown) {
const list = byKey.get(lvl.id);
if (list && list.length) {
groups.push({ key: lvl.id, name: lvl.name, rows: list });
used.add(lvl.id);
}
}
// Rest (Elemente ohne bekannte Zeichnungsebene, z. B. verwaiste Referenz).
for (const [key, list] of byKey) {
if (used.has(key)) continue;
groups.push({ key, name: list[0].floor, rows: list });
}
return groups;
}
/** Ob eine Zeile zum Suchtext passt (Name/Typ/Klasse, case-insensitive). */
function matchesQuery(row: ScheduleRow, classLabel: string, query: string): boolean {
const haystack = `${row.typeName} ${classLabel} ${row.id}`.toLowerCase();
return haystack.includes(query);
}
export function ElementTreePanel() {
const host = usePanelHost();
const [query, setQuery] = useState("");
const [collapsedFloors, setCollapsedFloors] = useState<Set<string>>(new Set());
const [collapsedClasses, setCollapsedClasses] = useState<Set<string>>(new Set());
const allRows = useMemo(
() => scheduleRows(host.project).filter((r) => r.kind !== "Tür"),
[host.project],
);
const q = query.trim().toLowerCase();
const searching = q.length > 0;
const visibleRows = useMemo(() => {
if (!searching) return allRows;
return allRows.filter((r) =>
matchesQuery(r, t(CLASS_LABEL_KEY[r.kind as Exclude<ScheduleKind, "Tür">]), q),
);
}, [allRows, q, searching]);
const floorGroups = useMemo(
() => buildFloorGroups(visibleRows, host.project.drawingLevels),
[visibleRows, host.project.drawingLevels],
);
const toggleFloor = (key: string) =>
setCollapsedFloors((s) => {
const next = new Set(s);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const toggleClass = (key: string) =>
setCollapsedClasses((s) => {
const next = new Set(s);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const onRowSelect = (row: ScheduleRow, zoom: boolean) => {
host.onSelectScheduleRow(row.kind, row.id, row.floorId, { zoom });
};
if (allRows.length === 0) {
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("elements.title")}</span>
</header>
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("elements.empty")}
</div>
</section>
);
}
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("elements.title")}</span>
</header>
<div style={{ padding: "0 6px 6px" }}>
<input
type="text"
value={query}
placeholder={t("elements.search")}
onChange={(e) => setQuery(e.target.value)}
style={{
width: "100%",
boxSizing: "border-box",
background: "var(--input)",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--label)",
fontFamily: "inherit",
fontSize: 11,
padding: "4px 8px",
}}
/>
</div>
{floorGroups.length === 0 ? (
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("elements.noMatch")}
</div>
) : (
floorGroups.map((floor) => {
const floorOpen = searching || !collapsedFloors.has(floor.key);
return (
<div className="level-cat" key={floor.key}>
<header
className="level-cat-head"
onClick={() => toggleFloor(floor.key)}
>
<span
className={`level-cat-chevron${floorOpen ? " open" : ""}`}
aria-hidden="true"
>
▸
</span>
<span className="level-cat-title">{floor.name}</span>
<span style={{ fontSize: 10, color: "var(--muted)" }}>
{floor.rows.length}
</span>
</header>
{floorOpen && (
<div className="nav-list">
{CLASS_ORDER.map((kind) => {
const rowsOfKind = floor.rows.filter((r) => r.kind === kind);
if (rowsOfKind.length === 0) return null;
const classKey = `${floor.key}::${kind}`;
const classOpen = searching || !collapsedClasses.has(classKey);
return (
<div key={classKey}>
<header
className="level-cat-head"
style={{ paddingLeft: 20, minHeight: 20 }}
onClick={() => toggleClass(classKey)}
>
<span
className={`level-cat-chevron${classOpen ? " open" : ""}`}
aria-hidden="true"
>
▸
</span>
<span className="level-cat-title" style={{ fontSize: 9 }}>
{t(CLASS_LABEL_KEY[kind])}
</span>
<span style={{ fontSize: 10, color: "var(--muted)" }}>
{rowsOfKind.length}
</span>
</header>
{classOpen && (
<div className="nav-list">
{rowsOfKind.map((row) => (
<div
key={row.id}
className="nav-row"
style={{ paddingLeft: 34 }}
title={row.id}
onClick={(e) => onRowSelect(row, e.shiftKey)}
onDoubleClick={() => onRowSelect(row, true)}
>
<span className="nav-label">{row.typeName}</span>
<span className="nav-elev">{shortId(row.id)}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})
)}
</section>
);
}
/** Tab-Symbol „Elemente" — kleiner Baum (Ast + drei Blätter). */
export function ElementsTabIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<line x1="3" y1="2.5" x2="3" y2="13.5" />
<line x1="3" y1="5" x2="8" y2="5" />
<line x1="3" y1="8" x2="8" y2="8" />
<line x1="3" y1="11" x2="8" y2="11" />
<circle cx="11" cy="5" r="1.6" />
<circle cx="11" cy="8" r="1.6" />
<circle cx="11" cy="11" r="1.6" />
</svg>
);
}
+912
View File
@@ -0,0 +1,912 @@
// Inhalts-Panel „Layouts" — Druck-/Plan-Blätter als ORDNER-/BAUM-Struktur
// (DOSSIER A3, ROADMAP §11). Ordner enthalten Layouts (Verschachtelung möglich);
// Masterlayouts bilden einen eigenen Abschnitt (Vorlagen, kein Ordner-Mitglied).
//
// Ein einziges „+"-Menü oben legt Ordner/Layout/Masterlayout an; Layout/Master
// öffnen zuvor einen Grössen-Dialog. Neu angelegte Elemente landen im aktiven
// Ordner (Klick auf eine Ordnerzeile) und werden SOFORT inline umbenennbar
// (autofocus, Enter bestätigt — KEIN window.prompt, im Tauri-WKWebView deaktiviert).
// Ein Ordner lässt sich als EIN Mehrseiten-PDF exportieren.
//
// Reine Verdrahtung über den Host-Context (usePanelHost); Mutationslogik in
// App.tsx (setProject), pure CRUD/Baum-Aufbau in panels/layoutModel.ts.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useState } from "react";
import { usePanelHost } from "./host";
import type {
Layout,
LayoutOrientation,
LayoutPaperFormat,
MasterLayout,
} from "../model/types";
import {
buildLayoutTree,
buildMasterTree,
PAPER_FORMAT_GROUPS,
type FolderNode,
type MasterFolderNode,
} from "./layoutModel";
import {
NewLayoutDialog,
NewMasterLayoutDialog,
type LayoutSizeStart,
type NewLayoutStart,
} from "../ui/LayoutCreateDialogs";
import { t } from "../i18n";
// ── Baum-Icons (inline-SVG im Panel-Stil: 14px, stroke currentColor 1.4) ──────
/** Gemeinsame SVG-Hülle der kleinen Baum-Icons. */
function TreeIcon({ children }: { children: React.ReactNode }) {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flex: "0 0 auto" }}
>
{children}
</svg>
);
}
/** Ordner-Symbol — geschlossen (Registerlasche) bzw. offen (aufgeklappter Deckel). */
function FolderIcon({ open }: { open: boolean }) {
return (
<TreeIcon>
{open ? (
<path d="M2 5.5l1-1.5h3.2l1.3 1.5H14v1.5H2.9L1.5 13h11l1.6-5H4" />
) : (
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v7.3H2z" />
)}
</TreeIcon>
);
}
/** Layout-Blatt — Papier-Rechteck mit ein paar Inhaltslinien. */
function LayoutSheetIcon() {
return (
<TreeIcon>
<rect x="3.5" y="2" width="9" height="12" rx="1" />
<line x1="5.5" y1="5" x2="10.5" y2="5" />
<line x1="5.5" y1="7.5" x2="10.5" y2="7.5" />
<line x1="5.5" y1="10" x2="8.5" y2="10" />
</TreeIcon>
);
}
/** Masterlayout — Papier mit Stern-Akzent (Vorlage), hebt sich vom Blatt ab. */
function MasterSheetIcon() {
return (
<TreeIcon>
<rect x="3" y="2" width="10" height="12" rx="1" />
<path d="M8 5l0.9 1.9 2 0.2-1.5 1.4 0.5 2L8 9.6 6.1 10.5l0.5-2L5.1 7.1l2-0.2z" />
</TreeIcon>
);
}
/** „Ordner erstellen" — geschlossener Ordner mit kleinem Plus oben rechts. */
function FolderPlusIcon() {
return (
<TreeIcon>
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v3" />
<path d="M2 4.2v9.3h6.5" />
<line x1="11.5" y1="9" x2="11.5" y2="14" />
<line x1="9" y1="11.5" x2="14" y2="11.5" />
</TreeIcon>
);
}
/** „Erstellen" — reines Plus (Layout/Masterlayout). */
function PlusIcon() {
return (
<TreeIcon>
<line x1="8" y1="3" x2="8" y2="13" />
<line x1="3" y1="8" x2="13" y2="8" />
</TreeIcon>
);
}
/** Welche Zeilenart gerade inline umbenannt wird (Commit-Handler unterscheidet sich). */
type RenameKind = "folder" | "layout" | "master";
interface Renaming {
id: string;
kind: RenameKind;
}
/** Ein Inline-Rename-Input (Muster wie ViewSnapshotsPanel: autoFocus/Enter/Escape/Blur). */
function RenameInput({
value,
onChange,
onCommit,
onCancel,
}: {
value: string;
onChange: (v: string) => void;
onCommit: () => void;
onCancel: () => void;
}) {
return (
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
value={value}
onClick={(e) => e.stopPropagation()}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onCommit();
else if (e.key === "Escape") onCancel();
}}
onBlur={onCommit}
/>
);
}
/** Kompakte Paper/Orientation-Auswahl (Master-Editor). Setzt Custom zurück. */
function PaperControls({
paper,
orientation,
onPaper,
onOrientation,
}: {
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
onPaper: (p: LayoutPaperFormat) => void;
onOrientation: (o: LayoutOrientation) => void;
}) {
return (
<>
<select
className="res-select"
value={paper}
title={t("layouts.paper")}
onChange={(e) => onPaper(e.target.value as LayoutPaperFormat)}
>
{PAPER_FORMAT_GROUPS.map((g) => (
<optgroup
key={g.label}
label={g.label === "A" ? t("layouts.paperGroup.a") : t("layouts.paperGroup.b")}
>
{g.formats.map((f) => (
<option key={f} value={f}>
{f.toUpperCase()}
</option>
))}
</optgroup>
))}
</select>
<select
className="res-select"
value={orientation}
title={t("layouts.orientation")}
onChange={(e) => onOrientation(e.target.value as LayoutOrientation)}
>
<option value="portrait">{t("layouts.portrait")}</option>
<option value="landscape">{t("layouts.landscape")}</option>
</select>
</>
);
}
function MasterRow({
master,
depth,
renaming,
renameDraft,
setRenameDraft,
startRename,
commitRename,
cancelRename,
}: {
master: MasterLayout;
depth: number;
renaming: boolean;
renameDraft: string;
setRenameDraft: (v: string) => void;
startRename: (e: React.MouseEvent) => void;
commitRename: () => void;
cancelRename: () => void;
}) {
const host = usePanelHost();
const [open, setOpen] = useState(false);
const tb = master.titleBlock;
const onDelete = (e: React.MouseEvent) => {
e.stopPropagation();
host.onDeleteMasterLayout(master.id);
};
const field = (key: keyof typeof tb, label: string) => (
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 11,
color: "var(--muted)",
}}
>
<span style={{ flex: "0 0 82px" }}>{label}</span>
<input
type="text"
className="res-input"
style={{ flex: 1 }}
value={tb[key] ?? ""}
placeholder={t("layouts.tb.autoPlaceholder")}
onChange={(e) =>
host.onPatchMasterLayout(master.id, {
titleBlock: { ...tb, [key]: e.target.value },
})
}
/>
</label>
);
return (
<div>
<div
className="nav-row"
onClick={() => setOpen((o) => !o)}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
paddingLeft: 10 + depth * 14,
}}
>
{renaming ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
>
<span style={{ flex: "0 0 auto" }}>{open ? "▾" : "▸"}</span>
<MasterSheetIcon />
{master.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.rename")}
onClick={startRename}
>
✎
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.delete")}
onClick={onDelete}
>
✕
</button>
</div>
{open && (
<div
style={{ display: "flex", flexDirection: "column", gap: 5, padding: "6px 10px 10px" }}
>
<div style={{ display: "flex", gap: 6 }}>
<PaperControls
paper={master.paper}
orientation={master.orientation}
onPaper={(p) =>
host.onPatchMasterLayout(master.id, {
paper: p,
customWidthMm: undefined,
customHeightMm: undefined,
})
}
onOrientation={(o) =>
host.onPatchMasterLayout(master.id, {
orientation: o,
customWidthMm: undefined,
customHeightMm: undefined,
})
}
/>
<label
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 11,
color: "var(--muted)",
}}
>
<input
type="checkbox"
checked={master.border ?? true}
onChange={(e) =>
host.onPatchMasterLayout(master.id, { border: e.target.checked })
}
/>
{t("layouts.border")}
</label>
</div>
{field("projectName", t("layouts.tb.projectName"))}
{field("sheetName", t("layouts.tb.sheetName"))}
{field("scale", t("layouts.tb.scale"))}
{field("date", t("layouts.tb.date"))}
{field("author", t("layouts.tb.author"))}
</div>
)}
</div>
);
}
export function LayoutsPanel() {
const host = usePanelHost();
const layouts = host.project.layouts ?? [];
const masters = host.project.masterLayouts ?? [];
const folders = host.project.layoutFolders ?? [];
// Getrennte Bäume je `kind` — Layout- und Master-Ordner werden NIE vermischt.
const layoutFolders = folders.filter((f) => f.kind !== "master");
const masterFolders = folders.filter((f) => f.kind === "master");
const tree = buildLayoutTree(layoutFolders, layouts);
const masterTree = buildMasterTree(masterFolders, masters);
// Aktiver Ordner = Ziel neuer Elemente (Klick auf eine Ordnerzeile). Null = Wurzel.
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
// Aktiver Master-Ordner (eigener Zustand — Ziel neuer Master/Master-Ordner).
const [activeMasterFolderId, setActiveMasterFolderId] = useState<string | null>(null);
// Auf-/zugeklappte Ordner.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// Inline-Rename (welche Zeile + Entwurf).
const [renaming, setRenaming] = useState<Renaming | null>(null);
const [renameDraft, setRenameDraft] = useState("");
// Offener Erstell-Dialog.
const [dialog, setDialog] = useState<null | "layout" | "master">(null);
// Drag&Drop: gezogenes Element + gerade überfahrenes Drop-Ziel (Ordner-Id
// oder "root" für die Wurzel-Ablagefläche).
const [dragItem, setDragItem] = useState<{
kind: "layout" | "folder";
id: string;
} | null>(null);
const [dragOverId, setDragOverId] = useState<string | "root" | null>(null);
const endDrag = () => {
setDragItem(null);
setDragOverId(null);
};
const dropOnFolder = (targetFolderId: string) => {
if (!dragItem) return;
if (dragItem.kind === "layout") {
host.onMoveLayoutToFolder(dragItem.id, targetFolderId);
} else if (dragItem.id !== targetFolderId) {
// Zyklen (Ordner in eigenen Nachfahren) verwirft der Host (No-op).
host.onMoveFolderToFolder(dragItem.id, targetFolderId);
}
expandFolder(targetFolderId);
endDrag();
};
const dropOnRoot = () => {
if (!dragItem) return;
if (dragItem.kind === "layout") host.onMoveLayoutToFolder(dragItem.id, null);
else host.onMoveFolderToFolder(dragItem.id, null);
endDrag();
};
const isExpanded = (id: string) => !collapsed.has(id);
const toggleFolder = (id: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const expandFolder = (id: string) =>
setCollapsed((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
const beginRename = (id: string, kind: RenameKind, currentName: string) => {
setRenaming({ id, kind });
setRenameDraft(currentName);
};
const commitRename = () => {
if (!renaming) return;
const name = renameDraft.trim();
if (name) {
if (renaming.kind === "folder") host.onRenameLayoutFolder(renaming.id, name);
else if (renaming.kind === "layout") host.onRenameLayout(renaming.id, name);
else host.onRenameMasterLayout(renaming.id, name);
}
setRenaming(null);
};
const cancelRename = () => setRenaming(null);
const renamingRow = (id: string, kind: RenameKind) =>
renaming?.id === id && renaming.kind === kind;
// ── Anlegen (zwei getrennte Buttons je Abschnitt) ─────────────────────────
const addFolder = () => {
const id = host.onAddLayoutFolder(activeFolderId ?? undefined);
if (activeFolderId) expandFolder(activeFolderId);
beginRename(id, "folder", t("layouts.folder.defaultName"));
};
const addMasterFolder = () => {
const id = host.onAddMasterFolder(activeMasterFolderId ?? undefined);
if (activeMasterFolderId) expandFolder(activeMasterFolderId);
beginRename(id, "folder", t("layouts.folder.defaultName"));
};
const createLayoutFromDialog = (start: NewLayoutStart) => {
const id = host.onCreateLayout({
folderId: activeFolderId ?? undefined,
masterId: start.masterId,
paper: start.paper,
orientation: start.orientation,
customWidthMm: start.customWidthMm,
customHeightMm: start.customHeightMm,
});
if (activeFolderId) expandFolder(activeFolderId);
setDialog(null);
beginRename(id, "layout", t("layouts.defaultName"));
};
const createMasterFromDialog = (start: LayoutSizeStart) => {
const id = host.onCreateMasterLayout({
folderId: activeMasterFolderId ?? undefined,
paper: start.paper,
orientation: start.orientation,
customWidthMm: start.customWidthMm,
customHeightMm: start.customHeightMm,
});
if (activeMasterFolderId) expandFolder(activeMasterFolderId);
setDialog(null);
beginRename(id, "master", t("layouts.defaultMasterName"));
};
// ── Baum-Zeilen ─────────────────────────────────────────────────────────
const LayoutRow = (layout: Layout, depth: number) => (
<div
key={layout.id}
className="nav-row"
title={t("layouts.openHint", { name: layout.name })}
draggable={!renamingRow(layout.id, "layout")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "layout", id: layout.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onClick={() =>
renamingRow(layout.id, "layout") ? undefined : host.onOpenLayout(layout.id)
}
onDoubleClick={() => host.onOpenLayout(layout.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
{renamingRow(layout.id, "layout") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
>
<LayoutSheetIcon />
{layout.name}
</span>
)}
<span style={{ fontSize: 9, color: "var(--muted)" }}>
{(layout.customWidthMm && layout.customHeightMm
? `${Math.round(layout.customWidthMm)}×${Math.round(layout.customHeightMm)}`
: layout.paper.toUpperCase())}{" "}
· {layout.viewports.length}
</span>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(layout.id, "layout", layout.name);
}}
>
✎
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.delete")}
onClick={(e) => {
e.stopPropagation();
host.onDeleteLayout(layout.id);
}}
>
✕
</button>
</div>
);
const FolderRowTree = (node: FolderNode, depth: number): React.ReactNode => {
const { folder } = node;
const expanded = isExpanded(folder.id);
const active = activeFolderId === folder.id;
const empty = node.layouts.length === 0 && node.subfolders.length === 0;
const isDragged = dragItem?.kind === "folder" && dragItem.id === folder.id;
const dragOver = dragOverId === folder.id && !isDragged;
return (
<div key={folder.id}>
<div
className={`nav-row${active ? " active" : ""}${dragOver ? " drag-over" : ""}`}
title={folder.name}
draggable={!renamingRow(folder.id, "folder")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "folder", id: folder.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onDragOver={(e) => {
if (!dragItem) return;
e.stopPropagation();
if (isDragged) {
if (dragOverId !== null) setDragOverId(null);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== folder.id) setDragOverId(folder.id);
}}
onDragLeave={() => {
if (dragOverId === folder.id) setDragOverId(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
dropOnFolder(folder.id);
}}
onClick={() => setActiveFolderId(active ? null : folder.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
<button
className="nav-add"
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
title={expanded ? "▾" : "▸"}
onClick={(e) => {
e.stopPropagation();
toggleFolder(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
{renamingRow(folder.id, "folder") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{
flex: 1,
minWidth: 0,
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<FolderIcon open={expanded} />
{folder.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.export")}
onClick={(e) => {
e.stopPropagation();
host.onExportFolderPdf(folder.id);
}}
>
⭳
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(folder.id, "folder", folder.name);
}}
>
✎
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.delete")}
onClick={(e) => {
e.stopPropagation();
if (activeFolderId === folder.id) setActiveFolderId(null);
host.onDeleteLayoutFolder(folder.id);
}}
>
✕
</button>
</div>
{expanded && (
<>
{node.subfolders.map((sub) => FolderRowTree(sub, depth + 1))}
{node.layouts.map((l) => LayoutRow(l, depth + 1))}
{empty && (
<div
style={{
padding: "2px 10px 4px",
paddingLeft: 10 + (depth + 1) * 14,
fontSize: 11,
color: "var(--muted)",
}}
>
{t("layouts.folder.empty")}
</div>
)}
</>
)}
</div>
);
};
// ── Master-Baum-Zeilen (eigener Baum; kein Drag&Drop, kein PDF-Export) ─────
const renderMasterRow = (m: MasterLayout, depth: number) => (
<MasterRow
key={m.id}
master={m}
depth={depth}
renaming={renamingRow(m.id, "master")}
renameDraft={renameDraft}
setRenameDraft={setRenameDraft}
startRename={(e) => {
e.stopPropagation();
beginRename(m.id, "master", m.name);
}}
commitRename={commitRename}
cancelRename={cancelRename}
/>
);
const MasterFolderRowTree = (node: MasterFolderNode, depth: number): React.ReactNode => {
const { folder } = node;
const expanded = isExpanded(folder.id);
const active = activeMasterFolderId === folder.id;
const empty = node.masters.length === 0 && node.subfolders.length === 0;
return (
<div key={folder.id}>
<div
className={`nav-row${active ? " active" : ""}`}
title={folder.name}
onClick={() => setActiveMasterFolderId(active ? null : folder.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
<button
className="nav-add"
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
title={expanded ? "▾" : "▸"}
onClick={(e) => {
e.stopPropagation();
toggleFolder(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
{renamingRow(folder.id, "folder") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{
flex: 1,
minWidth: 0,
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<FolderIcon open={expanded} />
{folder.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(folder.id, "folder", folder.name);
}}
>
✎
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.delete")}
onClick={(e) => {
e.stopPropagation();
if (activeMasterFolderId === folder.id) setActiveMasterFolderId(null);
host.onDeleteMasterFolder(folder.id);
}}
>
✕
</button>
</div>
{expanded && (
<>
{node.subfolders.map((sub) => MasterFolderRowTree(sub, depth + 1))}
{node.masters.map((m) => renderMasterRow(m, depth + 1))}
{empty && (
<div
style={{
padding: "2px 10px 4px",
paddingLeft: 10 + (depth + 1) * 14,
fontSize: 11,
color: "var(--muted)",
}}
>
{t("layouts.folder.empty")}
</div>
)}
</>
)}
</div>
);
};
const treeEmpty = tree.rootFolders.length === 0 && tree.rootLayouts.length === 0;
const masterTreeEmpty =
masterTree.rootFolders.length === 0 && masterTree.rootMasters.length === 0;
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("layouts.title")}</span>
<span className="nav-group-actions">
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.folder")}
onClick={addFolder}
>
<FolderPlusIcon />
</button>
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.layout")}
onClick={() => setDialog("layout")}
>
<PlusIcon />
</button>
</span>
</header>
{treeEmpty ? (
<div style={{ padding: "8px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("layouts.tree.empty")}
</div>
) : (
<div
className={`nav-list${dragOverId === "root" ? " drag-over-root" : ""}`}
onDragOver={(e) => {
if (!dragItem) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== "root") setDragOverId("root");
}}
onDragLeave={(e) => {
// Nur löschen, wenn der Zeiger den Container wirklich verlässt.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dragOverId === "root") setDragOverId(null);
}
}}
onDrop={(e) => {
e.preventDefault();
dropOnRoot();
}}
>
{tree.rootFolders.map((node) => FolderRowTree(node, 0))}
{tree.rootLayouts.map((l) => LayoutRow(l, 0))}
</div>
)}
{/* Master-Sektion — eigener Ordner-Baum (Master-Ordner, kind:"master"). */}
<header className="nav-group-head" style={{ marginTop: 6 }}>
<span className="nav-group-title">{t("layouts.masters")}</span>
<span className="nav-group-actions">
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.folder")}
onClick={addMasterFolder}
>
<FolderPlusIcon />
</button>
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.master")}
onClick={() => setDialog("master")}
>
<PlusIcon />
</button>
</span>
</header>
{masterTreeEmpty ? (
<div style={{ padding: "4px 10px 8px", fontSize: 12, color: "var(--muted)" }}>
{t("layouts.noMasters")}
</div>
) : (
<div className="nav-list">
{masterTree.rootFolders.map((node) => MasterFolderRowTree(node, 0))}
{masterTree.rootMasters.map((m) => renderMasterRow(m, 0))}
</div>
)}
{dialog === "layout" && (
<NewLayoutDialog
masters={masters}
onCreate={createLayoutFromDialog}
onClose={() => setDialog(null)}
/>
)}
{dialog === "master" && (
<NewMasterLayoutDialog
onCreate={createMasterFromDialog}
onClose={() => setDialog(null)}
/>
)}
</section>
);
}
/** Tab-Symbol „Layouts" — gestapelte Blätter. */
export function LayoutsTabIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<rect x="4.5" y="2.5" width="9" height="11" rx="1" />
<path d="M2.5 4.5v9h8" />
</svg>
);
}
+382 -63
View File
@@ -19,6 +19,7 @@ import { t } from "../i18n";
import { formatM } from "../model/types";
import type {
SiaCategory,
SliceTermination,
StairShape,
VerticalAnchor,
WallReferenceLine,
@@ -28,6 +29,8 @@ import { Dropdown } from "../ui/Dropdown";
import { usePanelHost } from "./host";
import type {
CeilingInfo,
ColumnInfo,
ExtrudedSolidInfo,
OpeningInfo,
RoomInfo,
StairInfo,
@@ -68,11 +71,24 @@ export function ObjectInfoPanel() {
const width = bbox.maxX - bbox.minX;
const height = bbox.maxY - bbox.minY;
// X/Y des gewählten Bezugspunkts (Modell-Meter). Read-only: Der Kontrakt
// bietet keinen Positions-Setter, daher wird hier kein Verschieben gefaket.
// X/Y des gewählten Bezugspunkts (Modell-Meter). Editierbar: Commit
// verschiebt die Selektion so, dass der gewählte Bezugspunkt auf die
// eingegebene Koordinate wandert (Delta = neu − aktuell → generischer Move
// über den Host). Bei Elementarten, die der Transform-Kern nicht kennt
// (Decke/Öffnung/Treppe/Raum), ist der Host-Aufruf ein No-op — das Feld
// zeigt danach wieder den unveränderten Wert (wie bei Breite/Höhe oben).
const px = bbox.minX + anchor.fx * width;
const py = bbox.minY + anchor.fy * height;
// Rohe Drawing2D-Geometrie der Selektion (nur bei kind==="drawing2d") — für
// die Linien-Länge/Kreis-Radius-Felder unten; die Selection-Sicht trägt
// keine Geometrieform, daher direkter (Lese-)Zugriff aufs Projekt.
const rawDrawing =
sel.kind === "drawing2d"
? host.project.drawings2d.find((d) => d.id === sel.id)
: undefined;
const drawingShape = rawDrawing?.geom.shape;
// ── Maß-Commit ──────────────────────────────────────────────────────────────
// Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven
// Anker. Negative/ungültige Werte werden ignoriert (kein Resize).
@@ -94,44 +110,57 @@ export function ObjectInfoPanel() {
<div className="objinfo-sep" />
{/* Bezugspunkt-Würfel (3×3). */}
{/* Bezugspunkt-Würfel (3×3, immer quadratisch) links, X/Y/Z als Spalte
rechts daneben (Nutzer-Wunsch). */}
<div className="objinfo-section-label">{t("objinfo.refpoint")}</div>
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
{FRACTIONS.map((fy) =>
FRACTIONS.map((fx) => {
const active = anchor.fx === fx && anchor.fy === fy;
return (
<button
key={anchorKey(fx, fy)}
type="button"
className={"objinfo-anchor" + (active ? " active" : "")}
title={t("objinfo.setRefpoint")}
aria-pressed={active}
onClick={() => setAnchor({ fx, fy })}
>
<span className="objinfo-dot" />
</button>
);
}),
)}
</div>
<div className="objinfo-refrow">
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
{FRACTIONS.map((fy) =>
FRACTIONS.map((fx) => {
const active = anchor.fx === fx && anchor.fy === fy;
return (
<button
key={anchorKey(fx, fy)}
type="button"
className={"objinfo-anchor" + (active ? " active" : "")}
title={t("objinfo.setRefpoint")}
aria-pressed={active}
onClick={() => setAnchor({ fx, fy })}
>
<span className="objinfo-dot" />
</button>
);
}),
)}
</div>
{/* X / Y des gewählten Bezugspunkts (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.x")}</span>
<span className="objinfo-fval">{formatM(px)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.y")}</span>
<span className="objinfo-fval">{formatM(py)}</span>
</div>
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
—
</span>
{/* X / Y des gewählten Bezugspunkts (editierbar) + Z (read-only) + ein
Dreh-Feld, als Spalte. */}
<div className="objinfo-xyzcol">
<DimensionField
label={t("objinfo.x")}
value={px}
min={-Infinity}
onCommit={(nx) => host.onMoveSelectionBy(nx - px, 0)}
/>
<DimensionField
label={t("objinfo.y")}
value={py}
min={-Infinity}
onCommit={(ny) => host.onMoveSelectionBy(0, ny - py)}
/>
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
—
</span>
</div>
{/* Drehen: Delta-Winkel (Grad) um den gewählten Bezugspunkt — kein
persistenter Zustand, das Feld zeigt nach dem Commit wieder „0". */}
<RotateField onCommit={(deg) => host.onRotateSelectionAround(px, py, deg)} />
</div>
</div>
<div className="objinfo-sep" />
@@ -148,6 +177,23 @@ export function ObjectInfoPanel() {
value={height}
onCommit={(h) => commitSize(width, h)}
/>
{/* Linien-Länge bzw. Kreis-Radius (nur wenn die Selektion genau eine
Linie/ein Kreis ist — das Modell gibt das direkt her, andere Formen
bleiben ohne Zusatzfeld). */}
{drawingShape === "line" && (
<DimensionField
label={t("objinfo.drawing.length")}
value={Math.hypot(width, height)}
onCommit={(v) => host.onSetDrawingLineLength(v)}
/>
)}
{drawingShape === "circle" && (
<DimensionField
label={t("objinfo.drawing.radius")}
value={width / 2}
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
/>
)}
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
die exportierten *Section-Komponenten). */}
@@ -228,6 +274,153 @@ export function RoomSection({
);
}
// ── Extrusions-Attribut-Abschnitt (truck-Integration) ───────────────────────
// Höhe editierbar (löst Re-Extrusion aus); Grundfläche + Geschoss read-only.
export function ExtrudedSolidSection({
solid,
host,
}: {
solid: ExtrudedSolidInfo;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.extrudedSolid.section")}</div>
{/* Höhe (editierbar, > 0) — löst eine Re-Extrusion (truck-WASM) aus. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.height")}</span>
<input
type="number"
className="objinfo-text"
step={0.01}
min={0.01}
value={solid.height}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v) && v > 0) host.onSetExtrudedSolidHeight(v);
}}
title={t("objinfo.extrudedSolid.height")}
/>
</div>
{/* Verjüngung (editierbar, 0..1) — löst eine Re-Extrusion (truck-WASM) aus. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.taper")}</span>
<input
type="number"
className="objinfo-text"
step={0.05}
min={0}
max={1}
value={solid.taper}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetExtrudedSolidTaper(v);
}}
title={t("objinfo.extrudedSolid.taper")}
/>
</div>
{/* Grundfläche (read-only, abgeleitet). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.area")}</span>
<span className="objinfo-fval">{solid.area.toFixed(2)} m²</span>
</div>
{/* Geschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.floor")}</span>
<span className="objinfo-fval">{solid.floorName}</span>
</div>
</>
);
}
// ── Stützen-Attribut-Abschnitt (Tragwerk) ───────────────────────────────────
// Profil (Rechteck/Kreis) + Masse, Höhe, Drehung editierbar; Geschoss + UK/OK
// read-only.
export function ColumnSection({
column,
host,
}: {
column: ColumnInfo;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.column.section")}</div>
{/* Profil-Dropdown (Rechteck/Kreis). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.profile")}</span>
<Dropdown
value={column.profile.kind}
onChange={(v) => host.onSetColumnProfileKind(v as "rect" | "round")}
options={[
{ value: "rect", label: t("objinfo.column.profile.rect") },
{ value: "round", label: t("objinfo.column.profile.round") },
]}
title={t("objinfo.column.profile")}
width={130}
/>
</div>
{/* Editierbare Masse je Profil. */}
{column.profile.kind === "round" ? (
<DimensionField
label={t("objinfo.column.radius")}
value={column.profile.radius}
onCommit={(v) => host.onSetColumnRadius(v)}
/>
) : (
<>
<DimensionField
label={t("objinfo.column.width")}
value={column.profile.kind === "rect" ? column.profile.width : 0}
onCommit={(v) => host.onSetColumnWidth(v)}
/>
<DimensionField
label={t("objinfo.column.depth")}
value={column.profile.kind === "rect" ? column.profile.depth : 0}
onCommit={(v) => host.onSetColumnDepth(v)}
/>
</>
)}
<DimensionField
label={t("objinfo.column.height")}
value={column.height}
onCommit={(v) => host.onSetColumnHeight(v)}
/>
{/* Drehung (Grad). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.rotation")}</span>
<input
type="number"
className="objinfo-text"
step={1}
value={Number(column.rotationDeg.toFixed(1))}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetColumnRotation(v);
}}
title={t("objinfo.column.rotation")}
/>
</div>
{/* Geschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.floor")}</span>
<span className="objinfo-fval">{column.floorName}</span>
</div>
</>
);
}
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
@@ -243,6 +436,24 @@ export function StairSection({
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.stair.section")}</div>
{/* Treppentyp (Bibliothek) — treibt u. a. die 3D-Tragart (massiv/offen). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.type")}</span>
<Dropdown
value={stair.typeId ?? ""}
onChange={(v) => host.onSetStairType(v)}
options={[
{ value: "", label: t("objinfo.type.none") },
...(host.project.stairTypes ?? []).map((st) => ({
value: st.id,
label: st.name,
})),
]}
title={t("objinfo.stair.type")}
width={130}
/>
</div>
{/* Grundform-Dropdown. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.shape")}</span>
@@ -360,6 +571,24 @@ export function OpeningSection({
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
{/* Tür-/Fenstertyp (Bibliothek) — je nach Art aus doorTypes bzw. windowTypes. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.type")}</span>
<Dropdown
value={opening.typeId ?? ""}
onChange={(v) => host.onSetOpeningType(v)}
options={[
{ value: "", label: t("objinfo.type.none") },
...(isDoor
? host.project.doorTypes ?? []
: host.project.windowTypes ?? []
).map((ty) => ({ value: ty.id, label: ty.name })),
]}
title={t("objinfo.opening.type")}
width={130}
/>
</div>
{/* Art-Segment Fenster/Tür. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.kind")}</span>
@@ -596,11 +825,15 @@ export function CeilingSection({
<span className="objinfo-fval">{ceiling.floorName}</span>
</div>
{/* Fläche (read-only). */}
{/* Fläche + Volumen (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.area")}</span>
<span className="objinfo-fval">{ceiling.area.toFixed(2)} m²</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.volume")}</span>
<span className="objinfo-fval">{ceiling.volume.toFixed(2)} m³</span>
</div>
<div className="objinfo-sep" />
@@ -614,11 +847,16 @@ export function CeilingSection({
onChange={(a) => host.onSetCeilingTop(a)}
/>
{/* UK = OK − Dicke (abgeleitet, read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.bottom")}</span>
<span className="objinfo-fval">{formatM(ceiling.zBottom)}</span>
</div>
{/* UK der Decke: an Geschoss gebunden ODER eigene Höhe; ohne Override
bleibt es beim heutigen Verhalten UK = OK − Dicke. */}
<VerticalAnchorRow
label={t("objinfo.ceiling.bottom")}
anchor={ceiling.bottom}
defaultZ={ceiling.zBottom}
floors={ceiling.floors}
defaultFloorId={ceiling.floorId}
onChange={(a) => host.onSetCeilingBottom?.(a)}
/>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.height")}</span>
<span className="objinfo-fval">{formatM(ceiling.zTop - ceiling.zBottom)}</span>
@@ -678,6 +916,23 @@ export function WallSection({
/>
</div>
{/* Terminierung am Deckenanschluss (Zuschnitt, NICHT Priorität): ob die Wand
an der Decke endet oder oberhalb wieder auftaucht. Default "both". */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.sliceTermination")}</span>
<Dropdown
value={wall.sliceTermination}
onChange={(v) => host.onSetWallSliceTermination(v as SliceTermination)}
options={[
{ value: "both", label: t("objinfo.wall.sliceTermination.both") },
{ value: "below", label: t("objinfo.wall.sliceTermination.below") },
{ value: "above", label: t("objinfo.wall.sliceTermination.above") },
]}
title={t("objinfo.wall.sliceTermination")}
width={120}
/>
</div>
{/* Aufbau-Typ-Segment. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
@@ -730,6 +985,26 @@ export function WallSection({
<span className="objinfo-fval">{wall.floorName}</span>
</div>
{/* Länge + Volumen (read-only). Volumen ist NETTO — Fenster/Türen
abgezogen; der Titel zeigt zusätzlich das Bruttovolumen. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.length")}</span>
<span className="objinfo-fval">{formatM(wall.length)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.volume")}</span>
<span
className="objinfo-fval"
title={
wall.openingVolume > 0
? `${t("objinfo.wall.volumeGross")}: ${wall.grossVolume.toFixed(2)} m³ − ${wall.openingVolume.toFixed(2)} m³`
: undefined
}
>
{wall.netVolume.toFixed(2)} m³
</span>
</div>
{/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */}
<label className="objinfo-field objinfo-check-field">
<span className="objinfo-flabel">{t("objinfo.wall.linkAbove")}</span>
@@ -755,15 +1030,8 @@ export function WallSection({
<div className="objinfo-sep" />
{/* UK & OK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". */}
<VerticalAnchorRow
label={t("objinfo.wall.bottom")}
anchor={wall.bottom}
defaultZ={wall.zBottom}
floors={wall.floors}
defaultFloorId={wall.floorId}
onChange={(a) => host.onSetWallBottom(a)}
/>
{/* OK & UK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". OK
(oben) steht oben, UK (unten) darunter — räumlich passend (Nutzer). */}
<VerticalAnchorRow
label={t("objinfo.wall.top")}
anchor={wall.top}
@@ -772,6 +1040,14 @@ export function WallSection({
defaultFloorId={wall.floorAbove?.id ?? wall.floorId}
onChange={(a) => host.onSetWallTop(a)}
/>
<VerticalAnchorRow
label={t("objinfo.wall.bottom")}
anchor={wall.bottom}
defaultZ={wall.zBottom}
floors={wall.floors}
defaultFloorId={wall.floorId}
onChange={(a) => host.onSetWallBottom(a)}
/>
{/* Höhe = OK − UK (abgeleitet, read-only). */}
<div className="objinfo-field">
@@ -836,25 +1112,27 @@ function VerticalAnchorRow({
</button>
</div>
</div>
{/* Unterzeile in normaler Label-/Wert-Struktur: Label links, Control in
der Wert-Spalte (Nutzer-Wunsch — vorher hing das Control ohne Label
im Einzug der linken Spalte). */}
{mode === "floor" ? (
<div className="objinfo-field objinfo-subfield">
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.anchor.refFloor")}</span>
<Dropdown
value={anchor?.mode === "floor" ? anchor.floorId : defaultFloorId}
onChange={(id) => onChange({ mode: "floor", floorId: id })}
options={floors.map((f) => ({ value: f.id, label: f.name }))}
title={label}
title={t("objinfo.anchor.refFloor")}
width={150}
/>
</div>
) : (
<div className="objinfo-subfield">
<DimensionField
label={t("objinfo.z")}
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
min={-Infinity}
onCommit={(z) => onChange({ mode: "custom", z })}
/>
</div>
<DimensionField
label={t("objinfo.anchor.customHeight")}
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
min={-Infinity}
onCommit={(z) => onChange({ mode: "custom", z })}
/>
)}
</div>
);
@@ -915,3 +1193,44 @@ function DimensionField({
</label>
);
}
// ── Dreh-Feld (Delta-Winkel in Grad) ─────────────────────────────────────────
// Anders als DimensionField zeigt dieses Feld KEINEN persistenten Host-Wert —
// eine „aktuelle Rotation" gibt es für die Selektion generell nicht (Wand ist
// nur eine Achse, Drawing2D/Extrusion tragen keinen Rotationswinkel). Das Feld
// ist daher ein Delta: Eingabe = Drehung ab jetzt, danach zeigt es wieder „0".
function RotateField({ onCommit }: { onCommit: (deg: number) => void }) {
const [draft, setDraft] = useState<string | null>(null);
const shown = draft ?? "0";
function commit() {
if (draft === null) return;
const v = Number(draft);
setDraft(null);
if (isFinite(v) && v !== 0) onCommit(v);
}
return (
<label className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.rotate")}</span>
<input
className="objinfo-input"
type="number"
step={1}
value={shown}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
commit();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(null);
(e.target as HTMLInputElement).blur();
}
}}
/>
<span className="objinfo-unit">°</span>
</label>
);
}
+34
View File
@@ -183,6 +183,25 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
/>
</label>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.roundingStep")}</span>
<select
className="roomstamp-input"
value={draft.roundingStep ?? ""}
disabled={!draft.showFloorArea}
onChange={(e) => {
const v = e.target.value;
patch({ roundingStep: v === "" ? undefined : Number(v) });
}}
>
<option value="">{t("roomStamp.roundingStepDefault")}</option>
<option value="0.01">0.01 m²</option>
<option value="0.1">0.1 m²</option>
<option value="0.5">0.5 m²</option>
<option value="1">1 m²</option>
</select>
</label>
<div className="roomstamp-row">
<label className="roomstamp-check">
<input
@@ -200,6 +219,21 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
onChange={(usageAlign) => patch({ usageAlign })}
/>
</div>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.occupancy")}</span>
<input
type="number"
min={0}
step={1}
className="roomstamp-input"
value={draft.occupancy ?? ""}
onChange={(e) => {
const v = e.target.value;
patch({ occupancy: v === "" ? undefined : Number(v) });
}}
/>
</label>
</div>
<div className="texteditor-foot">
+5 -3
View File
@@ -18,6 +18,7 @@ import { usePanelHost } from "./host";
import type { ContextObject } from "../model/types";
import { ContextImportDialog } from "../ui/ContextImportDialog";
import { Dropdown } from "../ui/Dropdown";
import { useStore } from "../state/appStore";
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
function typeBadgeKey(obj: ContextObject): TranslationKey {
@@ -33,6 +34,7 @@ function typeBadgeKey(obj: ContextObject): TranslationKey {
export function SitePanel() {
const host = usePanelHost();
const notify = useStore((s) => s.notify);
const objects = host.contextObjects;
const contourSets = objects.filter((o) => o.type === "contourSet");
@@ -50,9 +52,9 @@ export function SitePanel() {
if (!effectiveSet) return;
const id = host.onGenerateTerrain(effectiveSet);
if (id === null) {
// Kein sinnvolles TIN — ruhiger Hinweis (keine harte Fehlerbox).
// eslint-disable-next-line no-alert
alert(t("site.terrainFailed"));
// Kein sinnvolles TIN — ruhiger Hinweis als Toast (keine harte Fehlerbox;
// window.alert wäre im Tauri-WKWebView ohnehin wirkungslos).
notify(t("site.terrainFailed"), "error");
}
};
+22 -1
View File
@@ -124,6 +124,24 @@ export function ToolIcon({ id }: { id: ToolId }) {
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" />
</svg>
);
case "text":
// Serifen-„A" als Text-Symbol.
return (
<svg {...common}>
<path d="M 4 13 L 8 3 L 12 13" fill="none" />
<path d="M 5.5 9.5 L 10.5 9.5" fill="none" />
</svg>
);
case "textbox":
// Spalten-Rahmen mit drei Textzeilen (Absatztext).
return (
<svg {...common}>
<rect x="2.5" y="3" width="11" height="10" fill="none" />
<line x1="4.5" y1="6" x2="11.5" y2="6" />
<line x1="4.5" y1="8.5" x2="11.5" y2="8.5" />
<line x1="4.5" y1="11" x2="9" y2="11" />
</svg>
);
default:
return null;
}
@@ -140,7 +158,10 @@ export function ToolsPanel() {
{TOOL_ORDER.map((id) => {
const tool = getTool(id);
const active = host.activeTool === id;
const disabled = id !== "select" && !host.toolsEnabled;
// Nur Boden-gebundene (`floorOnly`) Werkzeuge sind ohne aktives Geschoss
// gesperrt; freie 2D-Werkzeuge (Linie/Kreis/Text/…) bleiben auf
// Zeichnungsebenen nutzbar — Parität mit dem Ribbon (RibbonBar).
const disabled = !!tool.floorOnly && !host.toolsEnabled;
const title = disabled
? t("tool.floorOnlyDisabled")
: t(tool.hintKey(tool.init()));
+667
View File
@@ -0,0 +1,667 @@
// Inhalts-Panel „Ausschnitte" — benannte, gespeicherte Ansichten (View-Snapshots,
// DOSSIER A2, ROADMAP §2c/§11) als ORDNER-/BAUM-Struktur (spiegelbildlich zum
// Layouts-Panel).
//
// Ein Ausschnitt fängt den kompletten Darstellungszustand ein (Ansichtstyp,
// Kamera-Preset, Massstab, Detailgrad, Ebenen-/Zeichnungssichtbarkeit, aktive
// Overrides, aktives Geschoss) und stellt ihn per Klick wieder her. Das Panel
// zeigt Ordner (auf-/zuklappbar) mit Ausschnitten als Blättern; zwei Header-
// Buttons legen einen Ordner bzw. einen neuen Ausschnitt im AKTIVEN Ordner an
// (Klick auf eine Ordnerzeile = aktiver Ordner; ohne Auswahl = Wurzel). Neue
// Ordner werden sofort inline umbenannt (autofocus/Enter — KEIN window.prompt,
// im Tauri-WKWebView deaktiviert). Ausschnitte/Ordner lassen sich per HTML5-
// Drag&Drop zwischen Ordnern verschieben (Zyklus-Schutz im Host).
//
// Die Footer-Bar (Massstab/Kombis/Overrides/Umbenennen bei angewähltem
// Ausschnitt) und die Anwahl-Persistenz (`selectedViewSnapshotId`) bleiben
// unverändert erhalten — sie hängen unter dem Baum.
//
// Reine Verdrahtung über den Host-Context (usePanelHost); die Erfassungs-/
// Anwendungslogik lebt in App.tsx (Setter/rAF) bzw. state/viewSnapshots.ts, die
// pure Baum-/Ordner-Logik in state/viewSnapshotFolders.ts.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useMemo, useState } from "react";
import { usePanelHost } from "./host";
import type { ViewSnapshot } from "../model/types";
import {
buildViewSnapshotTree,
type ViewSnapshotFolderNode,
} from "../state/viewSnapshotFolders";
import { t } from "../i18n";
// ── Baum-Icons (inline-SVG im Panel-Stil: 14px, stroke currentColor 1.4) ──────
/** Gemeinsame SVG-Hülle der kleinen Baum-Icons. */
function TreeIcon({ children }: { children: React.ReactNode }) {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flex: "0 0 auto" }}
>
{children}
</svg>
);
}
/** Ordner-Symbol — geschlossen (Registerlasche) bzw. offen (aufgeklappter Deckel). */
function FolderIcon({ open }: { open: boolean }) {
return (
<TreeIcon>
{open ? (
<path d="M2 5.5l1-1.5h3.2l1.3 1.5H14v1.5H2.9L1.5 13h11l1.6-5H4" />
) : (
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v7.3H2z" />
)}
</TreeIcon>
);
}
/** „Ordner erstellen" — geschlossener Ordner mit kleinem Plus oben rechts. */
function FolderPlusIcon() {
return (
<TreeIcon>
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v3" />
<path d="M2 4.2v9.3h6.5" />
<line x1="11.5" y1="9" x2="11.5" y2="14" />
<line x1="9" y1="11.5" x2="14" y2="11.5" />
</TreeIcon>
);
}
/** „Erstellen" — reines Plus (neuer Ausschnitt). */
function PlusIcon() {
return (
<TreeIcon>
<line x1="8" y1="3" x2="8" y2="13" />
<line x1="3" y1="8" x2="13" y2="8" />
</TreeIcon>
);
}
/** Ausschnitt-Blatt — Kamera-Sucherrahmen (passt zum Tab-Symbol). */
function SnapshotIcon() {
return (
<TreeIcon>
<rect x="2.5" y="4" width="11" height="8" rx="1" />
<circle cx="8" cy="8" r="2" />
<line x1="5" y1="4" x2="5.8" y2="2.8" />
</TreeIcon>
);
}
/** Ein Inline-Rename-Input (autoFocus/Enter/Escape/Blur). */
function RenameInput({
value,
onChange,
onCommit,
onCancel,
}: {
value: string;
onChange: (v: string) => void;
onCommit: () => void;
onCancel: () => void;
}) {
return (
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
value={value}
onClick={(e) => e.stopPropagation()}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onCommit();
else if (e.key === "Escape") onCancel();
}}
onBlur={onCommit}
/>
);
}
/** 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);
if (ka.length !== Object.keys(b).length) return false;
for (const k of ka) {
if (a[k] !== b[k]) return false;
}
return true;
}
/**
* Sucht unter den gespeicherten Kombinationen (`names` + `load`) diejenige,
* deren Map EXAKT `target` entspricht — liefert den Namen oder `null`. Grundlage
* der Footer-Anzeige „welche Ebenen-/Zeichnungskombi war aktiv".
*/
function matchingComboName(
names: string[],
load: (name: string) => Record<string, boolean> | null,
target: Record<string, boolean>,
): string | null {
for (const name of names) {
const map = load(name);
if (map && boolMapEqual(map, target)) return name;
}
return null;
}
/** Welche Zeilenart gerade inline umbenannt wird (Commit unterscheidet sich). */
type RenameKind = "folder" | "snapshot";
interface Renaming {
id: string;
kind: RenameKind;
}
export function ViewSnapshotsPanel() {
const host = usePanelHost();
const snaps = host.project.viewSnapshots ?? [];
const folders = host.project.viewSnapshotFolders ?? [];
const tree = useMemo(
() => buildViewSnapshotTree(folders, snaps),
[folders, snaps],
);
// In-App-Eingabe statt window.prompt (im Tauri-WKWebView deaktiviert → gäbe
// null zurück, nichts würde gespeichert). `naming` = neuer Ausschnitt.
const [naming, setNaming] = useState(false);
const [draft, setDraft] = useState("");
// Inline-Rename einer Baumzeile (Ordner ODER Ausschnitt) + Entwurf.
const [renaming, setRenaming] = useState<Renaming | null>(null);
const [renameDraft, setRenameDraft] = useState("");
// Inline-Umbenennen DIREKT in der Footer-Bar (eigener Draft, damit Liste und
// Footer sich nicht ins Gehege kommen). `footerRenaming` = Bearbeitung aktiv.
const [footerRenaming, setFooterRenaming] = useState(false);
const [footerDraft, setFooterDraft] = useState("");
// Aktiver Ordner = Ziel neuer Ausschnitte/Ordner (Klick auf Ordnerzeile). Null = Wurzel.
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
// Auf-/zugeklappte Ordner.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// Drag&Drop: gezogenes Element + überfahrenes Drop-Ziel (Ordner-Id oder "root").
const [dragItem, setDragItem] = useState<{
kind: "snapshot" | "folder";
id: string;
} | null>(null);
const [dragOverId, setDragOverId] = useState<string | "root" | null>(null);
// Der aktuell „angewählte" Ausschnitt (bleibt markiert, bis App bei einer
// Abweichung `selectedViewSnapshotId` auf null setzt). Footer-Bar nur sichtbar,
// solange dieser existiert.
const selectedId = host.selectedViewSnapshotId;
const selectedSnap = useMemo(
() => (selectedId ? snaps.find((s) => s.id === selectedId) ?? null : null),
[selectedId, snaps],
);
// Footer-Auflösung: Massstab, passende Ebenen-/Zeichnungskombi (Deep-Equal auf
// die gespeicherte Map) und die Namen der aktiven Override-Regeln.
const footer = useMemo(() => {
if (!selectedSnap) return null;
const layerCombo = matchingComboName(
host.listLayerCombos(),
host.loadLayerCombo,
selectedSnap.layerVisibility,
);
const drawingCombo = matchingComboName(
host.listDrawingCombos(),
host.loadDrawingCombo,
selectedSnap.drawingVisibility,
);
const ids = new Set(selectedSnap.enabledOverrideRuleIds ?? []);
const overrideNames = (host.project.overrideRules ?? [])
.filter((r) => ids.has(r.id))
.map((r) => r.name);
return { layerCombo, drawingCombo, overrideNames };
}, [selectedSnap, host]);
// ── Auf-/Zuklappen ────────────────────────────────────────────────────────
const isExpanded = (id: string) => !collapsed.has(id);
const toggleFolder = (id: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const expandFolder = (id: string) =>
setCollapsed((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
// ── Inline-Rename (Baumzeilen) ──────────────────────────────────────────────
const beginRename = (id: string, kind: RenameKind, currentName: string) => {
setRenaming({ id, kind });
setRenameDraft(currentName);
};
const commitRename = () => {
if (!renaming) return;
const name = renameDraft.trim();
if (name) {
if (renaming.kind === "folder")
host.onRenameViewSnapshotFolder(renaming.id, name);
else host.onRenameViewSnapshot(renaming.id, name);
}
setRenaming(null);
};
const cancelRename = () => setRenaming(null);
const renamingRow = (id: string, kind: RenameKind) =>
renaming?.id === id && renaming.kind === kind;
// ── Anlegen (zwei Header-Buttons) ──────────────────────────────────────────
const addFolder = () => {
const id = host.onAddViewSnapshotFolder(activeFolderId ?? undefined);
if (activeFolderId) expandFolder(activeFolderId);
beginRename(id, "folder", t("viewsnap.folder.defaultName"));
};
const commitSave = () => {
const name = draft.trim();
if (name) host.onCaptureViewSnapshot(name, activeFolderId ?? undefined);
if (activeFolderId) expandFolder(activeFolderId);
setNaming(false);
setDraft("");
};
// ── Drag&Drop ──────────────────────────────────────────────────────────────
const endDrag = () => {
setDragItem(null);
setDragOverId(null);
};
const dropOnFolder = (targetFolderId: string) => {
if (!dragItem) return;
if (dragItem.kind === "snapshot") {
host.onMoveViewSnapshotToFolder(dragItem.id, targetFolderId);
} else if (dragItem.id !== targetFolderId) {
// Zyklen (Ordner in eigenen Nachfahren) verwirft der Host (No-op).
host.onMoveViewSnapshotFolder(dragItem.id, targetFolderId);
}
expandFolder(targetFolderId);
endDrag();
};
const dropOnRoot = () => {
if (!dragItem) return;
if (dragItem.kind === "snapshot")
host.onMoveViewSnapshotToFolder(dragItem.id, null);
else host.onMoveViewSnapshotFolder(dragItem.id, null);
endDrag();
};
// ── Footer-Rename ──────────────────────────────────────────────────────────
const startFooterRename = () => {
if (!selectedSnap) return;
setFooterRenaming(true);
setFooterDraft(selectedSnap.name);
};
const commitFooterRename = () => {
if (!selectedSnap) return;
const name = footerDraft.trim();
if (name && name !== selectedSnap.name)
host.onRenameViewSnapshot(selectedSnap.id, name);
setFooterRenaming(false);
};
// ── Baum-Zeilen ────────────────────────────────────────────────────────────
const SnapshotRow = (snap: ViewSnapshot, depth: number) => (
<div
key={snap.id}
className={"nav-row" + (snap.id === selectedId ? " active" : "")}
title={t("viewsnap.applyHint", { name: snap.name })}
draggable={!renamingRow(snap.id, "snapshot")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "snapshot", id: snap.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onClick={() =>
renamingRow(snap.id, "snapshot")
? undefined
: host.onApplyViewSnapshot(snap.id)
}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
{renamingRow(snap.id, "snapshot") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
>
<SnapshotIcon />
{snap.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(snap.id, "snapshot", snap.name);
}}
>
✎
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.delete")}
onClick={(e) => {
e.stopPropagation();
host.onDeleteViewSnapshot(snap.id);
}}
>
✕
</button>
</div>
);
const FolderRowTree = (
node: ViewSnapshotFolderNode,
depth: number,
): React.ReactNode => {
const { folder } = node;
const expanded = isExpanded(folder.id);
const active = activeFolderId === folder.id;
const empty = node.snapshots.length === 0 && node.subfolders.length === 0;
const isDragged = dragItem?.kind === "folder" && dragItem.id === folder.id;
const dragOver = dragOverId === folder.id && !isDragged;
return (
<div key={folder.id}>
<div
className={`nav-row${active ? " active" : ""}${dragOver ? " drag-over" : ""}`}
title={folder.name}
draggable={!renamingRow(folder.id, "folder")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "folder", id: folder.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onDragOver={(e) => {
if (!dragItem) return;
e.stopPropagation();
if (isDragged) {
if (dragOverId !== null) setDragOverId(null);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== folder.id) setDragOverId(folder.id);
}}
onDragLeave={() => {
if (dragOverId === folder.id) setDragOverId(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
dropOnFolder(folder.id);
}}
onClick={() => setActiveFolderId(active ? null : folder.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
<button
className="nav-add"
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
title={expanded ? "▾" : "▸"}
onClick={(e) => {
e.stopPropagation();
toggleFolder(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
{renamingRow(folder.id, "folder") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{
flex: 1,
minWidth: 0,
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<FolderIcon open={expanded} />
{folder.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.folder.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(folder.id, "folder", folder.name);
}}
>
✎
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.folder.delete")}
onClick={(e) => {
e.stopPropagation();
if (activeFolderId === folder.id) setActiveFolderId(null);
host.onDeleteViewSnapshotFolder(folder.id);
}}
>
✕
</button>
</div>
{expanded && (
<>
{node.subfolders.map((sub) => FolderRowTree(sub, depth + 1))}
{node.snapshots.map((s) => SnapshotRow(s, depth + 1))}
{empty && (
<div
style={{
padding: "2px 10px 4px",
paddingLeft: 10 + (depth + 1) * 14,
fontSize: 11,
color: "var(--muted)",
}}
>
{t("viewsnap.folder.empty")}
</div>
)}
</>
)}
</div>
);
};
const treeEmpty =
tree.rootFolders.length === 0 && tree.rootSnapshots.length === 0;
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("viewsnap.title")}</span>
<span className="nav-group-actions">
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("viewsnap.add.folder")}
onClick={addFolder}
>
<FolderPlusIcon />
</button>
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("viewsnap.add.snapshot")}
onClick={() => {
setNaming(true);
setDraft("");
}}
>
<PlusIcon />
</button>
</span>
</header>
{naming && (
<div className="nav-row" style={{ display: "flex", gap: 4, padding: "4px 8px" }}>
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
placeholder={t("viewsnap.savePrompt")}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitSave();
else if (e.key === "Escape") {
setNaming(false);
setDraft("");
}
}}
/>
<button className="nav-add" onClick={commitSave} title={t("viewsnap.add.snapshot")}>
✓
</button>
</div>
)}
{treeEmpty ? (
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("viewsnap.empty")}
</div>
) : (
<div
className={`nav-list${dragOverId === "root" ? " drag-over-root" : ""}`}
onDragOver={(e) => {
if (!dragItem) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== "root") setDragOverId("root");
}}
onDragLeave={(e) => {
// Nur löschen, wenn der Zeiger den Container wirklich verlässt.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dragOverId === "root") setDragOverId(null);
}
}}
onDrop={(e) => {
e.preventDefault();
dropOnRoot();
}}
>
{tree.rootFolders.map((node) => FolderRowTree(node, 0))}
{tree.rootSnapshots.map((s) => SnapshotRow(s, 0))}
</div>
)}
{/* Footer-Bar: sichtbar, sobald ein Ausschnitt angewählt ist. Zeigt den
erfassten Massstab, die passende Ebenen-/Zeichnungskombi und die
aktiven Overrides und erlaubt das Umbenennen direkt hier. */}
{selectedSnap && footer && (
<div className="viewsnap-footer">
<div className="viewsnap-footer-name">
{footerRenaming ? (
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
value={footerDraft}
onChange={(e) => setFooterDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitFooterRename();
else if (e.key === "Escape") setFooterRenaming(false);
}}
onBlur={commitFooterRename}
/>
) : (
<>
<span className="viewsnap-footer-title">{selectedSnap.name}</span>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.rename")}
onClick={startFooterRename}
>
✎
</button>
</>
)}
</div>
<dl className="viewsnap-footer-grid">
<dt>{t("viewsnap.footer.scale")}</dt>
<dd>1:{selectedSnap.scaleDenominator}</dd>
<dt>{t("viewsnap.footer.layerCombo")}</dt>
<dd>{footer.layerCombo ?? t("viewsnap.footer.none")}</dd>
<dt>{t("viewsnap.footer.drawingCombo")}</dt>
<dd>{footer.drawingCombo ?? t("viewsnap.footer.none")}</dd>
<dt>{t("viewsnap.footer.overrides")}</dt>
<dd>
{footer.overrideNames.length
? footer.overrideNames.join(", ")
: t("viewsnap.footer.none")}
</dd>
</dl>
</div>
)}
</section>
);
}
/** Tab-Symbol „Ausschnitte" — Kamera-Blende / Sucherrahmen. */
export function ViewSnapshotsTabIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<rect x="2" y="3.5" width="12" height="9" rx="1.5" />
<circle cx="8" cy="8" r="2.4" />
<line x1="4.5" y1="3.5" x2="5.5" y2="2" />
<line x1="11.5" y1="3.5" x2="10.5" y2="2" />
</svg>
);
}
+35
View File
@@ -22,6 +22,9 @@ import { AttributesPanel } from "./AttributesPanel";
import { ObjectInfoPanel } from "./ObjectInfoPanel";
import { SitePanel } from "./SitePanel";
import { RoomBalancePanel } from "./RoomBalancePanel";
import { ElementTreePanel, ElementsTabIcon } from "./ElementTreePanel";
import { ViewSnapshotsPanel, ViewSnapshotsTabIcon } from "./ViewSnapshotsPanel";
import { LayoutsPanel, LayoutsTabIcon } from "./LayoutsPanel";
import {
ToolsTabIcon,
AttributesTabIcon,
@@ -41,6 +44,9 @@ export const BUILTIN_PANEL_IDS = {
objectInfo: "object-info",
site: "site",
roomBalance: "room-balance",
elements: "elements",
viewSnapshots: "view-snapshots",
layouts: "layouts",
} as const;
// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil).
@@ -105,3 +111,32 @@ registerPanel({
hasDisplayMode: false,
render: () => <RoomBalancePanel />,
});
// Element-Baum (ROADMAP §11) — Geschoss → Bauteilklasse → Element, Suche + Klick-Selektion.
registerPanel({
id: BUILTIN_PANEL_IDS.elements,
title: "elements.title",
icon: <ElementsTabIcon />,
hasDisplayMode: false,
render: () => <ElementTreePanel />,
});
// Ausschnitte / View-Snapshots (DOSSIER A2, ROADMAP §2c/§11) — benannte,
// wiederherstellbare Ansichten (Darstellungszustand als Preset).
registerPanel({
id: BUILTIN_PANEL_IDS.viewSnapshots,
title: "viewsnap.title",
icon: <ViewSnapshotsTabIcon />,
hasDisplayMode: false,
render: () => <ViewSnapshotsPanel />,
});
// Layouts / Masterlayouts (DOSSIER A3, ROADMAP §11) — Druck-/Plan-Blätter mit
// platzierten Viewports (an Ausschnitte gebunden), Editor als schwebendes Fenster.
registerPanel({
id: BUILTIN_PANEL_IDS.layouts,
title: "layouts.panelTitle",
icon: <LayoutsTabIcon />,
hasDisplayMode: false,
render: () => <LayoutsPanel />,
});
+224
View File
@@ -22,9 +22,13 @@ import type {
DrawingLevel,
HatchStyle,
LayerCategory,
LayoutOrientation,
LayoutPaperFormat,
LineStyle,
MasterLayout,
Project,
SiaCategory,
SliceTermination,
StairShape,
VerticalAnchor,
WallReferenceLine,
@@ -32,6 +36,7 @@ import type {
} from "../model/types";
import type { SnapSettings, ToolId } from "../tools/types";
import type { Selection } from "../state/selectionInfo";
import type { ScheduleKind } from "../export/exportSchedule";
// ── Darstellungsmodus-Steuerung je Dock-Inhalt ────────────────────────────
@@ -196,10 +201,38 @@ export interface PanelHostValue {
h: number,
anchor: { fx: number; fy: number },
) => void;
/**
* Verschiebt die Selektion um (dx,dy) Meter — generischer Move über das
* Transform-System (analog dem Move-Werkzeug/-Befehl), deckt Wand/Drawing2D/
* Extrusionskörper ab. Bei Decke/Öffnung/Treppe/Raum (Transform-Kern kennt
* diese Elementarten nicht) ein No-op.
*/
onMoveSelectionBy: (dx: number, dy: number) => void;
/**
* Dreht die Selektion um `deg` Grad um den Weltpunkt (cx,cy) — generischer
* Rotate über das Transform-System; deckt dieselben Selektionsarten ab wie
* {@link onMoveSelectionBy}.
*/
onRotateSelectionAround: (cx: number, cy: number, deg: number) => void;
/**
* Setzt die Länge einer Linien-Drawing2D (Endpunkt entlang der Richtung neu
* skaliert, Startpunkt bleibt fix). No-op, wenn die Selektion keine Linie ist.
*/
onSetDrawingLineLength: (length: number) => void;
/**
* Setzt den Radius einer Kreis-Drawing2D (Mittelpunkt bleibt fix). No-op,
* wenn die Selektion kein Kreis ist.
*/
onSetDrawingCircleRadius: (radius: number) => void;
// ── Wand-Attribute (Object-Info-Panel; wirken NUR auf die selektierte Wand) ─
/** Setzt die Lage der Wandachse über die Dicke (außen/mitte/innen). */
onSetWallReferenceLine: (ref: WallReferenceLine) => void;
/**
* Setzt die Terminierungs-Regel am Deckenanschluss (Zuschnitt, NICHT Priorität):
* "both" = heutiges Verhalten, "below"/"above" = die Wand endet an der Decke.
*/
onSetWallSliceTermination: (termination: SliceTermination) => void;
/** Weist der Wand einen Wandtyp-Preset (mehrschichtiger Aufbau) zu. */
onSetWallType: (wallTypeId: string) => void;
/** Setzt die Gesamtdicke einer einschichtigen Wand (Meter). */
@@ -216,6 +249,12 @@ export interface PanelHostValue {
onSetCeilingThickness: (thickness: number) => void;
/** Setzt/entfernt die OK-Bindung der Decke (`null` = Geschoss-Oberkante). */
onSetCeilingTop: (anchor: VerticalAnchor | null) => void;
/**
* Setzt/entfernt die UK-Bindung der Decke (`null` = OK − Dicke). Optional,
* da bestehende Hosts (App.tsx) diesen Setter ggf. noch nicht verdrahtet
* haben — additive Erweiterung analog `onSetWallBottom`.
*/
onSetCeilingBottom?: (anchor: VerticalAnchor | null) => void;
// ── Öffnungs-Attribute (Object-Info-Panel; nur die selektierte Öffnung) ─
/** Wechselt die Art (Fenster/Tür); setzt bei Tür die Tür-Defaults. */
@@ -242,6 +281,8 @@ export interface PanelHostValue {
onSetOpeningDoorType: (doorType: "normal" | "wandoeffnung") => void;
/** Setzt die Sturzlinien-Darstellung der Tür. */
onSetOpeningLintelLines: (lintelLines: "keine" | "innen" | "aussen" | "beide") => void;
/** Weist der Öffnung einen Tür-/Fenstertyp (Bibliothek) zu; "" löst die Zuweisung. */
onSetOpeningType: (typeId: string) => void;
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
/** Setzt die Grundform (gerade/L/Wendel). */
@@ -256,6 +297,8 @@ export interface PanelHostValue {
onSetStairUp: (up: boolean) => void;
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
/** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */
onSetStairType: (typeId: string) => void;
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
/** Setzt den Raum-Namen. */
@@ -265,6 +308,26 @@ export interface PanelHostValue {
/** Öffnet den Rich-Text-Editor des Raum-Stempels (per ID). */
onEditRoomStamp: (roomId: string) => void;
// ── Extrusions-Attribute (truck-Integration; nur der selektierte Körper) ──
/** Setzt die Extrusionshöhe (Meter, > 0) — löst eine Re-Extrusion aus. */
onSetExtrudedSolidHeight: (height: number) => void;
/** Setzt die Verjüngung (0..1) — löst eine Re-Extrusion aus. */
onSetExtrudedSolidTaper: (taper: number) => void;
// ── Stützen-Attribute (Tragwerk; nur die selektierte Stütze) ────────────
/** Wechselt das Profil (Rechteck/Kreis); leitet ein sinnvolles Mass ab. */
onSetColumnProfileKind: (kind: "rect" | "round") => void;
/** Setzt die Breite (X) eines Rechteckprofils (Meter). */
onSetColumnWidth: (width: number) => void;
/** Setzt die Tiefe (Y) eines Rechteckprofils (Meter). */
onSetColumnDepth: (depth: number) => void;
/** Setzt den Radius eines Kreisprofils (Meter). */
onSetColumnRadius: (radius: number) => void;
/** Setzt die Höhe der Stütze (Meter, > 0). */
onSetColumnHeight: (height: number) => void;
/** Setzt die Drehung der Stütze (Grad; intern in Radiant gespeichert). */
onSetColumnRotation: (deg: number) => void;
// ── Site-/Kontext-Schicht (SitePanel) ───────────────────────────────────
/** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */
contextObjects: ContextObject[];
@@ -279,6 +342,166 @@ export interface PanelHostValue {
onGenerateTerrain: (contourSetId: string) => string | null;
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
onImportDxf: () => void;
// ── Element-Baum (Elemente-Panel) ───────────────────────────────────────
/**
* Selektiert EIN Bauteil-Vorkommen aus der Elementliste (`scheduleRows`) im
* Baum: leert alle Auswahl-Kanäle und setzt genau dieses Element im
* passenden Kanal (Wand/Decke/Fenster+Öffnung/Treppe/Extrusion). Liegt das
* Element auf einem anderen Geschoss, wechselt zuerst die aktive
* Zeichnungsebene. Für Bauteilklassen ohne Auswahl-Kanal (aktuell nur die
* Legacy-„Tür"-Datensätze aus `project.doors`, die kein Werkzeug mehr
* anlegt) ein No-op. `opts.zoom` (Shift-Klick/Doppelklick in der Baumzeile)
* passt die Plan-Ansicht zusätzlich ein.
*/
onSelectScheduleRow: (
kind: ScheduleKind,
id: string,
floorId: string | undefined,
opts?: { zoom?: boolean },
) => void;
// ── Ausschnitte / View-Snapshots (Ausschnitte-Panel, DOSSIER A2) ─────────
// Die Liste selbst liest das Panel über `project.viewSnapshots`. Die
// Erfassungs-/Anwendungslogik (Setter/rAF) lebt in App.tsx; hier nur die
// immutablen Handler.
/**
* Erfasst den aktuellen Darstellungszustand als neuen, benannten Ausschnitt.
* `folderId` legt ihn direkt im angegebenen Ordner ab (sonst Wurzelebene) —
* additiv, Alt-Aufrufe ohne Ordner-Argument bleiben gültig.
*/
onCaptureViewSnapshot: (name: string, folderId?: string) => void;
/** Stellt alle Felder eines Ausschnitts wieder her (Geschoss/Sichtbarkeit/Overrides/Ansicht). */
onApplyViewSnapshot: (id: string) => void;
/** Benennt einen Ausschnitt um. */
onRenameViewSnapshot: (id: string, name: string) => void;
/** Löscht einen Ausschnitt. */
onDeleteViewSnapshot: (id: string) => void;
// ── Ausschnitte-Baum (Ordner, DOSSIER A2) ───────────────────────────────
// Die Listen liest das Panel über `project.viewSnapshots`/
// `project.viewSnapshotFolders`; pure CRUD in state/viewSnapshotFolders.ts.
/** Legt einen neuen Ordner an (optional in `parentId`); liefert die neue Id. */
onAddViewSnapshotFolder: (parentId?: string) => string;
/** Benennt einen Ausschnitte-Ordner um. */
onRenameViewSnapshotFolder: (id: string, name: string) => void;
/**
* Löscht einen Ausschnitte-Ordner (enthaltene Ausschnitte + Unterordner wandern
* auf die Elternebene, kein Datenverlust).
*/
onDeleteViewSnapshotFolder: (id: string) => void;
/**
* Verschiebt einen Ausschnitt per Drag&Drop in einen Ordner (`folderId`) bzw.
* auf die Wurzelebene (`null`).
*/
onMoveViewSnapshotToFolder: (snapshotId: string, folderId: string | null) => void;
/**
* Verschiebt einen Ordner per Drag&Drop unter einen Elternordner (`parentId`)
* bzw. auf die Wurzelebene (`null`). Zyklen (Ordner in sich/seinen Nachfahren)
* werden verworfen (No-op).
*/
onMoveViewSnapshotFolder: (folderId: string, parentId: string | null) => void;
/**
* Id des aktuell „angewählten" Ausschnitts (Footer-Bar sichtbar) oder `null`.
* Bleibt gesetzt, solange der Live-Zustand exakt dem Ausschnitt entspricht;
* App löscht ihn, sobald der Nutzer eine erfasste Grösse ändert.
*/
selectedViewSnapshotId: string | null;
/** Namen aller gespeicherten Ebenen-Kombinationen (localStorage). */
listLayerCombos: () => string[];
/** Lädt die `codes`-Map einer Ebenen-Kombination (`null`, wenn unbekannt). */
loadLayerCombo: (name: string) => Record<string, boolean> | null;
/** Namen aller gespeicherten Zeichnungs-Kombinationen (localStorage). */
listDrawingCombos: () => string[];
/** Lädt die `ids`-Map einer Zeichnungs-Kombination (`null`, wenn unbekannt). */
loadDrawingCombo: (name: string) => Record<string, boolean> | null;
// ── Layouts / Masterlayouts (Layouts-Panel, DOSSIER A3) ─────────────────
// Die Listen liest das Panel über `project.layouts`/`project.masterLayouts`.
// Die Mutationslogik lebt in App.tsx (setProject); pure CRUD in
// panels/layoutModel.ts.
/** Legt ein neues, leeres Layout an und öffnet den Editor. */
onAddLayout: (
name: string,
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
) => void;
/** Benennt ein Layout um. */
onRenameLayout: (id: string, name: string) => void;
/** Löscht ein Layout (schliesst ggf. den offenen Editor). */
onDeleteLayout: (id: string) => void;
/** Öffnet ein Layout im schwebenden Layout-Editor. */
onOpenLayout: (id: string) => void;
/** Legt ein neues Masterlayout an. */
onAddMasterLayout: (name: string) => void;
/** Benennt ein Masterlayout um. */
onRenameMasterLayout: (id: string, name: string) => void;
/** Löscht ein Masterlayout (löst Bindungen der Layouts). */
onDeleteMasterLayout: (id: string) => void;
/** Ändert Felder eines Masterlayouts (Titelblock/Papier/Rahmen). */
onPatchMasterLayout: (id: string, patch: Partial<MasterLayout>) => void;
// ── Layouts-Baum (Ordner) + Erstell-Dialoge (DOSSIER A3) ────────────────
/** Legt einen neuen Ordner an (optional in `parentId`); liefert die neue Id. */
onAddLayoutFolder: (parentId?: string) => string;
/**
* Legt einen neuen MASTER-Ordner (`kind:"master"`) an (optional in `parentId`);
* liefert die neue Id. Analog {@link onAddLayoutFolder}, aber im Master-Baum.
*/
onAddMasterFolder: (parentId?: string) => string;
/** Benennt einen Ordner um (Layout- wie Master-Ordner, per Id). */
onRenameLayoutFolder: (id: string, name: string) => void;
/** Löscht einen Ordner (Inhalt wandert auf die Elternebene, kein Datenverlust). */
onDeleteLayoutFolder: (id: string) => void;
/**
* Löscht einen Master-Ordner (enthaltene Masterlayouts + Unterordner wandern
* auf die Elternebene, kein Datenverlust). Analog {@link onDeleteLayoutFolder}.
*/
onDeleteMasterFolder: (id: string) => void;
/**
* Legt ein Layout mit expliziten Startwerten (Master-Vorlage ODER freie
* Grösse) an und liefert dessen Id (das Panel versetzt es in Inline-Rename).
* Öffnet das Blatt NICHT automatisch (anders als {@link onAddLayout}).
*/
onCreateLayout: (opts: CreateLayoutOptions) => string;
/** Legt ein Masterlayout mit Grösse an und liefert dessen Id. */
onCreateMasterLayout: (opts: CreateMasterLayoutOptions) => string;
/** Exportiert alle Layouts eines Ordners als EIN Mehrseiten-PDF. */
onExportFolderPdf: (folderId: string) => void;
/**
* Verschiebt ein Layout per Drag&Drop in einen Ordner (`folderId`) bzw. auf
* die Wurzelebene (`null`).
*/
onMoveLayoutToFolder: (layoutId: string, folderId: string | null) => void;
/**
* Verschiebt einen Ordner per Drag&Drop unter einen Elternordner (`parentId`)
* bzw. auf die Wurzelebene (`null`). Zyklen (Ordner in sich/seinen Nachfahren)
* werden verworfen (No-op).
*/
onMoveFolderToFolder: (folderId: string, parentId: string | null) => void;
}
/** Startwerte eines neuen Layouts (aus dem „Neues Layout"-Dialog). */
export interface CreateLayoutOptions {
/** Zielordner; ohne → Wurzelebene. */
folderId?: string;
/** Gebundenes Masterlayout (erbt dessen Grösse); ohne → freie Grösse. */
masterId?: string;
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
}
/** Startwerte eines neuen Masterlayouts (aus dem „Neues Masterlayout"-Dialog). */
export interface CreateMasterLayoutOptions {
/** Ziel-Master-Ordner; ohne → Wurzelebene des Master-Baums. */
folderId?: string;
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
}
// Re-Export der Modelltypen, die die Panels im selben Atemzug brauchen — so
@@ -286,6 +509,7 @@ export interface PanelHostValue {
export type { DrawingLevel, LayerCategory };
export type { SnapSettings, ToolId };
export type { Selection } from "../state/selectionInfo";
export type { ScheduleKind, ScheduleRow } from "../export/exportSchedule";
// ── Hook ───────────────────────────────────────────────────────────────────
+8 -2
View File
@@ -34,7 +34,13 @@ import type {
// v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) →
// Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen
// (Attribute volle linke Höhe statt Werkzeug-Palette oben).
export const LAYOUT_VERSION = 8;
// v9: Element-Baum ("elements", ROADMAP Paragraf 11) als weiterer Tab in der
// rechten unteren Gruppe (neben Zeichnungsebenen/Ebenen/Umgebung).
// v10: Ausschnitte-Panel ("view-snapshots", DOSSIER A2 / ROADMAP Paragraf 2c)
// als weiterer Tab in der rechten unteren Gruppe.
// v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer
// Tab in der rechten unteren Gruppe.
export const LAYOUT_VERSION = 11;
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
const CURRENT_KEY = "cad.layout";
@@ -68,7 +74,7 @@ const DEFAULT_LEFT_GROUPS: DockGroup[] = [
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
{
tabs: ["drawing-levels", "layers", "site"],
tabs: ["drawing-levels", "layers", "site", "elements", "view-snapshots", "layouts"],
activeTab: "drawing-levels",
weight: 1.5,
},
+654
View File
@@ -0,0 +1,654 @@
// Tests der reinen Layout-Logik (DOSSIER A3): Blatt-Geometrie, Fabriken,
// immutables Viewport-CRUD, Master-Vererbung der Titelblock-Werte,
// Viewport→Ausschnitt-Auflösung + Massstab, Sichtbarkeits-Ableitung.
import { describe, expect, it } from "vitest";
import type {
Layout,
LayoutFolder,
MasterLayout,
Project,
ViewSnapshot,
} from "../model/types";
import {
addAnnotation,
addViewport,
buildLayoutTree,
buildMasterTree,
collectFolderLayouts,
createAnnotation,
createLayout,
createLayoutFolder,
createMasterLayout,
createViewport,
deleteFolder,
deleteMasterFolder,
effectiveSheetSizeMm,
findMaster,
findSnapshot,
folderPdfPages,
isDescendant,
moveFolderToFolder,
moveLayoutToFolder,
PAPER_FORMATS,
patchAnnotation,
patchViewport,
removeAnnotation,
removeViewport,
resolveTitleBlock,
resolveViewportScale,
sheetSizeMm,
visibleCodesFromSnapshot,
} from "./layoutModel";
/** Minimaler Ausschnitt (nur die im Layout-System genutzten Felder tragen Werte). */
function snap(id: string, patch?: Partial<ViewSnapshot>): ViewSnapshot {
return {
id,
name: `Snap ${id}`,
viewType: "grundriss",
view3d: "top",
scaleDenominator: 100,
detail: "mittel",
activeLevelId: "floor-0",
layerVisibility: { A: true, B: false, C: true },
drawingVisibility: {},
...patch,
};
}
/** Minimales Projekt-Gerüst mit optionalen Layout-Feldern. */
function project(patch?: Partial<Project>): Project {
return {
id: "p1",
name: "Villa Test",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [],
layers: [],
walls: [],
doors: [],
drawings2d: [],
...patch,
};
}
describe("sheetSizeMm", () => {
it("liefert A4/A3 in Hoch- und Querformat", () => {
expect(sheetSizeMm("a4", "portrait")).toEqual({ w: 210, h: 297 });
expect(sheetSizeMm("a4", "landscape")).toEqual({ w: 297, h: 210 });
expect(sheetSizeMm("a3", "portrait")).toEqual({ w: 297, h: 420 });
expect(sheetSizeMm("a3", "landscape")).toEqual({ w: 420, h: 297 });
});
});
describe("Fabriken", () => {
it("createLayout: leer, ohne Master", () => {
const l = createLayout("l1", "Blatt 1", "a3", "landscape");
expect(l).toEqual({
id: "l1",
name: "Blatt 1",
paper: "a3",
orientation: "landscape",
viewports: [],
});
expect(l.masterId).toBeUndefined();
});
it("createMasterLayout: leerer Titelblock + Rahmen an", () => {
const m = createMasterLayout("m1", "Master", "a4", "portrait");
expect(m.titleBlock).toEqual({});
expect(m.border).toBe(true);
});
it("createViewport: Defaults + optionaler Massstab", () => {
const v = createViewport("v1", "s1");
expect(v).toMatchObject({ id: "v1", snapshotId: "s1", xMm: 20, yMm: 20, widthMm: 120, heightMm: 90 });
expect(v.scaleDenominator).toBeUndefined();
const v2 = createViewport("v2", "s1", { xMm: 5, scaleDenominator: 50 });
expect(v2.xMm).toBe(5);
expect(v2.scaleDenominator).toBe(50);
});
});
describe("Viewport-CRUD (immutabel)", () => {
const base = addViewport(createLayout("l1", "L", "a4", "portrait"), createViewport("v1", "s1"));
it("addViewport verändert das Original nicht", () => {
const before = createLayout("l1", "L", "a4", "portrait");
const after = addViewport(before, createViewport("v1", "s1"));
expect(before.viewports).toHaveLength(0);
expect(after.viewports).toHaveLength(1);
expect(after).not.toBe(before);
});
it("removeViewport entfernt per Id", () => {
const two = addViewport(base, createViewport("v2", "s2"));
const one = removeViewport(two, "v1");
expect(one.viewports.map((v) => v.id)).toEqual(["v2"]);
});
it("patchViewport ändert nur das Ziel, immutabel", () => {
const patched = patchViewport(base, "v1", { xMm: 42, scaleDenominator: 50 });
expect(patched.viewports[0]).toMatchObject({ xMm: 42, scaleDenominator: 50 });
expect(base.viewports[0].xMm).toBe(20);
// Unbekannte Id → No-op (gleiche Werte, neue Liste).
const noop = patchViewport(base, "nope", { xMm: 1 });
expect(noop.viewports[0].xMm).toBe(20);
});
});
// ── Annotationen (Linie/Rechteck/Text) ───────────────────────────────────────
describe("createAnnotation — Defaults je Art", () => {
it("line: Default-Geometrie ohne Farbe/Strichstärke", () => {
const a = createAnnotation("a1", "line");
expect(a).toEqual({ id: "a1", kind: "line", x1Mm: 20, y1Mm: 20, x2Mm: 80, y2Mm: 20 });
});
it("line: init überschreibt einzelne Felder + optionale Farbe/Strichstärke", () => {
const a = createAnnotation("a1", "line", { x1Mm: 5, color: "#f00", weightMm: 0.5 });
expect(a).toMatchObject({ x1Mm: 5, y1Mm: 20, color: "#f00", weightMm: 0.5 });
});
it("rect: Default-Grösse", () => {
const a = createAnnotation("a2", "rect");
expect(a).toEqual({ id: "a2", kind: "rect", xMm: 20, yMm: 20, widthMm: 40, heightMm: 30 });
});
it("text: Default-Zeilenhöhe 3.5mm, leerer Text", () => {
const a = createAnnotation("a3", "text");
expect(a).toEqual({ id: "a3", kind: "text", xMm: 20, yMm: 20, text: "", heightMm: 3.5 });
});
it("text: init setzt Inhalt + Höhe", () => {
const a = createAnnotation("a3", "text", { text: "EG", heightMm: 5 });
expect(a).toMatchObject({ text: "EG", heightMm: 5 });
});
});
describe("Annotations-CRUD (immutabel)", () => {
const base = createLayout("l1", "L", "a4", "portrait");
it("addAnnotation verändert das Original nicht", () => {
const after = addAnnotation(base, createAnnotation("a1", "line"));
expect(base.annotations).toBeUndefined();
expect(after.annotations).toHaveLength(1);
expect(after).not.toBe(base);
});
it("addAnnotation baut auf einer fehlenden `annotations`-Liste auf (Alt-Layout)", () => {
const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] };
const after = addAnnotation(legacy, createAnnotation("a1", "rect"));
expect(after.annotations).toHaveLength(1);
expect(legacy.annotations).toBeUndefined();
});
it("removeAnnotation entfernt per Id", () => {
let l = addAnnotation(base, createAnnotation("a1", "line"));
l = addAnnotation(l, createAnnotation("a2", "rect"));
const removed = removeAnnotation(l, "a1");
expect(removed.annotations!.map((a) => a.id)).toEqual(["a2"]);
});
it("removeAnnotation auf Alt-Layout ohne `annotations` bleibt heil (No-op)", () => {
const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] };
const r = removeAnnotation(legacy, "nope");
expect(r.annotations).toEqual([]);
});
it("patchAnnotation ändert nur das Ziel, immutabel", () => {
const l = addAnnotation(base, createAnnotation("a1", "line", { color: "#111" }));
const patched = patchAnnotation(l, "a1", { x1Mm: 42, color: "#0f0" });
expect(patched.annotations![0]).toMatchObject({ x1Mm: 42, color: "#0f0" });
expect(l.annotations![0].color).toBe("#111");
});
it("patchAnnotation: unbekannte Id → No-op (gleiche Werte, neue Liste)", () => {
const l = addAnnotation(base, createAnnotation("a1", "line"));
const noop = patchAnnotation(l, "nope", { x1Mm: 1 });
expect((noop.annotations![0] as { x1Mm: number }).x1Mm).toBe(20);
});
it("patchAnnotation auf einer Text-Annotation ändert den Inhalt", () => {
const l = addAnnotation(base, createAnnotation("t1", "text", { text: "EG" }));
const patched = patchAnnotation(l, "t1", { text: "1. OG" });
expect(patched.annotations![0]).toMatchObject({ text: "1. OG" });
});
});
describe("resolveViewportScale", () => {
const s = snap("s1", { scaleDenominator: 200 });
it("Viewport-Override gewinnt", () => {
expect(resolveViewportScale(createViewport("v", "s1", { scaleDenominator: 50 }), s)).toBe(50);
});
it("sonst Ausschnitt-Massstab", () => {
expect(resolveViewportScale(createViewport("v", "s1"), s)).toBe(200);
});
it("Fallback ohne beides", () => {
expect(resolveViewportScale(createViewport("v", "s1"), undefined, 100)).toBe(100);
});
});
describe("findSnapshot / findMaster", () => {
const p = project({
viewSnapshots: [snap("s1"), snap("s2")],
masterLayouts: [createMasterLayout("m1", "M1", "a4", "portrait")],
});
it("findSnapshot", () => {
expect(findSnapshot(p, "s2")?.id).toBe("s2");
expect(findSnapshot(p, "x")).toBeUndefined();
});
it("findMaster nur bei gesetzter masterId", () => {
const bound: Layout = { ...createLayout("l", "L", "a4", "portrait"), masterId: "m1" };
expect(findMaster(p, bound)?.id).toBe("m1");
expect(findMaster(p, createLayout("l2", "L2", "a4", "portrait"))).toBeUndefined();
// Dangling masterId → undefined.
expect(findMaster(p, { ...bound, masterId: "gone" })).toBeUndefined();
});
});
describe("resolveTitleBlock — Master-Vererbung + Defaults", () => {
const p = project({ name: "Villa Test", viewSnapshots: [snap("s1", { scaleDenominator: 50 })] });
it("ohne Master: Defaults aus Projekt/Layout + Massstab aus erstem Viewport", () => {
let l = createLayout("l1", "EG Grundriss", "a4", "portrait");
l = addViewport(l, createViewport("v1", "s1"));
const tb = resolveTitleBlock(p, l, undefined, "2026-07-07");
expect(tb.projectName).toBe("Villa Test");
expect(tb.sheetName).toBe("EG Grundriss");
expect(tb.scale).toBe("1:50");
expect(tb.date).toBe("2026-07-07");
expect(tb.author).toBe("");
});
it("Master-Werte übersteuern die Defaults", () => {
const master: MasterLayout = {
id: "m1",
name: "M",
paper: "a4",
orientation: "portrait",
titleBlock: { projectName: "Kundenprojekt", author: "GV", scale: "1:100" },
};
const l = createLayout("l1", "EG", "a4", "portrait");
const tb = resolveTitleBlock(p, l, master, "2026-07-07");
expect(tb.projectName).toBe("Kundenprojekt");
expect(tb.author).toBe("GV");
expect(tb.scale).toBe("1:100"); // Master-Wert schlägt den Viewport-Default
expect(tb.sheetName).toBe("EG"); // leer im Master → Layout-Name
});
it("leere/whitespace Master-Felder fallen auf Defaults zurück", () => {
const master: MasterLayout = {
id: "m1",
name: "M",
paper: "a4",
orientation: "portrait",
titleBlock: { projectName: " ", sheetName: "" },
};
const l = createLayout("l1", "OG", "a4", "portrait");
const tb = resolveTitleBlock(p, l, master, "2026-07-07");
expect(tb.projectName).toBe("Villa Test");
expect(tb.sheetName).toBe("OG");
expect(tb.scale).toBe("—"); // kein Viewport → kein Massstab
});
});
describe("visibleCodesFromSnapshot", () => {
it("nur sichtbare Codes landen im Set", () => {
const codes = visibleCodesFromSnapshot(snap("s1"));
expect([...codes].sort()).toEqual(["A", "C"]);
expect(codes.has("B")).toBe(false);
});
});
// ── Freie Grösse (custom übersteuert Papier) ──────────────────────────────────
describe("sheetSizeMm / effectiveSheetSizeMm — freie Grösse", () => {
it("custom übersteuert paper/orientation, wenn beide positiv gesetzt", () => {
expect(sheetSizeMm("a4", "portrait", 500, 300)).toEqual({ w: 500, h: 300 });
});
it("fällt auf paper zurück, wenn custom fehlt oder nicht positiv ist", () => {
expect(sheetSizeMm("a4", "portrait", undefined, 300)).toEqual({ w: 210, h: 297 });
expect(sheetSizeMm("a3", "landscape", 0, 300)).toEqual({ w: 420, h: 297 });
});
it("effectiveSheetSizeMm liest die Felder eines Layouts/Masters", () => {
expect(
effectiveSheetSizeMm({ paper: "a4", orientation: "portrait" }),
).toEqual({ w: 210, h: 297 });
expect(
effectiveSheetSizeMm({
paper: "a4",
orientation: "portrait",
customWidthMm: 250,
customHeightMm: 250,
}),
).toEqual({ w: 250, h: 250 });
});
});
describe("createMasterLayout — freie Grösse", () => {
it("übernimmt custom Breite/Höhe, wenn übergeben", () => {
const m = createMasterLayout("m", "M", "a4", "portrait", {
widthMm: 400,
heightMm: 250,
});
expect(m.customWidthMm).toBe(400);
expect(m.customHeightMm).toBe(250);
});
});
// ── Ordner-Baum (CRUD + Aufbau) ───────────────────────────────────────────────
function lay(id: string, folderId?: string): Layout {
return {
id,
name: id,
paper: "a4",
orientation: "portrait",
viewports: [],
...(folderId ? { folderId } : {}),
};
}
describe("createLayoutFolder", () => {
it("Wurzelordner ohne parentId", () => {
expect(createLayoutFolder("f1", "Ordner")).toEqual({ id: "f1", name: "Ordner" });
});
it("Unterordner mit parentId", () => {
expect(createLayoutFolder("f2", "Sub", "f1")).toEqual({
id: "f2",
name: "Sub",
parentId: "f1",
});
});
it("Master-Ordner trägt kind:\"master\"", () => {
expect(createLayoutFolder("m1", "Master-Ordner", undefined, "master")).toEqual({
id: "m1",
name: "Master-Ordner",
kind: "master",
});
});
it("kind \"layout\"/undefined wird NICHT gesetzt (rückwärtskompatibel)", () => {
expect(createLayoutFolder("l1", "Layout-Ordner", undefined, "layout")).toEqual({
id: "l1",
name: "Layout-Ordner",
});
expect(createLayoutFolder("l2", "Layout-Ordner", "p", undefined)).toEqual({
id: "l2",
name: "Layout-Ordner",
parentId: "p",
});
});
});
describe("buildLayoutTree", () => {
it("gruppiert Layouts in Ordner + Wurzel; Unterordner verschachtelt", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("root1"), lay("inA", "A"), lay("inB", "B")];
const tree = buildLayoutTree(folders, layouts);
expect(tree.rootLayouts.map((l) => l.id)).toEqual(["root1"]);
expect(tree.rootFolders).toHaveLength(1);
const a = tree.rootFolders[0];
expect(a.folder.id).toBe("A");
expect(a.layouts.map((l) => l.id)).toEqual(["inA"]);
expect(a.subfolders).toHaveLength(1);
expect(a.subfolders[0].folder.id).toBe("B");
expect(a.subfolders[0].layouts.map((l) => l.id)).toEqual(["inB"]);
});
it("verwaiste Referenzen landen auf der Wurzel", () => {
const tree = buildLayoutTree(
[createLayoutFolder("orphan", "O", "missing")],
[lay("ghost", "gone")],
);
// Ordner mit unbekanntem parent → Wurzel; Layout mit unbekanntem folder → Wurzel.
expect(tree.rootFolders.map((f) => f.folder.id)).toEqual(["orphan"]);
expect(tree.rootLayouts.map((l) => l.id)).toEqual(["ghost"]);
});
});
// ── Master-Baum + Layout/Master-Trennung (kind) ──────────────────────────────
function mas(id: string, folderId?: string): MasterLayout {
return {
id,
name: id,
paper: "a4",
orientation: "portrait",
titleBlock: {},
...(folderId ? { folderId } : {}),
};
}
describe("buildMasterTree", () => {
it("gruppiert Master in Master-Ordner + Wurzel; Unterordner verschachtelt", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A", undefined, "master"),
createLayoutFolder("B", "B", "A", "master"),
];
const masters = [mas("root1"), mas("inA", "A"), mas("inB", "B")];
const tree = buildMasterTree(folders, masters);
expect(tree.rootMasters.map((m) => m.id)).toEqual(["root1"]);
expect(tree.rootFolders).toHaveLength(1);
const a = tree.rootFolders[0];
expect(a.folder.id).toBe("A");
expect(a.masters.map((m) => m.id)).toEqual(["inA"]);
expect(a.subfolders[0].masters.map((m) => m.id)).toEqual(["inB"]);
});
});
describe("Layout-/Master-Ordner bleiben getrennt (kind-Filter)", () => {
it("Master-Ordner erscheint NICHT im Layout-Baum und umgekehrt", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("L", "Layout-Ordner"), // kind fehlt → Layout
createLayoutFolder("M", "Master-Ordner", undefined, "master"),
];
const layouts = [lay("l1", "L"), lay("lm", "M")]; // lm zeigt fälschlich auf Master-Ordner
const masters = [mas("m1", "M"), mas("ml", "L")]; // ml zeigt fälschlich auf Layout-Ordner
// Wie das Panel filtert: Layout-Baum nur kind!=="master", Master-Baum nur kind==="master".
const layoutTree = buildLayoutTree(
folders.filter((f) => f.kind !== "master"),
layouts,
);
const masterTree = buildMasterTree(
folders.filter((f) => f.kind === "master"),
masters,
);
// Layout-Baum kennt nur den Layout-Ordner; das auf M zeigende Layout fällt auf die Wurzel.
expect(layoutTree.rootFolders.map((f) => f.folder.id)).toEqual(["L"]);
expect(layoutTree.rootLayouts.map((l) => l.id)).toEqual(["lm"]);
// Master-Baum kennt nur den Master-Ordner; der auf L zeigende Master fällt auf die Wurzel.
expect(masterTree.rootFolders.map((f) => f.folder.id)).toEqual(["M"]);
expect(masterTree.rootMasters.map((m) => m.id)).toEqual(["ml"]);
expect(masterTree.rootFolders[0].masters.map((m) => m.id)).toEqual(["m1"]);
});
});
describe("deleteMasterFolder", () => {
it("zieht Master + Unterordner auf die Elternebene und erhält kind:\"master\"", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A", undefined, "master"),
createLayoutFolder("B", "B", "A", "master"),
];
const masters = [mas("a1", "A"), mas("b1", "B")];
const r = deleteMasterFolder("A", folders, masters);
expect(r.folders.map((f) => f.id)).toEqual(["B"]);
// B auf Wurzel hochgezogen, aber weiterhin Master-Ordner.
expect(r.folders[0].parentId).toBeUndefined();
expect(r.folders[0].kind).toBe("master");
expect(r.masters.find((m) => m.id === "a1")!.folderId).toBeUndefined();
expect(r.masters.find((m) => m.id === "b1")!.folderId).toBe("B");
});
});
describe("collectFolderLayouts", () => {
it("liefert direkte Blätter zuerst, dann rekursiv Unterordner (PDF-Reihenfolge)", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("root1"), lay("a1", "A"), lay("a2", "A"), lay("b1", "B")];
expect(collectFolderLayouts("A", folders, layouts).map((l) => l.id)).toEqual([
"a1",
"a2",
"b1",
]);
expect(collectFolderLayouts("B", folders, layouts).map((l) => l.id)).toEqual([
"b1",
]);
});
it("unbekannter Ordner → leer", () => {
expect(collectFolderLayouts("nope", [], [lay("x")])).toEqual([]);
});
});
describe("deleteFolder", () => {
it("zieht Layouts + Unterordner auf die Elternebene (kein Datenverlust)", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("a1", "A"), lay("b1", "B")];
// A (Wurzel) löschen → a1 auf Wurzel, B verliert parent → Wurzel.
const r = deleteFolder("A", folders, layouts);
expect(r.folders.map((f) => f.id)).toEqual(["B"]);
expect(r.folders[0].parentId).toBeUndefined();
expect(r.layouts.find((l) => l.id === "a1")!.folderId).toBeUndefined();
expect(r.layouts.find((l) => l.id === "b1")!.folderId).toBe("B");
});
it("Unterordner löschen zieht Kinder zum Grosselter", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("b1", "B")];
const r = deleteFolder("B", folders, layouts);
expect(r.folders.map((f) => f.id)).toEqual(["A"]);
expect(r.layouts[0].folderId).toBe("A");
});
});
describe("folderPdfPages", () => {
it("plant Seiten in Baum-Reihenfolge mit effektiver Blattgrösse", () => {
const master: MasterLayout = createMasterLayout("m", "M", "a3", "landscape");
const layouts: Layout[] = [
{ ...lay("p1", "F"), masterId: "m" }, // erbt A3 quer vom Master
{ ...lay("p2", "F"), customWidthMm: 500, customHeightMm: 200 }, // freie Grösse
lay("p3", "F"), // A4 hoch (Default)
];
const p = project({
layoutFolders: [createLayoutFolder("F", "F")],
layouts,
masterLayouts: [master],
});
const pages = folderPdfPages(p, "F");
expect(pages.map((x) => x.layout.id)).toEqual(["p1", "p2", "p3"]);
expect(pages[0]).toMatchObject({ widthMm: 420, heightMm: 297 }); // Master A3 quer
expect(pages[1]).toMatchObject({ widthMm: 500, heightMm: 200 }); // custom
expect(pages[2]).toMatchObject({ widthMm: 210, heightMm: 297 }); // A4 hoch
});
it("leerer/unbekannter Ordner → keine Seiten", () => {
expect(folderPdfPages(project(), "nope")).toEqual([]);
});
});
describe("PAGE_MM / Standard-Formate A0–A6 + B0–B6", () => {
it("PAPER_FORMATS listet 14 Formate (A-Reihe, dann B-Reihe)", () => {
expect(PAPER_FORMATS).toEqual([
"a0", "a1", "a2", "a3", "a4", "a5", "a6",
"b0", "b1", "b2", "b3", "b4", "b5", "b6",
]);
});
it("liefert offizielle ISO-216-Masse (Hochformat, mm)", () => {
expect(sheetSizeMm("a0", "portrait")).toEqual({ w: 841, h: 1189 });
expect(sheetSizeMm("a1", "portrait")).toEqual({ w: 594, h: 841 });
expect(sheetSizeMm("a2", "portrait")).toEqual({ w: 420, h: 594 });
expect(sheetSizeMm("a5", "portrait")).toEqual({ w: 148, h: 210 });
expect(sheetSizeMm("a6", "portrait")).toEqual({ w: 105, h: 148 });
expect(sheetSizeMm("b0", "portrait")).toEqual({ w: 1000, h: 1414 });
expect(sheetSizeMm("b3", "portrait")).toEqual({ w: 353, h: 500 });
expect(sheetSizeMm("b6", "portrait")).toEqual({ w: 125, h: 176 });
});
it("Querformat dreht die Basis (Beispiel A0)", () => {
expect(sheetSizeMm("a0", "landscape")).toEqual({ w: 1189, h: 841 });
});
it("jedes Format hat eine definierte Grösse", () => {
for (const f of PAPER_FORMATS) {
const s = sheetSizeMm(f, "portrait");
expect(s.w).toBeGreaterThan(0);
expect(s.h).toBeGreaterThan(0);
}
});
});
describe("isDescendant — Zyklus-Schutz", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
createLayoutFolder("C", "C", "B"),
createLayoutFolder("X", "X"),
];
it("ein Ordner ist sein eigener Nachfahre (self)", () => {
expect(isDescendant(folders, "A", "A")).toBe(true);
});
it("erkennt direkte + tiefe Nachfahren", () => {
expect(isDescendant(folders, "B", "A")).toBe(true);
expect(isDescendant(folders, "C", "A")).toBe(true);
});
it("nicht-verwandte Ordner sind keine Nachfahren", () => {
expect(isDescendant(folders, "X", "A")).toBe(false);
expect(isDescendant(folders, "A", "B")).toBe(false);
});
});
describe("moveLayoutToFolder", () => {
const layouts = [lay("l1", "A"), lay("l2")];
it("setzt folderId auf den Zielordner", () => {
const r = moveLayoutToFolder(layouts, "l2", "A");
expect(r.find((l) => l.id === "l2")!.folderId).toBe("A");
});
it("null → zurück auf Wurzel (folderId entfernt)", () => {
const r = moveLayoutToFolder(layouts, "l1", null);
expect(r.find((l) => l.id === "l1")!.folderId).toBeUndefined();
expect("folderId" in r.find((l) => l.id === "l1")!).toBe(false);
});
it("unbekannte Id → unverändert", () => {
expect(moveLayoutToFolder(layouts, "nope", "A")).toEqual(layouts);
});
});
describe("moveFolderToFolder", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
createLayoutFolder("X", "X"),
];
it("setzt parentId auf den Zielordner", () => {
const r = moveFolderToFolder(folders, "X", "A");
expect(r.find((f) => f.id === "X")!.parentId).toBe("A");
});
it("null → zurück auf Wurzel (parentId entfernt)", () => {
const r = moveFolderToFolder(folders, "B", null);
expect(r.find((f) => f.id === "B")!.parentId).toBeUndefined();
});
it("kind bleibt beim Wurzel-Verschieben erhalten (Master-Ordner)", () => {
const withMaster: LayoutFolder[] = [
createLayoutFolder("P", "P", undefined, "master"),
createLayoutFolder("Q", "Q", "P", "master"),
];
const r = moveFolderToFolder(withMaster, "Q", null);
const q = r.find((f) => f.id === "Q")!;
expect(q.parentId).toBeUndefined();
expect(q.kind).toBe("master");
});
it("Zyklus (in eigenen Nachfahren) → No-op, Original zurück", () => {
expect(moveFolderToFolder(folders, "A", "B")).toEqual(folders);
});
it("in sich selbst → No-op", () => {
expect(moveFolderToFolder(folders, "A", "A")).toEqual(folders);
});
});
+673
View File
@@ -0,0 +1,673 @@
// Layout-Blätter mit Masterlayout (DOSSIER A3) — reine, testbare Logik.
//
// Enthält KEINE React-/DOM-Abhängigkeit: nur immutable CRUD auf Layouts/Master/
// Viewports, die Master-Vererbung der Titelblock-Werte, die Auflösung eines
// Viewports auf seinen gebundenen Ausschnitt und die Blatt-Geometrie (Papier-mm).
// Das UI-Wiring (App.tsx setProject, LayoutEditor-Rendering) baut hierauf auf.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type {
Layout,
LayoutAnnotation,
LayoutAnnotationPatch,
LayoutFolder,
LayoutOrientation,
LayoutPaperFormat,
LayoutTitleBlock,
LayoutViewport,
MasterLayout,
Project,
ViewSnapshot,
} from "../model/types";
// ── Blatt-Geometrie ──────────────────────────────────────────────────────────
/**
* Blattmasse je Format im Hochformat (mm) — offizielle ISO-216-Masse (auf ganze
* mm gerundet). A-Reihe A0–A6 und B-Reihe B0–B6.
*/
const PAGE_MM: Record<LayoutPaperFormat, { w: number; h: number }> = {
a0: { w: 841, h: 1189 },
a1: { w: 594, h: 841 },
a2: { w: 420, h: 594 },
a3: { w: 297, h: 420 },
a4: { w: 210, h: 297 },
a5: { w: 148, h: 210 },
a6: { w: 105, h: 148 },
b0: { w: 1000, h: 1414 },
b1: { w: 707, h: 1000 },
b2: { w: 500, h: 707 },
b3: { w: 353, h: 500 },
b4: { w: 250, h: 353 },
b5: { w: 176, h: 250 },
b6: { w: 125, h: 176 },
};
/**
* Auswahlreihenfolge der Standard-Formate für Dropdowns (A-Reihe, dann B-Reihe).
* Gruppierbar über {@link PAPER_FORMAT_GROUPS} (A/B) für optgroups.
*/
export const PAPER_FORMATS: LayoutPaperFormat[] = [
"a0", "a1", "a2", "a3", "a4", "a5", "a6",
"b0", "b1", "b2", "b3", "b4", "b5", "b6",
];
/** Format-Liste je Reihe (A/B) — für gruppierte Dropdown-Anzeige (optgroup). */
export const PAPER_FORMAT_GROUPS: { label: string; formats: LayoutPaperFormat[] }[] = [
{ label: "A", formats: ["a0", "a1", "a2", "a3", "a4", "a5", "a6"] },
{ label: "B", formats: ["b0", "b1", "b2", "b3", "b4", "b5", "b6"] },
];
/**
* Blattmasse (mm) nach Format + Ausrichtung (Quer = Basis gedreht). Sind
* `customWidthMm`/`customHeightMm` (freie Grösse) gesetzt und positiv, gewinnen
* sie und `paper`/`orientation` werden ignoriert (additiv — Alt-Aufrufe ohne
* Custom-Argumente verhalten sich unverändert).
*/
export function sheetSizeMm(
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
customWidthMm?: number,
customHeightMm?: number,
): { w: number; h: number } {
if (
customWidthMm !== undefined &&
customHeightMm !== undefined &&
customWidthMm > 0 &&
customHeightMm > 0
) {
return { w: customWidthMm, h: customHeightMm };
}
const base = PAGE_MM[paper];
return orientation === "landscape" ? { w: base.h, h: base.w } : base;
}
/**
* Effektive Blattgrösse eines Layouts/Masters (mm) — respektiert die freie
* Grösse (`customWidthMm`/`customHeightMm`), sonst `paper`+`orientation`. EIN
* Ort für die „Custom übersteuert Papier"-Regel (Panel/PDF/Editor teilen ihn).
*/
export function effectiveSheetSizeMm(item: {
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
}): { w: number; h: number } {
return sheetSizeMm(
item.paper,
item.orientation,
item.customWidthMm,
item.customHeightMm,
);
}
// ── Fabriken ─────────────────────────────────────────────────────────────────
/** Neues, leeres Layout (ohne Viewports, ohne Master). */
export function createLayout(
id: string,
name: string,
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
): Layout {
return { id, name, paper, orientation, viewports: [] };
}
/** Neues Masterlayout mit leerem Titelblock + gezeichnetem Rahmen. */
export function createMasterLayout(
id: string,
name: string,
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
custom?: { widthMm: number; heightMm: number },
): MasterLayout {
return {
id,
name,
paper,
orientation,
titleBlock: {},
border: true,
...(custom ? { customWidthMm: custom.widthMm, customHeightMm: custom.heightMm } : {}),
};
}
/**
* Neuer, leerer Ordner für den Layouts- ODER Master-Baum. `kind:"master"` markiert
* einen Master-Ordner; `"layout"`/`undefined` bleibt ein (rückwärtskompatibler)
* Layout-Ordner — das Feld wird dann NICHT gesetzt (Alt-Projekte unverändert).
*/
export function createLayoutFolder(
id: string,
name: string,
parentId?: string,
kind?: "layout" | "master",
): LayoutFolder {
return {
id,
name,
...(parentId ? { parentId } : {}),
...(kind === "master" ? { kind } : {}),
};
}
// ── Layouts-Baum (Ordner + Blätter) ──────────────────────────────────────────
/** Ein Ordner-Knoten im Baum: der Ordner, seine Unterordner und direkten Blätter. */
export interface FolderNode {
folder: LayoutFolder;
subfolders: FolderNode[];
layouts: Layout[];
}
/** Der aufgebaute Baum: Wurzel-Blätter (ohne Ordner) + Wurzel-Ordner. */
export interface LayoutTree {
/** Layouts ohne (gültigen) `folderId` — auf Wurzelebene. */
rootLayouts: Layout[];
/** Ordner ohne (gültigen) `parentId` — auf Wurzelebene, je mit Unterbaum. */
rootFolders: FolderNode[];
}
/**
* Baut aus der flachen Ordner-/Layout-Liste den verschachtelten Baum. Robust
* gegen verwaiste Referenzen: ein Layout 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 buildLayoutTree(
folders: LayoutFolder[],
layouts: Layout[],
): LayoutTree {
const folderIds = new Set(folders.map((f) => f.id));
const nodes = new Map<string, FolderNode>();
for (const f of folders) {
nodes.set(f.id, { folder: f, subfolders: [], layouts: [] });
}
const rootFolders: FolderNode[] = [];
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 rootLayouts: Layout[] = [];
for (const l of layouts) {
const node =
l.folderId && folderIds.has(l.folderId) ? nodes.get(l.folderId) : undefined;
if (node) node.layouts.push(l);
else rootLayouts.push(l);
}
return { rootLayouts, rootFolders };
}
// ── Master-Baum (Ordner + Masterlayouts) ─────────────────────────────────────
// Eigener Baum NEBEN dem Layout-Baum: Master-Ordner (`kind:"master"`) tragen
// Masterlayouts als Blätter. Bewusst KEINE gemeinsame Struktur mit dem Layout-
// Baum, damit Layout- und Master-Ordner nie vermischt werden.
/** Ein Master-Ordner-Knoten: der Ordner, seine Unterordner und direkten Master. */
export interface MasterFolderNode {
folder: LayoutFolder;
subfolders: MasterFolderNode[];
masters: MasterLayout[];
}
/** Der Master-Baum: Wurzel-Master (ohne Ordner) + Wurzel-Ordner. */
export interface MasterTree {
/** Masterlayouts ohne (gültigen) `folderId` — auf Wurzelebene. */
rootMasters: MasterLayout[];
/** Master-Ordner ohne (gültigen) `parentId` — je mit Unterbaum. */
rootFolders: MasterFolderNode[];
}
/**
* Baut aus der flachen Master-Ordner-/Master-Liste den verschachtelten Baum —
* analog {@link buildLayoutTree}, aber mit Masterlayouts als Blättern. Der
* Aufrufer übergibt bereits die NUR-Master-Ordner (kind:"master"); verwaiste
* Referenzen landen robust auf der Wurzelebene.
*/
export function buildMasterTree(
folders: LayoutFolder[],
masters: MasterLayout[],
): MasterTree {
const folderIds = new Set(folders.map((f) => f.id));
const nodes = new Map<string, MasterFolderNode>();
for (const f of folders) {
nodes.set(f.id, { folder: f, subfolders: [], masters: [] });
}
const rootFolders: MasterFolderNode[] = [];
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 rootMasters: MasterLayout[] = [];
for (const m of masters) {
const node =
m.folderId && folderIds.has(m.folderId) ? nodes.get(m.folderId) : undefined;
if (node) node.masters.push(m);
else rootMasters.push(m);
}
return { rootMasters, rootFolders };
}
/**
* Alle Layouts eines Ordners in Baum-Reihenfolge — direkte Blätter zuerst, dann
* (rekursiv) die Blätter der Unterordner. Für den Ordner→Mehrseiten-PDF-Export
* (jedes Layout = eine Seite, deterministische Reihenfolge).
*/
export function collectFolderLayouts(
folderId: string,
folders: LayoutFolder[],
layouts: Layout[],
): Layout[] {
const tree = buildLayoutTree(folders, layouts);
const find = (nodes: FolderNode[]): FolderNode | undefined => {
for (const n of nodes) {
if (n.folder.id === folderId) return n;
const deep = find(n.subfolders);
if (deep) return deep;
}
return undefined;
};
const node = find(tree.rootFolders);
if (!node) return [];
const out: Layout[] = [];
const walk = (n: FolderNode) => {
out.push(...n.layouts);
for (const sub of n.subfolders) walk(sub);
};
walk(node);
return out;
}
/** Eine geplante Ordner-PDF-Seite: das Layout + seine effektive Blattgrösse (mm). */
export interface FolderPdfPage {
layout: Layout;
widthMm: number;
heightMm: number;
}
/**
* Reine Seiten-Planung des Ordner→Mehrseiten-PDF (ohne DOM/jsPDF): welche
* Layouts in welcher Reihenfolge mit welcher Blattgrösse. Reihenfolge =
* Baum-Reihenfolge (siehe {@link collectFolderLayouts}). Die effektive Grösse
* respektiert die freie Grösse (Custom übersteuert Papier); ein gebundener
* Master erzwingt die Blattgrösse (das Blatt erbt Papier/Ausrichtung des Masters).
*/
export function folderPdfPages(
project: Project,
folderId: string,
): FolderPdfPage[] {
const folders = project.layoutFolders ?? [];
const layouts = project.layouts ?? [];
return collectFolderLayouts(folderId, folders, layouts).map((layout) => {
const master = findMaster(project, layout);
const { w, h } = effectiveSheetSizeMm(master ?? layout);
return { layout, widthMm: w, heightMm: h };
});
}
/**
* Löscht einen Ordner (immutabel). Unterordner und Layouts des Ordners werden
* auf die Ebene des gelöschten Ordners (dessen `parentId`) hochgezogen, statt
* mitgelöscht zu werden — es gehen keine Blätter verloren. Liefert die neuen
* `folders`/`layouts`-Listen.
*/
export function deleteFolder(
folderId: string,
folders: LayoutFolder[],
layouts: Layout[],
): { folders: LayoutFolder[]; layouts: Layout[] } {
const { folders: nextFolders, grandParentId } = foldersAfterDelete(folderId, folders);
const nextLayouts = layouts.map((l) =>
l.folderId === folderId
? grandParentId
? { ...l, folderId: grandParentId }
: { ...l, folderId: undefined }
: l,
);
return { folders: nextFolders, layouts: nextLayouts };
}
/**
* Löscht einen Master-Ordner (immutabel) — analog {@link deleteFolder}, aber die
* enthaltenen Masterlayouts (und Unterordner) werden auf die Elternebene
* hochgezogen statt mitgelöscht. Liefert die neuen `folders`/`masters`-Listen.
*/
export function deleteMasterFolder(
folderId: string,
folders: LayoutFolder[],
masters: MasterLayout[],
): { folders: LayoutFolder[]; masters: MasterLayout[] } {
const { folders: nextFolders, grandParentId } = foldersAfterDelete(folderId, folders);
const nextMasters = masters.map((m) =>
m.folderId === folderId
? grandParentId
? { ...m, folderId: grandParentId }
: { ...m, folderId: undefined }
: m,
);
return { folders: nextFolders, masters: nextMasters };
}
/**
* Entfernt einen Ordner aus der flachen Liste und zieht dessen direkte
* Unterordner auf die Elternebene (`grandParentId`). Das `kind`-Feld bleibt dabei
* ERHALTEN (Spread statt Neu-Bau), damit ein hochgezogener Master-Ordner nicht
* versehentlich zum Layout-Ordner wird. Liefert die neue Liste + `grandParentId`
* (den die Blatt-Reparentierung der Aufrufer nutzt).
*/
function foldersAfterDelete(
folderId: string,
folders: LayoutFolder[],
): { folders: LayoutFolder[]; grandParentId?: string } {
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,
);
return { folders: nextFolders, grandParentId };
}
/** Ordner auf die Wurzelebene setzen (parentId entfernen, `kind` bleibt erhalten). */
function stripParentId(folder: LayoutFolder): LayoutFolder {
const next = { ...folder };
delete next.parentId;
return next;
}
// ── Baum-Umsortieren (Drag & Drop) ───────────────────────────────────────────
/**
* 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: LayoutFolder[],
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 ein Layout in einen anderen Ordner (immutabel). `folderId = null`
* → zurück auf die Wurzelebene. No-op, wenn die Layout-Id fehlt.
*/
export function moveLayoutToFolder(
layouts: Layout[],
layoutId: string,
folderId: string | null,
): Layout[] {
return layouts.map((l) => {
if (l.id !== layoutId) return l;
if (folderId) return { ...l, folderId };
const next = { ...l };
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 moveFolderToFolder(
folders: LayoutFolder[],
folderId: string,
parentId: string | null,
): LayoutFolder[] {
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);
});
}
/**
* Neuer Viewport (Standardgrösse/-position). `snapshotId` bindet ihn an einen
* Ausschnitt; Position/Grösse in Papier-mm setzt der Aufrufer per {@link patchViewport}.
*/
export function createViewport(
id: string,
snapshotId: string,
init?: Partial<Omit<LayoutViewport, "id" | "snapshotId">>,
): LayoutViewport {
return {
id,
snapshotId,
xMm: init?.xMm ?? 20,
yMm: init?.yMm ?? 20,
widthMm: init?.widthMm ?? 120,
heightMm: init?.heightMm ?? 90,
...(init?.scaleDenominator !== undefined
? { scaleDenominator: init.scaleDenominator }
: {}),
};
}
// ── Immutable CRUD (Viewports auf einem Layout) ──────────────────────────────
/** Fügt einen Viewport an (immutabel). */
export function addViewport(layout: Layout, viewport: LayoutViewport): Layout {
return { ...layout, viewports: [...layout.viewports, viewport] };
}
/** Entfernt einen Viewport per Id (immutabel). */
export function removeViewport(layout: Layout, viewportId: string): Layout {
return {
...layout,
viewports: layout.viewports.filter((v) => v.id !== viewportId),
};
}
/** Ändert Felder eines Viewports (immutabel). No-op, wenn die Id fehlt. */
export function patchViewport(
layout: Layout,
viewportId: string,
patch: Partial<Omit<LayoutViewport, "id">>,
): Layout {
return {
...layout,
viewports: layout.viewports.map((v) =>
v.id === viewportId ? { ...v, ...patch } : v,
),
};
}
// ── Fabrik + immutable CRUD (Annotationen auf einem Layout) ─────────────────
// Analog zu Viewports: Linie/Rechteck/Text als vom Viewport-Baum unabhängige
// Markup-Schicht in Papier-mm. `annotations ?? []` ist überall die Basis, damit
// Alt-Layouts (ohne das Feld) unverändert bleiben (additiv).
/**
* Neue Annotation mit Default-Geometrie je Art (Linie/Rechteck: sichtbare
* Mindestgrösse; Text: 3.5 mm Zeilenhöhe, wie im Attribute-Editor üblich).
* `init` überschreibt einzelne Felder — der Aufrufer (Editor) übergibt i. d. R.
* die vollständige, aus dem Zeichnen gewonnene Geometrie.
*/
export function createAnnotation(
id: string,
kind: LayoutAnnotation["kind"],
init: LayoutAnnotationPatch = {},
): LayoutAnnotation {
const colorPatch = init.color !== undefined ? { color: init.color } : {};
if (kind === "line") {
return {
id,
kind: "line",
x1Mm: init.x1Mm ?? 20,
y1Mm: init.y1Mm ?? 20,
x2Mm: init.x2Mm ?? 80,
y2Mm: init.y2Mm ?? 20,
...colorPatch,
...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}),
};
}
if (kind === "rect") {
return {
id,
kind: "rect",
xMm: init.xMm ?? 20,
yMm: init.yMm ?? 20,
widthMm: init.widthMm ?? 40,
heightMm: init.heightMm ?? 30,
...colorPatch,
...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}),
};
}
return {
id,
kind: "text",
xMm: init.xMm ?? 20,
yMm: init.yMm ?? 20,
text: init.text ?? "",
heightMm: init.heightMm ?? 3.5,
...colorPatch,
};
}
/** Fügt eine Annotation an (immutabel). */
export function addAnnotation(layout: Layout, annotation: LayoutAnnotation): Layout {
return { ...layout, annotations: [...(layout.annotations ?? []), annotation] };
}
/** Entfernt eine Annotation per Id (immutabel). */
export function removeAnnotation(layout: Layout, annotationId: string): Layout {
return {
...layout,
annotations: (layout.annotations ?? []).filter((a) => a.id !== annotationId),
};
}
/**
* Ändert Felder einer Annotation (immutabel). No-op, wenn die Id fehlt. Der
* Patch ist bewusst art-unabhängig ({@link LayoutAnnotationPatch}) — es werden
* nur die tatsächlich übergebenen Felder gemergt.
*/
export function patchAnnotation(
layout: Layout,
annotationId: string,
patch: LayoutAnnotationPatch,
): Layout {
return {
...layout,
annotations: (layout.annotations ?? []).map((a) =>
a.id === annotationId ? ({ ...a, ...patch } as LayoutAnnotation) : a,
),
};
}
// ── Auflösungen ──────────────────────────────────────────────────────────────
/** Findet einen Ausschnitt (View-Snapshot) im Projekt. */
export function findSnapshot(
project: Project,
snapshotId: string,
): ViewSnapshot | undefined {
return (project.viewSnapshots ?? []).find((s) => s.id === snapshotId);
}
/** Findet das Masterlayout eines Layouts (falls gebunden). */
export function findMaster(
project: Project,
layout: Layout,
): MasterLayout | undefined {
if (!layout.masterId) return undefined;
return (project.masterLayouts ?? []).find((m) => m.id === layout.masterId);
}
/**
* Effektiver Massstab-Nenner eines Viewports: sein `scaleDenominator` übersteuert
* den des gebundenen Ausschnitts. Fällt auf `fallback` zurück, wenn weder
* Viewport noch Ausschnitt einen Wert tragen (defensiv, sollte nicht auftreten).
*/
export function resolveViewportScale(
viewport: LayoutViewport,
snapshot: ViewSnapshot | undefined,
fallback = 100,
): number {
return viewport.scaleDenominator ?? snapshot?.scaleDenominator ?? fallback;
}
/** Aufgelöste Titelblock-Werte (alle Felder gefüllt) für die Darstellung. */
export interface ResolvedTitleBlock {
projectName: string;
sheetName: string;
scale: string;
date: string;
author: string;
}
/**
* Master-Vererbung der Titelblock-Werte: der Master liefert die Vorlagen-Texte;
* leere Felder werden aus Projekt/Layout mit sinnvollen Defaults gefüllt
* (Projektname ← `Project.name`, Blattname ← `Layout.name`, Datum ← `today`).
* Ohne Master bleibt der Titelblock trotzdem sinnvoll befüllt (nur Defaults).
* Der `scale`-Default ergibt sich aus dem ERSTEN Viewport des Layouts (falls
* vorhanden), damit ein Ein-Viewport-Blatt gleich seinen Massstab trägt.
*/
export function resolveTitleBlock(
project: Project,
layout: Layout,
master: MasterLayout | undefined,
today: string = new Date().toISOString().slice(0, 10),
): ResolvedTitleBlock {
const tb: LayoutTitleBlock = master?.titleBlock ?? {};
const firstVp = layout.viewports[0];
const firstSnap = firstVp ? findSnapshot(project, firstVp.snapshotId) : undefined;
const scaleN = firstVp
? resolveViewportScale(firstVp, firstSnap)
: undefined;
const nonEmpty = (s: string | undefined): string | undefined =>
s && s.trim() ? s.trim() : undefined;
return {
projectName: nonEmpty(tb.projectName) ?? project.name,
sheetName: nonEmpty(tb.sheetName) ?? layout.name,
scale: nonEmpty(tb.scale) ?? (scaleN ? `1:${scaleN}` : "—"),
date: nonEmpty(tb.date) ?? today,
author: nonEmpty(tb.author) ?? "",
};
}
/**
* Sichtbare Kategorie-Codes eines Ausschnitts als Set (für `generatePlan`). Aus
* der gespeicherten `layerVisibility`-Map (Code → sichtbar) abgeleitet.
*/
export function visibleCodesFromSnapshot(snap: ViewSnapshot): Set<string> {
const out = new Set<string>();
for (const [code, visible] of Object.entries(snap.layerVisibility)) {
if (visible) out.add(code);
}
return out;
}