From 9695748fbc5e7edc7e0def47322eeab246dc17a7 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 01:53:52 +0200 Subject: [PATCH] ResourceManager: Wandstile + Deckenstile im Master-Detail-Layout Wand- und Deckenstile bekommen dasselbe Master-Detail wie Schraffuren/Linien: Liste links mit Querschnitt-Thumbnail + Name, Detail rechts mit grossem Querschnitt und Aufbau-Editor (Schichten: Bauteil/Dicke/Fugenlinie, hinzufuegen/entfernen/umordnen), Inline-Rename, Trash-Delete, Neu anlegen. Neuer WallTypeSwatch (hatchPreview) zeichnet die geschichtete Wand/Decke als proportionale Baender mit den Bauteil-Schnittschraffuren. Add/Delete-Handler (addWallType/deleteWallType/addCeilingType/deleteCeilingType, In-Use-Schutz) in App.tsx + host ergaenzt. --- src/App.tsx | 55 +++++ src/i18n/de.ts | 13 ++ src/i18n/en.ts | 13 ++ src/panels/ResourcesPanel.tsx | 4 + src/panels/host.ts | 4 + src/styles.css | 62 ++++++ src/ui/ResourceManager.tsx | 403 +++++++++++++++++++++++++--------- src/ui/hatchPreview.tsx | 183 +++++++++++++-- 8 files changed, 612 insertions(+), 125 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b2a477c..fd41ab1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1706,6 +1706,53 @@ export default function App() { ct.id === id ? { ...ct, ...patch } : ct, ), })); + // Neuen Wandtyp anlegen: eine Default-Schicht (erstes Bauteil, 200 mm). + const addWallType = () => + setProject((p) => { + const componentId = p.components[0]?.id ?? ""; + const wallType: WallType = { + id: `wt-${Date.now()}`, + name: t("default.wallTypeName"), + layers: [{ componentId, thickness: 0.2 }], + }; + return { ...p, wallTypes: [...p.wallTypes, wallType] }; + }); + // Wandtyp löschen — geschützt, solange ihn eine Wand verwendet. + const deleteWallType = (id: string) => + setProject((p) => { + const used = p.walls.some((w) => w.wallTypeId === id); + if (used) { + const name = p.wallTypes.find((wt) => wt.id === id)?.name ?? id; + window.alert(t("alert.wallTypeInUse", { name })); + return p; + } + return { ...p, wallTypes: p.wallTypes.filter((wt) => wt.id !== id) }; + }); + // Neuen Deckentyp anlegen — analog `addWallType` (150 mm Default). + const addCeilingType = () => + setProject((p) => { + const componentId = p.components[0]?.id ?? ""; + const ceilingType: CeilingType = { + id: `ct-${Date.now()}`, + name: t("default.ceilingTypeName"), + layers: [{ componentId, thickness: 0.15 }], + }; + return { ...p, ceilingTypes: [...(p.ceilingTypes ?? []), ceilingType] }; + }); + // Deckentyp löschen — geschützt, solange ihn eine Decke verwendet. + const deleteCeilingType = (id: string) => + setProject((p) => { + const used = (p.ceilings ?? []).some((c) => c.ceilingTypeId === id); + if (used) { + const name = (p.ceilingTypes ?? []).find((ct) => ct.id === id)?.name ?? id; + window.alert(t("alert.ceilingTypeInUse", { name })); + return p; + } + return { + ...p, + ceilingTypes: (p.ceilingTypes ?? []).filter((ct) => ct.id !== id), + }; + }); const importLineStyles = useStore((s) => s.importLineStyles); const importHatches = useStore((s) => s.importHatches); // Site-/Kontext-Schicht (siteSlice): Import/Entfernen/Gelände. @@ -1996,7 +2043,11 @@ export default function App() { onAddLineStyle: addLineStyle, onDeleteLineStyle: deleteLineStyle, onPatchWallType: patchWallType, + onAddWallType: addWallType, + onDeleteWallType: deleteWallType, onPatchCeilingType: patchCeilingType, + onAddCeilingType: addCeilingType, + onDeleteCeilingType: deleteCeilingType, onImportLineStyles: importLineStyles, onImportHatches: importHatches, selection, @@ -2247,7 +2298,11 @@ export default function App() { const resourceHandlers: ResourceManagerHandlers = { onPatchComponent: patchComponent, onPatchWallType: patchWallType, + onAddWallType: addWallType, + onDeleteWallType: deleteWallType, onPatchCeilingType: patchCeilingType, + onAddCeilingType: addCeilingType, + onDeleteCeilingType: deleteCeilingType, onAddComponent: addComponent, onDeleteComponent: deleteComponent, onPatchHatch: patchHatch, diff --git a/src/i18n/de.ts b/src/i18n/de.ts index cb651e6..9739dff 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -446,12 +446,16 @@ export const de = { "default.copySuffixLayer": "{name} KOPIE", "default.wallTypeSingle": "Wand {thickness} m", "default.ceilingTypeSingle": "Decke {thickness} m", + "default.wallTypeName": "Neuer Wandtyp", + "default.ceilingTypeName": "Neuer Deckentyp", // ── Warnungen (Lösch-Schutz) ───────────────────────────────────────────── "alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.", "alert.componentInUse": "Bauteil „{name}\" wird von einem Wandtyp verwendet und kann nicht gelöscht werden.", "alert.hatchInUse": "Schraffur „{name}\" wird von einem Bauteil verwendet und kann nicht gelöscht werden.", "alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.", + "alert.wallTypeInUse": "Wandtyp „{name}\" wird von einer Wand verwendet und kann nicht gelöscht werden.", + "alert.ceilingTypeInUse": "Deckentyp „{name}\" wird von einer Decke verwendet und kann nicht gelöscht werden.", // ── Ressourcen-Manager ─────────────────────────────────────────────────── "resources.title": "Ressourcen", @@ -470,10 +474,17 @@ export const de = { "resources.ceilingStyles.hint": "Je Deckentyp (liegender Schichtaufbau, oben → unten) die Schichtfugen als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.", "resources.ceilingStyles.empty": "Noch keine Deckentypen.", + "resources.wallStyles.placeholder": "Wandtyp", + "resources.ceilingStyles.placeholder": "Deckentyp", "resources.col.layer": "Schicht", + "resources.col.component": "Bauteil", "resources.col.thickness": "Dicke (mm)", "resources.col.joint": "Fuge", "resources.joint.default": "— (Haarlinie)", + "resources.layer.add": "Schicht hinzufügen", + "resources.layer.remove": "Schicht entfernen", + "resources.layer.moveUp": "Schicht nach oben", + "resources.layer.moveDown": "Schicht nach unten", "resources.materials.hint": "Kugel-Vorschau der eingebauten PBR-Materialbibliothek — durchsuchen und vergleichen. Zuweisen an ein Bauteil weiterhin über dessen Spalte „Material …\".", @@ -556,6 +567,8 @@ export const de = { "resources.col.lineStyle": "Linienstil", "resources.lineStyle.none": "— ohne —", "resources.delete.hatch": "Schraffur „{name}\" löschen", + "resources.delete.wallType": "Wandtyp „{name}\" löschen", + "resources.delete.ceilingType": "Deckentyp „{name}\" löschen", // Schraffur-/Linien-Typ (Master-Detail-Editor). "resources.field.type": "Typ", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index f39c6d8..4884fcd 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -443,12 +443,16 @@ export const en: Record = { "default.copySuffixLayer": "{name} COPY", "default.wallTypeSingle": "Wall {thickness} m", "default.ceilingTypeSingle": "Ceiling {thickness} m", + "default.wallTypeName": "New wall type", + "default.ceilingTypeName": "New ceiling type", // Alerts. "alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.", "alert.componentInUse": "Component “{name}” is used by a wall type and cannot be deleted.", "alert.hatchInUse": "Hatch “{name}” is used by a component and cannot be deleted.", "alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.", + "alert.wallTypeInUse": "Wall type “{name}” is used by a wall and cannot be deleted.", + "alert.ceilingTypeInUse": "Ceiling type “{name}” is used by a ceiling and cannot be deleted.", // Resource manager. "resources.title": "Resources", @@ -467,10 +471,17 @@ export const en: Record = { "resources.ceilingStyles.hint": "Per ceiling type (horizontal build-up, top → bottom), the layer joints as a selectable line style. Unassigned: 0.02 mm hairline.", "resources.ceilingStyles.empty": "No ceiling types yet.", + "resources.wallStyles.placeholder": "Wall type", + "resources.ceilingStyles.placeholder": "Ceiling type", "resources.col.layer": "Layer", + "resources.col.component": "Component", "resources.col.thickness": "Thickness (mm)", "resources.col.joint": "Joint", "resources.joint.default": "— (hairline)", + "resources.layer.add": "Add layer", + "resources.layer.remove": "Remove layer", + "resources.layer.moveUp": "Move layer up", + "resources.layer.moveDown": "Move layer down", "resources.materials.hint": "Sphere preview of the built-in PBR material library — browse and compare. Assign to a component still via its “Material …” column.", @@ -551,6 +562,8 @@ export const en: Record = { "resources.col.lineStyle": "Line style", "resources.lineStyle.none": "— none —", "resources.delete.hatch": "Delete hatch “{name}”", + "resources.delete.wallType": "Delete wall type “{name}”", + "resources.delete.ceilingType": "Delete ceiling type “{name}”", // Hatch/line type (master-detail editor). "resources.field.type": "Type", diff --git a/src/panels/ResourcesPanel.tsx b/src/panels/ResourcesPanel.tsx index 3f966ad..a7d2da6 100644 --- a/src/panels/ResourcesPanel.tsx +++ b/src/panels/ResourcesPanel.tsx @@ -35,7 +35,11 @@ export function ResourcesPanel() { onAddLineStyle: host.onAddLineStyle, onDeleteLineStyle: host.onDeleteLineStyle, onPatchWallType: host.onPatchWallType, + onAddWallType: host.onAddWallType, + onDeleteWallType: host.onDeleteWallType, onPatchCeilingType: host.onPatchCeilingType, + onAddCeilingType: host.onAddCeilingType, + onDeleteCeilingType: host.onDeleteCeilingType, onImportLineStyles: host.onImportLineStyles, onImportHatches: host.onImportHatches, }; diff --git a/src/panels/host.ts b/src/panels/host.ts index 93c7d7b..4e07780 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -120,8 +120,12 @@ export interface PanelHostValue { onDeleteLineStyle: (id: string) => void; /** Wandstile: immutable Änderung eines Wandtyps (Schichtfugen-Stile). */ onPatchWallType: (id: string, patch: Partial) => void; + onAddWallType: () => void; + onDeleteWallType: (id: string) => void; /** Deckenstile: immutable Änderung eines Deckentyps (Schichtfugen-Stile). */ onPatchCeilingType: (id: string, patch: Partial) => void; + onAddCeilingType: () => void; + onDeleteCeilingType: (id: string) => void; /** Import fertiger (id-loser) Linienstile/Schraffuren (.lin/.pat). */ onImportLineStyles: (styles: Omit[]) => void; onImportHatches: (hatches: Omit[]) => void; diff --git a/src/styles.css b/src/styles.css index 077cad8..9cc41f6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2095,6 +2095,68 @@ body { padding-left: 4px; } +/* ── Wand-/Deckenstil-Aufbau (Master-Detail) ────────────────────────────── */ +/* Der Querschnitt-Swatch (WallTypeSwatch) ist frei skalierbar. */ +.res-wallswatch { + border-radius: 3px; +} + +/* Aufbau-Editor: die Schichten gestapelt, darunter „Schicht hinzufügen". */ +.res-layers { + display: flex; + flex-direction: column; + gap: 8px; +} +.res-layer { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--input); +} +.res-layer-move { + display: flex; + flex-direction: column; + gap: 2px; +} +.res-layer-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 16px; + padding: 0; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--panel-2); + color: var(--ink); + font-size: 9px; + line-height: 1; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.res-layer-btn:hover:not(:disabled) { + background: var(--accent-dim); + border-color: var(--accent-border); +} +.res-layer-btn:disabled { + opacity: 0.35; + cursor: default; +} +.res-layer-fields { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.res-layer-fields .res-field { + grid-template-columns: 76px 1fr; + gap: 8px; +} + /* ── Tabelle (saubere Liste, CONVENTIONS.md) ────────────────────────────────── Ein CSS-Grid trägt die Spalten (gridTemplateColumns wird je Tab inline gesetzt). Kopfzeile und Datenzeilen sind `display:contents`, damit ihre diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx index 2e1b8e6..9d5d639 100644 --- a/src/ui/ResourceManager.tsx +++ b/src/ui/ResourceManager.tsx @@ -24,6 +24,7 @@ import type { ComponentMaterial, HatchPattern, HatchStyle, + Layer, LineStyle, Project, WallType, @@ -43,7 +44,8 @@ import { } from "../materials/ambientcg"; import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview"; import { EyeIcon } from "./EyeIcon"; -import { HatchSwatch, LineSwatch } from "./hatchPreview"; +import { HatchSwatch, LineSwatch, WallTypeSwatch } from "./hatchPreview"; +import type { SwatchLayer } from "./hatchPreview"; import { segmentsToDash, dashToSegments, @@ -1498,26 +1500,54 @@ function LinesTab({ ); } -// ── Wandstile (Wall Styles) ──────────────────────────────────────────────── -// Pro Wandtyp die geordneten Schichten (Bauteil + Dicke) und je INNERER Fuge -// (zwischen zwei aufeinanderfolgenden Schichten) ein LineStyle-Dropdown, das an -// layer[i].jointLineStyleId hängt (i = Schicht, deren innere Kante die Fuge ist). -// Die innerste Schicht hat keine innere Fuge (ihre Kante ist der Wand-Innenumriss). +// ── 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. -// Spalten: Schicht | Dicke (mm) | Fuge (innere). -const WALLSTYLE_COLUMNS: ResColumn[] = [ - { titleKey: "resources.col.layer" }, - { titleKey: "resources.col.thickness", align: "right" }, - { titleKey: "resources.col.joint" }, -]; -const WALLSTYLE_TEMPLATE = "minmax(120px, 1fr) 72px minmax(140px, 1.2fr)"; +/** Wand- und Deckentyp teilen dieselbe Form (`{id,name,layers}`). */ +type LayeredStyle = WallType | CeilingType; -function WallStylesTab({ +/** + * 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, - onPatchWallType, + orientation, + onPatch, + onDelete, + deleteTitle, + namePlaceholder, }: { + style: LayeredStyle; project: Project; - onPatchWallType: (id: string, patch: Partial) => void; + 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__"; @@ -1525,111 +1555,266 @@ function WallStylesTab({ { value: DEFAULT, label: t("resources.joint.default") }, ...project.lineStyles.map((l) => ({ value: l.id, label: l.name })), ]; - const setJoint = (wt: WallType, li: number, styleId: string | undefined) => { - const layers = wt.layers.map((l, i) => - i === li ? { ...l, jointLineStyleId: styleId } : l, - ); - onPatchWallType(wt.id, { layers }); + 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 ( -
-
{t("resources.wallStyles.hint")}
- {project.wallTypes.map((wt) => ( -
-
{wt.name}
- - {wt.layers.map((layer, li) => { - const comp = project.components.find((c) => c.id === layer.componentId); - const isInner = li === wt.layers.length - 1; - return ( - - {comp ? comp.name : layer.componentId} - {Math.round(layer.thickness * 1000)} - - {isInner ? ( - - ) : ( - setJoint(wt, li, v === DEFAULT ? undefined : v)} - options={jointOptions} - /> - )} - - - ); - })} - -
- ))} - {project.wallTypes.length === 0 && ( - - )} +
+
+ 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")} + /> +
+ ); + })} + +
); } /** - * Tab „Deckenstile" — das liegende Gegenstück zu {@link WallStylesTab}: pro - * Deckentyp (`project.ceilingTypes`) die Schichten mit Dicke + einem per-Fuge - * wählbaren Linienstil (Line Manager). Gleiche Tabelle/Sentinel-Logik wie bei - * den Wandstilen — nur die Ressource (`ceilingTypes` statt `wallTypes`) und der - * Handler (`onPatchCeilingType`) unterscheiden sich. + * 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; }) { - const DEFAULT = "__default__"; - const jointOptions = [ - { value: DEFAULT, label: t("resources.joint.default") }, - ...project.lineStyles.map((l) => ({ value: l.id, label: l.name })), - ]; - const setJoint = (ct: CeilingType, li: number, styleId: string | undefined) => { - const layers = ct.layers.map((l, i) => - i === li ? { ...l, jointLineStyleId: styleId } : l, - ); - onPatchCeilingType(ct.id, { layers }); - }; - const ceilingTypes = project.ceilingTypes ?? []; return ( -
-
{t("resources.ceilingStyles.hint")}
- {ceilingTypes.map((ct) => ( -
-
{ct.name}
- - {ct.layers.map((layer, li) => { - const comp = project.components.find((c) => c.id === layer.componentId); - const isInner = li === ct.layers.length - 1; - return ( - - {comp ? comp.name : layer.componentId} - {Math.round(layer.thickness * 1000)} - - {isInner ? ( - - ) : ( - setJoint(ct, li, v === DEFAULT ? undefined : v)} - options={jointOptions} - /> - )} - - - ); - })} - -
- ))} - {ceilingTypes.length === 0 && ( - - )} -
+ ); } @@ -1911,8 +2096,12 @@ export interface ResourceManagerHandlers { 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; @@ -2097,12 +2286,16 @@ export function ResourceManager({ )} {tab === "ceilingStyles" && ( )} {tab === "materials" && } diff --git a/src/ui/hatchPreview.tsx b/src/ui/hatchPreview.tsx index de39cc8..771fee8 100644 --- a/src/ui/hatchPreview.tsx +++ b/src/ui/hatchPreview.tsx @@ -70,25 +70,14 @@ function parallelLines( } /** - * Live-SVG-Swatch einer Schraffur. Rendert: - * • Bild-Schraffur (`kind==="image"`) als gekachelten /-Fill - * mit unabhängiger Skalierung (scaleX/scaleY) und Rotation. - * • Vektor-Schraffur nach `pattern`: solid/none/diagonal/crosshatch/insulation. - * Untermodus `lines==="random"` verteilt kurze Striche zufällig (Kies/Splitt). - * `scale` verdichtet/streckt das Muster, `angle` dreht es. + * Reine Muster-Füllung EINER Schraffur als SVG-Knoten für eine Box `w`×`h` + * (ohne Rahmen/Clip — die übernimmt der Aufrufer). Ausgelagert aus + * {@link HatchSwatch}, damit auch der geschichtete {@link WallTypeSwatch} pro + * Band DASSELBE Muster zeichnet. `uid` macht interne -IDs eindeutig + * (mehrere Bänder je SVG). */ -export function HatchSwatch({ - hatch, - size = 34, -}: { - hatch: HatchStyle; - size?: number; -}) { - const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); - const clipId = `hsc_${rawId}`; - const patId = `hsp_${rawId}`; - const w = size; - const h = size; +function hatchInner(hatch: HatchStyle, w: number, h: number, uid: string): React.ReactNode { + const patId = `hsp_${uid}`; // Grundabstand der Musterlinien (px), über den Maßstab moduliert. const scale = hatch.scale > 0 ? hatch.scale : 1; @@ -101,7 +90,7 @@ export function HatchSwatch({ // Bild-Kachel: Basiskachel skaliert mit scaleX/scaleY, gedreht via // patternTransform. Fehlt die Quelle, zeigen wir einen dezenten Rahmen. const src = hatch.image?.src; - const base = Math.max(6, size * 0.6); + const base = Math.max(6, Math.min(w, h) * 0.6); const tileW = Math.max(2, base * (hatch.image?.scaleX ?? 1)); const tileH = Math.max(2, base * (hatch.image?.scaleY ?? 1)); const rot = hatch.image?.rotation ?? 0; @@ -217,6 +206,29 @@ export function HatchSwatch({ inner = parallelLines(w, h, angle, spacing, 0.8, "d"); } + return inner; +} + +/** + * Live-SVG-Swatch einer Schraffur. Rendert: + * • Bild-Schraffur (`kind==="image"`) als gekachelten /-Fill + * mit unabhängiger Skalierung (scaleX/scaleY) und Rotation. + * • Vektor-Schraffur nach `pattern`: solid/none/diagonal/crosshatch/insulation. + * Untermodus `lines==="random"` verteilt kurze Striche zufällig (Kies/Splitt). + * `scale` verdichtet/streckt das Muster, `angle` dreht es. + */ +export function HatchSwatch({ + hatch, + size = 34, +}: { + hatch: HatchStyle; + size?: number; +}) { + const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const clipId = `hsc_${rawId}`; + const w = size; + const h = size; + return ( - {inner} + {hatchInner(hatch, w, h, rawId)} + + ); +} + +/** + * Eine Schicht für den geschichteten Wand-/Decken-Querschnitt: ihre + * Schnittschraffur (oder `undefined` = leer/weiß) und die relative Dicke + * (proportional zur Bandbreite). Farbe kommt wie sonst über `currentColor`. + */ +export interface SwatchLayer { + hatch: HatchStyle | undefined; + thickness: number; +} + +/** + * Live-SVG-Querschnitt EINES Wand-/Deckentyps: die Schichten als aneinander- + * liegende Bänder, proportional zur Dicke, jedes Band mit der Schnittschraffur + * seines Bauteils. `orientation="wall"` legt die Bänder NEBENEINANDER (Blick + * entlang der Wand — Schichten quer über die Dicke), `"ceiling"` STAPELT sie + * (liegender Deckenaufbau, oben → innen). Dünne Trennlinien markieren die Fugen; + * ein gerundeter Rahmen fasst das Ganze wie die übrigen Swatches ein. + */ +export function WallTypeSwatch({ + layers, + width = 128, + height = 64, + orientation = "wall", +}: { + layers: SwatchLayer[]; + width?: number; + height?: number; + orientation?: "wall" | "ceiling"; +}) { + const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const clipId = `wtc_${rawId}`; + const w = width; + const h = height; + + // Gewichte: 0-dicke Schichten dünn, aber sichtbar. Proportional zur Summe. + const weights = layers.map((l) => (l.thickness > 0 ? l.thickness : 0.01)); + const total = weights.reduce((a, b) => a + b, 0) || 1; + const along = orientation === "wall" ? w : h; + + // Bänder entlang der Stapelachse aufsummieren (px-Offsets). + let acc = 0; + const bands = layers.map((l, i) => { + const len = (weights[i] / total) * along; + const start = acc; + acc += len; + const bx = orientation === "wall" ? start : 0; + const by = orientation === "wall" ? 0 : start; + const bw = orientation === "wall" ? len : w; + const bh = orientation === "wall" ? h : len; + return { hatch: l.hatch, bx, by, bw, bh, start, len }; + }); + + return ( + + + + + + {bands.map((b, i) => ( + + + + ))} + + + + {bands.map((b, i) => ( + + {b.hatch && hatchInner(b.hatch, b.bw, b.bh, `${rawId}_${i}`)} + + ))} + {/* Fugen: dünne Trennlinie an jeder INNEREN Bandgrenze. */} + {bands.slice(1).map((b, i) => + orientation === "wall" ? ( + + ) : ( + + ), + )} + + ); }