// 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 ( 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 -ID mit Vorschlagswerten (z. B. Stiftstärken). */ list?: string; }) { return ( { const v = Number(e.target.value); if (!Number.isNaN(v)) onChange(v); }} /> ); } /** * Farb-Swatch mit nativem Color-Picker. Das
)} {/* Kachelgröße (m) für Schnellauswahl + Browse, plus Material entfernen. */} {mode !== "upload" && (
)} ); } /** * 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("1K"); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [offset, setOffset] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // ID des gerade herunterladenden Materials (Spinner auf der Kachel). const [downloadingId, setDownloadingId] = useState(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 (
{t("material.browse.hint")}
setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitSearch(); }} /> setCategory(v)} options={[ { value: "", label: t("material.browse.category.all") }, ...AMBIENT_CATEGORIES.map((c) => ({ value: c, label: c })), ]} /> setResolution(v as AmbientResolution)} options={[ { value: "1K", label: "1K" }, { value: "2K", label: "2K" }, { value: "4K", label: "4K" }, ]} />
{error &&
{error}
}
{items.map((it) => ( ))}
{!loading && items.length === 0 && !error && (
{t("material.browse.empty")}
)}
{loading && {t("material.browse.loading")}} {!loading && items.length > 0 && ( {t("material.browse.count", { shown: items.length, total })} )} {!loading && items.length < total && ( )}
); } /** Versteckter Bild-Datei-Picker mit sichtbarem Button. */ function ImagePickButton({ onFile }: { onFile: (file: File | null) => void }) { const ref = useRef(null); return ( <> { 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 ( ); } /** * 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(null); const [visible, setVisible] = useState(false); const [previewUrl, setPreviewUrl] = useState(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 ( {previewUrl && } ); } /** Detail-Panel EINES Bauteils (rechte Seite des Master-Detail-Layouts). */ function ComponentDetail({ component, hatches, onPatch, onDelete, }: { component: Component; hatches: HatchStyle[]; onPatch: (patch: Partial) => 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 (
onPatch({ name: e.target.value })} />
{component.material ? ( ) : previewHatch ? ( ) : ( )}
onPatch({ abbrev: v || undefined })} placeholder={t("resources.col.abbrev.placeholder")} /> {/* Vordergrund = Farbe der Muster-/Schraffurlinien. Fehlt sie, greift als Anzeige-Fallback `color`; das Setzen definiert `foreground`. */} onPatch({ foreground })} /> {/* Hintergrund = Füllung (Poché). Fehlt sie, gilt `color` als Fallback. */} onPatch({ background })} /> onPatch({ hatchId })} options={hatchOptions} /> onPatch({ viewHatchId: viewHatchId || undefined })} options={viewHatchOptions} /> onPatch({ joinPriority })} step={10} min={0} /> onPatch({ texture3d: v || undefined })} placeholder={t("resources.texture.empty")} mono /> onPatch({ material })} onClear={() => onPatch({ material: undefined })} />
); } function ComponentsTab({ project, onPatchComponent, onAddComponent, onDeleteComponent, }: { project: Project; onPatchComponent: (id: string, patch: Partial) => void; onAddComponent: () => void; onDeleteComponent: (id: string) => void; }) { const [selectedId, setSelectedId] = useState(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 (
{t("resources.components.hint")}
{components.map((c) => { const hatch = project.hatches.find((h) => h.id === c.hatchId); return ( ); })} {components.length === 0 && (
{t("resources.components.empty")}
)}
{selected ? ( onPatchComponent(selected.id, patch)} onDelete={() => onDeleteComponent(selected.id)} /> ) : (
{t("resources.components.empty")}
)}
); } // ── 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) => 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>) => { 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 (
onPatch({ name: e.target.value })} />
{kind === "vector" ? ( <> onPatch({ lines: v })} options={[ { value: "parallel", label: t("resources.lines.parallel") }, { value: "random", label: t("resources.lines.random") }, ]} /> onPatch({ pattern })} options={PATTERN_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey), }))} /> onPatch({ scale })} step={0.1} min={0.01} /> onPatch({ angle })} step={5} /> onPatch({ relativeToWall: e.target.checked })} /> onPatch({ lineStyleId: v === NONE ? undefined : v })} options={lineOptions} /> {lines === "random" && ( <> onPatch({ density })} step={0.1} min={0.1} /> onPatch({ lengthMin: v, lengthMax: hatch.lengthMax ?? lenMax })} step={0.5} min={0} /> onPatch({ lengthMax: v, lengthMin: hatch.lengthMin ?? lenMin })} step={0.5} min={0} /> )} ) : ( <> patchImage({ scaleX })} step={0.1} min={0.05} /> patchImage({ scaleY })} step={0.1} min={0.05} /> patchImage({ rotation })} step={5} /> )}
); } function HatchesTab({ project, onPatchHatch, onAddHatch, onDeleteHatch, onImportHatches, }: { project: Project; onPatchHatch: (id: string, patch: Partial) => void; onAddHatch: () => void; onDeleteHatch: (id: string) => void; onImportHatches: (hatches: Omit[]) => void; }) { const [selectedId, setSelectedId] = useState(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 (
{t("resources.hatches.hint")}
{hatches.map((h) => ( ))} {hatches.length === 0 && (
{t("resources.hatches.empty")}
)}
onImportHatches(patToHatches(text))} />
{selected ? ( onPatchHatch(selected.id, patch)} onDelete={() => onDeleteHatch(selected.id)} /> ) : (
{t("resources.hatches.empty")}
)}
); } // ── 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 = { 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) => 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>) => { 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 (
onPatch({ name: e.target.value })} />
onPatch({ color })} /> {kind === "zigzag" ? ( <> patchZig({ amplitude })} step={0.1} min={0.05} /> patchZig({ wavelength })} step={0.5} min={0.5} /> ) : kind === "custom" ? ( onPatch({ motif: { points, length } })} /> ) : ( <> {/* Schnell-Presets: Volllinie / Strichlinie / Punktlinie / Strich-Punkt. */} 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") }, ]} /> {!isSolid && (
{segments.map((seg, i) => (
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") }, ]} /> {seg.type === "dot" ? ( 0 ) : ( setSegLen(i, x)} step={0.5} min={0} /> )}
))}
)} )}
); } function LinesTab({ project, onPatchLineStyle, onAddLineStyle, onDeleteLineStyle, onImportLineStyles, }: { project: Project; onPatchLineStyle: (id: string, patch: Partial) => void; onAddLineStyle: () => void; onDeleteLineStyle: (id: string) => void; onImportLineStyles: (styles: Omit[]) => void; }) { const [selectedId, setSelectedId] = useState(null); const styles = project.lineStyles; const selected = styles.find((l) => l.id === selectedId) ?? styles[0]; return (
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */} {PEN_WEIGHTS.map((w) => (
{t("resources.lines.hint")}
{styles.map((l) => ( ))} {styles.length === 0 && (
{t("resources.lines.empty")}
)}
onImportLineStyles(linToStyles(text))} />
{selected ? ( onPatchLineStyle(selected.id, patch)} onDelete={() => onDeleteLineStyle(selected.id)} /> ) : (
{t("resources.lines.empty")}
)}
); } // ── 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) => 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) => 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 (
onPatch({ name: e.target.value })} />
{layers.map((layer, i) => { const isInner = i === layers.length - 1; return (
patchLayer(i, { componentId })} options={compOptions} /> patchLayer(i, { thickness: Math.max(0, mm) / 1000 }) } step={5} min={0} /> {isInner ? ( ) : ( patchLayer(i, { jointLineStyleId: v === DEFAULT ? undefined : v, }) } options={jointOptions} /> )}
removeLayer(i)} title={t("resources.layer.remove")} />
); })}
); } /** * 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) => void; onAdd: () => void; onDelete: (id: string) => void; }) { const [selectedId, setSelectedId] = useState(null); const selected = types.find((s) => s.id === selectedId) ?? types[0]; return (
{t(hintKey)}
{types.map((s) => ( ))} {types.length === 0 && (
{t(emptyKey)}
)}
{selected ? ( onPatch(selected.id, patch)} onDelete={() => onDelete(selected.id)} deleteTitle={t(deleteKeyPrefix, { name: selected.name })} namePlaceholder={t(placeholderKey)} /> ) : (
{t(emptyKey)}
)}
); } function WallStylesTab({ project, onPatchWallType, onAddWallType, onDeleteWallType, }: { project: Project; onPatchWallType: (id: string, patch: Partial) => void; onAddWallType: () => void; onDeleteWallType: (id: string) => void; }) { return ( ); } /** * 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) => void; onAddCeilingType: () => void; onDeleteCeilingType: (id: string) => void; }) { return ( ); } /** * Datei-Import-Schaltfläche (verstecktes ). 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(null); return ( <> { 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[] { 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[] { 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; 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; 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; 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({ 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) => void; onDelete?: (id: string) => void; renderFields: (item: T, onPatch: (patch: Partial) => void) => React.ReactNode; }) { const [selectedId, setSelectedId] = useState(null); const selected = types.find((x) => x.id === selectedId) ?? types[0]; return (
{t(hintKey)}
{types.map((item) => ( ))} {types.length === 0 && (
{t(emptyKey)}
)}
{selected ? (
onPatch?.(selected.id, { name: e.target.value } as Partial) } /> onDelete?.(selected.id)} title={t(deleteKeyPrefix, { name: selected.name })} />
{renderFields(selected, (patch) => onPatch?.(selected.id, patch))}
) : (
{t(emptyKey)}
)}
); } /** 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) => void; onDeleteDoorType?: (id: string) => void; }) { return ( ( <> patch({ kind })} options={DOOR_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} /> patch({ leafCount: (v === "2" ? 2 : 1) as 1 | 2 })} options={[ { value: "1", label: t("resources.leafCount.1") }, { value: "2", label: t("resources.leafCount.2") }, ]} /> patch({ leafStyle })} options={DOOR_LEAF_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} /> {door.leafStyle === "glas" && ( patch({ glazingRatio })} step={0.01} min={0} max={1} /> )} patch({ frameThickness })} step={0.01} min={0} /> patch({ frameDepth })} step={0.01} min={0} /> patch({ frameKind })} options={DOOR_FRAME_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} /> patch({ frameWidth })} step={0.005} min={0} /> patch({ insetFromFace })} step={0.01} min={0} /> {(door.insetFromFace ?? 0) > 0 && ( patch({ insetFace })} options={INSET_FACE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} /> )} patch({ transomHeight })} step={0.01} min={0} /> patch({ defaultWidth })} step={0.01} min={0} /> patch({ defaultHeight })} step={0.01} min={0} /> patch({ threshold: e.target.checked })} /> )} /> ); } /** Tab „Fenstertypen" — Öffnungsart, Flügelzahl, Verglasung, Zarge, Brüstung. */ function WindowStylesTab({ project, onAddWindowType, onPatchWindowType, onDeleteWindowType, }: { project: Project; onAddWindowType?: () => void; onPatchWindowType?: (id: string, patch: Partial) => void; onDeleteWindowType?: (id: string) => void; }) { return ( ( <> patch({ kind })} options={WINDOW_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} /> patch({ wingCount: Math.round(wingCount) })} step={1} min={1} max={4} /> patch({ mullionRows: Math.max(1, Math.round(mullionRows)) })} step={1} min={1} max={4} /> patch({ glazing })} options={WINDOW_GLAZING_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} /> patch({ frameThickness })} step={0.01} min={0} /> patch({ frameDepth })} step={0.01} min={0} /> patch({ frameWidth })} step={0.005} min={0} /> patch({ insetFromFace })} step={0.01} min={0} /> {(win.insetFromFace ?? 0) > 0 && ( patch({ insetFace })} options={INSET_FACE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} /> )} patch({ transomHeight })} step={0.01} min={0} /> patch({ defaultSillHeight })} step={0.01} min={0} /> patch({ defaultWidth })} step={0.01} min={0} /> patch({ defaultHeight })} step={0.01} min={0} /> patch({ sillBoard })} options={WINDOW_SILL_BOARD_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} /> )} /> ); } /** Tab „Treppentypen" — Tragart, Stufenausbildung, Geländer und Laufbreite. */ function StairStylesTab({ project, onAddStairType, onPatchStairType, onDeleteStairType, }: { project: Project; onAddStairType?: () => void; onPatchStairType?: (id: string, patch: Partial) => void; onDeleteStairType?: (id: string) => void; }) { return ( ( <> patch({ structure })} options={STAIR_STRUCTURE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} /> patch({ closedRisers: e.target.checked })} /> patch({ treadThickness })} step={0.01} min={0} /> patch({ nosing })} step={0.01} min={0} /> patch({ railing })} options={STAIR_RAILING_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} /> patch({ defaultWidth })} step={0.01} min={0} /> )} /> ); } // ── 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(); 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(null); const [visible, setVisible] = useState(false); const [previewUrl, setPreviewUrl] = useState(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 ( ); } function MaterialsTab() { const [query, setQuery] = useState(""); const [category, setCategory] = useState(null); const [selectedId, setSelectedId] = useState(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 (
{t("resources.materials.hint")}
setQuery(e.target.value)} />
{MATERIAL_CATEGORIES.map((c) => ( ))}
{selected && (
{t("resources.materials.selected", { name: selected.name, category: selected.category, })}
)}
{filtered.map((asset) => ( setSelectedId((cur) => (cur === asset.id ? null : asset.id))} /> ))}
{filtered.length === 0 && }
{t("resources.materials.count", { shown: filtered.length, total: MATERIAL_LIBRARY.length, })}
); } // ── 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 (
{active ? children : }
); } /** Detail-Panel EINER Override-Regel (rechte Seite des Master-Detail-Layouts). */ function OverrideRuleDetail({ rule, project, onPatch, onDelete, }: { rule: OverrideRule; project: Project; onPatch: (patch: Partial) => void; onDelete: () => void; }) { const patchCondition = (patch: Partial) => onPatch({ condition: { ...rule.condition, ...patch } }); const patchActions = (patch: Partial) => 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 (
onPatch({ name: e.target.value })} />
{t("resources.overrides.condition")}
patchCondition({ type })} options={OVERRIDE_TYPE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey), }))} /> patchCondition({ operator })} options={OVERRIDE_OP_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey), }))} /> patchCondition({ value })} />
{t("resources.overrides.actions")}
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */} {PEN_WEIGHTS.map((w) => ( patchActions({ color: on ? "#808080" : undefined })} > patchActions({ color })} /> patchActions({ lineweight: on ? 0.35 : undefined })} > patchActions({ lineweight: Math.max(0, lineweight) })} step={0.05} min={0} list="ov-pen-weights" /> patchActions({ linetypeId: on ? project.lineStyles[0]?.id : undefined, }) } > patchActions({ linetypeId: v === NONE ? undefined : v }) } options={lineOptions} />
); } /** 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(null); const selected = rules.find((r) => r.id === selectedId) ?? rules[0]; const setRules = (next: OverrideRule[]) => onSetOverrideRules?.(next); const patchRule = (id: string, patch: Partial) => 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 (
{t("resources.overrides.hint")}
{rules.map((r) => (
setSelectedId(r.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") setSelectedId(r.id); }} > e.stopPropagation()} onChange={(e) => patchRule(r.id, { enabled: e.target.checked })} /> {r.name || t("resources.overrides.placeholder")} {overrideConditionSummary(r)}
))} {rules.length === 0 && (
{t("resources.overrides.empty")}
)}
{selected ? ( patchRule(selected.id, patch)} onDelete={() => deleteRule(selected.id)} /> ) : (
{t("resources.overrides.empty")}
)}
); } // ── Gemeinsame kleine Bausteine ──────────────────────────────────────────── function EmptyState({ text }: { text: string }) { return
{text}
; } // ── 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) => void; onAddComponent: () => void; onDeleteComponent: (id: string) => void; onPatchHatch: (id: string, patch: Partial) => void; onAddHatch: () => void; onDeleteHatch: (id: string) => void; onPatchLineStyle: (id: string, patch: Partial) => void; onAddLineStyle: () => void; onDeleteLineStyle: (id: string) => void; /** Wandstile: immutable Änderung eines Wandtyps (z. B. Schichtfugen-Stile). */ onPatchWallType: (id: string, patch: Partial) => void; onAddWallType: () => void; onDeleteWallType: (id: string) => void; /** Deckenstile: immutable Änderung eines Deckentyps (z. B. Schichtfugen-Stile). */ onPatchCeilingType: (id: string, patch: Partial) => void; onAddCeilingType: () => void; onDeleteCeilingType: (id: string) => void; /** Import: fügt fertige (id-lose) Linienstile/Schraffuren hinzu (.lin/.pat). */ onImportLineStyles: (styles: Omit[]) => void; onImportHatches: (hatches: Omit[]) => 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) => void; onDeleteDoorType?: (id: string) => void; onAddWindowType?: () => void; onPatchWindowType?: (id: string, patch: Partial) => void; onDeleteWindowType?: (id: string) => void; onAddStairType?: () => void; onPatchStairType?: (id: string, patch: Partial) => 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(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(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) => { // 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) => { 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 ( ); }