Files
DOSSIER-STANDALONE/src/ui/ResourceManager.tsx
T
karim 586c1c99bf Öffnung: ⚙-Knopf springt in den Tür-/Fenstertyp-Editor
Discoverability für die vertieften Bauteil-Typen: neben dem Typ-Dropdown
einer gewählten Öffnung öffnet ein ⚙-Knopf das Ressourcen-Fenster direkt
beim passenden Typeditor-Tab (Tür→doorStyles, Fenster→windowStyles).
- ResourceManager: initialTab-Prop (springt auch bei bereits offenem
  Fenster auf den angeforderten Tab).
- host.onEditOpeningType(kind) + App-Wiring (resourcesTab-State).
tsc + vitest 620 grün.
2026-07-09 01:44:13 +02:00

3266 lines
112 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ressourcen-Manager — verwaltete Stil-Bibliotheken nach DOSSIER-Vorbild
// (Vectorworks-Stil). Drei Sektionen in einer rechten Schublade (Drawer):
// • Bauteile (Components) — Poché-/3D-Farbe, Verschneidungs-Rang, Schraffur.
// • Schraffuren (Hatches) — Muster/Maßstab/Winkel/Farbe + Linienstil.
// • Linien (Line Styles) — Strichstärke/Farbe/Strichmuster.
//
// Darstellung als saubere Tabelle (CONVENTIONS.md): pro Tab EINE sticky Kopfzeile
// mit Spaltentiteln, darunter kompakte Datenzeilen mit Inline-Edit pro Zelle —
// keine wiederholten Feld-Beschriftungen je Zeile. Umgesetzt als CSS-Grid, das
// Kopf- und Datenzeilen dieselben Spalten teilen (`grid-template-columns` je
// Tab), damit die Spalten exakt fluchten.
//
// Reine Darstellung + Callbacks: alle Mutationen laufen immutabel über die
// vom App gereichten Handler (setProject). Plan & 3D leiten aus demselben
// Projekt ab und aktualisieren dadurch live — diese Komponente kennt weder
// Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch
// (CONVENTIONS.md).
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";
import { parsePat } from "../io/patParser";
import { MATERIAL_LIBRARY } from "../materials/library";
import type { MaterialAsset } from "../materials/library";
import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../materials/runtime";
import {
searchMaterials,
fetchMaterialMaps,
AMBIENT_CATEGORIES,
type AmbientMaterial,
type AmbientResolution,
} from "../materials/ambientcg";
import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview";
import { Dropdown } from "./Dropdown";
import { EyeIcon } from "./EyeIcon";
import { HatchSwatch, LineSwatch, WallTypeSwatch } from "./hatchPreview";
import type { SwatchLayer } from "./hatchPreview";
import {
segmentsToDash,
dashToSegments,
presetOfDash,
LINE_PRESETS,
type LineSegment,
type LineSegmentType,
type LinePresetKey,
} from "./lineSegments";
import { MotifEditor } from "./MotifEditor";
import { t } from "../i18n";
// ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ───────────
/** Schraffur-Muster mit i18n-Key für die Beschriftung. */
const PATTERN_OPTIONS: { value: HatchPattern; labelKey: string }[] = [
{ value: "none", labelKey: "pattern.none" },
{ value: "solid", labelKey: "pattern.solid" },
{ value: "insulation", labelKey: "pattern.insulation" },
{ value: "diagonal", labelKey: "pattern.diagonal" },
{ value: "crosshatch", labelKey: "pattern.crosshatch" },
];
// ── Wiederverwendbare Feld-Bausteine (DOSSIER-Look) ────────────────────────
// Alle Steuerelemente füllen ihre Tabellenzelle (width:100%) und werden über
// die Spaltenbreite des Grids dimensioniert; Zahlenfelder sind rechtsbündig.
/** Kompaktes Textfeld (Pill) für Namen/Platzhalter. */
function TextField({
value,
onChange,
placeholder,
mono = false,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
mono?: boolean;
}) {
return (
<input
type="text"
className={`res-input${mono ? " mono" : ""}`}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
);
}
/** Zahlenfeld (Pill, rechtsbündig, monospace) mit Live-Commit. */
function NumberField({
value,
onChange,
step = 1,
min,
max,
list,
}: {
value: number;
onChange: (v: number) => void;
step?: number;
min?: number;
max?: number;
/** Optionale <datalist>-ID mit Vorschlagswerten (z. B. Stiftstärken). */
list?: string;
}) {
return (
<input
type="number"
className="res-input mono num"
value={Number.isFinite(value) ? value : 0}
step={step}
min={min}
max={max}
list={list}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v)) onChange(v);
}}
/>
);
}
/**
* Farb-Swatch mit nativem Color-Picker. Das <label> umschließt einen
* sichtbaren Swatch und den unsichtbaren color-input — Klick öffnet den
* Picker, der Swatch zeigt die Farbe live (nach DOSSIER ColorBar).
*/
function ColorField({
color,
onChange,
}: {
color: string;
onChange: (v: string) => void;
}) {
return (
<label className="res-color" title={color}>
<span className="res-color-swatch" style={{ background: color }} />
<input
type="color"
value={color}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
/** Pill-Dropdown (nutzt die gemeinsame Dropdown-Komponente statt <select>). */
function SelectField<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
}) {
return (
<Dropdown
value={value}
onChange={(v) => onChange(v as T)}
options={options}
/>
);
}
/** Runder Löschen-Knopf (Mülleimer-Icon) in der letzten Spalte. */
function DeleteButton({ onClick, title }: { onClick: () => void; title: string }) {
return (
<button className="res-delete" title={title} onClick={onClick}>
<svg
width={15}
height={15}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
);
}
// ── Master-Detail-Bausteine (DOSSIER-Stil: Liste links, Detail rechts) ──────
/**
* Segmentierter Umschalter (Pillen-Gruppe) für kleine, sich ausschließende
* Auswahlen — z. B. Schraffur-Typ Vektor/Bild oder Linien-Typ Strich/Zickzack.
*/
function Segmented<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
}) {
return (
<div className="res-seg" role="group">
{options.map((o) => (
<button
key={o.value}
type="button"
className={`res-seg-btn${value === o.value ? " active" : ""}`}
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
>
{o.label}
</button>
))}
</div>
);
}
/**
* Eine beschriftete Detail-Zeile (Label links, Steuerelement rechts). Bewusst
* ein <div> (kein <label>): Zeilen enthalten teils mehrere interaktive Elemente
* (Segmented-Buttons), und ein umschließendes <label> würde den Klick an das
* ERSTE labelbare Kind umleiten — das würde einen Segment-Klick sofort wieder
* zurücksetzen. Die Steuerelemente sind selbst klickbar; ein Label ist unnötig.
*/
function FieldRow({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="res-field" title={hint}>
<span className="res-field-label">{label}</span>
<span className="res-field-control">{children}</span>
</div>
);
}
// ── Bauteile (Components) ──────────────────────────────────────────────────
/**
* Material-Zelle der Bauteil-Tabelle: ein Button, der den aktuellen Material-
* Namen zeigt (oder „ohne") und ein Auswahl-Modal öffnet. Die Zuweisung läuft
* über `onAssign`/`onClear` (immutabel via onPatchComponent).
*/
function MaterialCell({
material,
onAssign,
onClear,
}: {
material: ComponentMaterial | undefined;
onAssign: (m: ComponentMaterial) => void;
onClear: () => void;
}) {
const [open, setOpen] = useState(false);
const label = materialLabel(material);
return (
<>
<button
className="res-select res-material-btn"
onClick={() => setOpen(true)}
title={t("material.assign")}
>
{material?.color && (
<span
className="res-material-swatch"
style={{ backgroundImage: `url(${material.color})` }}
/>
)}
<span className="res-material-name">{label}</span>
</button>
{open && (
<MaterialPicker
material={material}
onAssign={(m) => {
onAssign(m);
}}
onClear={() => {
onClear();
}}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
/** Anzeigename eines zugewiesenen Materials (Bibliothek → Name, sonst Upload). */
function materialLabel(m: ComponentMaterial | undefined): string {
if (!m) return t("material.none");
if (m.libraryId) {
const asset = MATERIAL_LIBRARY.find((a) => a.id === m.libraryId);
if (asset) return asset.name;
}
return t("material.uploaded");
}
/**
* Material-Auswahl-Modal: oben die eingebaute Bibliothek als Vorschau-Grid
* (Farb-Karte als Thumbnail) — Klick weist sofort zu; darunter ein Upload-
* Bereich (Farbe/Normal/Rauheit/Höhe/AO als Bild-Dateien → ObjectURL) plus die
* physische Kachelgröße. „Zuweisen" übernimmt den Upload; „Material entfernen"
* setzt das Bauteil auf kein Material zurück.
*/
function MaterialPicker({
material,
onAssign,
onClear,
onClose,
}: {
material: ComponentMaterial | undefined;
onAssign: (m: ComponentMaterial) => void;
onClear: () => void;
onClose: () => void;
}) {
// Upload-Zustand: gewählte Karten als ObjectURLs + Kachelgröße. Startwert aus
// einem bereits zugewiesenen Upload-Material (damit Größe/Karten sichtbar sind).
const [upload, setUpload] = useState<ComponentMaterial>(() =>
material && !material.libraryId ? { ...material } : { sizeM: DEFAULT_TILE_SIZE_M },
);
const [sizeM, setSizeM] = useState<number>(material?.sizeM ?? DEFAULT_TILE_SIZE_M);
// Modus des Pickers: Schnellauswahl (lokaler Starter, offline), Live-Browse
// der kompletten ambientCG-Bibliothek, oder Upload eigener Karten.
const [mode, setMode] = useState<"starter" | "browse" | "upload">("starter");
const pickFile = (kind: keyof ComponentMaterial) => (file: File | null) => {
if (!file) return;
const url = URL.createObjectURL(file);
setUpload((u) => ({ ...u, [kind]: url }));
};
const uploadFields: { kind: keyof ComponentMaterial; labelKey: string }[] = [
{ kind: "color", labelKey: "material.upload.color" },
{ kind: "normal", labelKey: "material.upload.normal" },
{ kind: "roughness", labelKey: "material.upload.roughness" },
{ kind: "displacement", labelKey: "material.upload.displacement" },
{ kind: "ao", labelKey: "material.upload.ao" },
];
const applyUpload = () => {
if (!upload.color && !upload.normal && !upload.roughness && !upload.ao && !upload.displacement) {
return;
}
onAssign({ ...upload, libraryId: undefined, sizeM });
onClose();
};
return (
<div className="res-overlay mat-overlay" onClick={onClose}>
<div
className="mat-dialog"
role="dialog"
aria-label={t("material.dialog.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="mat-head">
<span className="mat-title">{t("material.dialog.title")}</span>
<button className="res-close" onClick={onClose} aria-label={t("material.close")}>
×
</button>
</header>
<div className="mat-hint">{t("material.dialog.hint")}</div>
{/* Modus-Umschalter: Schnellauswahl / Bibliothek durchsuchen / Upload. */}
<nav className="mat-modes" role="tablist">
<button
role="tab"
aria-selected={mode === "starter"}
className={`mat-mode-btn${mode === "starter" ? " active" : ""}`}
onClick={() => setMode("starter")}
>
{t("material.browse.starter")}
</button>
<button
role="tab"
aria-selected={mode === "browse"}
className={`mat-mode-btn${mode === "browse" ? " active" : ""}`}
onClick={() => setMode("browse")}
>
{t("material.browse")}
</button>
<button
role="tab"
aria-selected={mode === "upload"}
className={`mat-mode-btn${mode === "upload" ? " active" : ""}`}
onClick={() => setMode("upload")}
>
{t("material.upload")}
</button>
</nav>
{/* Schnellauswahl: lokaler 12er-Starter (offline verfügbar). */}
{mode === "starter" && (
<div className="mat-grid">
{MATERIAL_LIBRARY.map((asset) => (
<button
key={asset.id}
className={`mat-tile${material?.libraryId === asset.id ? " active" : ""}`}
title={asset.name}
onClick={() => {
onAssign(materialFromAsset(asset, sizeM));
onClose();
}}
>
<span
className="mat-thumb"
style={{ backgroundImage: `url(${asset.maps.color})` }}
/>
<span className="mat-tile-name">{asset.name}</span>
</button>
))}
</div>
)}
{/* Live-Browse der kompletten ambientCG-Bibliothek. */}
{mode === "browse" && (
<AmbientBrowser
sizeM={sizeM}
activeId={material?.libraryId}
onAssign={(m) => {
onAssign(m);
onClose();
}}
/>
)}
{/* Upload eigener Karten. */}
{mode === "upload" && (
<>
<div className="mat-hint">{t("material.upload.hint")}</div>
<div className="mat-upload-grid">
{uploadFields.map((f) => (
<label key={f.kind} className="mat-upload-row">
<span className="mat-upload-label">{t(f.labelKey)}</span>
<ImagePickButton onFile={pickFile(f.kind)} />
{upload[f.kind] && <span className="mat-upload-ok"></span>}
</label>
))}
<label className="mat-upload-row">
<span className="mat-upload-label">{t("material.size")}</span>
<input
type="number"
className="res-input mono num"
value={sizeM}
step={0.1}
min={0.05}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v) && v > 0) setSizeM(v);
}}
/>
</label>
</div>
<div className="mat-actions">
<button className="res-add" onClick={applyUpload}>
{t("material.upload.apply")}
</button>
</div>
</>
)}
{/* Kachelgröße (m) für Schnellauswahl + Browse, plus Material entfernen. */}
{mode !== "upload" && (
<div className="mat-actions mat-actions-browse">
<label className="mat-size-inline">
<span className="mat-upload-label">{t("material.size")}</span>
<input
type="number"
className="res-input mono num"
value={sizeM}
step={0.1}
min={0.05}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v) && v > 0) setSizeM(v);
}}
/>
</label>
<button
className="res-add mat-danger"
onClick={() => {
onClear();
onClose();
}}
>
{t("material.clear")}
</button>
</div>
)}
</div>
</div>
);
}
/**
* Live-Browse der kompletten ambientCG-Bibliothek: Suchfeld + Kategorie-Filter
* + Auflösungswahl über der Trefferliste, Thumbnail-Grid (aus der API, direkt
* geladen — CORS `*`) mit Pagination („mehr laden"). Ein Klick lädt die Textur-
* Karten des Materials herunter (`fetchMaterialMaps`, über den Proxy), entpackt
* sie und weist das entstehende `ComponentMaterial` zu. Lade-/Fehlerzustände
* sauber (Spinner, Offline-/CORS-Hinweis).
*/
function AmbientBrowser({
sizeM,
activeId,
onAssign,
}: {
sizeM: number;
activeId: string | undefined;
onAssign: (m: ComponentMaterial) => void;
}) {
const [query, setQuery] = useState("");
const [committedQuery, setCommittedQuery] = useState("");
const [category, setCategory] = useState("");
const [resolution, setResolution] = useState<AmbientResolution>("1K");
const [items, setItems] = useState<AmbientMaterial[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ID des gerade herunterladenden Materials (Spinner auf der Kachel).
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const LIMIT = 24;
// Suche (bei committedQuery/category-Wechsel neu laden — Seite 0).
useEffect(() => {
const ctrl = new AbortController();
setLoading(true);
setError(null);
searchMaterials({
query: committedQuery,
category,
limit: LIMIT,
offset: 0,
signal: ctrl.signal,
})
.then((res) => {
setItems(res.items);
setTotal(res.total);
setOffset(0);
})
.catch((e) => {
if (ctrl.signal.aborted) return;
setError(t("material.browse.error"));
setItems([]);
setTotal(0);
console.error("[ambientcg] search failed:", e);
})
.finally(() => {
if (!ctrl.signal.aborted) setLoading(false);
});
return () => ctrl.abort();
}, [committedQuery, category]);
const loadMore = () => {
const nextOffset = offset + LIMIT;
setLoading(true);
searchMaterials({ query: committedQuery, category, limit: LIMIT, offset: nextOffset })
.then((res) => {
setItems((prev) => [...prev, ...res.items]);
setTotal(res.total);
setOffset(nextOffset);
})
.catch((e) => {
setError(t("material.browse.error"));
console.error("[ambientcg] load more failed:", e);
})
.finally(() => setLoading(false));
};
const pick = async (id: string) => {
setDownloadingId(id);
setError(null);
try {
const { material } = await fetchMaterialMaps(id, resolution, sizeM);
onAssign(material);
} catch (e) {
setError(t("material.browse.downloadError"));
console.error("[ambientcg] download failed:", e);
} finally {
setDownloadingId(null);
}
};
const submitSearch = () => setCommittedQuery(query);
return (
<div className="mat-browse">
<div className="mat-hint">{t("material.browse.hint")}</div>
<div className="mat-browse-bar">
<input
type="text"
className="res-input mat-search"
value={query}
placeholder={t("material.browse.searchPlaceholder")}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitSearch();
}}
/>
<button className="res-add" onClick={submitSearch}>
{t("material.browse.search")}
</button>
<Dropdown
value={category}
onChange={(v) => setCategory(v)}
options={[
{ value: "", label: t("material.browse.category.all") },
...AMBIENT_CATEGORIES.map((c) => ({ value: c, label: c })),
]}
/>
<Dropdown
value={resolution}
title={t("material.browse.resolution")}
onChange={(v) => setResolution(v as AmbientResolution)}
options={[
{ value: "1K", label: "1K" },
{ value: "2K", label: "2K" },
{ value: "4K", label: "4K" },
]}
/>
</div>
{error && <div className="mat-browse-error">{error}</div>}
<div className="mat-grid mat-browse-grid">
{items.map((it) => (
<button
key={it.id}
className={`mat-tile${activeId === it.id ? " active" : ""}${
downloadingId === it.id ? " loading" : ""
}`}
title={`${it.name}${it.category ? `${it.category}` : ""}`}
disabled={downloadingId !== null}
onClick={() => pick(it.id)}
>
<span
className="mat-thumb"
style={{ backgroundImage: it.thumbUrl ? `url(${it.thumbUrl})` : undefined }}
/>
{downloadingId === it.id && <span className="mat-tile-spinner" />}
<span className="mat-tile-name">{it.name}</span>
</button>
))}
</div>
{!loading && items.length === 0 && !error && (
<div className="res-empty">{t("material.browse.empty")}</div>
)}
<div className="mat-browse-foot">
{loading && <span className="mat-browse-status">{t("material.browse.loading")}</span>}
{!loading && items.length > 0 && (
<span className="mat-browse-status">
{t("material.browse.count", { shown: items.length, total })}
</span>
)}
{!loading && items.length < total && (
<button className="res-add" onClick={loadMore}>
{t("material.browse.more")}
</button>
)}
</div>
</div>
);
}
/** Versteckter Bild-Datei-Picker mit sichtbarem Button. */
function ImagePickButton({ onFile }: { onFile: (file: File | null) => void }) {
const ref = useRef<HTMLInputElement | null>(null);
return (
<>
<button className="res-add mat-pick" onClick={() => ref.current?.click()}>
{t("material.upload.choose")}
</button>
<input
ref={ref}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
onFile(e.target.files?.[0] ?? null);
e.target.value = "";
}}
/>
</>
);
}
/** Kleiner Farb-Swatch als Listen-Thumbnail für Bauteile ohne Schraffur. */
function ColorSwatch({ color, size = 30 }: { color: string; size?: number }) {
return (
<span
className="res-swatch"
style={{
display: "block",
width: size,
height: size,
background: color,
border: "1px solid var(--border)",
}}
/>
);
}
/**
* 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,
hatches,
onPatch,
onDelete,
}: {
component: Component;
hatches: HatchStyle[];
onPatch: (patch: Partial<Component>) => void;
onDelete: () => void;
}) {
const hatchOptions = hatches.map((h) => ({ value: h.id, label: h.name }));
// Ansichts-Schraffur ist optional; Leer-Eintrag = keine Schraffur (weiss).
const viewHatchOptions = [
{ value: "", label: t("resources.viewHatch.none") },
...hatchOptions,
];
const previewHatch = hatches.find((h) => h.id === component.hatchId);
return (
<div className="res-md-detail-inner">
<div className="res-md-head">
<input
className="res-input res-md-rename"
value={component.name}
placeholder={t("resources.components.placeholder")}
onChange={(e) => onPatch({ name: e.target.value })}
/>
<DeleteButton
onClick={onDelete}
title={t("resources.delete.component", { name: component.name })}
/>
</div>
<div className="res-md-preview">
{component.material ? (
<ComponentMaterialSphere material={component.material} size={128} />
) : previewHatch ? (
<HatchSwatch hatch={previewHatch} size={128} />
) : (
<ColorSwatch color={component.foreground ?? component.color} size={128} />
)}
</div>
<div className="res-fields">
<FieldRow label={t("resources.col.abbrev")} hint={t("resources.col.abbrev.hint")}>
<TextField
value={component.abbrev ?? ""}
onChange={(v) => onPatch({ abbrev: v || undefined })}
placeholder={t("resources.col.abbrev.placeholder")}
/>
</FieldRow>
<FieldRow
label={t("resources.col.foreground")}
hint={t("resources.col.foreground.hint")}
>
{/* Vordergrund = Farbe der Muster-/Schraffurlinien. Fehlt sie, greift
als Anzeige-Fallback `color`; das Setzen definiert `foreground`. */}
<ColorField
color={component.foreground ?? component.color}
onChange={(foreground) => onPatch({ foreground })}
/>
</FieldRow>
<FieldRow
label={t("resources.col.background")}
hint={t("resources.col.background.hint")}
>
{/* Hintergrund = Füllung (Poché). Fehlt sie, gilt `color` als Fallback. */}
<ColorField
color={component.background ?? component.color}
onChange={(background) => onPatch({ background })}
/>
</FieldRow>
<FieldRow label={t("resources.col.hatch")}>
<SelectField
value={component.hatchId}
onChange={(hatchId) => onPatch({ hatchId })}
options={hatchOptions}
/>
</FieldRow>
<FieldRow label={t("resources.col.viewHatch")}>
<SelectField
value={component.viewHatchId ?? ""}
onChange={(viewHatchId) => onPatch({ viewHatchId: viewHatchId || undefined })}
options={viewHatchOptions}
/>
</FieldRow>
<FieldRow label={t("resources.col.prio")} hint={t("resources.col.prio.hint")}>
<NumberField
value={component.joinPriority}
onChange={(joinPriority) => onPatch({ joinPriority })}
step={10}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.col.texture")}>
<TextField
value={component.texture3d ?? ""}
onChange={(v) => onPatch({ texture3d: v || undefined })}
placeholder={t("resources.texture.empty")}
mono
/>
</FieldRow>
<FieldRow label={t("resources.col.material")}>
<MaterialCell
material={component.material}
onAssign={(material) => onPatch({ material })}
onClear={() => onPatch({ material: undefined })}
/>
</FieldRow>
</div>
</div>
);
}
function ComponentsTab({
project,
onPatchComponent,
onAddComponent,
onDeleteComponent,
}: {
project: Project;
onPatchComponent: (id: string, patch: Partial<Component>) => void;
onAddComponent: () => void;
onDeleteComponent: (id: string) => void;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const components = project.components;
// Auswahl gegen die Liste validieren (nach Delete/Add stabil bleiben).
const selected = components.find((c) => c.id === selectedId) ?? components[0];
return (
<div className="res-tab">
<div className="res-hint">{t("resources.components.hint")}</div>
<div className="res-md">
<div className="res-md-list">
{components.map((c) => {
const hatch = project.hatches.find((h) => h.id === c.hatchId);
return (
<button
key={c.id}
className={`res-md-row${selected?.id === c.id ? " active" : ""}`}
onClick={() => setSelectedId(c.id)}
>
<span className="res-md-row-thumb">
{c.material ? (
<ComponentMaterialSphere material={c.material} size={30} />
) : hatch ? (
<HatchSwatch hatch={hatch} size={30} />
) : (
<ColorSwatch color={c.foreground ?? c.color} />
)}
</span>
<span className="res-md-row-name">{c.name}</span>
</button>
);
})}
{components.length === 0 && (
<div className="res-md-empty">{t("resources.components.empty")}</div>
)}
<div className="res-md-listfoot">
<button className="res-add" onClick={onAddComponent}>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
</div>
</div>
<div className="res-md-detail">
{selected ? (
<ComponentDetail
key={selected.id}
component={selected}
hatches={project.hatches}
onPatch={(patch) => onPatchComponent(selected.id, patch)}
onDelete={() => onDeleteComponent(selected.id)}
/>
) : (
<div className="res-md-empty">{t("resources.components.empty")}</div>
)}
</div>
</div>
</div>
);
}
// ── Schraffuren (Hatches) ──────────────────────────────────────────────────
/** Detail-Panel EINER Schraffur (rechte Seite des Master-Detail-Layouts). */
function HatchDetail({
hatch,
lineStyles,
onPatch,
onDelete,
}: {
hatch: HatchStyle;
lineStyles: LineStyle[];
onPatch: (patch: Partial<HatchStyle>) => void;
onDelete: () => void;
}) {
const NONE = "__none__";
const lineOptions = [
{ value: NONE, label: t("resources.lineStyle.none") },
...lineStyles.map((l) => ({ value: l.id, label: l.name })),
];
const kind = hatch.kind ?? "vector";
const lines = hatch.lines ?? "parallel";
/** Wechselt den Typ; beim Umschalten auf „Bild" Default-Bildparameter setzen. */
const setKind = (k: "vector" | "image") => {
if (k === "image" && !hatch.image) {
onPatch({ kind: k, image: { src: "", scaleX: 1, scaleY: 1, rotation: 0 } });
} else {
onPatch({ kind: k });
}
};
/** Merge-Patch für die Bild-Parameter (behält bestehende Werte). */
const patchImage = (part: Partial<NonNullable<HatchStyle["image"]>>) => {
const cur = hatch.image ?? { src: "", scaleX: 1, scaleY: 1, rotation: 0 };
onPatch({ image: { ...cur, ...part } });
};
/** Datei → Data-URL (readAsDataURL) → image.src. */
const loadImage = (file: File | null) => {
if (!file) return;
const r = new FileReader();
r.onload = () => patchImage({ src: String(r.result ?? "") });
r.readAsDataURL(file);
};
// ── Random-Vektor-Schraffur (Kies/Splitt) ───────────────────────────────────
// Dichte + Längenspanne + „Neu würfeln". Der Seed wird beim Klick gesetzt (KEIN
// Math.random() zur Renderzeit — die Streuung selbst ist deterministisch per
// Seed). Länge min/max in mm; bis der Nutzer sie berührt, bleibt der Default.
const lenMin = hatch.lengthMin ?? 1;
const lenMax = hatch.lengthMax ?? 3;
const reroll = () => onPatch({ seed: Math.floor(Math.random() * 0x7fffffff) });
return (
<div className="res-md-detail-inner">
<div className="res-md-head">
<input
className="res-input res-md-rename"
value={hatch.name}
placeholder={t("resources.hatches.placeholder")}
onChange={(e) => onPatch({ name: e.target.value })}
/>
<DeleteButton
onClick={onDelete}
title={t("resources.delete.hatch", { name: hatch.name })}
/>
</div>
<div className="res-md-preview">
<HatchSwatch hatch={hatch} size={128} />
</div>
<div className="res-fields">
<FieldRow label={t("resources.field.type")}>
<Segmented
value={kind}
onChange={setKind}
options={[
{ value: "vector", label: t("resources.hatchKind.vector") },
{ value: "image", label: t("resources.hatchKind.image") },
]}
/>
</FieldRow>
{kind === "vector" ? (
<>
<FieldRow label={t("resources.field.lines")}>
<Segmented
value={lines}
onChange={(v) => onPatch({ lines: v })}
options={[
{ value: "parallel", label: t("resources.lines.parallel") },
{ value: "random", label: t("resources.lines.random") },
]}
/>
</FieldRow>
<FieldRow label={t("resources.col.pattern")}>
<SelectField
value={hatch.pattern}
onChange={(pattern) => onPatch({ pattern })}
options={PATTERN_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
/>
</FieldRow>
<FieldRow label={t("resources.col.scale")}>
<NumberField
value={hatch.scale}
onChange={(scale) => onPatch({ scale })}
step={0.1}
min={0.01}
/>
</FieldRow>
<FieldRow label={t("resources.col.angle")}>
<NumberField
value={hatch.angle}
onChange={(angle) => onPatch({ angle })}
step={5}
/>
</FieldRow>
<FieldRow
label={t("resources.col.relativeToWall")}
hint={t("resources.col.relativeToWall.hint")}
>
<input
type="checkbox"
checked={!!hatch.relativeToWall}
onChange={(e) => onPatch({ relativeToWall: e.target.checked })}
/>
</FieldRow>
<FieldRow label={t("resources.col.lineStyle")}>
<SelectField
value={hatch.lineStyleId ?? NONE}
onChange={(v) => onPatch({ lineStyleId: v === NONE ? undefined : v })}
options={lineOptions}
/>
</FieldRow>
{lines === "random" && (
<>
<FieldRow label={t("resources.field.density")}>
<NumberField
value={hatch.density ?? 1}
onChange={(density) => onPatch({ density })}
step={0.1}
min={0.1}
/>
</FieldRow>
<FieldRow label={t("resources.field.lengthMin")}>
<NumberField
value={lenMin}
onChange={(v) => onPatch({ lengthMin: v, lengthMax: hatch.lengthMax ?? lenMax })}
step={0.5}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.lengthMax")}>
<NumberField
value={lenMax}
onChange={(v) => onPatch({ lengthMax: v, lengthMin: hatch.lengthMin ?? lenMin })}
step={0.5}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.random.reroll")}>
<button type="button" className="res-seg-btn" onClick={reroll}>
{t("resources.random.reroll")}
</button>
</FieldRow>
</>
)}
</>
) : (
<>
<FieldRow label={t("resources.field.image")}>
<ImagePickButton onFile={loadImage} />
</FieldRow>
<FieldRow label={t("resources.field.scaleX")}>
<NumberField
value={hatch.image?.scaleX ?? 1}
onChange={(scaleX) => patchImage({ scaleX })}
step={0.1}
min={0.05}
/>
</FieldRow>
<FieldRow label={t("resources.field.scaleY")}>
<NumberField
value={hatch.image?.scaleY ?? 1}
onChange={(scaleY) => patchImage({ scaleY })}
step={0.1}
min={0.05}
/>
</FieldRow>
<FieldRow label={t("resources.field.rotation")}>
<NumberField
value={hatch.image?.rotation ?? 0}
onChange={(rotation) => patchImage({ rotation })}
step={5}
/>
</FieldRow>
</>
)}
</div>
</div>
);
}
function HatchesTab({
project,
onPatchHatch,
onAddHatch,
onDeleteHatch,
onImportHatches,
}: {
project: Project;
onPatchHatch: (id: string, patch: Partial<HatchStyle>) => void;
onAddHatch: () => void;
onDeleteHatch: (id: string) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const hatches = project.hatches;
// Auswahl gegen die Liste validieren (nach Delete/Add stabil bleiben).
const selected = hatches.find((h) => h.id === selectedId) ?? hatches[0];
return (
<div className="res-tab">
<div className="res-hint">{t("resources.hatches.hint")}</div>
<div className="res-md">
<div className="res-md-list">
{hatches.map((h) => (
<button
key={h.id}
className={`res-md-row${selected?.id === h.id ? " active" : ""}`}
onClick={() => setSelectedId(h.id)}
>
<span className="res-md-row-thumb">
<HatchSwatch hatch={h} size={30} />
</span>
<span className="res-md-row-name">{h.name}</span>
</button>
))}
{hatches.length === 0 && (
<div className="res-md-empty">{t("resources.hatches.empty")}</div>
)}
<div className="res-md-listfoot">
<button className="res-add" onClick={onAddHatch}>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
<FileImportButton
accept=".pat,text/plain"
label={t("resources.import.pat")}
onText={(text) => onImportHatches(patToHatches(text))}
/>
</div>
</div>
<div className="res-md-detail">
{selected ? (
<HatchDetail
key={selected.id}
hatch={selected}
lineStyles={project.lineStyles}
onPatch={(patch) => onPatchHatch(selected.id, patch)}
onDelete={() => onDeleteHatch(selected.id)}
/>
) : (
<div className="res-md-empty">{t("resources.hatches.empty")}</div>
)}
</div>
</div>
</div>
);
}
// ── Linien (Line Styles) ───────────────────────────────────────────────────
/** Detail-Panel EINES Linienstils (rechte Seite des Master-Detail-Layouts). */
/**
* Default-Motiv beim Umschalten auf „Custom": eine Dreieckszelle (0..4 mm entlang,
* ±1.5 mm quer), die entlang der Linie loopt. Der Nutzer passt sie im MotifEditor an.
*/
const DEFAULT_MOTIF: NonNullable<LineStyle["motif"]> = {
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1.5 },
{ x: 2, y: 0 },
{ x: 3, y: -1.5 },
{ x: 4, y: 0 },
],
length: 4,
};
function LineStyleDetail({
style,
onPatch,
onDelete,
}: {
style: LineStyle;
onPatch: (patch: Partial<LineStyle>) => void;
onDelete: () => void;
}) {
const kind = style.kind ?? "dash";
/**
* Wechselt den Typ; beim Umschalten auf „Zickzack"/„Custom" Default-Parameter
* setzen, falls noch keine vorhanden.
*/
const setKind = (k: "dash" | "zigzag" | "custom") => {
if (k === "zigzag" && !style.zigzag) {
onPatch({ kind: k, zigzag: { amplitude: 1, wavelength: 4 } });
} else if (k === "custom" && !style.motif) {
onPatch({ kind: k, motif: DEFAULT_MOTIF });
} else {
onPatch({ kind: k });
}
};
/** Merge-Patch für die Zickzack-Parameter. */
const patchZig = (part: Partial<NonNullable<LineStyle["zigzag"]>>) => {
const cur = style.zigzag ?? { amplitude: 1, wavelength: 4 };
onPatch({ zigzag: { ...cur, ...part } });
};
// ── Modulares Segment-System (dash) ─────────────────────────────────────────
// Eine musterbasierte Linie ist eine geordnete, loopende Folge aus Segmenten
// (Strich / Punkt / Lücke). Die Folge wird aus `dash` abgeleitet und beim
// Editieren via `segmentsToDash` wieder nach `dash` geschrieben — `dash` bleibt
// die einzige Datenbasis. „Volllinie" = `dash: null`. Die Dicke gehört NICHT
// mehr hierher (sie wird per Element-Attribut aufgelöst; By-Object/By-Layer folgt).
const dash = style.dash;
const isSolid = dash === null;
const segments = dashToSegments(dash);
const preset = presetOfDash(dash);
const applyPreset = (p: LinePresetKey) => {
if (p === "custom") return;
onPatch({ dash: LINE_PRESETS[p] });
};
const commitSegments = (next: LineSegment[]) => onPatch({ dash: segmentsToDash(next) });
const setSegType = (i: number, type: LineSegmentType) => {
const next = segments.slice();
const cur = next[i];
next[i] = { type, length: type === "dot" ? 0 : cur.length > 0 ? cur.length : 2 };
commitSegments(next);
};
const setSegLen = (i: number, v: number) => {
const next = segments.slice();
next[i] = { ...next[i], length: Math.max(0, v) };
commitSegments(next);
};
const addSegOf = (type: LineSegmentType) =>
commitSegments([...segments, { type, length: type === "dot" ? 0 : 2 }]);
const removeSeg = (i: number) => commitSegments(segments.filter((_, k) => k !== i));
return (
<div className="res-md-detail-inner">
<div className="res-md-head">
<input
className="res-input res-md-rename"
value={style.name}
placeholder={t("resources.lines.placeholder")}
onChange={(e) => onPatch({ name: e.target.value })}
/>
<DeleteButton
onClick={onDelete}
title={t("resources.delete.lineStyle", { name: style.name })}
/>
</div>
<div className="res-md-preview">
<LineSwatch style={style} width={220} height={40} />
</div>
<div className="res-fields">
<FieldRow label={t("resources.field.type")}>
<Segmented
value={kind}
onChange={setKind}
options={[
{ value: "dash", label: t("resources.lineKind.dash") },
{ value: "zigzag", label: t("resources.lineKind.zigzag") },
{ value: "custom", label: t("resources.lineKind.custom") },
]}
/>
</FieldRow>
<FieldRow label={t("resources.col.color")}>
<ColorField color={style.color} onChange={(color) => onPatch({ color })} />
</FieldRow>
{kind === "zigzag" ? (
<>
<FieldRow label={t("resources.field.amplitude")}>
<NumberField
value={style.zigzag?.amplitude ?? 1}
onChange={(amplitude) => patchZig({ amplitude })}
step={0.1}
min={0.05}
/>
</FieldRow>
<FieldRow label={t("resources.field.wavelength")}>
<NumberField
value={style.zigzag?.wavelength ?? 4}
onChange={(wavelength) => patchZig({ wavelength })}
step={0.5}
min={0.5}
/>
</FieldRow>
</>
) : kind === "custom" ? (
<FieldRow label={t("resources.field.motif")}>
<MotifEditor
points={style.motif?.points ?? DEFAULT_MOTIF.points}
length={style.motif?.length ?? DEFAULT_MOTIF.length}
onChange={(points, length) => onPatch({ motif: { points, length } })}
/>
</FieldRow>
) : (
<>
{/* Schnell-Presets: Volllinie / Strichlinie / Punktlinie / Strich-Punkt. */}
<FieldRow label={t("resources.linePreset.label")}>
<Segmented<string>
value={preset}
onChange={(p) => applyPreset(p as LinePresetKey)}
options={[
{ value: "solid", label: t("resources.linePreset.solid") },
{ value: "dash", label: t("resources.linePreset.dash") },
{ value: "dot", label: t("resources.linePreset.dot") },
{ value: "dashDot", label: t("resources.linePreset.dashDot") },
]}
/>
</FieldRow>
{!isSolid && (
<FieldRow
label={t("resources.dash.segments")}
hint={t("resources.dash.hint")}
>
<div className="line-seg-editor">
{segments.map((seg, i) => (
<div className="line-seg-row" key={i}>
<SelectField
value={seg.type}
onChange={(type) => setSegType(i, type)}
options={[
{ value: "dash", label: t("resources.seg.dash") },
{ value: "dot", label: t("resources.seg.dot") },
{ value: "gap", label: t("resources.seg.gap") },
]}
/>
<span className="line-seg-num">
{seg.type === "dot" ? (
<span className="line-seg-dotlabel">0</span>
) : (
<NumberField
value={seg.length}
onChange={(x) => setSegLen(i, x)}
step={0.5}
min={0}
/>
)}
</span>
<button
type="button"
className="res-seg-btn"
title={t("resources.dash.remove")}
onClick={() => removeSeg(i)}
style={{ padding: "2px 6px", lineHeight: 1 }}
>
×
</button>
</div>
))}
<div className="line-seg-add">
<button
type="button"
className="res-seg-btn"
onClick={() => addSegOf("dash")}
style={{ padding: "2px 8px", lineHeight: 1 }}
>
{t("resources.seg.addDash")}
</button>
<button
type="button"
className="res-seg-btn"
onClick={() => addSegOf("dot")}
style={{ padding: "2px 8px", lineHeight: 1 }}
>
{t("resources.seg.addDot")}
</button>
<button
type="button"
className="res-seg-btn"
onClick={() => addSegOf("gap")}
style={{ padding: "2px 8px", lineHeight: 1 }}
>
{t("resources.seg.addGap")}
</button>
</div>
</div>
</FieldRow>
)}
</>
)}
</div>
</div>
);
}
function LinesTab({
project,
onPatchLineStyle,
onAddLineStyle,
onDeleteLineStyle,
onImportLineStyles,
}: {
project: Project;
onPatchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
onAddLineStyle: () => void;
onDeleteLineStyle: (id: string) => void;
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const styles = project.lineStyles;
const selected = styles.find((l) => l.id === selectedId) ?? styles[0];
return (
<div className="res-tab">
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */}
<datalist id="pen-weights">
{PEN_WEIGHTS.map((w) => (
<option key={w} value={w} />
))}
</datalist>
<div className="res-hint">{t("resources.lines.hint")}</div>
<div className="res-md">
<div className="res-md-list">
{styles.map((l) => (
<button
key={l.id}
className={`res-md-row res-md-row-line${
selected?.id === l.id ? " active" : ""
}`}
onClick={() => setSelectedId(l.id)}
>
<span className="res-md-row-name">{l.name}</span>
<LineSwatch style={l} width={150} height={14} />
</button>
))}
{styles.length === 0 && (
<div className="res-md-empty">{t("resources.lines.empty")}</div>
)}
<div className="res-md-listfoot">
<button className="res-add" onClick={onAddLineStyle}>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
<FileImportButton
accept=".lin,text/plain"
label={t("resources.import.lin")}
onText={(text) => onImportLineStyles(linToStyles(text))}
/>
</div>
</div>
<div className="res-md-detail">
{selected ? (
<LineStyleDetail
key={selected.id}
style={selected}
onPatch={(patch) => onPatchLineStyle(selected.id, patch)}
onDelete={() => onDeleteLineStyle(selected.id)}
/>
) : (
<div className="res-md-empty">{t("resources.lines.empty")}</div>
)}
</div>
</div>
</div>
);
}
// ── Wandstile & Deckenstile (geschichteter Aufbau) ─────────────────────────
// Beide Ressourcen teilen Struktur (WallType/CeilingType = {id,name,layers[]})
// und Editor: ein Master-Detail-Layout wie bei Schraffuren/Linien. Liste links
// = je Typ eine Zeile mit kleiner Querschnitt-Vorschau + Name; Detail rechts =
// Aufbau-Editor (Schichten stapeln: Bauteil/Dicke/Fugenlinie, hinzufügen/
// entfernen/umordnen) + großer Live-Querschnitt. Ein gemeinsames Detail-Panel
// {@link LayeredStyleDetail}, parametrisiert über die Orientierung (Wand:
// Bänder nebeneinander / Decke: liegend gestapelt) und die jeweiligen Handler.
//
// Fugen: `layer[i].jointLineStyleId` ist der Linienstil der INNEREN Kante der
// Schicht i (Fuge zur nächst-inneren Schicht). Die innerste Schicht hat keine
// innere Fuge (ihre Kante ist der Innenumriss) → dort kein Dropdown.
/** Wand- und Deckentyp teilen dieselbe Form (`{id,name,layers}`). */
type LayeredStyle = WallType | CeilingType;
/**
* Baut die Querschnitt-Bänder (eine je Schicht) aus den Bauteil-Schnitt-
* schraffuren: Schicht → Bauteil → dessen `hatchId` → HatchStyle. Fehlt das
* Bauteil/die Schraffur, bleibt das Band leer (weiß).
*/
function swatchLayersOf(style: LayeredStyle, project: Project): SwatchLayer[] {
return style.layers.map((l) => {
const comp = project.components.find((c) => c.id === l.componentId);
const hatch = comp
? project.hatches.find((hh) => hh.id === comp.hatchId)
: undefined;
return { hatch, thickness: l.thickness };
});
}
/** Detail-Panel EINES Wand-/Deckentyps (rechte Seite des Master-Detail-Layouts). */
function LayeredStyleDetail({
style,
project,
orientation,
onPatch,
onDelete,
deleteTitle,
namePlaceholder,
}: {
style: LayeredStyle;
project: Project;
orientation: "wall" | "ceiling";
onPatch: (patch: Partial<LayeredStyle>) => void;
onDelete: () => void;
deleteTitle: string;
namePlaceholder: string;
}) {
// Sentinel „— (Haarlinie)": kein Feld gesetzt → Standard-Haarlinie 0.02 mm.
const DEFAULT = "__default__";
const jointOptions = [
{ value: DEFAULT, label: t("resources.joint.default") },
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
];
const compOptions =
project.components.length > 0
? project.components.map((c) => ({ value: c.id, label: c.name }))
: [{ value: "", label: "—" }];
const layers = style.layers;
const setLayers = (next: Layer[]) => onPatch({ layers: next });
const patchLayer = (i: number, patch: Partial<Layer>) =>
setLayers(layers.map((l, k) => (k === i ? { ...l, ...patch } : l)));
const addLayer = () =>
setLayers([
...layers,
{ componentId: project.components[0]?.id ?? "", thickness: 0.1 },
]);
const removeLayer = (i: number) => {
if (layers.length <= 1) return;
setLayers(layers.filter((_, k) => k !== i));
};
const moveLayer = (i: number, dir: -1 | 1) => {
const j = i + dir;
if (j < 0 || j >= layers.length) return;
const next = layers.slice();
[next[i], next[j]] = [next[j], next[i]];
setLayers(next);
};
return (
<div className="res-md-detail-inner">
<div className="res-md-head">
<input
className="res-input res-md-rename"
value={style.name}
placeholder={namePlaceholder}
onChange={(e) => onPatch({ name: e.target.value })}
/>
<DeleteButton onClick={onDelete} title={deleteTitle} />
</div>
<div className="res-md-preview">
<WallTypeSwatch
layers={swatchLayersOf(style, project)}
width={orientation === "wall" ? 240 : 132}
height={orientation === "wall" ? 96 : 132}
orientation={orientation}
/>
</div>
<div className="res-layers">
{layers.map((layer, i) => {
const isInner = i === layers.length - 1;
return (
<div key={i} className="res-layer">
<div className="res-layer-move">
<button
type="button"
className="res-layer-btn"
title={t("resources.layer.moveUp")}
disabled={i === 0}
onClick={() => moveLayer(i, -1)}
>
</button>
<button
type="button"
className="res-layer-btn"
title={t("resources.layer.moveDown")}
disabled={isInner}
onClick={() => moveLayer(i, 1)}
>
</button>
</div>
<div className="res-layer-fields">
<FieldRow label={t("resources.col.component")}>
<SelectField
value={layer.componentId}
onChange={(componentId) => patchLayer(i, { componentId })}
options={compOptions}
/>
</FieldRow>
<FieldRow label={t("resources.col.thickness")}>
<NumberField
value={Math.round(layer.thickness * 1000)}
onChange={(mm) =>
patchLayer(i, { thickness: Math.max(0, mm) / 1000 })
}
step={5}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.col.joint")}>
{isInner ? (
<span className="res-joint-none"></span>
) : (
<SelectField
value={layer.jointLineStyleId ?? DEFAULT}
onChange={(v) =>
patchLayer(i, {
jointLineStyleId: v === DEFAULT ? undefined : v,
})
}
options={jointOptions}
/>
)}
</FieldRow>
</div>
<DeleteButton
onClick={() => removeLayer(i)}
title={t("resources.layer.remove")}
/>
</div>
);
})}
<button type="button" className="res-add" onClick={addLayer}>
<span className="res-add-plus">+</span> {t("resources.layer.add")}
</button>
</div>
</div>
);
}
/**
* Gemeinsamer Master-Detail-Tab-Rumpf für Wand-/Deckenstile: Liste links
* (Querschnitt-Vorschau + Name, -Neu in der Fusszeile), Detail rechts über
* {@link LayeredStyleDetail}. Parametrisiert über die Ressource + Orientierung.
*/
function LayeredStylesTab({
project,
types,
orientation,
hintKey,
emptyKey,
placeholderKey,
deleteKeyPrefix,
onPatch,
onAdd,
onDelete,
}: {
project: Project;
types: LayeredStyle[];
orientation: "wall" | "ceiling";
hintKey: string;
emptyKey: string;
placeholderKey: string;
deleteKeyPrefix: string;
onPatch: (id: string, patch: Partial<LayeredStyle>) => void;
onAdd: () => void;
onDelete: (id: string) => void;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = types.find((s) => s.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((s) => (
<button
key={s.id}
className={`res-md-row${selected?.id === s.id ? " active" : ""}`}
onClick={() => setSelectedId(s.id)}
>
<span className="res-md-row-thumb">
<WallTypeSwatch
layers={swatchLayersOf(s, project)}
width={46}
height={28}
orientation={orientation}
/>
</span>
<span className="res-md-row-name">{s.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 ? (
<LayeredStyleDetail
key={selected.id}
style={selected}
project={project}
orientation={orientation}
onPatch={(patch) => onPatch(selected.id, patch)}
onDelete={() => onDelete(selected.id)}
deleteTitle={t(deleteKeyPrefix, { name: selected.name })}
namePlaceholder={t(placeholderKey)}
/>
) : (
<div className="res-md-empty">{t(emptyKey)}</div>
)}
</div>
</div>
</div>
);
}
function WallStylesTab({
project,
onPatchWallType,
onAddWallType,
onDeleteWallType,
}: {
project: Project;
onPatchWallType: (id: string, patch: Partial<WallType>) => void;
onAddWallType: () => void;
onDeleteWallType: (id: string) => void;
}) {
return (
<LayeredStylesTab
project={project}
types={project.wallTypes}
orientation="wall"
hintKey="resources.wallStyles.hint"
emptyKey="resources.wallStyles.empty"
placeholderKey="resources.wallStyles.placeholder"
deleteKeyPrefix="resources.delete.wallType"
onPatch={onPatchWallType}
onAdd={onAddWallType}
onDelete={onDeleteWallType}
/>
);
}
/**
* Tab „Deckenstile" — das liegende Gegenstück zu {@link WallStylesTab}: nutzt
* denselben {@link LayeredStylesTab}-Rumpf, nur die Ressource (`ceilingTypes`),
* die Orientierung (gestapelt) und die Handler unterscheiden sich.
*/
function CeilingStylesTab({
project,
onPatchCeilingType,
onAddCeilingType,
onDeleteCeilingType,
}: {
project: Project;
onPatchCeilingType: (id: string, patch: Partial<CeilingType>) => void;
onAddCeilingType: () => void;
onDeleteCeilingType: (id: string) => void;
}) {
return (
<LayeredStylesTab
project={project}
types={project.ceilingTypes ?? []}
orientation="ceiling"
hintKey="resources.ceilingStyles.hint"
emptyKey="resources.ceilingStyles.empty"
placeholderKey="resources.ceilingStyles.placeholder"
deleteKeyPrefix="resources.delete.ceilingType"
onPatch={onPatchCeilingType}
onAdd={onAddCeilingType}
onDelete={onDeleteCeilingType}
/>
);
}
/**
* Datei-Import-Schaltfläche (verstecktes <input type=file>). Liest die gewählte
* Datei als Text und reicht ihn nach oben. Für .lin/.pat-Import.
*/
function FileImportButton({
accept,
label,
onText,
}: {
accept: string;
label: string;
onText: (text: string) => void;
}) {
const ref = useRef<HTMLInputElement | null>(null);
return (
<>
<button className="res-add" onClick={() => ref.current?.click()}>
{label}
</button>
<input
ref={ref}
type="file"
accept={accept}
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) {
const r = new FileReader();
r.onload = () => onText(String(r.result ?? ""));
r.readAsText(f);
}
e.target.value = "";
}}
/>
</>
);
}
/** .lin-Text → id-lose Linienstile (schwarze Haarlinie 0.18 als Default). */
function linToStyles(text: string): Omit<LineStyle, "id">[] {
return parseLin(text).map((p) => ({
name: p.name,
weight: 0.18,
color: "#1a1a1a",
dash: p.dash,
}));
}
/**
* .pat-Text → id-lose Schraffuren. Unser Modell kennt nur feste Muster; daher
* approximieren wir: ≥2 Linienfamilien → Kreuzschraffur, sonst Diagonal, mit dem
* Winkel der ersten Familie. (Vollwertiges custom-Pattern später.)
*/
function patToHatches(text: string): Omit<HatchStyle, "id">[] {
return parsePat(text).map((p) => ({
name: p.name,
pattern: (p.families.length >= 2 ? "crosshatch" : "diagonal") as HatchPattern,
scale: 1,
angle: p.families[0]?.angle ?? 45,
color: "#1a1a1a",
// Kanonische Volllinie 0.13 (früher "hatch-line", zusammengeführt auf "thin").
lineStyleId: "thin",
}));
}
// ── 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 DOOR_FRAME_KIND_OPTIONS: { value: NonNullable<DoorType["frameKind"]>; labelKey: string }[] = [
{ value: "zarge", labelKey: "resources.frameKind.zarge" },
{ value: "blockrahmen", labelKey: "resources.frameKind.blockrahmen" },
];
// Bezugsfläche des Schichteinzugs — von aussen oder innen gemessen. Für Tür UND
// Fenster genutzt (gleiche Semantik).
const INSET_FACE_OPTIONS: { value: "aussen" | "innen"; labelKey: string }[] = [
{ value: "aussen", labelKey: "resources.insetFace.aussen" },
{ value: "innen", labelKey: "resources.insetFace.innen" },
];
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.frameKind")}>
<SelectField
value={door.frameKind ?? "zarge"}
onChange={(frameKind) => patch({ frameKind })}
options={DOOR_FRAME_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.frameWidth")}>
<NumberField
value={door.frameWidth ?? 0.06}
onChange={(frameWidth) => patch({ frameWidth })}
step={0.005}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.insetFromFace")}>
<NumberField
value={door.insetFromFace ?? 0}
onChange={(insetFromFace) => patch({ insetFromFace })}
step={0.01}
min={0}
/>
</FieldRow>
{(door.insetFromFace ?? 0) > 0 && (
<FieldRow label={t("resources.field.insetFace")}>
<SelectField
value={door.insetFace ?? "aussen"}
onChange={(insetFace) => patch({ insetFace })}
options={INSET_FACE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
)}
<FieldRow label={t("resources.field.transomHeight")}>
<NumberField
value={door.transomHeight ?? 0}
onChange={(transomHeight) => patch({ transomHeight })}
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.mullionRows")}>
<NumberField
value={win.mullionRows ?? 1}
onChange={(mullionRows) => patch({ mullionRows: Math.max(1, Math.round(mullionRows)) })}
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.frameWidth")}>
<NumberField
value={win.frameWidth ?? 0.06}
onChange={(frameWidth) => patch({ frameWidth })}
step={0.005}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.insetFromFace")}>
<NumberField
value={win.insetFromFace ?? 0}
onChange={(insetFromFace) => patch({ insetFromFace })}
step={0.01}
min={0}
/>
</FieldRow>
{(win.insetFromFace ?? 0) > 0 && (
<FieldRow label={t("resources.field.insetFace")}>
<SelectField
value={win.insetFace ?? "aussen"}
onChange={(insetFace) => patch({ insetFace })}
options={INSET_FACE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
)}
<FieldRow label={t("resources.field.transomHeight")}>
<NumberField
value={win.transomHeight ?? 0}
onChange={(transomHeight) => patch({ transomHeight })}
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
// als Badge, Suche + Kategorie-Chips kombinierbar. Die Zuweisung an ein
// konkretes Bauteil bleibt der bestehende Weg (Spalte „Material" der Bauteil-
// Tabelle → MaterialPicker); dieser Tab dient dem Durchsuchen/Vergleichen der
// Bibliothek, Klick auf eine Kachel markiert sie (aktiv, wie `mat-tile.active`
// im Picker) und zeigt Name/Kategorie in der Statuszeile.
/**
* Alle in der Bibliothek vorkommenden Kategorien, alphabetisch. Das Manifest
* schreibt Kategorien uneinheitlich („PavingStones" neben „Paving Stones") —
* für Chips und Filter zählt der normalisierte Schlüssel (ohne Leerzeichen,
* case-insensitiv); angezeigt wird die zuerst gesehene Schreibweise.
*/
const categoryKey = (c: string) => c.replace(/\s+/g, "").toLowerCase();
const MATERIAL_CATEGORIES: string[] = (() => {
const byKey = new Map<string, string>();
for (const a of MATERIAL_LIBRARY) {
const k = categoryKey(a.category);
if (!byKey.has(k)) byKey.set(k, a.category);
}
return Array.from(byKey.values()).sort((a, b) => a.localeCompare(b));
})();
/**
* Eine Bibliotheks-Kachel: rendert erst eine PBR-Kugel-Vorschau, sobald sie
* tatsächlich sichtbar ist (IntersectionObserver) — bei ~90 Materialien sonst
* unnötige GPU-Arbeit für Kacheln außerhalb des Sichtfensters. Der eigentliche
* Render läuft ohnehin seriell über einen einzigen geteilten WebGL-Kontext
* (spherePreview.ts), die Sichtbarkeitsprüfung drosselt zusätzlich die
* Warteschlange auf das gerade Interessante.
*/
function MaterialLibraryTile({
asset,
active,
onSelect,
}: {
asset: MaterialAsset;
active: boolean;
onSelect: () => void;
}) {
const ref = useRef<HTMLButtonElement | 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 (
<button
ref={ref}
className={`mat-tile${active ? " active" : ""}`}
title={`${asset.name}${asset.category}`}
onClick={onSelect}
>
<span className="mat-sphere">
{previewUrl && <img src={previewUrl} alt="" draggable={false} />}
</span>
<span className="mat-tile-name">{asset.name}</span>
<span className="mat-tile-badge">{asset.category}</span>
</button>
);
}
function MaterialsTab() {
const [query, setQuery] = useState("");
const [category, setCategory] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const filtered = MATERIAL_LIBRARY.filter((asset) => {
if (category && categoryKey(asset.category) !== categoryKey(category)) return false;
const q = query.trim().toLowerCase();
if (q && !asset.name.toLowerCase().includes(q) && !asset.id.toLowerCase().includes(q)) {
return false;
}
return true;
});
const selected = MATERIAL_LIBRARY.find((a) => a.id === selectedId);
return (
<div className="res-tab">
<div className="res-hint">{t("resources.materials.hint")}</div>
<div className="mat-lib-filterbar">
<input
type="text"
className="res-input mat-search"
value={query}
placeholder={t("resources.materials.searchPlaceholder")}
onChange={(e) => setQuery(e.target.value)}
/>
<div
className="mat-lib-chips"
role="group"
aria-label={t("resources.materials.categoryLabel")}
>
<button
className={`mat-chip${category === null ? " active" : ""}`}
onClick={() => setCategory(null)}
>
{t("resources.materials.categoryAll")}
</button>
{MATERIAL_CATEGORIES.map((c) => (
<button
key={c}
className={`mat-chip${category === c ? " active" : ""}`}
onClick={() => setCategory((cur) => (cur === c ? null : c))}
>
{c}
</button>
))}
</div>
</div>
{selected && (
<div className="mat-lib-selected">
{t("resources.materials.selected", {
name: selected.name,
category: selected.category,
})}
</div>
)}
<div className="mat-grid mat-lib-grid">
{filtered.map((asset) => (
<MaterialLibraryTile
key={asset.id}
asset={asset}
active={selectedId === asset.id}
onSelect={() => setSelectedId((cur) => (cur === asset.id ? null : asset.id))}
/>
))}
</div>
{filtered.length === 0 && <EmptyState text={t("resources.materials.empty")} />}
<div className="mat-browse-foot">
<span className="mat-browse-status">
{t("resources.materials.count", {
shown: filtered.length,
total: MATERIAL_LIBRARY.length,
})}
</span>
</div>
</div>
);
}
// ── 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 }) {
return <div className="res-empty">{text}</div>;
}
// ── Manager-Schublade ──────────────────────────────────────────────────────
export type TabId =
| "components"
| "hatches"
| "lines"
| "wallStyles"
| "ceilingStyles"
| "doorStyles"
| "windowStyles"
| "stairStyles"
| "materials"
| "overrides";
const TABS: { id: TabId; labelKey: string }[] = [
{ id: "components", labelKey: "resources.tab.components" },
{ id: "hatches", labelKey: "resources.tab.hatches" },
{ 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). */
export interface ResourceManagerHandlers {
onPatchComponent: (id: string, patch: Partial<Component>) => void;
onAddComponent: () => void;
onDeleteComponent: (id: string) => void;
onPatchHatch: (id: string, patch: Partial<HatchStyle>) => void;
onAddHatch: () => void;
onDeleteHatch: (id: string) => void;
onPatchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
onAddLineStyle: () => void;
onDeleteLineStyle: (id: string) => void;
/** Wandstile: immutable Änderung eines Wandtyps (z. B. Schichtfugen-Stile). */
onPatchWallType: (id: string, patch: Partial<WallType>) => void;
onAddWallType: () => void;
onDeleteWallType: (id: string) => void;
/** Deckenstile: immutable Änderung eines Deckentyps (z. B. Schichtfugen-Stile). */
onPatchCeilingType: (id: string, patch: Partial<CeilingType>) => void;
onAddCeilingType: () => void;
onDeleteCeilingType: (id: string) => void;
/** 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) ──────────────────────────────────────
// Der Ressourcen-Manager schwebt frei über dem Grundriss, OHNE abdunkelnden
// Hintergrund: der Plan bleibt bedienbar, damit Schraffur-/Linien-Änderungen
// live am Modell sichtbar sind. Position/Größe leben lokal im Fenster; die
// Kopfzeile (res-head) ist der Verschiebegriff, unten rechts sitzt ein
// Größen-Greifer — beide über POINTER-Events mit Pointer-Capture (dieselbe
// Technik wie FloatingPanel), damit die Geste auch außerhalb weiterläuft.
/** Standard- und Mindestgröße des schwebenden Ressourcen-Fensters (CSS-Pixel). */
const RES_DEFAULT_W = 760;
const RES_DEFAULT_H = 640;
const RES_MIN_W = 420;
const RES_MIN_H = 320;
/** Rechteck eines frei positionierten Fensters. */
interface WindowRect {
x: number;
y: number;
w: number;
h: number;
}
/** Sinnvolle Startlage: rechts oben unter der Oberleiste, in den Viewport geklemmt. */
function initialRect(): WindowRect {
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
const vh = typeof window !== "undefined" ? window.innerHeight : 800;
const w = Math.min(RES_DEFAULT_W, Math.round(vw * 0.9));
const h = Math.min(RES_DEFAULT_H, Math.round(vh * 0.85));
const x = Math.max(16, vw - w - 24);
const y = 64;
return { x, y, w, h };
}
/**
* Rechte Schublade mit mehreren Tabs (Bauteile / Schraffuren / Linien / …). Wird
* über den „Ressourcen"-Eintrag der Oberleiste geöffnet und mutiert das Projekt
* live über die gereichten Handler. Rendert als FREI SCHWEBENDES, nicht-modales
* Fenster (kein Backdrop) — verschiebbar über die Kopfzeile, größenveränderbar
* über den Greifer unten rechts.
*/
export function ResourceManager({
project,
onClose,
handlers,
initialTab,
}: {
project: Project;
onClose: () => void;
handlers: ResourceManagerHandlers;
/** Beim Öffnen (bzw. bei erneutem Sprung) direkt aktiver Tab, z. B. „doorStyles". */
initialTab?: TabId;
}) {
const [tab, setTab] = useState<TabId>(initialTab ?? "components");
// Erneuter Sprung aus einem Element heraus (z. B. „Typ bearbeiten" an einer
// Öffnung): auf den angeforderten Tab wechseln, auch wenn das Fenster schon offen ist.
useEffect(() => {
if (initialTab) setTab(initialTab);
}, [initialTab]);
const [rect, setRect] = useState<WindowRect>(initialRect);
// Verschieben über die Kopfzeile: Cursor linke/obere Kante festhalten und die
// Kante der Cursorbewegung folgen lassen (Pointer-Capture hält die Geste).
const onTitlePointerDown = (e: ReactPointerEvent<HTMLElement>) => {
// Klicks auf Knöpfe (Schließen) nicht als Verschiebe-Start werten.
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault();
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const dx = e.clientX - rect.x;
const dy = e.clientY - rect.y;
const move = (ev: PointerEvent) => {
setRect((r) => ({ ...r, x: ev.clientX - dx, y: ev.clientY - dy }));
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
};
// Größe ändern über den Greifer unten rechts (Deltas ab Startpunkt, Mindestmaß).
const onResizePointerDown = (e: ReactPointerEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startY = e.clientY;
const startW = rect.w;
const startH = rect.h;
const move = (ev: PointerEvent) => {
const w = Math.max(RES_MIN_W, Math.round(startW + (ev.clientX - startX)));
const h = Math.max(RES_MIN_H, Math.round(startH + (ev.clientY - startY)));
setRect((r) => ({ ...r, w, h }));
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
};
return (
<aside
className="res-window"
role="dialog"
aria-label={t("resources.label")}
style={{
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.w}px`,
height: `${rect.h}px`,
}}
>
<header className="res-head" onPointerDown={onTitlePointerDown}>
<div className="res-head-title">
<span className="res-head-icon">
<EyeIcon open />
</span>
{t("resources.title")}
</div>
<button
className="res-close"
onClick={onClose}
aria-label={t("resources.close")}
>
×
</button>
</header>
<nav className="res-tabs" role="tablist">
{TABS.map((tabDef) => (
<button
key={tabDef.id}
role="tab"
aria-selected={tab === tabDef.id}
className={`res-tab-btn${tab === tabDef.id ? " active" : ""}`}
onClick={() => setTab(tabDef.id)}
>
{t(tabDef.labelKey)}
</button>
))}
</nav>
<div className="res-body">
{tab === "components" && (
<ComponentsTab
project={project}
onPatchComponent={handlers.onPatchComponent}
onAddComponent={handlers.onAddComponent}
onDeleteComponent={handlers.onDeleteComponent}
/>
)}
{tab === "hatches" && (
<HatchesTab
project={project}
onPatchHatch={handlers.onPatchHatch}
onAddHatch={handlers.onAddHatch}
onDeleteHatch={handlers.onDeleteHatch}
onImportHatches={handlers.onImportHatches}
/>
)}
{tab === "lines" && (
<LinesTab
project={project}
onPatchLineStyle={handlers.onPatchLineStyle}
onAddLineStyle={handlers.onAddLineStyle}
onDeleteLineStyle={handlers.onDeleteLineStyle}
onImportLineStyles={handlers.onImportLineStyles}
/>
)}
{tab === "wallStyles" && (
<WallStylesTab
project={project}
onPatchWallType={handlers.onPatchWallType}
onAddWallType={handlers.onAddWallType}
onDeleteWallType={handlers.onDeleteWallType}
/>
)}
{tab === "ceilingStyles" && (
<CeilingStylesTab
project={project}
onPatchCeilingType={handlers.onPatchCeilingType}
onAddCeilingType={handlers.onAddCeilingType}
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). */}
<div
className="floating-resize res-resize"
role="separator"
aria-label={t("dock.resizePanel")}
title={t("dock.resizePanel")}
onPointerDown={onResizePointerDown}
/>
</aside>
);
}