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.
This commit is contained in:
2026-07-04 01:53:52 +02:00
parent c34b7e5771
commit 777e02c927
8 changed files with 612 additions and 125 deletions
+298 -105
View File
@@ -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<WallType>) => void;
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__";
@@ -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<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-tab">
<div className="res-hint">{t("resources.wallStyles.hint")}</div>
{project.wallTypes.map((wt) => (
<div key={wt.id} className="res-wallstyle">
<div className="res-wallstyle-head">{wt.name}</div>
<ResTable columns={WALLSTYLE_COLUMNS} template={WALLSTYLE_TEMPLATE}>
{wt.layers.map((layer, li) => {
const comp = project.components.find((c) => c.id === layer.componentId);
const isInner = li === wt.layers.length - 1;
return (
<ResRow key={li}>
<ResCell emphasis>{comp ? comp.name : layer.componentId}</ResCell>
<ResCell align="right">{Math.round(layer.thickness * 1000)}</ResCell>
<ResCell>
{isInner ? (
<span className="res-joint-none"></span>
) : (
<SelectField
value={layer.jointLineStyleId ?? DEFAULT}
onChange={(v) => setJoint(wt, li, v === DEFAULT ? undefined : v)}
options={jointOptions}
/>
)}
</ResCell>
</ResRow>
);
})}
</ResTable>
</div>
))}
{project.wallTypes.length === 0 && (
<EmptyState text={t("resources.wallStyles.empty")} />
)}
<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>
);
}
/**
* 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<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;
}) {
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 (
<div className="res-tab">
<div className="res-hint">{t("resources.ceilingStyles.hint")}</div>
{ceilingTypes.map((ct) => (
<div key={ct.id} className="res-wallstyle">
<div className="res-wallstyle-head">{ct.name}</div>
<ResTable columns={WALLSTYLE_COLUMNS} template={WALLSTYLE_TEMPLATE}>
{ct.layers.map((layer, li) => {
const comp = project.components.find((c) => c.id === layer.componentId);
const isInner = li === ct.layers.length - 1;
return (
<ResRow key={li}>
<ResCell emphasis>{comp ? comp.name : layer.componentId}</ResCell>
<ResCell align="right">{Math.round(layer.thickness * 1000)}</ResCell>
<ResCell>
{isInner ? (
<span className="res-joint-none"></span>
) : (
<SelectField
value={layer.jointLineStyleId ?? DEFAULT}
onChange={(v) => setJoint(ct, li, v === DEFAULT ? undefined : v)}
options={jointOptions}
/>
)}
</ResCell>
</ResRow>
);
})}
</ResTable>
</div>
))}
{ceilingTypes.length === 0 && (
<EmptyState text={t("resources.ceilingStyles.empty")} />
)}
</div>
<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}
/>
);
}
@@ -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<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;
@@ -2097,12 +2286,16 @@ export function ResourceManager({
<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 === "materials" && <MaterialsTab />}
+163 -20
View File
@@ -70,25 +70,14 @@ function parallelLines(
}
/**
* Live-SVG-Swatch einer Schraffur. Rendert:
* • Bild-Schraffur (`kind==="image"`) als gekachelten <pattern>/<image>-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 <pattern>-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 <pattern>/<image>-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 (
<svg
className="res-swatch"
@@ -240,7 +252,138 @@ export function HatchSwatch({
stroke="var(--border)"
strokeWidth={1}
/>
<g clipPath={`url(#${clipId})`}>{inner}</g>
<g clipPath={`url(#${clipId})`}>{hatchInner(hatch, w, h, rawId)}</g>
</svg>
);
}
/**
* 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 (
<svg
className="res-swatch res-wallswatch"
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
style={{ display: "block" }}
>
<defs>
<clipPath id={clipId}>
<rect x={0} y={0} width={w} height={h} rx={3} />
</clipPath>
{bands.map((b, i) => (
<clipPath key={i} id={`${clipId}_b${i}`}>
<rect x={0} y={0} width={b.bw} height={b.bh} />
</clipPath>
))}
</defs>
<rect
x={0.5}
y={0.5}
width={w - 1}
height={h - 1}
rx={3}
fill="var(--input)"
stroke="var(--border)"
strokeWidth={1}
/>
<g clipPath={`url(#${clipId})`}>
{bands.map((b, i) => (
<g
key={i}
transform={`translate(${b.bx.toFixed(2)},${b.by.toFixed(2)})`}
clipPath={`url(#${clipId}_b${i})`}
>
{b.hatch && hatchInner(b.hatch, b.bw, b.bh, `${rawId}_${i}`)}
</g>
))}
{/* Fugen: dünne Trennlinie an jeder INNEREN Bandgrenze. */}
{bands.slice(1).map((b, i) =>
orientation === "wall" ? (
<line
key={i}
x1={b.start}
y1={0}
x2={b.start}
y2={h}
stroke="currentColor"
strokeWidth={0.6}
opacity={0.5}
/>
) : (
<line
key={i}
x1={0}
y1={b.start}
x2={w}
y2={b.start}
stroke="currentColor"
strokeWidth={0.6}
opacity={0.5}
/>
),
)}
</g>
<rect
x={0.5}
y={0.5}
width={w - 1}
height={h - 1}
rx={3}
fill="none"
stroke="var(--border)"
strokeWidth={1}
/>
</svg>
);
}