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
+2 -1
View File
@@ -3,6 +3,7 @@
// Text-Anzeige des Hex-Werts, die nicht anklickbar/editierbar war.
import { useEffect, useState } from "react";
import { t } from "../i18n";
export function ColorHexField({
value,
@@ -32,7 +33,7 @@ export function ColorHexField({
return (
<span className="attr-color">
<label className="attr-color-swatch-wrap" title="Farbe wählen">
<label className="attr-color-swatch-wrap" title={t("attr.pickColor")}>
<span className="attr-color-swatch" style={{ background: value }} />
<input
type="color"
+43 -4
View File
@@ -1,7 +1,7 @@
// Standort-Import-Dialog (modal). Sucht einen Ort/Adresse (geo.admin
// SearchServer), lässt einen Radius wählen und importiert Gebäude-Grundrisse
// (swisstopo) sowie OSM-Features (Gebäude/Strassen/Wasser/Grün) als Kontext-
// Geometrie ins Projekt.
// (swisstopo) sowie OSM-Features (Gebäude/Strassen/Wasser/Grün/Parkplätze/
// Bahn/Wald) als Kontext-Geometrie ins Projekt.
//
// Muster wie ExportPdfDialog/ImportDialog: abgedunkeltes Overlay, Klick auf den
// Hintergrund + Esc schließen. Der eigentliche Netz-Abruf läuft hier (io/*); das
@@ -34,6 +34,9 @@ type Sources = {
osmRoads: boolean;
osmWater: boolean;
osmGreen: boolean;
osmParking: boolean;
osmRailway: boolean;
osmForest: boolean;
};
const DEFAULT_SOURCES: Sources = {
@@ -43,6 +46,9 @@ const DEFAULT_SOURCES: Sources = {
osmRoads: true,
osmWater: true,
osmGreen: true,
osmParking: false,
osmRailway: false,
osmForest: false,
};
export function ContextImportDialog({
@@ -98,7 +104,10 @@ export function ContextImportDialog({
sources.osmBuildings ||
sources.osmRoads ||
sources.osmWater ||
sources.osmGreen;
sources.osmGreen ||
sources.osmParking ||
sources.osmRailway ||
sources.osmForest;
const runImport = async () => {
if (!selected || !anySource) return;
@@ -121,12 +130,18 @@ export function ContextImportDialog({
roads: sources.osmRoads,
water: sources.osmWater,
green: sources.osmGreen,
parking: sources.osmParking,
railway: sources.osmRailway,
forest: sources.osmForest,
};
if (
osmSel.buildings ||
osmSel.roads ||
osmSel.water ||
osmSel.green
osmSel.green ||
osmSel.parking ||
osmSel.railway ||
osmSel.forest
) {
const r = await fetchOsm(center, radius, osmSel, origin);
origin = r.origin;
@@ -305,6 +320,30 @@ export function ContextImportDialog({
/>
{t("ctxImport.src.osmGreen")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmParking}
onChange={() => toggle("osmParking")}
/>
{t("ctxImport.src.osmParking")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmRailway}
onChange={() => toggle("osmRailway")}
/>
{t("ctxImport.src.osmRailway")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmForest}
onChange={() => toggle("osmForest")}
/>
{t("ctxImport.src.osmForest")}
</label>
</div>
</section>
+125
View File
@@ -0,0 +1,125 @@
// Schnellexport-Dialog (modal) für die Direkt-Formate CSV/IFC/OBJ/STL aus dem
// Topbar-Export-Menü: Format wählen + Dateiname festlegen, dann speichern.
// (PDF/DXF haben eigene Options-Dialoge.) Später bekommen „Ausschnitte" einen
// vordefinierten Namen + Speicherort; dies ist der interaktive Schnellweg.
//
// Muster wie ExportDxfDialog: abgedunkeltes Overlay, Klick auf Hintergrund + Esc
// schliessen. Rein gesteuert — meldet Format + Dateiname über `onSave` nach oben
// (App führt Erzeugung + Download aus). Bezeichner englisch, UI deutsch (t()).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { Dropdown } from "./Dropdown";
/** Direkt-herunterladbare Exportformate (mit Datei-Endung). */
export type ExportSaveFormat = "csv" | "ifc" | "obj" | "stl";
/** Datei-Endung je Format. */
export const EXPORT_FORMAT_EXT: Record<ExportSaveFormat, string> = {
csv: "csv",
ifc: "ifc",
obj: "obj",
stl: "stl",
};
export interface ExportSaveDialogProps {
/** Vorausgewähltes Format (aus dem angeklickten Menüeintrag). */
initialFormat: ExportSaveFormat;
/** Basis-Dateiname OHNE Endung (i. d. R. Projektname). */
baseName: string;
onSave: (format: ExportSaveFormat, filename: string) => void;
onClose: () => void;
}
/** Endung sicher an den Basisnamen hängen (doppelte Endung vermeiden). */
function withExt(base: string, ext: string): string {
const trimmed = base.trim() || "modell";
const lower = trimmed.toLowerCase();
return lower.endsWith(`.${ext}`) ? trimmed : `${trimmed}.${ext}`;
}
export function ExportSaveDialog({
initialFormat,
baseName,
onSave,
onClose,
}: ExportSaveDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [format, setFormat] = useState<ExportSaveFormat>(initialFormat);
// Der Dateiname wird OHNE Endung editiert; die Endung folgt aus dem Format.
const [name, setName] = useState(baseName);
const filename = withExt(name, EXPORT_FORMAT_EXT[format]);
const onConfirm = () => onSave(format, filename);
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("exportSave.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("exportSave.title")}</span>
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">
<section className="imp-section">
<div className="imp-section-title">{t("exportSave.format")}</div>
<Dropdown
value={format}
onChange={(v) => setFormat(v as ExportSaveFormat)}
title={t("exportSave.format")}
options={[
{ value: "csv", label: t("exportSave.fmt.csv") },
{ value: "ifc", label: t("exportSave.fmt.ifc") },
{ value: "obj", label: t("exportSave.fmt.obj") },
{ value: "stl", label: t("exportSave.fmt.stl") },
]}
/>
</section>
<section className="imp-section">
<div className="imp-section-title">{t("exportSave.filename")}</div>
<input
className="settings-num-input"
type="text"
value={name}
autoFocus
spellCheck={false}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onConfirm();
}}
placeholder="modell"
/>
<div className="imp-note">
{filename} · {t("exportSave.locationNote")}
</div>
</section>
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("export.cancel")}
</button>
<button className="imp-btn primary" onClick={onConfirm}>
{t("export.confirm")}
</button>
</footer>
</div>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
// Erstell-Dialoge für den Layouts-Baum (DOSSIER A3): „Neues Layout" und „Neues
// Masterlayout". Beide liefern beim Bestätigen die Startwerte (Papier/Custom-
// Grösse/Ausrichtung bzw. Master-Vorlage) nach oben; das Panel legt das Element
// an und versetzt es direkt in den Inline-Rename-Modus.
//
// Muster wie ExportSaveDialog: `imp-*`-Overlay/Dialog, Klick auf Hintergrund +
// Esc schliessen, Enter bestätigt. KEIN window.prompt/confirm (im Tauri-WKWebView
// deaktiviert). Bezeichner englisch, UI-Text deutsch via t() (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import type {
LayoutOrientation,
LayoutPaperFormat,
MasterLayout,
} from "../model/types";
import { PAPER_FORMAT_GROUPS } from "../panels/layoutModel";
/** Startwerte eines neuen Blatts/Masters (Grösse). */
export interface LayoutSizeStart {
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
}
/** Startwerte eines neuen Layouts: entweder Master-Vorlage ODER freie Grösse. */
export interface NewLayoutStart extends LayoutSizeStart {
/** Gebundenes Masterlayout (erbt dessen Papier/Ausrichtung); sonst undefined. */
masterId?: string;
}
type SizeMode = LayoutPaperFormat | "custom";
/** Baut die Grösse-Startwerte aus dem Grössen-Auswahlzustand. */
function sizeStart(
mode: SizeMode,
orientation: LayoutOrientation,
wMm: number,
hMm: number,
): LayoutSizeStart {
if (mode === "custom") {
return {
paper: "a4",
orientation,
customWidthMm: Math.max(1, Math.round(wMm)),
customHeightMm: Math.max(1, Math.round(hMm)),
};
}
return { paper: mode, orientation };
}
/** Geteilte Grössen-Auswahl: Format (A4/A3) als Segment ODER freie mm-Felder + Ausrichtung. */
function SizePicker({
mode,
setMode,
orientation,
setOrientation,
wMm,
setWMm,
hMm,
setHMm,
}: {
mode: SizeMode;
setMode: (m: SizeMode) => void;
orientation: LayoutOrientation;
setOrientation: (o: LayoutOrientation) => void;
wMm: number;
setWMm: (v: number) => void;
hMm: number;
setHMm: (v: number) => void;
}) {
return (
<>
<section className="imp-section">
<div className="imp-section-title">{t("layouts.size.format")}</div>
<select
className="imp-select"
value={mode}
onChange={(e) => setMode(e.target.value as SizeMode)}
>
{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>
))}
<option value="custom">{t("layouts.size.custom")}</option>
</select>
</section>
{mode === "custom" ? (
<section className="imp-section">
<div className="imp-size-row">
<label className="imp-size-field">
<span>{t("layouts.size.widthMm")}</span>
<input
className="settings-num-input"
type="number"
min={1}
value={wMm}
onChange={(e) => setWMm(parseFloat(e.target.value) || 0)}
/>
</label>
<span className="imp-size-x">×</span>
<label className="imp-size-field">
<span>{t("layouts.size.heightMm")}</span>
<input
className="settings-num-input"
type="number"
min={1}
value={hMm}
onChange={(e) => setHMm(parseFloat(e.target.value) || 0)}
/>
</label>
</div>
</section>
) : (
<section className="imp-section">
<div className="imp-section-title">{t("layouts.orientation")}</div>
<div className="imp-seg">
{(["portrait", "landscape"] as const).map((o) => (
<button
key={o}
type="button"
className={`imp-seg-btn${orientation === o ? " active" : ""}`}
onClick={() => setOrientation(o)}
>
{o === "portrait" ? t("layouts.portrait") : t("layouts.landscape")}
</button>
))}
</div>
</section>
)}
</>
);
}
/** Overlay-Rahmen (geteilt von beiden Dialogen). */
function DialogFrame({
title,
onClose,
onConfirm,
children,
}: {
title: string;
onClose: () => void;
onConfirm: () => void;
children: React.ReactNode;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
else if (e.key === "Enter") onConfirm();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, onConfirm]);
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{title}</span>
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">{children}</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("export.cancel")}
</button>
<button className="imp-btn primary" onClick={onConfirm}>
{t("layouts.size.create")}
</button>
</footer>
</div>
</div>
);
}
/** Dialog „Neues Masterlayout" — Format/freie Grösse + Ausrichtung. */
export function NewMasterLayoutDialog({
onCreate,
onClose,
}: {
onCreate: (start: LayoutSizeStart) => void;
onClose: () => void;
}) {
const [mode, setMode] = useState<SizeMode>("a4");
const [orientation, setOrientation] = useState<LayoutOrientation>("portrait");
const [wMm, setWMm] = useState(210);
const [hMm, setHMm] = useState(297);
const confirm = () => onCreate(sizeStart(mode, orientation, wMm, hMm));
return (
<DialogFrame title={t("layouts.newMaster.title")} onClose={onClose} onConfirm={confirm}>
<SizePicker
mode={mode}
setMode={setMode}
orientation={orientation}
setOrientation={setOrientation}
wMm={wMm}
setWMm={setWMm}
hMm={hMm}
setHMm={setHMm}
/>
</DialogFrame>
);
}
/**
* Dialog „Neues Layout" — Masterlayout als Vorlage (erbt dessen Grösse) ODER
* freie Grösse (Format/Custom + Ausrichtung).
*/
export function NewLayoutDialog({
masters,
onCreate,
onClose,
}: {
masters: MasterLayout[];
onCreate: (start: NewLayoutStart) => void;
onClose: () => void;
}) {
const hasMasters = masters.length > 0;
const [source, setSource] = useState<"master" | "free">(
hasMasters ? "master" : "free",
);
const [masterId, setMasterId] = useState<string>(masters[0]?.id ?? "");
const [mode, setMode] = useState<SizeMode>("a4");
const [orientation, setOrientation] = useState<LayoutOrientation>("portrait");
const [wMm, setWMm] = useState(210);
const [hMm, setHMm] = useState(297);
const confirm = () => {
if (source === "master" && masterId) {
// Grösse erbt der Aufrufer vom Master; wir liefern nur die Bindung + eine
// sinnvolle Rückfall-Grösse (A4 hoch), falls der Master unauflösbar ist.
onCreate({ masterId, paper: "a4", orientation: "portrait" });
return;
}
onCreate(sizeStart(mode, orientation, wMm, hMm));
};
return (
<DialogFrame title={t("layouts.newLayout.title")} onClose={onClose} onConfirm={confirm}>
<section className="imp-section">
<div className="imp-section-title">{t("layouts.newLayout.source")}</div>
<label className="imp-radio">
<input
type="radio"
checked={source === "master"}
disabled={!hasMasters}
onChange={() => setSource("master")}
/>
<span>{t("layouts.newLayout.fromMaster")}</span>
</label>
<label className="imp-radio">
<input
type="radio"
checked={source === "free"}
onChange={() => setSource("free")}
/>
<span>{t("layouts.newLayout.freeSize")}</span>
</label>
</section>
{source === "master" ? (
<section className="imp-section">
{hasMasters ? (
<select
className="imp-select"
value={masterId}
onChange={(e) => setMasterId(e.target.value)}
>
{masters.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
) : (
<div className="imp-note">{t("layouts.newLayout.noMasters")}</div>
)}
</section>
) : (
<SizePicker
mode={mode}
setMode={setMode}
orientation={orientation}
setOrientation={setOrientation}
wMm={wMm}
setWMm={setWMm}
hMm={hMm}
setHMm={setHMm}
/>
)}
</DialogFrame>
);
}
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
// Meldung, wenn eine Projektdatei bereits in einer anderen Instanz gesperrt ist
// (Lock-Konflikt, s. src/io/projectFile.ts acquireProjectLock). Ersetzt
// `window.confirm` (im Tauri-WKWebView deaktiviert) — gleiches Overlay-Muster
// wie PromptDialog (imp-overlay/imp-dialog/imp-head/imp-body/imp-foot/imp-btn).
import { useEffect } from "react";
import { t } from "../i18n";
import type { LockInfo } from "../io/projectFile";
export interface LockConflictDialogProps {
/** Info aus der Lock-Sidecar-Datei (pid/hostname/timestamp) — kann fehlen. */
info: LockInfo | null;
/** Projekt ohne Lock nur lesend öffnen (kein Schreibzugriff-Anspruch). */
onReadOnly: () => void;
/** Lock ignorieren und trotzdem schreibend öffnen (Risiko: zwei Schreiber). */
onForceOpen: () => void;
onCancel: () => void;
}
export function LockConflictDialog({
info,
onReadOnly,
onForceOpen,
onCancel,
}: LockConflictDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
return (
<div className="imp-overlay" onClick={onCancel}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("lock.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("lock.title")}</span>
<button className="imp-close" onClick={onCancel} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">
<section className="imp-section">
<div className="imp-section-title">{t("lock.message")}</div>
{info && (
<div className="imp-section-title" style={{ opacity: 0.7, fontWeight: 400 }}>
{t("lock.messageInfo", { host: info.hostname, pid: String(info.pid) })}
</div>
)}
</section>
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onCancel}>
{t("export.cancel")}
</button>
<button className="imp-btn danger" onClick={onForceOpen} title={t("lock.forceOpenWarning")}>
{t("lock.forceOpen")}
</button>
<button className="imp-btn primary" onClick={onReadOnly}>
{t("lock.openReadOnly")}
</button>
</footer>
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
// Generischer Text-Eingabe-Dialog — ersetzt `window.prompt` überall im Code.
// GRUND: `window.prompt`/`window.confirm` sind im Tauri-WKWebView deaktiviert
// (liefern sofort `null` zurück) — jede Stelle, die sie nutzt, war in der
// Desktop-App faktisch kaputt (kein Name kam je an). Dieser Dialog ist der
// einheitliche Ersatz: gleiches Muster wie ExportSaveDialog/SettingsDialog
// (abgedunkeltes Overlay, Klick auf Hintergrund + Esc schliessen), Enter
// bestätigt. Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { t } from "../i18n";
export interface PromptDialogProps {
/** Kopfzeile des Dialogs. */
title: string;
/** Feld-Beschriftung/Frage (entspricht dem früheren `window.prompt`-Text). */
label: string;
/** Vorbelegter Wert (entspricht dem früheren zweiten `window.prompt`-Arg). */
defaultValue?: string;
/** Eingabetyp — "text" (Default) oder "number" für reine Zahlen-Prompts. */
type?: "text" | "number";
onConfirm: (value: string) => void;
onClose: () => void;
}
export function PromptDialog({
title,
label,
defaultValue = "",
type = "text",
onConfirm,
onClose,
}: PromptDialogProps) {
const [value, setValue] = useState(defaultValue);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const commit = () => {
const v = value.trim();
if (v) onConfirm(v);
};
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{title}</span>
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">
<section className="imp-section">
<div className="imp-section-title">{label}</div>
<input
className="settings-num-input"
type={type}
autoFocus
spellCheck={false}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
</section>
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("export.cancel")}
</button>
<button className="imp-btn primary" onClick={commit}>
{t("export.confirm")}
</button>
</footer>
</div>
</div>
);
}
+886 -4
View File
@@ -16,18 +16,24 @@
// Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch
// (CONVENTIONS.md).
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import type {
CeilingType,
Component,
ComponentMaterial,
DoorType,
HatchPattern,
HatchStyle,
Layer,
LineStyle,
OverrideConditionType,
OverrideOperator,
OverrideRule,
Project,
StairType,
WallType,
WindowType,
} from "../model/types";
import { PEN_WEIGHTS } from "../model/types";
import { parseLin } from "../io/linParser";
@@ -720,6 +726,95 @@ function ColorSwatch({ color, size = 30 }: { color: string; size?: number }) {
);
}
/**
* Baut aus einem zugewiesenen Bauteil-Material ein `MaterialAsset` für die
* PBR-Kugel-Vorschau (`requestMaterialPreview`): verweist es auf die
* Bibliothek (`libraryId`), wird deren Asset wiederverwendet (Name/Kategorie
* fürs Tooltip); sonst entsteht aus den hochgeladenen Karten ein Ad-hoc-Asset.
*/
function materialAssetOf(material: ComponentMaterial): MaterialAsset {
if (material.libraryId) {
const asset = MATERIAL_LIBRARY.find((a) => a.id === material.libraryId);
if (asset) return asset;
}
return {
id: material.libraryId ?? "custom",
name: t("material.uploaded"),
category: "",
maps: {
color: material.color,
normal: material.normal,
roughness: material.roughness,
metalness: material.metalness,
displacement: material.displacement,
ao: material.ao,
},
};
}
/**
* PBR-Kugel-Vorschau eines zugewiesenen Bauteil-Materials — dieselbe
* Lazy-Render-Logik wie `MaterialLibraryTile` (IntersectionObserver +
* `requestMaterialPreview`, geteilter Renderer), nur ohne Klick-Interaktion,
* als Thumbnail-Ersatz für `ColorSwatch`/`HatchSwatch` sobald ein Bauteil ein
* Material trägt.
*/
function ComponentMaterialSphere({
material,
size = 30,
}: {
material: ComponentMaterial;
size?: number;
}) {
const asset = useMemo(
() => materialAssetOf(material),
[
material.libraryId,
material.color,
material.normal,
material.roughness,
material.metalness,
material.displacement,
material.ao,
],
);
const ref = useRef<HTMLSpanElement | null>(null);
const [visible, setVisible] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || visible) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) setVisible(true);
},
{ rootMargin: "200px" },
);
io.observe(el);
return () => io.disconnect();
}, [visible]);
useEffect(() => {
if (!visible) return;
const handleReady = (url: string) => setPreviewUrl(url);
const cached = requestMaterialPreview(asset, handleReady);
if (cached) setPreviewUrl(cached);
return () => cancelMaterialPreview(asset, handleReady);
}, [visible, asset]);
return (
<span
ref={ref}
className="mat-sphere"
title={asset.name || undefined}
style={{ width: size, height: size, flex: "0 0 auto" }}
>
{previewUrl && <img src={previewUrl} alt="" draggable={false} />}
</span>
);
}
/** Detail-Panel EINES Bauteils (rechte Seite des Master-Detail-Layouts). */
function ComponentDetail({
component,
@@ -755,7 +850,9 @@ function ComponentDetail({
</div>
<div className="res-md-preview">
{previewHatch ? (
{component.material ? (
<ComponentMaterialSphere material={component.material} size={128} />
) : previewHatch ? (
<HatchSwatch hatch={previewHatch} size={128} />
) : (
<ColorSwatch color={component.foreground ?? component.color} size={128} />
@@ -863,7 +960,9 @@ function ComponentsTab({
onClick={() => setSelectedId(c.id)}
>
<span className="res-md-row-thumb">
{hatch ? (
{c.material ? (
<ComponentMaterialSphere material={c.material} size={30} />
) : hatch ? (
<HatchSwatch hatch={hatch} size={30} />
) : (
<ColorSwatch color={c.foreground ?? c.color} />
@@ -1859,6 +1958,424 @@ function patToHatches(text: string): Omit<HatchStyle, "id">[] {
}));
}
// ── Bauteil-Typen (Tür/Fenster/Treppe) ─────────────────────────────────────
// Formular-basierte Bibliotheken (im Gegensatz zu Wand-/Deckenstilen, die
// schichtbasiert über LayeredStylesTab laufen): jeder Typ ist ein flaches
// Feld-Bündel (Bauart, Maße, …), daher genügt ein generischer Master-Detail-
// Rumpf ({@link TypeLibraryTab}), der die konkreten Felder je Typ als Render-
// Funktion einreicht — analog zum Bauteile-Tab (Liste links, Editor rechts).
const DOOR_KIND_OPTIONS: { value: DoorType["kind"]; labelKey: string }[] = [
{ value: "dreh", labelKey: "resources.doorKind.dreh" },
{ value: "schiebe", labelKey: "resources.doorKind.schiebe" },
{ value: "wandoeffnung", labelKey: "resources.doorKind.wandoeffnung" },
];
const DOOR_LEAF_STYLE_OPTIONS: { value: DoorType["leafStyle"]; labelKey: string }[] = [
{ value: "glatt", labelKey: "resources.leafStyle.glatt" },
{ value: "kassette", labelKey: "resources.leafStyle.kassette" },
{ value: "glas", labelKey: "resources.leafStyle.glas" },
];
const WINDOW_KIND_OPTIONS: { value: WindowType["kind"]; labelKey: string }[] = [
{ value: "dreh", labelKey: "resources.windowKind.dreh" },
{ value: "kipp", labelKey: "resources.windowKind.kipp" },
{ value: "drehkipp", labelKey: "resources.windowKind.drehkipp" },
{ value: "fest", labelKey: "resources.windowKind.fest" },
{ value: "schiebe", labelKey: "resources.windowKind.schiebe" },
];
const WINDOW_GLAZING_OPTIONS: { value: WindowType["glazing"]; labelKey: string }[] = [
{ value: "einfach", labelKey: "resources.glazing.einfach" },
{ value: "zweifach", labelKey: "resources.glazing.zweifach" },
{ value: "dreifach", labelKey: "resources.glazing.dreifach" },
];
const WINDOW_SILL_BOARD_OPTIONS: { value: NonNullable<WindowType["sillBoard"]>; labelKey: string }[] = [
{ value: "keine", labelKey: "resources.sillBoard.keine" },
{ value: "innen", labelKey: "resources.sillBoard.innen" },
{ value: "aussen", labelKey: "resources.sillBoard.aussen" },
{ value: "beide", labelKey: "resources.sillBoard.beide" },
];
const STAIR_STRUCTURE_OPTIONS: { value: StairType["structure"]; labelKey: string }[] = [
{ value: "massiv", labelKey: "resources.structure.massiv" },
{ value: "beton", labelKey: "resources.structure.beton" },
{ value: "wange", labelKey: "resources.structure.wange" },
{ value: "aufgesattelt", labelKey: "resources.structure.aufgesattelt" },
{ value: "spindel", labelKey: "resources.structure.spindel" },
];
const STAIR_RAILING_OPTIONS: { value: NonNullable<StairType["railing"]>; labelKey: string }[] = [
{ value: "keine", labelKey: "resources.railing.keine" },
{ value: "links", labelKey: "resources.railing.links" },
{ value: "rechts", labelKey: "resources.railing.rechts" },
{ value: "beide", labelKey: "resources.railing.beide" },
];
/**
* Generischer Rumpf für formular-basierte Typ-Bibliotheken (Tür-/Fenster-/
* Treppentypen): Liste links (Name je Zeile, „+ Neu" im Fuß), Editor rechts
* (Umbenennen-Feld + Löschen-Knopf im Kopf, darunter die typspezifischen
* Felder aus `renderFields`). Handler sind optional (nur gesetzt, wenn die
* einbettende App sie bereitstellt) — Aufrufe laufen daher über `?.()`.
*/
function TypeLibraryTab<T extends { id: string; name: string }>({
types,
hintKey,
emptyKey,
placeholderKey,
deleteKeyPrefix,
onAdd,
onPatch,
onDelete,
renderFields,
}: {
types: T[];
hintKey: string;
emptyKey: string;
placeholderKey: string;
deleteKeyPrefix: string;
onAdd?: () => void;
onPatch?: (id: string, patch: Partial<T>) => void;
onDelete?: (id: string) => void;
renderFields: (item: T, onPatch: (patch: Partial<T>) => void) => React.ReactNode;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = types.find((x) => x.id === selectedId) ?? types[0];
return (
<div className="res-tab">
<div className="res-hint">{t(hintKey)}</div>
<div className="res-md">
<div className="res-md-list">
{types.map((item) => (
<button
key={item.id}
className={`res-md-row${selected?.id === item.id ? " active" : ""}`}
onClick={() => setSelectedId(item.id)}
>
<span className="res-md-row-name">{item.name}</span>
</button>
))}
{types.length === 0 && (
<div className="res-md-empty">{t(emptyKey)}</div>
)}
<div className="res-md-listfoot">
<button className="res-add" onClick={() => onAdd?.()}>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
</div>
</div>
<div className="res-md-detail">
{selected ? (
<div className="res-md-detail-inner" key={selected.id}>
<div className="res-md-head">
<input
className="res-input res-md-rename"
value={selected.name}
placeholder={t(placeholderKey)}
onChange={(e) =>
onPatch?.(selected.id, { name: e.target.value } as Partial<T>)
}
/>
<DeleteButton
onClick={() => onDelete?.(selected.id)}
title={t(deleteKeyPrefix, { name: selected.name })}
/>
</div>
<div className="res-fields">
{renderFields(selected, (patch) => onPatch?.(selected.id, patch))}
</div>
</div>
) : (
<div className="res-md-empty">{t(emptyKey)}</div>
)}
</div>
</div>
</div>
);
}
/** Tab „Türtypen" — Bauart, Blattausführung/-zahl, Zarge und Standardmaße. */
function DoorStylesTab({
project,
onAddDoorType,
onPatchDoorType,
onDeleteDoorType,
}: {
project: Project;
onAddDoorType?: () => void;
onPatchDoorType?: (id: string, patch: Partial<DoorType>) => void;
onDeleteDoorType?: (id: string) => void;
}) {
return (
<TypeLibraryTab
types={project.doorTypes ?? []}
hintKey="resources.doorTypes.hint"
emptyKey="resources.doorTypes.empty"
placeholderKey="resources.doorTypes.placeholder"
deleteKeyPrefix="resources.delete.doorType"
onAdd={onAddDoorType}
onPatch={onPatchDoorType}
onDelete={onDeleteDoorType}
renderFields={(door, patch) => (
<>
<FieldRow label={t("resources.field.doorKind")}>
<SelectField
value={door.kind}
onChange={(kind) => patch({ kind })}
options={DOOR_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.leafCount")}>
<Segmented
value={String(door.leafCount)}
onChange={(v) => patch({ leafCount: (v === "2" ? 2 : 1) as 1 | 2 })}
options={[
{ value: "1", label: t("resources.leafCount.1") },
{ value: "2", label: t("resources.leafCount.2") },
]}
/>
</FieldRow>
<FieldRow label={t("resources.field.leafStyle")}>
<SelectField
value={door.leafStyle}
onChange={(leafStyle) => patch({ leafStyle })}
options={DOOR_LEAF_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
{door.leafStyle === "glas" && (
<FieldRow label={t("resources.field.glazingRatio")}>
<NumberField
value={door.glazingRatio ?? 0.6}
onChange={(glazingRatio) => patch({ glazingRatio })}
step={0.01}
min={0}
max={1}
/>
</FieldRow>
)}
<FieldRow label={t("resources.field.frameThickness")}>
<NumberField
value={door.frameThickness}
onChange={(frameThickness) => patch({ frameThickness })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.frameDepth")}>
<NumberField
value={door.frameDepth ?? 0}
onChange={(frameDepth) => patch({ frameDepth })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.defaultWidth")}>
<NumberField
value={door.defaultWidth}
onChange={(defaultWidth) => patch({ defaultWidth })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.defaultHeight")}>
<NumberField
value={door.defaultHeight}
onChange={(defaultHeight) => patch({ defaultHeight })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.threshold")}>
<input
type="checkbox"
checked={!!door.threshold}
onChange={(e) => patch({ threshold: e.target.checked })}
/>
</FieldRow>
</>
)}
/>
);
}
/** Tab „Fenstertypen" — Öffnungsart, Flügelzahl, Verglasung, Zarge, Brüstung. */
function WindowStylesTab({
project,
onAddWindowType,
onPatchWindowType,
onDeleteWindowType,
}: {
project: Project;
onAddWindowType?: () => void;
onPatchWindowType?: (id: string, patch: Partial<WindowType>) => void;
onDeleteWindowType?: (id: string) => void;
}) {
return (
<TypeLibraryTab
types={project.windowTypes ?? []}
hintKey="resources.windowTypes.hint"
emptyKey="resources.windowTypes.empty"
placeholderKey="resources.windowTypes.placeholder"
deleteKeyPrefix="resources.delete.windowType"
onAdd={onAddWindowType}
onPatch={onPatchWindowType}
onDelete={onDeleteWindowType}
renderFields={(win, patch) => (
<>
<FieldRow label={t("resources.field.windowKind")}>
<SelectField
value={win.kind}
onChange={(kind) => patch({ kind })}
options={WINDOW_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.wingCount")}>
<NumberField
value={win.wingCount}
onChange={(wingCount) => patch({ wingCount: Math.round(wingCount) })}
step={1}
min={1}
max={4}
/>
</FieldRow>
<FieldRow label={t("resources.field.glazing")}>
<SelectField
value={win.glazing}
onChange={(glazing) => patch({ glazing })}
options={WINDOW_GLAZING_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.frameThickness")}>
<NumberField
value={win.frameThickness}
onChange={(frameThickness) => patch({ frameThickness })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.frameDepth")}>
<NumberField
value={win.frameDepth ?? 0}
onChange={(frameDepth) => patch({ frameDepth })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.defaultSillHeight")}>
<NumberField
value={win.defaultSillHeight}
onChange={(defaultSillHeight) => patch({ defaultSillHeight })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.defaultWidth")}>
<NumberField
value={win.defaultWidth}
onChange={(defaultWidth) => patch({ defaultWidth })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.defaultHeight")}>
<NumberField
value={win.defaultHeight}
onChange={(defaultHeight) => patch({ defaultHeight })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.sillBoard")}>
<SelectField
value={win.sillBoard ?? "keine"}
onChange={(sillBoard) => patch({ sillBoard })}
options={WINDOW_SILL_BOARD_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
</>
)}
/>
);
}
/** Tab „Treppentypen" — Tragart, Stufenausbildung, Geländer und Laufbreite. */
function StairStylesTab({
project,
onAddStairType,
onPatchStairType,
onDeleteStairType,
}: {
project: Project;
onAddStairType?: () => void;
onPatchStairType?: (id: string, patch: Partial<StairType>) => void;
onDeleteStairType?: (id: string) => void;
}) {
return (
<TypeLibraryTab
types={project.stairTypes ?? []}
hintKey="resources.stairTypes.hint"
emptyKey="resources.stairTypes.empty"
placeholderKey="resources.stairTypes.placeholder"
deleteKeyPrefix="resources.delete.stairType"
onAdd={onAddStairType}
onPatch={onPatchStairType}
onDelete={onDeleteStairType}
renderFields={(stair, patch) => (
<>
<FieldRow label={t("resources.field.structure")}>
<SelectField
value={stair.structure}
onChange={(structure) => patch({ structure })}
options={STAIR_STRUCTURE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.closedRisers")}>
<input
type="checkbox"
checked={!!stair.closedRisers}
onChange={(e) => patch({ closedRisers: e.target.checked })}
/>
</FieldRow>
<FieldRow label={t("resources.field.treadThickness")}>
<NumberField
value={stair.treadThickness}
onChange={(treadThickness) => patch({ treadThickness })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.nosing")}>
<NumberField
value={stair.nosing ?? 0}
onChange={(nosing) => patch({ nosing })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.railing")}>
<SelectField
value={stair.railing ?? "keine"}
onChange={(railing) => patch({ railing })}
options={STAIR_RAILING_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.stairWidth")}>
<NumberField
value={stair.defaultWidth}
onChange={(defaultWidth) => patch({ defaultWidth })}
step={0.01}
min={0}
/>
</FieldRow>
</>
)}
/>
);
}
// ── Materialien (PBR-Bibliothek durchsuchen) ───────────────────────────────
// Eigenständiger Tab, der die eingebaute Materialbibliothek (MATERIAL_LIBRARY)
// als Kachel-Grid mit gerenderter PBR-Kugel-Vorschau zeigt — Name/Kategorie
@@ -2025,6 +2542,313 @@ function MaterialsTab() {
);
}
// ── Grafische Overrides (Regel-Engine, A1) ─────────────────────────────────
// Master-Detail wie Linien/Wandstile: Liste links = Regeln in PRIORITÄTS-
// Reihenfolge (oben gewinnt pro Aktions-Feld) mit Aktiv-Checkbox, Auf/Ab und
// Löschen; Detail rechts = Bedingung (Kriterium/Operator/Wert) + optionale
// Aktionen (Farbe/Strichstärke/Linienstil). Reines Rendering-Overlay — die
// Auswertung liegt in src/overrides/engine.ts, der Einhängepunkt in
// plan/generatePlan.ts; Elementdaten bleiben unverändert.
/** Bedingungs-Typen (UI-Beschriftung übersetzt, Werte englisch). */
const OVERRIDE_TYPE_OPTIONS: { value: OverrideConditionType; labelKey: string }[] = [
{ value: "layer_name", labelKey: "resources.overrides.condType.layer" },
{ value: "object_name", labelKey: "resources.overrides.condType.object" },
];
/** Vergleichs-Operatoren (UI-Beschriftung übersetzt, Werte englisch). */
const OVERRIDE_OP_OPTIONS: { value: OverrideOperator; labelKey: string }[] = [
{ value: "equals", labelKey: "resources.overrides.op.equals" },
{ value: "contains", labelKey: "resources.overrides.op.contains" },
{ value: "starts_with", labelKey: "resources.overrides.op.startsWith" },
{ value: "not_equals", labelKey: "resources.overrides.op.notEquals" },
];
/** Kompakte Bedingungs-Zusammenfassung für die Listenzeile. */
function overrideConditionSummary(rule: OverrideRule): string {
const type = OVERRIDE_TYPE_OPTIONS.find((o) => o.value === rule.condition.type);
const op = OVERRIDE_OP_OPTIONS.find((o) => o.value === rule.condition.operator);
return `${type ? t(type.labelKey) : rule.condition.type} ${
op ? t(op.labelKey) : rule.condition.operator
} „${rule.condition.value}"`;
}
/** Optionale Aktion: Checkbox (an/aus) + Steuerelement, solange sie an ist. */
function OptionalAction({
label,
active,
onToggle,
children,
}: {
label: string;
active: boolean;
onToggle: (on: boolean) => void;
children?: React.ReactNode;
}) {
return (
<div className="res-field">
<span className="res-field-label">
<label className="res-ov-action-toggle">
<input
type="checkbox"
checked={active}
onChange={(e) => onToggle(e.target.checked)}
/>{" "}
{label}
</label>
</span>
<span className="res-field-control">
{active ? children : <span className="res-joint-none">–</span>}
</span>
</div>
);
}
/** Detail-Panel EINER Override-Regel (rechte Seite des Master-Detail-Layouts). */
function OverrideRuleDetail({
rule,
project,
onPatch,
onDelete,
}: {
rule: OverrideRule;
project: Project;
onPatch: (patch: Partial<OverrideRule>) => void;
onDelete: () => void;
}) {
const patchCondition = (patch: Partial<OverrideRule["condition"]>) =>
onPatch({ condition: { ...rule.condition, ...patch } });
const patchActions = (patch: Partial<OverrideRule["actions"]>) =>
onPatch({ actions: { ...rule.actions, ...patch } });
// Sentinel „—": kein Linienstil-Override.
const NONE = "__none__";
const lineOptions = [
{ value: NONE, label: t("resources.overrides.linetype.none") },
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
];
return (
<div className="res-md-detail-inner">
<div className="res-md-head">
<input
className="res-input res-md-rename"
value={rule.name}
placeholder={t("resources.overrides.placeholder")}
onChange={(e) => onPatch({ name: e.target.value })}
/>
<DeleteButton onClick={onDelete} title={t("resources.overrides.delete")} />
</div>
<div className="res-hint">{t("resources.overrides.condition")}</div>
<FieldRow label={t("resources.overrides.condType")}>
<SelectField
value={rule.condition.type}
onChange={(type) => patchCondition({ type })}
options={OVERRIDE_TYPE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
/>
</FieldRow>
<FieldRow label={t("resources.overrides.operator")}>
<SelectField
value={rule.condition.operator}
onChange={(operator) => patchCondition({ operator })}
options={OVERRIDE_OP_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
/>
</FieldRow>
<FieldRow label={t("resources.overrides.value")}>
<TextField
value={rule.condition.value}
placeholder={t("resources.overrides.value.placeholder")}
onChange={(value) => patchCondition({ value })}
/>
</FieldRow>
<div className="res-hint">{t("resources.overrides.actions")}</div>
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */}
<datalist id="ov-pen-weights">
{PEN_WEIGHTS.map((w) => (
<option key={w} value={w} />
))}
</datalist>
<OptionalAction
label={t("resources.overrides.action.color")}
active={rule.actions.color !== undefined}
onToggle={(on) => patchActions({ color: on ? "#808080" : undefined })}
>
<ColorField
color={rule.actions.color ?? "#808080"}
onChange={(color) => patchActions({ color })}
/>
</OptionalAction>
<OptionalAction
label={t("resources.overrides.action.lineweight")}
active={rule.actions.lineweight !== undefined}
onToggle={(on) => patchActions({ lineweight: on ? 0.35 : undefined })}
>
<NumberField
value={rule.actions.lineweight ?? 0.35}
onChange={(lineweight) => patchActions({ lineweight: Math.max(0, lineweight) })}
step={0.05}
min={0}
list="ov-pen-weights"
/>
</OptionalAction>
<OptionalAction
label={t("resources.overrides.action.linetype")}
active={rule.actions.linetypeId !== undefined}
onToggle={(on) =>
patchActions({
linetypeId: on ? project.lineStyles[0]?.id : undefined,
})
}
>
<SelectField
value={rule.actions.linetypeId ?? NONE}
onChange={(v) =>
patchActions({ linetypeId: v === NONE ? undefined : v })
}
options={lineOptions}
/>
</OptionalAction>
</div>
);
}
/** Tab „Overrides": Regelliste (Priorität von oben nach unten) + Detail. */
function OverridesTab({
project,
onSetOverrideRules,
}: {
project: Project;
onSetOverrideRules?: (rules: OverrideRule[]) => void;
}) {
const rules = project.overrideRules ?? [];
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = rules.find((r) => r.id === selectedId) ?? rules[0];
const setRules = (next: OverrideRule[]) => onSetOverrideRules?.(next);
const patchRule = (id: string, patch: Partial<OverrideRule>) =>
setRules(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
const addRule = () => {
const rule: OverrideRule = {
id: `ov-${Date.now()}`,
name: t("resources.overrides.defaultName"),
enabled: true,
condition: { type: "layer_name", operator: "contains", value: "" },
actions: {},
};
setRules([...rules, rule]);
setSelectedId(rule.id);
};
const deleteRule = (id: string) => setRules(rules.filter((r) => r.id !== id));
const moveRule = (id: string, dir: -1 | 1) => {
const i = rules.findIndex((r) => r.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= rules.length) return;
const next = rules.slice();
[next[i], next[j]] = [next[j], next[i]];
setRules(next);
};
return (
<div className="res-tab">
<div className="res-hint">{t("resources.overrides.hint")}</div>
<div className="res-md">
<div className="res-md-list">
{rules.map((r) => (
<div
key={r.id}
role="button"
tabIndex={0}
className={`res-md-row res-ov-row${
selected?.id === r.id ? " active" : ""
}`}
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
onClick={() => setSelectedId(r.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setSelectedId(r.id);
}}
>
<input
type="checkbox"
checked={r.enabled}
title={t("resources.overrides.enabled")}
onClick={(e) => e.stopPropagation()}
onChange={(e) => patchRule(r.id, { enabled: e.target.checked })}
/>
<span
className="res-md-row-name"
style={{ flex: 1, opacity: r.enabled ? 1 : 0.5 }}
>
{r.name || t("resources.overrides.placeholder")}
<span
style={{ display: "block", fontSize: "0.85em", opacity: 0.65 }}
>
{overrideConditionSummary(r)}
</span>
</span>
<button
type="button"
className="res-layer-btn"
title={t("resources.overrides.moveUp")}
disabled={rules[0]?.id === r.id}
onClick={(e) => {
e.stopPropagation();
moveRule(r.id, -1);
}}
>
▲
</button>
<button
type="button"
className="res-layer-btn"
title={t("resources.overrides.moveDown")}
disabled={rules[rules.length - 1]?.id === r.id}
onClick={(e) => {
e.stopPropagation();
moveRule(r.id, 1);
}}
>
▼
</button>
</div>
))}
{rules.length === 0 && (
<div className="res-md-empty">{t("resources.overrides.empty")}</div>
)}
<div className="res-md-listfoot">
<button
className="res-add"
onClick={addRule}
disabled={!onSetOverrideRules}
>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
</div>
</div>
<div className="res-md-detail">
{selected ? (
<OverrideRuleDetail
key={selected.id}
rule={selected}
project={project}
onPatch={(patch) => patchRule(selected.id, patch)}
onDelete={() => deleteRule(selected.id)}
/>
) : (
<div className="res-md-empty">{t("resources.overrides.empty")}</div>
)}
</div>
</div>
</div>
);
}
// ── Gemeinsame kleine Bausteine ────────────────────────────────────────────
function EmptyState({ text }: { text: string }) {
@@ -2039,7 +2863,11 @@ type TabId =
| "lines"
| "wallStyles"
| "ceilingStyles"
| "materials";
| "doorStyles"
| "windowStyles"
| "stairStyles"
| "materials"
| "overrides";
const TABS: { id: TabId; labelKey: string }[] = [
{ id: "components", labelKey: "resources.tab.components" },
@@ -2047,7 +2875,11 @@ const TABS: { id: TabId; labelKey: string }[] = [
{ id: "lines", labelKey: "resources.tab.lines" },
{ id: "wallStyles", labelKey: "resources.tab.wallStyles" },
{ id: "ceilingStyles", labelKey: "resources.tab.ceilingStyles" },
{ id: "doorStyles", labelKey: "resources.tab.doorStyles" },
{ id: "windowStyles", labelKey: "resources.tab.windowStyles" },
{ id: "stairStyles", labelKey: "resources.tab.stairStyles" },
{ id: "materials", labelKey: "resources.tab.materials" },
{ id: "overrides", labelKey: "resources.tab.overrides" },
];
/** Bündel der immutablen Mutationen (von App über setProject bereitgestellt). */
@@ -2072,6 +2904,26 @@ export interface ResourceManagerHandlers {
/** Import: fügt fertige (id-lose) Linienstile/Schraffuren hinzu (.lin/.pat). */
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
/**
* Grafische Override-Regeln: ersetzt die GANZE Regelliste (Reihenfolge =
* Priorität). Optional, damit bestehende Einbettungen (z. B. ResourcesPanel)
* ohne Änderung weiter kompilieren — ohne Handler ist der Tab nur lesend.
*/
onSetOverrideRules?: (rules: OverrideRule[]) => void;
/**
* Bauteil-Typen (Tür/Fenster/Treppe) — Bibliotheks-CRUD, analog Wand-/
* Deckentypen. Optional, damit bestehende Einbettungen (z. B. ResourcesPanel)
* ohne Änderung weiter kompilieren; ohne Handler bleiben die Tabs verborgen.
*/
onAddDoorType?: () => void;
onPatchDoorType?: (id: string, patch: Partial<DoorType>) => void;
onDeleteDoorType?: (id: string) => void;
onAddWindowType?: () => void;
onPatchWindowType?: (id: string, patch: Partial<WindowType>) => void;
onDeleteWindowType?: (id: string) => void;
onAddStairType?: () => void;
onPatchStairType?: (id: string, patch: Partial<StairType>) => void;
onDeleteStairType?: (id: string) => void;
}
// ── Schwebendes Fenster (nicht-modal) ──────────────────────────────────────
@@ -2265,7 +3117,37 @@ export function ResourceManager({
onDeleteCeilingType={handlers.onDeleteCeilingType}
/>
)}
{tab === "doorStyles" && (
<DoorStylesTab
project={project}
onAddDoorType={handlers.onAddDoorType}
onPatchDoorType={handlers.onPatchDoorType}
onDeleteDoorType={handlers.onDeleteDoorType}
/>
)}
{tab === "windowStyles" && (
<WindowStylesTab
project={project}
onAddWindowType={handlers.onAddWindowType}
onPatchWindowType={handlers.onPatchWindowType}
onDeleteWindowType={handlers.onDeleteWindowType}
/>
)}
{tab === "stairStyles" && (
<StairStylesTab
project={project}
onAddStairType={handlers.onAddStairType}
onPatchStairType={handlers.onPatchStairType}
onDeleteStairType={handlers.onDeleteStairType}
/>
)}
{tab === "materials" && <MaterialsTab />}
{tab === "overrides" && (
<OverridesTab
project={project}
onSetOverrideRules={handlers.onSetOverrideRules}
/>
)}
</div>
{/* Greifer unten rechts (Größe ändern). */}
+9
View File
@@ -10,6 +10,7 @@
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { t } from "../i18n";
import { ACCENT_SWATCHES } from "../theme/accents";
import type { Project } from "../model/types";
@@ -32,6 +33,8 @@ export interface SettingsDialogProps {
* Live-Remount der Renderer-Hooks).
*/
onRendererModeChange: (mode: RendererMode) => void;
/** Arbeitsumgebung-Auswahl (Fenster-/Dock-Layouts) — früher in der TopBar. */
layoutMenu: ReactNode;
onClose: () => void;
}
@@ -45,6 +48,7 @@ export function SettingsDialog({
rendererMode,
webGpuAvailable,
onRendererModeChange,
layoutMenu,
onClose,
}: SettingsDialogProps) {
useEffect(() => {
@@ -98,6 +102,11 @@ export function SettingsDialog({
</header>
<div className="imp-body">
<section className="imp-section">
<div className="imp-section-title">{t("settings.section.workspace")}</div>
{layoutMenu}
</section>
<section className="imp-section">
<div className="imp-section-title">{t("status.renderer")}</div>
<span className="sb-renderer-toggle" role="group" aria-label={t("status.renderer")}>
+81
View File
@@ -0,0 +1,81 @@
// Toast — nicht-blockierende Kurzmeldungen (Ersatz für `window.alert`, das im
// Tauri-WKWebView deaktiviert ist und lautlos nichts tut, siehe
// state/notifySlice.ts). Liest `notifications` aus dem Store und rendert
// einen gestapelten Container oben rechts; jede Meldung verschwindet
// automatisch nach ~4s oder per Klick sofort.
//
// Auto-Dismiss-Timer sitzt HIER (nicht in der Slice): jede gemountete Meldung
// bekommt so GENAU einen Timer, sauber an ihren Lebenszyklus gebunden (kein
// Doppel-Timer bei erneutem `notify()`-Aufruf mit identischer Nachricht).
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect } from "react";
import { useStore } from "../state/appStore";
import { t } from "../i18n";
import type { NotifyKind } from "../state/notifySlice";
const AUTO_DISMISS_MS = 4000;
/** Top-level Toast-Stack — einmal in App.tsx gerendert. */
export function Toast() {
const notifications = useStore((s) => s.notifications);
const dismissNotify = useStore((s) => s.dismissNotify);
if (notifications.length === 0) return null;
return (
<div className="toast-stack" role="status" aria-live="polite">
{notifications.map((n) => (
<ToastItem
key={n.id}
id={n.id}
message={n.message}
kind={n.kind}
onDismiss={dismissNotify}
/>
))}
</div>
);
}
function ToastItem({
id,
message,
kind,
onDismiss,
}: {
id: string;
message: string;
kind: NotifyKind;
onDismiss: (id: string) => void;
}) {
// Auto-Dismiss: EIN Timer pro gemounteter Meldung, an ihre id gebunden.
useEffect(() => {
const timer = window.setTimeout(() => onDismiss(id), AUTO_DISMISS_MS);
return () => window.clearTimeout(timer);
// onDismiss ist referenzstabil (Store-Action) — bewusst nur auf `id` reagieren.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
return (
<div
className={`toast-item toast-${kind}`}
onClick={() => onDismiss(id)}
role="alert"
>
<span className="toast-message">{message}</span>
<button
type="button"
className="toast-close"
aria-label={t("dock.close")}
onClick={(e) => {
e.stopPropagation();
onDismiss(id);
}}
>
×
</button>
</div>
);
}
+601 -135
View File
@@ -8,15 +8,18 @@
// aus App, Aktionen melden sich über Callbacks zurück. Bezeichner englisch,
// UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { ReactNode } from "react";
import type { Project } from "../model/types";
import { t } from "../i18n";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown";
import { PromptDialog } from "./PromptDialog";
import { WindowControls } from "./WindowControls";
import { RibbonTabs } from "./ribbon/RibbonBar";
import type { RibbonTabId } from "./ribbon/ribbonItems";
import { getCommand } from "../commands/registry";
import { CommandIcon } from "./ribbon/CommandIcon";
import {
applyMark,
applyPreset,
@@ -58,11 +61,25 @@ export type RenderMode =
| "hidden";
/**
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). „front",
* „top", „side", „iso" springen auf die jeweilige Standardansicht; „perspective"
* ist der freie 3/4-Blick. Wirkt nur in der Perspektive.
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). „front"/
* „back"/„side"/„left" springen auf die vier Kardinalrichtungen (Vorne/Hinten/
* Rechts/Links — KEINE Himmelsrichtungs-Labels, da es noch keinen Nordwinkel
* gibt); „top" auf die Draufsicht. „iso"/„isoFrontLeft"/„isoBackRight"/
* „isoBackLeft" sind die vier oberen Iso-Oktanten (untere vier bleiben im
* Hochbau ungenutzt) — „iso" (vorne-oben-rechts) ist der bestehende Default.
* „perspective" ist der freie 3/4-Blick. Wirkt nur in der Perspektive.
*/
export type View3d = "front" | "top" | "side" | "iso" | "perspective";
export type View3d =
| "front"
| "back"
| "side"
| "left"
| "top"
| "iso"
| "isoFrontLeft"
| "isoBackRight"
| "isoBackLeft"
| "perspective";
/**
* Farb-Modus des Grundrisses: „color" zeigt Kategorie-/Element-Farben (heutiges
@@ -103,6 +120,21 @@ export interface TopBarProps {
onExportDxf: () => void;
/** Lädt die Bauteilliste des Projekts als CSV herunter. */
onExportSchedule: () => void;
/** Lädt das Projekt als IFC4-STEP-Datei herunter. */
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
// Kamera-Settings (FOV), ZUSÄTZLICH zur bestehenden im „Ansichten"-Ribbon-
// Tab (ViewRibbonTab) und im 3D-Ribbon-Tab (Ribbon3dTab) — hier direkt neben
// dem CSV-Export in der schmalen Chrome-Zeile, damit sie immer erreichbar
// ist (Nutzer-Wunsch). Dieselben Felder/Handler wie dort.
viewType: ViewType;
fov: number;
onFov: (n: number) => void;
/** Presets nur sinnvoll, solange ein Geschoss aktiv ist (wie `viewToggleEnabled`
* in {@link ViewRibbonTabProps}). */
cameraEnabled: boolean;
// Ressourcen-Fenster.
resourcesOpen: boolean;
@@ -112,7 +144,6 @@ export interface TopBarProps {
onOpenSettings: () => void;
/** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */
layoutMenu: ReactNode;
// ── Datei-Menü (Burger, ganz rechts) ───────────────────────────────────────
/** Öffnet den DXF/DWG-Import-Datei-Dialog. */
@@ -200,14 +231,17 @@ function ComboMenu({
// Dropdown gesteuert ist, halten wir die aktuelle Liste im State und
// aktualisieren sie beim Trigger-Klick (siehe `items`-Aufbau unten).
const [names, setNames] = useState<string[]>([]);
// Name-Erfassung über PromptDialog statt window.prompt (im Tauri-WKWebView
// deaktiviert — liefert sofort null, nichts würde je gespeichert).
const [saving, setSaving] = useState(false);
const refresh = () => setNames(list());
const save = () => {
const name = window.prompt(savePrompt);
if (!name) return;
const save = () => setSaving(true);
const commitSave = (name: string) => {
onSave(name.trim());
refresh();
setSaving(false);
};
// Aktions-Menü (analog ContextMenu-Items): „Aktuelle speichern" oben, dann je
@@ -240,6 +274,14 @@ function ComboMenu({
placeholder={t("combo.placeholder")}
title={title}
/>
{saving && (
<PromptDialog
title={title}
label={savePrompt}
onConfirm={commitSave}
onClose={() => setSaving(false)}
/>
)}
</span>
);
}
@@ -326,6 +368,133 @@ function CameraMenu({
);
}
/** Die vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt). */
const ISO_VARIANTS: View3d[] = ["iso", "isoFrontLeft", "isoBackRight", "isoBackLeft"];
/** Häkchen vor der aktiven Iso-Zeile (gleiche Glyphe wie `Dropdown`s Check). */
function IsoCheck() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path
d="M2.5 6.2 L4.7 8.6 L9.5 3"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/**
* Iso-Oktanten als Split-Button (Vectorworks-Stil): Klick auf das Würfel-Icon
* springt direkt auf die zuletzt gewählte Iso-Ansicht (Default „iso" =
* vorne-oben-rechts, s. View3d); der schmale Pfeil daneben öffnet ein Popover
* mit allen vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt).
* Popover-Markup/Klassen wie `Dropdown`s Listenmodus (`ctx-menu`/`tb-dd-item`),
* Positionierung wie `CameraMenu` (fixed, kein Portal — entkommt dem
* `overflow:hidden` der Oberleiste).
*/
function IsoMenu({
view3d,
onView3d,
viewType,
onViewType,
disabled,
}: {
view3d: View3d;
onView3d: (v: View3d) => void;
viewType: ViewType;
onViewType: (v: ViewType) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const wrapRef = useRef<HTMLDivElement | null>(null);
const caretRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const current: View3d = ISO_VARIANTS.includes(view3d) ? view3d : "iso";
const active = viewType === "perspektive" && ISO_VARIANTS.includes(view3d);
const select = (v: View3d) => {
onView3d(v);
if (viewType !== "perspektive") onViewType("perspektive");
setOpen(false);
};
const toggle = () => {
const next = !open;
if (next && caretRef.current) {
const r = caretRef.current.getBoundingClientRect();
setPos({ left: r.left, top: r.bottom + 6 });
}
setOpen(next);
};
return (
<div className="tb-popwrap view-icon-split" ref={wrapRef}>
<button
className={`view-icon view-icon-split-main${active ? " active" : ""}`}
disabled={disabled}
onClick={() => select(current)}
title={disabled ? t("topbar.viewGroup.disabled") : `${t(`view3d.${current}`)} — ${t(`view3d.${current}.hint`)}`}
aria-label={t("view3d.iso")}
>
<ViewIcon name="iso" />
</button>
<button
ref={caretRef}
className={`view-icon-caret${open ? " active" : ""}`}
disabled={disabled}
onClick={toggle}
title={disabled ? t("topbar.viewGroup.disabled") : `${t("view3d.iso.menu")} — ${t("view3d.iso.menu.hint")}`}
aria-label={t("view3d.iso.menu")}
aria-haspopup="true"
aria-expanded={open}
>
<svg width="9" height="6" viewBox="0 0 10 6" aria-hidden="true">
<path d="M0 0l5 6 5-6z" fill="currentColor" />
</svg>
</button>
{open && !disabled && (
<div
className="ctx-menu tb-dd-menu"
role="menu"
aria-label={t("view3d.iso.menu")}
style={{ left: pos.left, top: pos.top }}
>
{ISO_VARIANTS.map((v) => (
<button
key={v}
type="button"
role="menuitemradio"
aria-checked={view3d === v}
className={`ctx-item tb-dd-item${view3d === v ? " is-active" : ""}`}
onClick={() => select(v)}
title={t(`view3d.${v}.hint`)}
>
<span className="tb-dd-check-slot">{view3d === v && <IsoCheck />}</span>
<span className="ctx-item-label">{t(`view3d.${v}`)}</span>
</button>
))}
</div>
)}
</div>
);
}
/**
* Parst eine Massstab-Eingabe „1:35" ODER „35" zum Nenner N (gerundet, > 0).
* Liefert null bei ungültiger/leerer Eingabe.
@@ -365,9 +534,19 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
</svg>
);
case "front":
// Kardinal-Quartett (front/back/side/left): Quadrat + kleiner Steg an der
// Aussenkante zeigt, von welcher Seite die Kamera blickt (Vorne = unten).
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="8" y1="12.5" x2="8" y2="14.3" />
</svg>
);
case "back":
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="8" y1="1.7" x2="8" y2="3.5" />
</svg>
);
case "top":
@@ -381,7 +560,14 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="8" y1="3.5" x2="8" y2="12.5" />
<line x1="12.5" y1="8" x2="14.3" y2="8" />
</svg>
);
case "left":
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="1.7" y1="8" x2="3.5" y2="8" />
</svg>
);
case "perspective":
@@ -515,6 +701,8 @@ export function TextGroup({
const [defBold, setDefBold] = useState(false);
const [defItalic, setDefItalic] = useState(false);
const [defUnderline, setDefUnderline] = useState(false);
// Freie Grösse über PromptDialog statt window.prompt (Tauri-WKWebView).
const [sizePromptOpen, setSizePromptOpen] = useState(false);
// Effektiver Bereich am Ziel: expliziter Bereich oder — ohne Bereich — das
// ganze Dokument (mind. 1 Zeichen, damit isMarkActive/applyMark greifen).
@@ -624,17 +812,17 @@ export function TextGroup({
const onSizeChange = (value: string) => {
if (value === "__custom__") {
const input = window.prompt(
t("text.size.customPrompt"),
String(curSize > 0 ? curSize : 12),
);
const n = input == null ? NaN : Math.round(Number(input.trim()));
if (Number.isFinite(n) && n > 0) setSizeValue(n);
setSizePromptOpen(true);
return;
}
if (value === "__current__") return;
setSizeValue(Number(value));
};
const onSizePromptConfirm = (input: string) => {
const n = Math.round(Number(input.trim()));
if (Number.isFinite(n) && n > 0) setSizeValue(n);
setSizePromptOpen(false);
};
return (
<div className="tb-group tb-textgroup" role="group" aria-label={t("text.group")}>
@@ -763,6 +951,16 @@ export function TextGroup({
</button>
</div>
</div>
{sizePromptOpen && (
<PromptDialog
title={t("text.size.custom")}
label={t("text.size.customPrompt")}
defaultValue={String(curSize > 0 ? curSize : 12)}
type="number"
onConfirm={onSizePromptConfirm}
onClose={() => setSizePromptOpen(false)}
/>
)}
</div>
);
}
@@ -904,6 +1102,200 @@ function QaSep() {
* Ressourcen/Einstellungen als kleine Icon-Knöpfe direkt in der TopBar-Zeile.
* Ersetzt das Burger-Menü (`AppMenu`). Rein gesteuert über Callbacks (i18n via t).
*/
/**
* Kompakte Kamera-Gruppe der Quick-Access-Leiste: NUR das Kamera-Settings-
* Popover (FOV), damit es immer erreichbar ist, ohne die Ansichts-Presets
* (die bleiben unverändert im „Ansichten"-Tab und im 3D-Ribbon-Tab).
*/
function QaCameraGroup({
viewType,
fov,
onFov,
cameraEnabled,
}: {
viewType: ViewType;
fov: number;
onFov: (n: number) => void;
cameraEnabled: boolean;
}) {
return (
<div className="tb-qa-camera" role="toolbar" aria-label={t("topbar.viewGroup")}>
<CameraMenu
fov={fov}
onFov={onFov}
disabled={!cameraEnabled || viewType !== "perspektive"}
/>
</div>
);
}
/** App-Version (spiegelt src-tauri/tauri.conf.json). */
const APP_VERSION = "0.1.0";
/**
* Export-Sammelmenü: EIN „Export"-Knopf in der Quick-Access-Leiste öffnet ein
* Dropdown (wie ein Kontextmenü) mit allen Export-Formaten — ersetzt die früher
* einzeln aufgereihten Export-Icons. Popover fixed positioniert (die Oberleiste
* hat overflow:hidden), Muster wie {@link CameraMenu}.
*/
function ExportMenu({
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
exportEnabled,
}: {
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
exportEnabled: boolean;
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const wrapRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
// Das Popover hängt per Portal an body (NICHT in wrapRef) — der Outside-Handler
// muss es daher separat als „innen" behandeln, sonst schliesst der mousedown
// das Menü vor dem Klick.
const popRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (wrapRef.current?.contains(target)) return;
if (popRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const toggle = () => {
const next = !open;
if (next && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ left: r.left, top: r.bottom + 6 });
}
setOpen(next);
};
const pick = (fn: () => void) => {
setOpen(false);
fn();
};
const items: { icon: string; label: string; onClick: () => void; disabled?: boolean }[] = [
{ icon: "picture_as_pdf", label: t("file.exportPdf"), onClick: onExportPdf, disabled: !exportEnabled },
{ icon: "output", label: t("file.exportDxf"), onClick: onExportDxf, disabled: !exportEnabled },
{ icon: "table_view", label: t("file.exportSchedule"), onClick: onExportSchedule },
{ icon: "deployed_code", label: t("file.exportIfc"), onClick: onExportIfc },
{ icon: "view_in_ar", label: t("file.exportObj"), onClick: onExportObj },
{ icon: "landscape", label: t("file.exportStl"), onClick: onExportStl },
];
return (
<div className="tb-popwrap" ref={wrapRef}>
<button
ref={btnRef}
type="button"
className={`tb-qa-btn${open ? " active" : ""}`}
onClick={toggle}
title={t("file.export")}
aria-label={t("file.export")}
aria-haspopup="true"
aria-expanded={open}
>
<TbIcon name="ios_share" />
</button>
{open &&
createPortal(
<div
ref={popRef}
className="tb-popover tb-menu"
role="menu"
aria-label={t("file.export")}
style={{ left: pos.left, top: pos.top }}
>
{items.map((it) => (
<button
key={it.label}
type="button"
className="ctx-item"
role="menuitem"
disabled={it.disabled}
onClick={() => pick(it.onClick)}
>
<span className="ctx-item-icon">
<TbIcon name={it.icon} />
</span>
<span className="ctx-item-label">{it.label}</span>
</button>
))}
</div>,
document.body,
)}
</div>
);
}
/**
* „Über"-Dialog (In-App, dunkel) — App-Marke, Version, Kurzbeschreibung und die
* Lizenzen der wichtigsten Open-Source-Bausteine. Öffnet per Klick auf die
* Wortmarke. Klick ausserhalb / Esc schliesst.
*/
function AboutDialog({ onClose }: { onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="about-overlay" onMouseDown={onClose}>
<div
className="about-dialog"
role="dialog"
aria-modal="true"
aria-label={t("about.title")}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="about-brand">
dossier<span className="brand-dot">.</span>
</div>
<div className="about-version">
{t("about.version")} {APP_VERSION}
</div>
<p className="about-desc">{t("about.desc")}</p>
<div className="about-meta">
<div>{t("about.copyright")}</div>
<div>{t("about.license")}</div>
<div>{t("about.suite")}</div>
</div>
<div className="about-sec-label">{t("about.licenses")}</div>
<ul className="about-licenses">
<li>React · Vite — MIT</li>
<li>Tauri — MIT / Apache-2.0</li>
<li>wgpu (render3d) — MIT / Apache-2.0</li>
<li>truck · csgrs — Apache-2.0</li>
<li>OpenCascade.js — LGPL-2.1</li>
<li>LibreDWG — GPL-3.0</li>
<li>acadrust — MPL-2.0</li>
</ul>
<button type="button" className="about-close" onClick={onClose}>
{t("about.close")}
</button>
</div>
</div>
);
}
function QuickAccess({
onOpenProject,
onSaveProject,
@@ -911,10 +1303,17 @@ function QuickAccess({
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
onToggleResources,
onOpenSettings,
resourcesOpen,
exportEnabled,
viewType,
fov,
onFov,
cameraEnabled,
}: {
onOpenProject: () => void;
onSaveProject: () => void;
@@ -922,10 +1321,17 @@ function QuickAccess({
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
onToggleResources: () => void;
onOpenSettings: () => void;
resourcesOpen: boolean;
exportEnabled: boolean;
viewType: ViewType;
fov: number;
onFov: (n: number) => void;
cameraEnabled: boolean;
}) {
return (
<div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
@@ -933,22 +1339,21 @@ function QuickAccess({
<QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
<QaSep />
<QaButton icon="download" label={t("file.import")} onClick={onImport} />
<QaButton
icon="picture_as_pdf"
label={t("file.exportPdf")}
onClick={onExportPdf}
disabled={!exportEnabled}
<ExportMenu
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule}
onExportIfc={onExportIfc}
onExportObj={onExportObj}
onExportStl={onExportStl}
exportEnabled={exportEnabled}
/>
<QaButton
icon="output"
label={t("file.exportDxf")}
onClick={onExportDxf}
disabled={!exportEnabled}
/>
<QaButton
icon="table_view"
label={t("file.exportSchedule")}
onClick={onExportSchedule}
<QaSep />
<QaCameraGroup
viewType={viewType}
fov={fov}
onFov={onFov}
cameraEnabled={cameraEnabled}
/>
<QaSep />
<QaButton
@@ -972,8 +1377,6 @@ export interface ViewRibbonTabProps {
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
fov: number;
onFov: (n: number) => void;
detail: DetailLevel;
onDetail: (d: DetailLevel) => void;
detailEnabled: boolean;
@@ -1009,8 +1412,6 @@ export function ViewRibbonTab({
onViewType,
view3d,
onView3d,
fov,
onFov,
detail,
onDetail,
detailEnabled,
@@ -1030,20 +1431,22 @@ export function ViewRibbonTab({
combos,
textTarget,
}: ViewRibbonTabProps) {
// „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35".
// „frei" wird über einen In-App-Dialog erfasst (window.prompt ist im Tauri-
// WKWebView deaktiviert); akzeptiert „1:35" ODER „35".
const [scalePromptOpen, setScalePromptOpen] = useState(false);
const onScaleChange = (value: string) => {
if (value === "__custom__") {
const input = window.prompt(
t("topbar.scale.prompt"),
`1:${scaleDenominator}`,
);
const n = parseScaleInput(input);
if (n != null) onApplyScale(n);
setScalePromptOpen(true);
return;
}
const n = Number(value);
if (Number.isFinite(n) && n > 0) onApplyScale(n);
};
const onScalePromptConfirm = (input: string) => {
const n = parseScaleInput(input);
if (n != null) onApplyScale(n);
setScalePromptOpen(false);
};
const inPresets = SCALE_PRESETS.includes(scaleDenominator);
const scaleOptions: DropdownOption[] = [
...(inPresets
@@ -1080,35 +1483,43 @@ export function ViewRibbonTab({
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
["back", t("view3d.back")],
["left", t("view3d.left")],
] as [View3d, string][]
).map(([key, label]) => (
<button
key={key}
className={`view-icon${
viewToggleEnabled && viewType === "perspektive" && view3d === key
? " active"
: ""
}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={
viewToggleEnabled
? `${label} — ${t(`view3d.${key}.hint`)}`
: t("topbar.viewGroup.disabled")
}
aria-label={label}
>
<ViewIcon name={key} />
</button>
))}
<CameraMenu
fov={fov}
onFov={onFov}
disabled={!viewToggleEnabled || viewType !== "perspektive"}
/>
).map(([key, label]) =>
key === "iso" ? (
<IsoMenu
key={key}
view3d={view3d}
onView3d={onView3d}
viewType={viewType}
onViewType={onViewType}
disabled={!viewToggleEnabled}
/>
) : (
<button
key={key}
className={`view-icon${
viewToggleEnabled && viewType === "perspektive" && view3d === key
? " active"
: ""
}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={
viewToggleEnabled
? `${label} — ${t(`view3d.${key}.hint`)}`
: t("topbar.viewGroup.disabled")
}
aria-label={label}
>
<ViewIcon name={key} />
</button>
),
)}
</div>
</div>
<Sep />
@@ -1140,8 +1551,9 @@ export function ViewRibbonTab({
</div>
<Sep />
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss. */}
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
{/* Detailgrad (grob/mittel/fein) + Darstellungsart übereinander gestapelt
(Nutzer-Wunsch) — gleiches Stapel-Muster wie die Kombinationen. */}
<div className="tb-group tb-stack" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.detail")}</span>
<Dropdown
@@ -1160,6 +1572,34 @@ export function ViewRibbonTab({
]}
/>
</label>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("display.style")}</span>
{viewType === "perspektive" ? (
<Dropdown
value={renderMode}
disabled={!renderModeEnabled}
onChange={(v) => onRenderMode(v as RenderMode)}
title={t("display.style.hint")}
options={[
{ value: "shaded", label: t("display.style.shaded") },
{ value: "white", label: t("display.style.white") },
{ value: "textured", label: t("display.style.textured") },
{ value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") },
]}
/>
) : (
<Dropdown
value={planColorMode}
onChange={(v) => onPlanColorMode(v as PlanColorMode)}
title={t("display.style.hint")}
options={[
{ value: "color", label: t("display.style.color") },
{ value: "mono", label: t("display.style.mono") },
]}
/>
)}
</label>
</div>
<Sep />
@@ -1221,43 +1661,20 @@ export function ViewRibbonTab({
</div>
<Sep />
{/* Darstellungsart — Grundriss: Farbig/SW; Perspektive: Render-Modus. */}
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
<label className="tb-field">
<span className="tb-label">{t("display.style")}</span>
{viewType === "perspektive" ? (
<Dropdown
value={renderMode}
disabled={!renderModeEnabled}
onChange={(v) => onRenderMode(v as RenderMode)}
title={t("display.style.hint")}
options={[
{ value: "shaded", label: t("display.style.shaded") },
{ value: "white", label: t("display.style.white") },
{ value: "textured", label: t("display.style.textured") },
{ value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") },
]}
/>
) : (
<Dropdown
value={planColorMode}
onChange={(v) => onPlanColorMode(v as PlanColorMode)}
title={t("display.style.hint")}
options={[
{ value: "color", label: t("display.style.color") },
{ value: "mono", label: t("display.style.mono") },
]}
/>
)}
</label>
</div>
<Sep />
{/* Text-/Font-Formatierung auf DERSELBEN Leiste (Nutzer-Wunsch: Text und
Ansichten zusammen) — Stil/Schrift/Grösse + B/I/U · L/C/R + Zeilenhöhe,
formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
<TextGroup textTarget={textTarget} />
{scalePromptOpen && (
<PromptDialog
title={t("topbar.scale.custom")}
label={t("topbar.scale.prompt")}
defaultValue={`1:${scaleDenominator}`}
onConfirm={onScalePromptConfirm}
onClose={() => setScalePromptOpen(false)}
/>
)}
</div>
);
}
@@ -1268,11 +1685,11 @@ export interface Ribbon3dTabProps {
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
fov: number;
onFov: (n: number) => void;
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
renderModeEnabled: boolean;
onRunCommand: (name: string) => void;
activeCommand: string | null;
}
/** 3D-Tab des Ribbons — Kamera-Presets + Darstellungsart. Kein Duplikat des
@@ -1283,11 +1700,11 @@ export function Ribbon3dTab({
onViewType,
view3d,
onView3d,
fov,
onFov,
renderMode,
onRenderMode,
renderModeEnabled,
onRunCommand,
activeCommand,
}: Ribbon3dTabProps) {
return (
<div className="ribbon-views">
@@ -1309,27 +1726,35 @@ export function Ribbon3dTab({
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
["back", t("view3d.back")],
["left", t("view3d.left")],
] as [View3d, string][]
).map(([key, label]) => (
<button
key={key}
className={`view-icon${viewToggleEnabled && viewType === "perspektive" && view3d === key ? " active" : ""}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? `${label} — ${t(`view3d.${key}.hint`)}` : t("topbar.viewGroup.disabled")}
aria-label={label}
>
<ViewIcon name={key} />
</button>
))}
<CameraMenu
fov={fov}
onFov={onFov}
disabled={!viewToggleEnabled || viewType !== "perspektive"}
/>
).map(([key, label]) =>
key === "iso" ? (
<IsoMenu
key={key}
view3d={view3d}
onView3d={onView3d}
viewType={viewType}
onViewType={onViewType}
disabled={!viewToggleEnabled}
/>
) : (
<button
key={key}
className={`view-icon${viewToggleEnabled && viewType === "perspektive" && view3d === key ? " active" : ""}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? `${label} — ${t(`view3d.${key}.hint`)}` : t("topbar.viewGroup.disabled")}
aria-label={label}
>
<ViewIcon name={key} />
</button>
),
)}
</div>
</div>
<Sep />
@@ -1351,6 +1776,26 @@ export function Ribbon3dTab({
/>
</label>
</div>
<Sep />
<div className="tb-group" role="group" aria-label={t("cmd.extrude.label")}>
{(() => {
const cmd = getCommand("extrude");
const label = cmd ? t(cmd.labelKey) : "extrude";
const active = activeCommand === "extrude";
return (
<button
type="button"
className={`ribbon-btn${active ? " active" : ""}`}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? label : t("tool.floorOnlyDisabled")}
onClick={() => onRunCommand("extrude")}
>
<CommandIcon name="extrude" />
<span>{label}</span>
</button>
);
})()}
</div>
</div>
);
}
@@ -1363,10 +1808,16 @@ export function TopBar({
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
viewType,
fov,
onFov,
cameraEnabled,
resourcesOpen,
onToggleResources,
onOpenSettings,
layoutMenu,
onImport,
onSaveProject,
onOpenProject,
@@ -1393,6 +1844,8 @@ export function TopBar({
// Unter Tauri (native macOS-Ampeln oben links via titleBarStyle "Overlay")
// braucht die Zeile links Platz, damit `dossier.` nicht darunter liegt.
const [aboutOpen, setAboutOpen] = useState(false);
const tauriNativeTitlebar =
typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
@@ -1415,10 +1868,17 @@ export function TopBar({
Access-Leiste: alle Datei-/Export-Aktionen + Ressourcen/Einstellungen
als kleine Icons direkt in der Zeile (ersetzt das Burger-Menü). */}
<div className="tb-brandgroup">
<span className="brand-word">
<button
type="button"
className="brand-word"
onClick={() => setAboutOpen(true)}
title={t("about.title")}
aria-label={t("about.title")}
>
dossier<span className="brand-dot">.</span>
</span>
</button>
</div>
{aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)} />}
<QuickAccess
onOpenProject={onOpenProject}
onSaveProject={onSaveProject}
@@ -1426,10 +1886,17 @@ export function TopBar({
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule}
onExportIfc={onExportIfc}
onExportObj={onExportObj}
onExportStl={onExportStl}
onToggleResources={onToggleResources}
onOpenSettings={onOpenSettings}
resourcesOpen={resourcesOpen}
exportEnabled={zoomEnabled}
viewType={viewType}
fov={fov}
onFov={onFov}
cameraEnabled={cameraEnabled}
/>
<Sep />
@@ -1439,11 +1906,10 @@ export function TopBar({
Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */}
<RibbonTabs tab={ribbonTab} onTab={onRibbonTab} />
{/* Rechte Gruppe: Layout-Menü · Projektname. Die Fensterknöpfe sitzen jetzt
ganz links (macOS-Titelleisten-Konvention); Datei-/Export-Aktionen in
der Quick-Access-Leiste (kein Burger). */}
{/* Rechte Gruppe: Projektname. Die Fensterknöpfe sitzen ganz links (macOS-
Titelleisten-Konvention); Datei-/Export-Aktionen in der Quick-Access-
Leiste; die Arbeitsumgebung (Layout) liegt jetzt in den Einstellungen. */}
<div className="topbar-right">
{layoutMenu}
<span className="project-name">{project.name}</span>
</div>
</header>
+304
View File
@@ -0,0 +1,304 @@
import { describe, expect, it } from "vitest";
import type { LayoutAnnotation, LayoutViewport } from "../model/types";
import {
annotationBoundsMm,
applyHandleDrag,
clampRectToSheet,
distanceToSegment,
fitTransform,
handleAt,
mmToScreen,
normalizeRect,
panBy,
pickAnnotation,
pickViewport,
screenToMm,
translateAnnotation,
vpRect,
zoomAtCursor,
type RectMm,
type SheetTransform,
} from "./layoutSheetMath";
const vp = (
id: string,
xMm: number,
yMm: number,
widthMm: number,
heightMm: number,
): LayoutViewport => ({ id, snapshotId: "s", xMm, yMm, widthMm, heightMm });
describe("mm ↔ screen", () => {
const t: SheetTransform = { scale: 2, tx: 100, ty: 50 };
it("mmToScreen applies scale + translate", () => {
expect(mmToScreen(t, { xMm: 10, yMm: 20 })).toEqual({ x: 120, y: 90 });
});
it("screenToMm is the inverse", () => {
const p = screenToMm(t, 120, 90);
expect(p.xMm).toBeCloseTo(10);
expect(p.yMm).toBeCloseTo(20);
});
it("roundtrips arbitrary points", () => {
const s = mmToScreen(t, { xMm: 37.5, yMm: -12.25 });
const m = screenToMm(t, s.x, s.y);
expect(m.xMm).toBeCloseTo(37.5);
expect(m.yMm).toBeCloseTo(-12.25);
});
});
describe("fitTransform", () => {
it("centres an A4 portrait sheet in a wide canvas", () => {
const t = fitTransform(210, 297, 1000, 600, 0.92);
// Höhe ist der Engpass: scale = 600*0.92/297.
expect(t.scale).toBeCloseTo((600 * 0.92) / 297);
// Zentriert: linker Rand = (1000 - 210*scale)/2.
expect(t.tx).toBeCloseTo((1000 - 210 * t.scale) / 2);
expect(t.ty).toBeCloseTo((600 - 297 * t.scale) / 2);
});
it("falls back to scale 1 on degenerate canvas", () => {
const t = fitTransform(210, 297, 0, 0);
expect(t.scale).toBe(1);
});
});
describe("zoomAtCursor", () => {
it("keeps the mm point under the cursor fixed", () => {
const t: SheetTransform = { scale: 2, tx: 100, ty: 50 };
const cursor = { x: 300, y: 220 };
const before = screenToMm(t, cursor.x, cursor.y);
const zt = zoomAtCursor(t, 1.25, cursor.x, cursor.y);
expect(zt.scale).toBeCloseTo(2.5);
const after = screenToMm(zt, cursor.x, cursor.y);
expect(after.xMm).toBeCloseTo(before.xMm);
expect(after.yMm).toBeCloseTo(before.yMm);
});
it("clamps scale to bounds", () => {
const t: SheetTransform = { scale: 30, tx: 0, ty: 0 };
const zt = zoomAtCursor(t, 4, 0, 0);
expect(zt.scale).toBeLessThanOrEqual(40);
});
});
describe("panBy", () => {
it("shifts translation, keeps scale", () => {
const t = panBy({ scale: 3, tx: 10, ty: 20 }, 5, -7);
expect(t).toEqual({ scale: 3, tx: 15, ty: 13 });
});
});
describe("handleAt", () => {
const r: RectMm = { xMm: 20, yMm: 20, widthMm: 100, heightMm: 60 };
it("detects corners with priority over edges", () => {
expect(handleAt(r, 20, 20, 3)).toBe("nw");
expect(handleAt(r, 120, 80, 3)).toBe("se");
});
it("detects edges", () => {
expect(handleAt(r, 70, 20, 3)).toBe("n");
expect(handleAt(r, 120, 50, 3)).toBe("e");
expect(handleAt(r, 20, 50, 3)).toBe("w");
expect(handleAt(r, 70, 80, 3)).toBe("s");
});
it("detects the body inside", () => {
expect(handleAt(r, 70, 50, 3)).toBe("body");
});
it("returns null well outside", () => {
expect(handleAt(r, 5, 5, 3)).toBe(null);
expect(handleAt(r, 200, 200, 3)).toBe(null);
});
});
describe("pickViewport", () => {
const vps = [vp("a", 0, 0, 100, 100), vp("b", 40, 40, 100, 100)];
it("returns the topmost (last) viewport in overlap", () => {
const hit = pickViewport(vps, 60, 60, 2);
expect(hit?.id).toBe("b");
expect(hit?.handle).toBe("body");
});
it("returns the lower one where only it is hit", () => {
const hit = pickViewport(vps, 10, 10, 2);
expect(hit?.id).toBe("a");
});
it("returns null on empty space", () => {
expect(pickViewport(vps, 300, 300, 2)).toBe(null);
});
});
describe("applyHandleDrag", () => {
const start: RectMm = { xMm: 20, yMm: 20, widthMm: 100, heightMm: 60 };
it("moves the whole rect for body", () => {
expect(applyHandleDrag(start, "body", 10, -5)).toEqual({
xMm: 30,
yMm: 15,
widthMm: 100,
heightMm: 60,
});
});
it("drags the east edge (width only)", () => {
const r = applyHandleDrag(start, "e", 25, 0);
expect(r).toEqual({ xMm: 20, yMm: 20, widthMm: 125, heightMm: 60 });
});
it("drags the west edge (x + width, right edge fixed)", () => {
const r = applyHandleDrag(start, "w", 30, 0);
expect(r.xMm).toBe(50);
expect(r.widthMm).toBe(70);
expect(r.xMm + r.widthMm).toBe(120); // rechte Kante unverändert
});
it("enforces min width when dragging west past the limit", () => {
const r = applyHandleDrag(start, "w", 200, 0, 5);
expect(r.widthMm).toBe(5);
expect(r.xMm + r.widthMm).toBe(120);
});
it("drags a corner on both axes", () => {
const r = applyHandleDrag(start, "se", 10, 20);
expect(r).toEqual({ xMm: 20, yMm: 20, widthMm: 110, heightMm: 80 });
});
it("enforces min height dragging north past the limit", () => {
const r = applyHandleDrag(start, "n", 0, 200, 5);
expect(r.heightMm).toBe(5);
expect(r.yMm + r.heightMm).toBe(80);
});
});
describe("normalizeRect", () => {
it("orders corners into min + positive size", () => {
expect(normalizeRect(120, 90, 20, 20)).toEqual({
xMm: 20,
yMm: 20,
widthMm: 100,
heightMm: 70,
});
});
});
describe("clampRectToSheet", () => {
it("keeps an in-bounds rect unchanged", () => {
const r: RectMm = { xMm: 10, yMm: 10, widthMm: 50, heightMm: 50 };
expect(clampRectToSheet(r, 210, 297)).toEqual(r);
});
it("pushes an overhanging rect back inside", () => {
const r: RectMm = { xMm: 200, yMm: 280, widthMm: 50, heightMm: 50 };
const c = clampRectToSheet(r, 210, 297);
expect(c.xMm).toBe(160);
expect(c.yMm).toBe(247);
expect(c.widthMm).toBe(50);
});
it("shrinks a rect larger than the sheet", () => {
const r: RectMm = { xMm: -20, yMm: -20, widthMm: 400, heightMm: 500 };
const c = clampRectToSheet(r, 210, 297);
expect(c).toEqual({ xMm: 0, yMm: 0, widthMm: 210, heightMm: 297 });
});
});
describe("vpRect", () => {
it("projects a viewport to its rect", () => {
expect(vpRect(vp("x", 5, 6, 7, 8))).toEqual({
xMm: 5,
yMm: 6,
widthMm: 7,
heightMm: 8,
});
});
});
// ── Annotationen (Linie/Rechteck/Text) ──────────────────────────────────────
const line = (id: string, x1: number, y1: number, x2: number, y2: number): LayoutAnnotation => ({
id,
kind: "line",
x1Mm: x1,
y1Mm: y1,
x2Mm: x2,
y2Mm: y2,
});
const rect = (id: string, x: number, y: number, w: number, h: number): LayoutAnnotation => ({
id,
kind: "rect",
xMm: x,
yMm: y,
widthMm: w,
heightMm: h,
});
const text = (id: string, x: number, y: number, t: string, h = 3.5): LayoutAnnotation => ({
id,
kind: "text",
xMm: x,
yMm: y,
text: t,
heightMm: h,
});
describe("distanceToSegment", () => {
it("returns perpendicular distance for a point beside the segment", () => {
expect(distanceToSegment(5, 3, 0, 0, 10, 0)).toBeCloseTo(3);
});
it("clamps to the nearest endpoint beyond the segment", () => {
expect(distanceToSegment(-4, 0, 0, 0, 10, 0)).toBeCloseTo(4);
expect(distanceToSegment(14, 0, 0, 0, 10, 0)).toBeCloseTo(4);
});
it("degenerates to point distance for a zero-length segment", () => {
expect(distanceToSegment(3, 4, 0, 0, 0, 0)).toBeCloseTo(5);
});
});
describe("annotationBoundsMm", () => {
it("rect: its own rectangle", () => {
expect(annotationBoundsMm(rect("r", 10, 20, 30, 40))).toEqual({
xMm: 10,
yMm: 20,
widthMm: 30,
heightMm: 40,
});
});
it("line: normalized bounding rect of both endpoints", () => {
expect(annotationBoundsMm(line("l", 30, 30, 10, 10))).toEqual({
xMm: 10,
yMm: 10,
widthMm: 20,
heightMm: 20,
});
});
it("text: box sits above the baseline, width grows with character count", () => {
const b = annotationBoundsMm(text("t", 10, 20, "AB", 4));
expect(b.xMm).toBe(10);
expect(b.yMm).toBe(16); // 20 - heightMm
expect(b.heightMm).toBeCloseTo(5); // heightMm * 1.25
expect(b.widthMm).toBeGreaterThan(0);
});
});
describe("pickAnnotation", () => {
it("hits a line within tolerance of the segment", () => {
expect(pickAnnotation([line("l", 0, 0, 10, 0)], 5, 1, 2)).toBe("l");
expect(pickAnnotation([line("l", 0, 0, 10, 0)], 5, 5, 2)).toBe(null);
});
it("hits a rect on its border and its interior", () => {
const r = rect("r", 20, 20, 10, 10);
expect(pickAnnotation([r], 20, 20, 1)).toBe("r"); // corner
expect(pickAnnotation([r], 25, 25, 1)).toBe("r"); // interior
expect(pickAnnotation([r], 100, 100, 1)).toBe(null);
});
it("hits a text annotation near its baseline box", () => {
const tx = text("t", 10, 20, "Hallo", 4);
expect(pickAnnotation([tx], 12, 18, 1)).toBe("t");
expect(pickAnnotation([tx], 200, 200, 1)).toBe(null);
});
it("returns the topmost (last) annotation in overlap, like pickViewport", () => {
const overlapping = [rect("a", 0, 0, 50, 50), rect("b", 10, 10, 50, 50)];
expect(pickAnnotation(overlapping, 30, 30, 1)).toBe("b");
});
});
describe("translateAnnotation", () => {
it("moves both endpoints of a line", () => {
const moved = translateAnnotation(line("l", 0, 0, 10, 10), 5, -5);
expect(moved).toMatchObject({ x1Mm: 5, y1Mm: -5, x2Mm: 15, y2Mm: 5 });
});
it("moves the origin of a rect", () => {
const moved = translateAnnotation(rect("r", 10, 10, 20, 20), 3, 4);
expect(moved).toMatchObject({ xMm: 13, yMm: 14, widthMm: 20, heightMm: 20 });
});
it("moves the anchor point of a text annotation", () => {
const moved = translateAnnotation(text("t", 10, 10, "Hi"), 1, 2);
expect(moved).toMatchObject({ xMm: 11, yMm: 12, text: "Hi" });
});
});
+429
View File
@@ -0,0 +1,429 @@
// Reine, testbare Geometrie für die In-Viewport-Layout-Ansicht (Phase 2b):
// Bildschirm↔Blatt-mm-Transform (Pan/Zoom-to-Cursor/Einpassen), Viewport-
// Trefferlogik (Body + 8 Resize-Griffe), Resize-Mathe mit Mindestgrösse,
// Rechteck-Normalisierung (Aufziehen) und Clamp an die Blattkanten.
//
// KEINE React-/DOM-Abhängigkeit — nur Zahlen. Das UI (LayoutSheet.tsx) baut
// darauf auf. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { LayoutAnnotation, LayoutViewport } from "../model/types";
/**
* Affine Blatt-Transform: Bildschirm-Pixel = mm · scale + translate.
* `scale` ist px pro mm (immer > 0), `tx/ty` die Blatt-Ursprungslage (mm 0,0)
* in Canvas-Pixeln.
*/
export interface SheetTransform {
/** Pixel pro Millimeter (> 0). */
scale: number;
/** Bildschirm-X des Blatt-Ursprungs (mm x=0) in Canvas-Pixeln. */
tx: number;
/** Bildschirm-Y des Blatt-Ursprungs (mm y=0) in Canvas-Pixeln. */
ty: number;
}
/** Ein Punkt in Blatt-Millimetern. */
export interface Mm {
xMm: number;
yMm: number;
}
/** Ein achsparalleles Rechteck in Blatt-Millimetern. */
export interface RectMm {
xMm: number;
yMm: number;
widthMm: number;
heightMm: number;
}
/** Resize-Griff (8 Ränder) oder Körper (verschieben) eines Viewports. */
export type Handle =
| "nw"
| "n"
| "ne"
| "e"
| "se"
| "s"
| "sw"
| "w"
| "body";
export const MIN_SCALE = 0.05;
export const MAX_SCALE = 40;
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
// ── Transform ────────────────────────────────────────────────────────────────
/** Blatt-mm → Canvas-Pixel. */
export function mmToScreen(t: SheetTransform, p: Mm): { x: number; y: number } {
return { x: p.xMm * t.scale + t.tx, y: p.yMm * t.scale + t.ty };
}
/** Canvas-Pixel → Blatt-mm. */
export function screenToMm(t: SheetTransform, sx: number, sy: number): Mm {
return { xMm: (sx - t.tx) / t.scale, yMm: (sy - t.ty) / t.scale };
}
/**
* Einpass-Transform: Blatt (mm) zentriert in die Canvas-Fläche (px), mit
* `margin` (Anteil, z. B. 0.92 = 8 % Rand). Degenerierte Eingaben → scale=1.
*/
export function fitTransform(
sheetWMm: number,
sheetHMm: number,
canvasWPx: number,
canvasHPx: number,
margin = 0.92,
): SheetTransform {
const sx = (canvasWPx * margin) / sheetWMm;
const sy = (canvasHPx * margin) / sheetHMm;
let scale = Math.min(sx, sy);
if (!Number.isFinite(scale) || scale <= 0) scale = 1;
scale = clamp(scale, MIN_SCALE, MAX_SCALE);
const tx = (canvasWPx - sheetWMm * scale) / 2;
const ty = (canvasHPx - sheetHMm * scale) / 2;
return { scale, tx, ty };
}
/**
* Zoom um den Cursor: der mm-Punkt unter (sx,sy) bleibt fix. `factor` > 1 zoomt
* rein, < 1 raus; der Massstab wird auf [MIN_SCALE, MAX_SCALE] geklemmt.
*/
export function zoomAtCursor(
t: SheetTransform,
factor: number,
sx: number,
sy: number,
): SheetTransform {
const newScale = clamp(t.scale * factor, MIN_SCALE, MAX_SCALE);
// mm-Punkt unter dem Cursor VOR dem Zoom.
const m = screenToMm(t, sx, sy);
// Neue Translation, damit derselbe mm-Punkt wieder unter (sx,sy) liegt.
return { scale: newScale, tx: sx - m.xMm * newScale, ty: sy - m.yMm * newScale };
}
/** Verschiebt die Transform um (dxPx,dyPx) Canvas-Pixel (Pan). */
export function panBy(t: SheetTransform, dxPx: number, dyPx: number): SheetTransform {
return { scale: t.scale, tx: t.tx + dxPx, ty: t.ty + dyPx };
}
// ── Viewport-Rechtecke / Treffer ─────────────────────────────────────────────
/** Rechteck-Sicht eines Viewports (nur die Blattgeometrie). */
export function vpRect(vp: LayoutViewport): RectMm {
return { xMm: vp.xMm, yMm: vp.yMm, widthMm: vp.widthMm, heightMm: vp.heightMm };
}
/** Punkt-in-Rechteck (mm), inklusive Rand. */
export function rectContains(r: RectMm, xMm: number, yMm: number): boolean {
return (
xMm >= r.xMm &&
xMm <= r.xMm + r.widthMm &&
yMm >= r.yMm &&
yMm <= r.yMm + r.heightMm
);
}
/**
* Griff (oder Körper) an einem mm-Punkt für EINEN Viewport. `tolMm` ist die
* Fangzone der Ecken/Kanten (in mm). Rückgabe null, wenn ausserhalb des
* Rechtecks (+Toleranz). Ecken haben Vorrang vor Kanten, Kanten vor Körper.
*/
export function handleAt(
r: RectMm,
xMm: number,
yMm: number,
tolMm: number,
): Handle | null {
const x0 = r.xMm;
const y0 = r.yMm;
const x1 = r.xMm + r.widthMm;
const y1 = r.yMm + r.heightMm;
// Ausserhalb des um tol vergrösserten Rechtecks → kein Treffer.
if (xMm < x0 - tolMm || xMm > x1 + tolMm || yMm < y0 - tolMm || yMm > y1 + tolMm) {
return null;
}
const nearL = Math.abs(xMm - x0) <= tolMm;
const nearR = Math.abs(xMm - x1) <= tolMm;
const nearT = Math.abs(yMm - y0) <= tolMm;
const nearB = Math.abs(yMm - y1) <= tolMm;
// Innerhalb der vertikalen/horizontalen Spanne (für Kanten-Treffer).
const inX = xMm >= x0 - tolMm && xMm <= x1 + tolMm;
const inY = yMm >= y0 - tolMm && yMm <= y1 + tolMm;
// Ecken zuerst.
if (nearL && nearT) return "nw";
if (nearR && nearT) return "ne";
if (nearR && nearB) return "se";
if (nearL && nearB) return "sw";
// Kanten.
if (nearT && inX) return "n";
if (nearB && inX) return "s";
if (nearL && inY) return "w";
if (nearR && inY) return "e";
// Körper (echt innen).
if (rectContains(r, xMm, yMm)) return "body";
return null;
}
/**
* Topmost-Treffer über eine Viewport-Liste (letzte = oberste, wie Z-Order der
* Darstellung). Liefert Id + Griff des ERSTEN Treffers von oben, sonst null.
*/
export function pickViewport(
viewports: LayoutViewport[],
xMm: number,
yMm: number,
tolMm: number,
): { id: string; handle: Handle } | null {
for (let i = viewports.length - 1; i >= 0; i--) {
const vp = viewports[i];
const h = handleAt(vpRect(vp), xMm, yMm, tolMm);
if (h) return { id: vp.id, handle: h };
}
return null;
}
// ── Resize / Move ────────────────────────────────────────────────────────────
/**
* Wendet einen Griff-Drag auf ein Start-Rechteck an: `handle` bestimmt, welche
* Kanten mit (dxMm,dyMm) wandern. Körper verschiebt das ganze Rechteck. Die
* Mindestbreite/-höhe `minMm` wird eingehalten (die feste Kante bleibt fix).
*/
export function applyHandleDrag(
start: RectMm,
handle: Handle,
dxMm: number,
dyMm: number,
minMm = 5,
): RectMm {
if (handle === "body") {
return { ...start, xMm: start.xMm + dxMm, yMm: start.yMm + dyMm };
}
let { xMm, yMm, widthMm, heightMm } = start;
const movesLeft = handle === "nw" || handle === "w" || handle === "sw";
const movesRight = handle === "ne" || handle === "e" || handle === "se";
const movesTop = handle === "nw" || handle === "n" || handle === "ne";
const movesBottom = handle === "sw" || handle === "s" || handle === "se";
if (movesLeft) {
// Rechte Kante fix bei start.xMm + start.widthMm.
const right = start.xMm + start.widthMm;
let nx = start.xMm + dxMm;
if (right - nx < minMm) nx = right - minMm;
xMm = nx;
widthMm = right - nx;
} else if (movesRight) {
let nw = start.widthMm + dxMm;
if (nw < minMm) nw = minMm;
widthMm = nw;
}
if (movesTop) {
const bottom = start.yMm + start.heightMm;
let ny = start.yMm + dyMm;
if (bottom - ny < minMm) ny = bottom - minMm;
yMm = ny;
heightMm = bottom - ny;
} else if (movesBottom) {
let nh = start.heightMm + dyMm;
if (nh < minMm) nh = minMm;
heightMm = nh;
}
return { xMm, yMm, widthMm, heightMm };
}
/**
* Normalisiert ein aufgezogenes Rechteck aus zwei Eckpunkten (mm) — Min-Ecke +
* absolute Breite/Höhe. Für die „Rechteck aufziehen"-Platzierung.
*/
export function normalizeRect(
x0: number,
y0: number,
x1: number,
y1: number,
): RectMm {
return {
xMm: Math.min(x0, x1),
yMm: Math.min(y0, y1),
widthMm: Math.abs(x1 - x0),
heightMm: Math.abs(y1 - y0),
};
}
/**
* Klemmt ein Rechteck so, dass es ganz auf dem Blatt (0..sheet) liegt. Ist das
* Rechteck breiter/höher als das Blatt, wird es auf Blattgrösse gestutzt und an
* (0,0) gelegt. Sonst bleibt die Grösse erhalten und nur die Position wandert.
*/
export function clampRectToSheet(
r: RectMm,
sheetWMm: number,
sheetHMm: number,
): RectMm {
let { xMm, yMm, widthMm, heightMm } = r;
if (widthMm > sheetWMm) {
widthMm = sheetWMm;
xMm = 0;
} else {
xMm = clamp(xMm, 0, sheetWMm - widthMm);
}
if (heightMm > sheetHMm) {
heightMm = sheetHMm;
yMm = 0;
} else {
yMm = clamp(yMm, 0, sheetHMm - heightMm);
}
return { xMm, yMm, widthMm, heightMm };
}
/** CSS-Cursor je Resize-Griff (Körper = move). */
export function handleCursor(handle: Handle): string {
switch (handle) {
case "nw":
case "se":
return "nwse-resize";
case "ne":
case "sw":
return "nesw-resize";
case "n":
case "s":
return "ns-resize";
case "e":
case "w":
return "ew-resize";
default:
return "move";
}
}
/**
* Die 8 Griff-Anker eines Rechtecks in mm (für die Darstellung der Griffe).
* Reihenfolge passend zu {@link Handle} (ohne „body").
*/
export function handleAnchors(r: RectMm): { handle: Handle; xMm: number; yMm: number }[] {
const { xMm: x, yMm: y, widthMm: w, heightMm: h } = r;
const cx = x + w / 2;
const cy = y + h / 2;
return [
{ handle: "nw", xMm: x, yMm: y },
{ handle: "n", xMm: cx, yMm: y },
{ handle: "ne", xMm: x + w, yMm: y },
{ handle: "e", xMm: x + w, yMm: cy },
{ handle: "se", xMm: x + w, yMm: y + h },
{ handle: "s", xMm: cx, yMm: y + h },
{ handle: "sw", xMm: x, yMm: y + h },
{ handle: "w", xMm: x, yMm: cy },
];
}
// ── Annotationen (Linie/Rechteck/Text) ──────────────────────────────────────
// Annotationen tragen KEINE Resize-Griffe (nur verschieben + löschen) — daher
// reicht ein einfacher Treffer-Test (kein Handle-Ergebnis wie bei Viewports).
/** Kürzester Abstand (mm) eines Punkts zu einem Liniensegment. */
export function distanceToSegment(
px: number,
py: number,
x1: number,
y1: number,
x2: number,
y2: number,
): number {
const dx = x2 - x1;
const dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(px - x1, py - y1);
const t = clamp(((px - x1) * dx + (py - y1) * dy) / lenSq, 0, 1);
const cx = x1 + t * dx;
const cy = y1 + t * dy;
return Math.hypot(px - cx, py - cy);
}
/**
* Umhüllendes Rechteck (mm) einer Annotation — für Text nur eine GROBE Box
* (kein echtes Font-Measuring im SVG; Breite aus Zeichenzahl geschätzt), reicht
* aber für Trefferlogik + Auswahl-Rahmen. `yMm` einer Text-Annotation ist die
* Baseline, die Box liegt daher oberhalb davon.
*/
export function annotationBoundsMm(ann: LayoutAnnotation): RectMm {
if (ann.kind === "rect") {
return { xMm: ann.xMm, yMm: ann.yMm, widthMm: ann.widthMm, heightMm: ann.heightMm };
}
if (ann.kind === "line") {
return normalizeRect(ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm);
}
const widthMm = Math.max(ann.heightMm, ann.text.length * ann.heightMm * 0.6);
return {
xMm: ann.xMm,
yMm: ann.yMm - ann.heightMm,
widthMm,
heightMm: ann.heightMm * 1.25,
};
}
/**
* Trefferlogik (mm) für EINE Annotation: Linie → Abstand zum Segment ≤ tolMm;
* Rechteck → Rahmen ODER Innenfläche (wiederverwendet {@link handleAt}, jeder
* Nicht-null-Rückgabewert zählt als Treffer); Text → Bounding-Box + Toleranz.
*/
export function annotationHit(
ann: LayoutAnnotation,
xMm: number,
yMm: number,
tolMm: number,
): boolean {
if (ann.kind === "line") {
return distanceToSegment(xMm, yMm, ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm) <= tolMm;
}
if (ann.kind === "rect") {
const r: RectMm = { xMm: ann.xMm, yMm: ann.yMm, widthMm: ann.widthMm, heightMm: ann.heightMm };
return handleAt(r, xMm, yMm, tolMm) !== null;
}
const b = annotationBoundsMm(ann);
return (
xMm >= b.xMm - tolMm &&
xMm <= b.xMm + b.widthMm + tolMm &&
yMm >= b.yMm - tolMm &&
yMm <= b.yMm + b.heightMm + tolMm
);
}
/**
* Topmost-Treffer über eine Annotations-Liste (letzte = oberste, wie
* {@link pickViewport}). Liefert die Id des ERSTEN Treffers von oben, sonst null.
*/
export function pickAnnotation(
annotations: LayoutAnnotation[],
xMm: number,
yMm: number,
tolMm: number,
): string | null {
for (let i = annotations.length - 1; i >= 0; i--) {
const a = annotations[i];
if (annotationHit(a, xMm, yMm, tolMm)) return a.id;
}
return null;
}
/** Verschiebt eine Annotation um ein mm-Delta — alle ihre Punkte wandern mit. */
export function translateAnnotation(
ann: LayoutAnnotation,
dxMm: number,
dyMm: number,
): LayoutAnnotation {
if (ann.kind === "line") {
return {
...ann,
x1Mm: ann.x1Mm + dxMm,
y1Mm: ann.y1Mm + dyMm,
x2Mm: ann.x2Mm + dxMm,
y2Mm: ann.y2Mm + dyMm,
};
}
return { ...ann, xMm: ann.xMm + dxMm, yMm: ann.yMm + dyMm };
}
+28
View File
@@ -36,6 +36,26 @@ export function CommandIcon({ name }: { name: string }) {
<rect x="5.5" y="5.5" width="8" height="8" />
</svg>
);
case "measure":
// Lineal/Massband: Strecke mit Endmarken + kleiner Skala.
return (
<svg {...common}>
<line x1="2.5" y1="12.5" x2="13.5" y2="3.5" />
<line x1="2" y1="11" x2="4" y2="13" />
<line x1="12.5" y1="2.5" x2="14.5" y2="4.5" />
<line x1="6" y1="9" x2="7" y2="10" />
<line x1="9" y1="6" x2="10" y2="7" />
</svg>
);
case "rotate":
// Kreisbogen mit Pfeil (Drehung) um einen Mittelpunkt.
return (
<svg {...common}>
<path d="M12.5 6 A5 5 0 1 0 13 9" />
<polyline points="10.5,5.5 12.5,6 12.9,3.8" />
<circle cx="8" cy="8" r="1" fill="currentColor" stroke="none" />
</svg>
);
case "mirror":
// Spiegelachse (gestrichelt) mit zwei gespiegelten Dreiecken.
return (
@@ -71,6 +91,14 @@ export function CommandIcon({ name }: { name: string }) {
<circle cx="8" cy="13" r="1.4" fill="currentColor" stroke="none" />
</svg>
);
case "column":
// Stütze: Quadrat-Querschnitt mit vertikalen Kanten (Pfeiler).
return (
<svg {...common}>
<rect x="5.5" y="2.5" width="5" height="11" />
<line x1="8" y1="2.5" x2="8" y2="13.5" strokeDasharray="1.6 1.6" />
</svg>
);
default:
return null;
}
+4 -2
View File
@@ -87,9 +87,11 @@ function RibbonButton({ item, ctx }: { item: RibbonItem; ctx: ButtonCtx }) {
</button>
);
}
// Befehls-Element: Label aus der Befehls-Registry ableiten.
// Befehls-Element: Label aus der Befehls-Registry ableiten. Für Aktionen ohne
// eigenes Engine-Kommando (z. B. „rotate", das über die interaktive
// Transformation läuft) auf den i18n-Schlüssel `cmd.<name>.label` zurückfallen.
const cmd = getCommand(item.name);
const label = cmd ? t(cmd.labelKey) : item.name;
const label = cmd ? t(cmd.labelKey) : t(`cmd.${item.name}.label`);
const active = ctx.activeCommand === item.name;
return (
<button
+5
View File
@@ -68,6 +68,8 @@ export const RIBBON_TABS: RibbonTab[] = [
t("rect"),
t("circle"),
t("arc"),
t("text"),
t("textbox"),
],
},
{
@@ -75,10 +77,12 @@ export const RIBBON_TABS: RibbonTab[] = [
items: [
c("move"),
c("copy"),
c("rotate"),
c("mirror"),
c("offset"),
c("trim"),
c("join"),
c("measure"),
],
},
],
@@ -99,6 +103,7 @@ export const RIBBON_TABS: RibbonTab[] = [
t("window"),
t("door"),
t("stair"),
c("column"),
t("ceiling"),
t("room"),
],