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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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). */}
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user