ResourceManager: Master-Detail-Editoren fuer Schraffur- und Linien-Typen

Schraffuren- und Linien-Tab im Master-Detail-Layout (DOSSIER-Vorbild): Liste
links mit Live-SVG-Thumbnail + Name, Detail rechts mit grosser Live-Vorschau,
Inline-Rename, Trash-Delete, Typ-Umschalter. Schraffur: Vektor (parallel/
zufaellig) oder Bild (Upload -> Data-URL, scaleX/scaleY/rotation); kein
Farbfeld mehr (Farbe kommt von Bauteil/Attribut). Linie: Strich oder Zickzack
(Amplitude/Wellenlaenge). Bauteil-Tabelle: Vordergrund + Hintergrund statt
einer Farbe. Neuer eigenstaendiger Vorschau-Helfer ui/hatchPreview.tsx (kein
Renderer-Import).
This commit is contained in:
2026-07-04 00:41:09 +02:00
parent 0613bb596b
commit f0e777efc5
5 changed files with 908 additions and 186 deletions
+20
View File
@@ -500,6 +500,10 @@ export const de = {
"resources.col.prio.hint": "Höhere Priorität läuft am Stoß durch", "resources.col.prio.hint": "Höhere Priorität läuft am Stoß durch",
"resources.col.texture": "Textur", "resources.col.texture": "Textur",
"resources.col.material": "Material", "resources.col.material": "Material",
"resources.col.foreground": "Vordergrund",
"resources.col.foreground.hint": "Farbe der Muster-/Schraffurlinien (Vordergrund)",
"resources.col.background": "Hintergrund",
"resources.col.background.hint": "Füllfarbe (Poché/Hintergrund)",
"resources.delete.component": "Bauteil „{name}\" löschen", "resources.delete.component": "Bauteil „{name}\" löschen",
// 3D-Material (PBR) — Bibliotheks-Auswahl + Upload. // 3D-Material (PBR) — Bibliotheks-Auswahl + Upload.
@@ -553,6 +557,22 @@ export const de = {
"resources.lineStyle.none": "— ohne —", "resources.lineStyle.none": "— ohne —",
"resources.delete.hatch": "Schraffur „{name}\" löschen", "resources.delete.hatch": "Schraffur „{name}\" löschen",
// Schraffur-/Linien-Typ (Master-Detail-Editor).
"resources.field.type": "Typ",
"resources.field.lines": "Striche",
"resources.field.image": "Bild",
"resources.field.scaleX": "Skalierung X",
"resources.field.scaleY": "Skalierung Y",
"resources.field.rotation": "Rotation °",
"resources.field.amplitude": "Amplitude",
"resources.field.wavelength": "Wellenlänge",
"resources.hatchKind.vector": "Vektor",
"resources.hatchKind.image": "Bild",
"resources.lines.parallel": "Parallel",
"resources.lines.random": "Zufällig",
"resources.lineKind.dash": "Strich",
"resources.lineKind.zigzag": "Zickzack",
// Schraffur-Muster. // Schraffur-Muster.
"pattern.none": "Ohne", "pattern.none": "Ohne",
"pattern.solid": "Vollfläche", "pattern.solid": "Vollfläche",
+20
View File
@@ -496,6 +496,10 @@ export const en: Record<TranslationKey, string> = {
"resources.col.prio.hint": "Higher priority runs through at the join", "resources.col.prio.hint": "Higher priority runs through at the join",
"resources.col.texture": "Texture", "resources.col.texture": "Texture",
"resources.col.material": "Material", "resources.col.material": "Material",
"resources.col.foreground": "Foreground",
"resources.col.foreground.hint": "Color of the pattern/hatch lines (foreground)",
"resources.col.background": "Background",
"resources.col.background.hint": "Fill color (poché/background)",
"resources.delete.component": "Delete component “{name}”", "resources.delete.component": "Delete component “{name}”",
// 3D material (PBR) — built-in library + upload. // 3D material (PBR) — built-in library + upload.
@@ -548,6 +552,22 @@ export const en: Record<TranslationKey, string> = {
"resources.lineStyle.none": "— none —", "resources.lineStyle.none": "— none —",
"resources.delete.hatch": "Delete hatch “{name}”", "resources.delete.hatch": "Delete hatch “{name}”",
// Hatch/line type (master-detail editor).
"resources.field.type": "Type",
"resources.field.lines": "Lines",
"resources.field.image": "Image",
"resources.field.scaleX": "Scale X",
"resources.field.scaleY": "Scale Y",
"resources.field.rotation": "Rotation °",
"resources.field.amplitude": "Amplitude",
"resources.field.wavelength": "Wavelength",
"resources.hatchKind.vector": "Vector",
"resources.hatchKind.image": "Image",
"resources.lines.parallel": "Parallel",
"resources.lines.random": "Random",
"resources.lineKind.dash": "Dash",
"resources.lineKind.zigzag": "Zigzag",
"pattern.none": "None", "pattern.none": "None",
"pattern.solid": "Solid", "pattern.solid": "Solid",
"pattern.insulation": "Insulation", "pattern.insulation": "Insulation",
+172
View File
@@ -2315,6 +2315,178 @@ body {
background: var(--panel-2); background: var(--panel-2);
} }
/* ── Master-Detail (Schraffuren / Linien): Liste links, Detail rechts ──────── */
.res-md {
display: grid;
grid-template-columns: 240px 1fr;
gap: 14px;
align-items: start;
min-height: 320px;
}
.res-md-list {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel-2);
max-height: 62vh;
overflow-y: auto;
}
.res-md-row {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--ink);
font-family: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.res-md-row:hover {
background: var(--accent-dim);
}
.res-md-row.active {
background: var(--accent-dim);
border-color: var(--accent-border);
color: var(--accent);
}
.res-md-row-thumb {
flex: 0 0 auto;
display: inline-flex;
}
.res-md-row-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 550;
}
/* Linien-Zeile: Name oben schmal, Swatch daneben rechts. */
.res-md-row-line .res-line-preview {
flex: 0 0 auto;
width: 150px;
min-width: 0;
}
.res-md-listfoot {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 4px 2px;
}
.res-md-empty {
padding: 22px 14px;
text-align: center;
font-size: 12px;
color: var(--muted);
}
/* Detail-Panel (rechts). */
.res-md-detail {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel-2);
min-height: 300px;
}
.res-md-detail-inner {
display: flex;
flex-direction: column;
gap: 14px;
padding: 14px 16px 18px;
}
.res-md-head {
display: flex;
align-items: center;
gap: 10px;
}
.res-md-rename {
flex: 1 1 auto;
font-size: 13px;
font-weight: 600;
}
.res-md-preview {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--input);
color: var(--ink);
}
.res-md-preview .res-line-preview {
border: none;
background: transparent;
}
/* Detail-Felder: Label links, Steuerelement rechts. */
.res-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.res-field {
display: grid;
grid-template-columns: 120px 1fr;
align-items: center;
gap: 12px;
}
.res-field-label {
font-size: 12px;
color: var(--muted);
}
.res-field-control {
display: flex;
align-items: center;
min-width: 0;
}
.res-field-control > * {
min-width: 0;
}
/* Segmentierter Umschalter (Pillen-Gruppe). */
.res-seg {
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--input);
}
.res-seg-btn {
border: none;
background: transparent;
color: var(--muted);
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 4px 14px;
border-radius: 999px;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.res-seg-btn:hover {
color: var(--ink);
}
.res-seg-btn.active {
background: var(--accent);
color: #fff;
}
/* Schraffur-Swatch (kleine Vorschau in Liste + große im Detail). */
.res-swatch {
border-radius: 3px;
}
/* ══ Panel-Chrome (Andockrahmen: Docks, Tabs, Panel-Rahmen) ═══════════════════ /* ══ Panel-Chrome (Andockrahmen: Docks, Tabs, Panel-Rahmen) ═══════════════════
Der erweiterbare Rahmen: links/rechts je ein Dock mit Tab-Leiste und dem Der erweiterbare Rahmen: links/rechts je ein Dock mit Tab-Leiste und dem
aktiven Panel. Greift dieselben Tokens wie die nav- und res-Klassen (dunkles DOSSIER- aktiven Panel. Greift dieselben Tokens wie die nav- und res-Klassen (dunkles DOSSIER-
+388 -186
View File
@@ -42,6 +42,7 @@ import {
} from "../materials/ambientcg"; } from "../materials/ambientcg";
import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview"; import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview";
import { EyeIcon } from "./EyeIcon"; import { EyeIcon } from "./EyeIcon";
import { HatchSwatch, LineSwatch } from "./hatchPreview";
import { t } from "../i18n"; import { t } from "../i18n";
// ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ─────────── // ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ───────────
@@ -210,6 +211,62 @@ function DeleteButton({ onClick, title }: { onClick: () => void; title: string }
); );
} }
// ── Master-Detail-Bausteine (DOSSIER-Stil: Liste links, Detail rechts) ──────
/**
* Segmentierter Umschalter (Pillen-Gruppe) für kleine, sich ausschließende
* Auswahlen z. B. Schraffur-Typ Vektor/Bild oder Linien-Typ Strich/Zickzack.
*/
function Segmented<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
}) {
return (
<div className="res-seg" role="group">
{options.map((o) => (
<button
key={o.value}
type="button"
className={`res-seg-btn${value === o.value ? " active" : ""}`}
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
>
{o.label}
</button>
))}
</div>
);
}
/**
* Eine beschriftete Detail-Zeile (Label links, Steuerelement rechts). Bewusst
* ein <div> (kein <label>): Zeilen enthalten teils mehrere interaktive Elemente
* (Segmented-Buttons), und ein umschließendes <label> würde den Klick an das
* ERSTE labelbare Kind umleiten das würde einen Segment-Klick sofort wieder
* zurücksetzen. Die Steuerelemente sind selbst klickbar; ein Label ist unnötig.
*/
function FieldRow({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="res-field" title={hint}>
<span className="res-field-label">{label}</span>
<span className="res-field-control">{children}</span>
</div>
);
}
// ── Tabellen-Gerüst ──────────────────────────────────────────────────────── // ── Tabellen-Gerüst ────────────────────────────────────────────────────────
/** Eine Spaltendefinition: Titel-Key + optionale rechtsbündige Ausrichtung. */ /** Eine Spaltendefinition: Titel-Key + optionale rechtsbündige Ausrichtung. */
@@ -283,10 +340,11 @@ function ResCell({
// ── Bauteile (Components) ────────────────────────────────────────────────── // ── Bauteile (Components) ──────────────────────────────────────────────────
// Spalten: Name | Farbe | Schnittschraffur | Ansichtsschraffur | Prio | Textur | Material | (löschen). // Spalten: Name | Vordergrund | Hintergrund | Schnittschraffur | Ansichtsschraffur | Prio | Textur | Material | (löschen).
const COMPONENT_COLUMNS: ResColumn[] = [ const COMPONENT_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.name" }, { titleKey: "resources.col.name" },
{ titleKey: "resources.col.color" }, { titleKey: "resources.col.foreground", hintKey: "resources.col.foreground.hint" },
{ titleKey: "resources.col.background", hintKey: "resources.col.background.hint" },
{ titleKey: "resources.col.hatch" }, { titleKey: "resources.col.hatch" },
{ titleKey: "resources.col.viewHatch" }, { titleKey: "resources.col.viewHatch" },
{ {
@@ -299,7 +357,7 @@ const COMPONENT_COLUMNS: ResColumn[] = [
{ titleKey: "" }, { titleKey: "" },
]; ];
const COMPONENT_TEMPLATE = const COMPONENT_TEMPLATE =
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) minmax(120px, 0.8fr) 64px minmax(90px, 0.7fr) minmax(130px, 0.9fr) 36px"; "minmax(120px, 1fr) auto auto minmax(120px, 0.8fr) minmax(120px, 0.8fr) 64px minmax(90px, 0.7fr) minmax(130px, 0.9fr) 36px";
/** /**
* Material-Zelle der Bauteil-Tabelle: ein Button, der den aktuellen Material- * Material-Zelle der Bauteil-Tabelle: ein Button, der den aktuellen Material-
@@ -782,9 +840,18 @@ function ComponentRow({
/> />
</ResCell> </ResCell>
<ResCell> <ResCell>
{/* Vordergrund = Farbe der Muster-/Schraffurlinien. Fehlt sie, greift
als Anzeige-Fallback `color`; das Setzen definiert `foreground`. */}
<ColorField <ColorField
color={component.color} color={component.foreground ?? component.color}
onChange={(color) => onPatch({ color })} onChange={(foreground) => onPatch({ foreground })}
/>
</ResCell>
<ResCell>
{/* Hintergrund = Füllung (Poché). Fehlt sie, gilt `color` als Fallback. */}
<ColorField
color={component.background ?? component.color}
onChange={(background) => onPatch({ background })}
/> />
</ResCell> </ResCell>
<ResCell> <ResCell>
@@ -869,21 +936,8 @@ function ComponentsTab({
// ── Schraffuren (Hatches) ────────────────────────────────────────────────── // ── Schraffuren (Hatches) ──────────────────────────────────────────────────
// Spalten: Name | Muster | Maßstab | Winkel | Farbe | Linienstil | (löschen). /** Detail-Panel EINER Schraffur (rechte Seite des Master-Detail-Layouts). */
const HATCH_COLUMNS: ResColumn[] = [ function HatchDetail({
{ titleKey: "resources.col.name" },
{ titleKey: "resources.col.pattern" },
{ titleKey: "resources.col.scale", align: "right" },
{ titleKey: "resources.col.angle", align: "right" },
{ titleKey: "resources.col.relativeToWall" },
{ titleKey: "resources.col.color" },
{ titleKey: "resources.col.lineStyle" },
{ titleKey: "" },
];
const HATCH_TEMPLATE =
"minmax(110px, 1fr) minmax(120px, 0.9fr) 64px 60px 64px auto minmax(120px, 0.9fr) 36px";
function HatchRow({
hatch, hatch,
lineStyles, lineStyles,
onPatch, onPatch,
@@ -894,71 +948,155 @@ function HatchRow({
onPatch: (patch: Partial<HatchStyle>) => void; onPatch: (patch: Partial<HatchStyle>) => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
// Sentinel-Eintrag „— ohne —", da lineStyleId optional ist.
const NONE = "__none__"; const NONE = "__none__";
const lineOptions = [ const lineOptions = [
{ value: NONE, label: t("resources.lineStyle.none") }, { value: NONE, label: t("resources.lineStyle.none") },
...lineStyles.map((l) => ({ value: l.id, label: l.name })), ...lineStyles.map((l) => ({ value: l.id, label: l.name })),
]; ];
const kind = hatch.kind ?? "vector";
const lines = hatch.lines ?? "parallel";
/** Wechselt den Typ; beim Umschalten auf „Bild" Default-Bildparameter setzen. */
const setKind = (k: "vector" | "image") => {
if (k === "image" && !hatch.image) {
onPatch({ kind: k, image: { src: "", scaleX: 1, scaleY: 1, rotation: 0 } });
} else {
onPatch({ kind: k });
}
};
/** Merge-Patch für die Bild-Parameter (behält bestehende Werte). */
const patchImage = (part: Partial<NonNullable<HatchStyle["image"]>>) => {
const cur = hatch.image ?? { src: "", scaleX: 1, scaleY: 1, rotation: 0 };
onPatch({ image: { ...cur, ...part } });
};
/** Datei → Data-URL (readAsDataURL) → image.src. */
const loadImage = (file: File | null) => {
if (!file) return;
const r = new FileReader();
r.onload = () => patchImage({ src: String(r.result ?? "") });
r.readAsDataURL(file);
};
return ( return (
<ResRow> <div className="res-md-detail-inner">
<ResCell> <div className="res-md-head">
<TextField
value={hatch.name}
onChange={(name) => onPatch({ name })}
placeholder={t("resources.hatches.placeholder")}
/>
</ResCell>
<ResCell>
<SelectField
value={hatch.pattern}
onChange={(pattern) => onPatch({ pattern })}
options={PATTERN_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
/>
</ResCell>
<ResCell align="right">
<NumberField
value={hatch.scale}
onChange={(scale) => onPatch({ scale })}
step={0.1}
min={0.01}
/>
</ResCell>
<ResCell align="right">
<NumberField
value={hatch.angle}
onChange={(angle) => onPatch({ angle })}
step={5}
/>
</ResCell>
<ResCell>
<input <input
type="checkbox" className="res-input res-md-rename"
checked={!!hatch.relativeToWall} value={hatch.name}
onChange={(e) => onPatch({ relativeToWall: e.target.checked })} placeholder={t("resources.hatches.placeholder")}
title={t("resources.col.relativeToWall.hint")} onChange={(e) => onPatch({ name: e.target.value })}
/> />
</ResCell>
<ResCell>
<ColorField color={hatch.color} onChange={(color) => onPatch({ color })} />
</ResCell>
<ResCell>
<SelectField
value={hatch.lineStyleId ?? NONE}
onChange={(v) => onPatch({ lineStyleId: v === NONE ? undefined : v })}
options={lineOptions}
/>
</ResCell>
<ResCell>
<DeleteButton <DeleteButton
onClick={onDelete} onClick={onDelete}
title={t("resources.delete.hatch", { name: hatch.name })} title={t("resources.delete.hatch", { name: hatch.name })}
/> />
</ResCell> </div>
</ResRow>
<div className="res-md-preview">
<HatchSwatch hatch={hatch} size={128} />
</div>
<div className="res-fields">
<FieldRow label={t("resources.field.type")}>
<Segmented
value={kind}
onChange={setKind}
options={[
{ value: "vector", label: t("resources.hatchKind.vector") },
{ value: "image", label: t("resources.hatchKind.image") },
]}
/>
</FieldRow>
{kind === "vector" ? (
<>
<FieldRow label={t("resources.field.lines")}>
<Segmented
value={lines}
onChange={(v) => onPatch({ lines: v })}
options={[
{ value: "parallel", label: t("resources.lines.parallel") },
{ value: "random", label: t("resources.lines.random") },
]}
/>
</FieldRow>
<FieldRow label={t("resources.col.pattern")}>
<SelectField
value={hatch.pattern}
onChange={(pattern) => onPatch({ pattern })}
options={PATTERN_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
/>
</FieldRow>
<FieldRow label={t("resources.col.scale")}>
<NumberField
value={hatch.scale}
onChange={(scale) => onPatch({ scale })}
step={0.1}
min={0.01}
/>
</FieldRow>
<FieldRow label={t("resources.col.angle")}>
<NumberField
value={hatch.angle}
onChange={(angle) => onPatch({ angle })}
step={5}
/>
</FieldRow>
<FieldRow
label={t("resources.col.relativeToWall")}
hint={t("resources.col.relativeToWall.hint")}
>
<input
type="checkbox"
checked={!!hatch.relativeToWall}
onChange={(e) => onPatch({ relativeToWall: e.target.checked })}
/>
</FieldRow>
<FieldRow label={t("resources.col.lineStyle")}>
<SelectField
value={hatch.lineStyleId ?? NONE}
onChange={(v) => onPatch({ lineStyleId: v === NONE ? undefined : v })}
options={lineOptions}
/>
</FieldRow>
</>
) : (
<>
<FieldRow label={t("resources.field.image")}>
<ImagePickButton onFile={loadImage} />
</FieldRow>
<FieldRow label={t("resources.field.scaleX")}>
<NumberField
value={hatch.image?.scaleX ?? 1}
onChange={(scaleX) => patchImage({ scaleX })}
step={0.1}
min={0.05}
/>
</FieldRow>
<FieldRow label={t("resources.field.scaleY")}>
<NumberField
value={hatch.image?.scaleY ?? 1}
onChange={(scaleY) => patchImage({ scaleY })}
step={0.1}
min={0.05}
/>
</FieldRow>
<FieldRow label={t("resources.field.rotation")}>
<NumberField
value={hatch.image?.rotation ?? 0}
onChange={(rotation) => patchImage({ rotation })}
step={5}
/>
</FieldRow>
</>
)}
</div>
</div>
); );
} }
@@ -975,32 +1113,55 @@ function HatchesTab({
onDeleteHatch: (id: string) => void; onDeleteHatch: (id: string) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void; onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
}) { }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const hatches = project.hatches;
// Auswahl gegen die Liste validieren (nach Delete/Add stabil bleiben).
const selected = hatches.find((h) => h.id === selectedId) ?? hatches[0];
return ( return (
<div className="res-tab"> <div className="res-tab">
<div className="res-hint">{t("resources.hatches.hint")}</div> <div className="res-hint">{t("resources.hatches.hint")}</div>
<ResTable columns={HATCH_COLUMNS} template={HATCH_TEMPLATE}> <div className="res-md">
{project.hatches.map((h) => ( <div className="res-md-list">
<HatchRow {hatches.map((h) => (
key={h.id} <button
hatch={h} key={h.id}
lineStyles={project.lineStyles} className={`res-md-row${selected?.id === h.id ? " active" : ""}`}
onPatch={(patch) => onPatchHatch(h.id, patch)} onClick={() => setSelectedId(h.id)}
onDelete={() => onDeleteHatch(h.id)} >
/> <span className="res-md-row-thumb">
))} <HatchSwatch hatch={h} size={30} />
{project.hatches.length === 0 && ( </span>
<EmptyState text={t("resources.hatches.empty")} /> <span className="res-md-row-name">{h.name}</span>
)} </button>
</ResTable> ))}
<div className="res-add-row"> {hatches.length === 0 && (
<button className="res-add" onClick={onAddHatch}> <div className="res-md-empty">{t("resources.hatches.empty")}</div>
<span className="res-add-plus">+</span> {t("resources.add")} )}
</button> <div className="res-md-listfoot">
<FileImportButton <button className="res-add" onClick={onAddHatch}>
accept=".pat,text/plain" <span className="res-add-plus">+</span> {t("resources.add")}
label={t("resources.import.pat")} </button>
onText={(text) => onImportHatches(patToHatches(text))} <FileImportButton
/> accept=".pat,text/plain"
label={t("resources.import.pat")}
onText={(text) => onImportHatches(patToHatches(text))}
/>
</div>
</div>
<div className="res-md-detail">
{selected ? (
<HatchDetail
key={selected.id}
hatch={selected}
lineStyles={project.lineStyles}
onPatch={(patch) => onPatchHatch(selected.id, patch)}
onDelete={() => onDeleteHatch(selected.id)}
/>
) : (
<div className="res-md-empty">{t("resources.hatches.empty")}</div>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -1008,40 +1169,8 @@ function HatchesTab({
// ── Linien (Line Styles) ─────────────────────────────────────────────────── // ── Linien (Line Styles) ───────────────────────────────────────────────────
/** Kleine Vorschau eines Linienstils (Stärke + Strichmuster) als SVG. */ /** Detail-Panel EINES Linienstils (rechte Seite des Master-Detail-Layouts). */
function LinePreview({ style }: { style: LineStyle }) { function LineStyleDetail({
// Stärke (mm) optisch skaliert auf 16 px für die Vorschau.
const px = Math.max(1, Math.min(6, style.weight * 6));
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
return (
<svg className="res-line-preview" viewBox="0 0 120 12" preserveAspectRatio="none">
<line
x1={4}
y1={6}
x2={116}
y2={6}
stroke={style.color}
strokeWidth={px}
strokeDasharray={dasharray}
strokeLinecap="butt"
/>
</svg>
);
}
// Spalten: Name | Stärke (mm) | Farbe | Strich | Vorschau | (löschen).
const LINE_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.name" },
{ titleKey: "resources.col.weight", align: "right" },
{ titleKey: "resources.col.color" },
{ titleKey: "resources.col.stroke" },
{ titleKey: "resources.col.preview" },
{ titleKey: "" },
];
const LINE_TEMPLATE =
"minmax(110px, 1fr) 80px auto minmax(120px, 0.9fr) minmax(90px, 1fr) 36px";
function LineStyleRow({
style, style,
onPatch, onPatch,
onDelete, onDelete,
@@ -1050,48 +1179,99 @@ function LineStyleRow({
onPatch: (patch: Partial<LineStyle>) => void; onPatch: (patch: Partial<LineStyle>) => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const kind = style.kind ?? "dash";
/** Wechselt den Typ; beim Umschalten auf „Zickzack" Default-Parameter setzen. */
const setKind = (k: "dash" | "zigzag") => {
if (k === "zigzag" && !style.zigzag) {
onPatch({ kind: k, zigzag: { amplitude: 1, wavelength: 4 } });
} else {
onPatch({ kind: k });
}
};
/** Merge-Patch für die Zickzack-Parameter. */
const patchZig = (part: Partial<NonNullable<LineStyle["zigzag"]>>) => {
const cur = style.zigzag ?? { amplitude: 1, wavelength: 4 };
onPatch({ zigzag: { ...cur, ...part } });
};
return ( return (
<ResRow> <div className="res-md-detail-inner">
<ResCell> <div className="res-md-head">
<TextField <input
className="res-input res-md-rename"
value={style.name} value={style.name}
onChange={(name) => onPatch({ name })}
placeholder={t("resources.lines.placeholder")} placeholder={t("resources.lines.placeholder")}
onChange={(e) => onPatch({ name: e.target.value })}
/> />
</ResCell>
<ResCell align="right">
<NumberField
value={style.weight}
onChange={(weight) => onPatch({ weight })}
step={0.05}
min={0.01}
max={2}
list="pen-weights"
/>
</ResCell>
<ResCell>
<ColorField color={style.color} onChange={(color) => onPatch({ color })} />
</ResCell>
<ResCell>
<SelectField
value={dashKey(style.dash)}
onChange={(key) => {
const opt = DASH_OPTIONS.find((o) => o.key === key);
if (opt) onPatch({ dash: opt.dash });
}}
options={DASH_OPTIONS.map((o) => ({ value: o.key, label: t(o.labelKey) }))}
/>
</ResCell>
<ResCell>
<LinePreview style={style} />
</ResCell>
<ResCell>
<DeleteButton <DeleteButton
onClick={onDelete} onClick={onDelete}
title={t("resources.delete.lineStyle", { name: style.name })} title={t("resources.delete.lineStyle", { name: style.name })}
/> />
</ResCell> </div>
</ResRow>
<div className="res-md-preview">
<LineSwatch style={style} width={220} height={40} />
</div>
<div className="res-fields">
<FieldRow label={t("resources.field.type")}>
<Segmented
value={kind}
onChange={setKind}
options={[
{ value: "dash", label: t("resources.lineKind.dash") },
{ value: "zigzag", label: t("resources.lineKind.zigzag") },
]}
/>
</FieldRow>
<FieldRow label={t("resources.col.weight")}>
<NumberField
value={style.weight}
onChange={(weight) => onPatch({ weight })}
step={0.05}
min={0.01}
max={2}
list="pen-weights"
/>
</FieldRow>
<FieldRow label={t("resources.col.color")}>
<ColorField color={style.color} onChange={(color) => onPatch({ color })} />
</FieldRow>
{kind === "zigzag" ? (
<>
<FieldRow label={t("resources.field.amplitude")}>
<NumberField
value={style.zigzag?.amplitude ?? 1}
onChange={(amplitude) => patchZig({ amplitude })}
step={0.1}
min={0.05}
/>
</FieldRow>
<FieldRow label={t("resources.field.wavelength")}>
<NumberField
value={style.zigzag?.wavelength ?? 4}
onChange={(wavelength) => patchZig({ wavelength })}
step={0.5}
min={0.5}
/>
</FieldRow>
</>
) : (
<FieldRow label={t("resources.col.stroke")}>
<SelectField
value={dashKey(style.dash)}
onChange={(key) => {
const opt = DASH_OPTIONS.find((o) => o.key === key);
if (opt) onPatch({ dash: opt.dash });
}}
options={DASH_OPTIONS.map((o) => ({ value: o.key, label: t(o.labelKey) }))}
/>
</FieldRow>
)}
</div>
</div>
); );
} }
@@ -1108,6 +1288,10 @@ function LinesTab({
onDeleteLineStyle: (id: string) => void; onDeleteLineStyle: (id: string) => void;
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void; onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
}) { }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const styles = project.lineStyles;
const selected = styles.find((l) => l.id === selectedId) ?? styles[0];
return ( return (
<div className="res-tab"> <div className="res-tab">
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */} {/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */}
@@ -1117,28 +1301,46 @@ function LinesTab({
))} ))}
</datalist> </datalist>
<div className="res-hint">{t("resources.lines.hint")}</div> <div className="res-hint">{t("resources.lines.hint")}</div>
<ResTable columns={LINE_COLUMNS} template={LINE_TEMPLATE}> <div className="res-md">
{project.lineStyles.map((l) => ( <div className="res-md-list">
<LineStyleRow {styles.map((l) => (
key={l.id} <button
style={l} key={l.id}
onPatch={(patch) => onPatchLineStyle(l.id, patch)} className={`res-md-row res-md-row-line${
onDelete={() => onDeleteLineStyle(l.id)} selected?.id === l.id ? " active" : ""
/> }`}
))} onClick={() => setSelectedId(l.id)}
{project.lineStyles.length === 0 && ( >
<EmptyState text={t("resources.lines.empty")} /> <span className="res-md-row-name">{l.name}</span>
)} <LineSwatch style={l} width={150} height={14} />
</ResTable> </button>
<div className="res-add-row"> ))}
<button className="res-add" onClick={onAddLineStyle}> {styles.length === 0 && (
<span className="res-add-plus">+</span> {t("resources.add")} <div className="res-md-empty">{t("resources.lines.empty")}</div>
</button> )}
<FileImportButton <div className="res-md-listfoot">
accept=".lin,text/plain" <button className="res-add" onClick={onAddLineStyle}>
label={t("resources.import.lin")} <span className="res-add-plus">+</span> {t("resources.add")}
onText={(text) => onImportLineStyles(linToStyles(text))} </button>
/> <FileImportButton
accept=".lin,text/plain"
label={t("resources.import.lin")}
onText={(text) => onImportLineStyles(linToStyles(text))}
/>
</div>
</div>
<div className="res-md-detail">
{selected ? (
<LineStyleDetail
key={selected.id}
style={selected}
onPatch={(patch) => onPatchLineStyle(selected.id, patch)}
onDelete={() => onDeleteLineStyle(selected.id)}
/>
) : (
<div className="res-md-empty">{t("resources.lines.empty")}</div>
)}
</div>
</div> </div>
</div> </div>
); );
+308
View File
@@ -0,0 +1,308 @@
// Live-SVG-Vorschau (Swatch) für Schraffuren und Linienstile — eigenständig.
//
// Bewusst OHNE Rückgriff auf die Plan-Renderer (generatePlan/toRenderScene):
// diese Datei zeichnet die Muster direkt aus den HatchStyle-/LineStyle-Parametern
// als SVG nach, damit die Vorschau im Ressourcen-Manager auch dann konsistent
// bleibt, während der Plan-Renderer separat umgebaut wird. Die Farbe kommt hier
// bewusst NICHT vom (deprecated) HatchStyle.color, sondern über `currentColor`
// aus dem umgebenden CSS (var(--ink)) — die echte Vordergrundfarbe stammt im Plan
// vom Bauteil/Attribut. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useId } from "react";
import type { HatchStyle, LineStyle } from "../model/types";
// ── Kleiner deterministischer PRNG (mulberry32) ────────────────────────────
// Für „random"-Striche: stabil pro Seed, damit die Vorschau bei gleichem
// Maßstab/Winkel nicht bei jedem Render „springt".
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Parallele Linienschar über die Box (w×h) unter `angleDeg`, Abstand `spacing`
* px senkrecht zur Linienrichtung. Liefert fertige <line>-Elemente, die die Box
* komplett überdecken (Clip übernimmt der Aufrufer).
*/
function parallelLines(
w: number,
h: number,
angleDeg: number,
spacing: number,
strokeWidth: number,
keyPrefix: string,
): React.ReactNode[] {
const a = (angleDeg * Math.PI) / 180;
const cos = Math.cos(a);
const sin = Math.sin(a);
// Senkrechte zur Linienrichtung.
const px = -sin;
const py = cos;
const cx = w / 2;
const cy = h / 2;
const half = Math.hypot(w, h); // Linien lang genug, um die Box zu überdecken.
const step = Math.max(spacing, 1.2);
const n = Math.ceil(half / step) + 2;
const out: React.ReactNode[] = [];
for (let k = -n; k <= n; k++) {
const mx = cx + k * step * px;
const my = cy + k * step * py;
out.push(
<line
key={`${keyPrefix}${k}`}
x1={mx - cos * half}
y1={my - sin * half}
x2={mx + cos * half}
y2={my + sin * half}
stroke="currentColor"
strokeWidth={strokeWidth}
/>,
);
}
return out;
}
/**
* 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 patId = `hsp_${rawId}`;
const w = size;
const h = size;
// Grundabstand der Musterlinien (px), über den Maßstab moduliert.
const scale = hatch.scale > 0 ? hatch.scale : 1;
const spacing = Math.max(3, 8 / Math.sqrt(scale));
const angle = hatch.angle || 0;
let inner: React.ReactNode = null;
if (hatch.kind === "image") {
// 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 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;
if (src) {
inner = (
<>
<defs>
<pattern
id={patId}
patternUnits="userSpaceOnUse"
width={tileW}
height={tileH}
patternTransform={`rotate(${rot})`}
>
<image
href={src}
x={0}
y={0}
width={tileW}
height={tileH}
preserveAspectRatio="none"
/>
</pattern>
</defs>
<rect x={0} y={0} width={w} height={h} fill={`url(#${patId})`} />
</>
);
} else {
inner = (
<text
x={w / 2}
y={h / 2}
textAnchor="middle"
dominantBaseline="central"
fontSize={9}
fill="currentColor"
opacity={0.5}
>
?
</text>
);
}
} else if (hatch.pattern === "solid") {
inner = <rect x={0} y={0} width={w} height={h} fill="currentColor" />;
} else if (hatch.pattern === "none") {
inner = null;
} else if (hatch.lines === "random") {
// Zufällig verteilte kurze Striche (deterministisch geseedet).
const rnd = mulberry32(Math.round(scale * 1000) + Math.round(angle));
const count = Math.round(18 * Math.min(2, scale)) + 6;
const len = Math.max(3, 6 / Math.sqrt(scale));
const out: React.ReactNode[] = [];
for (let i = 0; i < count; i++) {
const cx = rnd() * w;
const cy = rnd() * h;
const ang = rnd() * Math.PI;
const dx = (Math.cos(ang) * len) / 2;
const dy = (Math.sin(ang) * len) / 2;
out.push(
<line
key={i}
x1={cx - dx}
y1={cy - dy}
x2={cx + dx}
y2={cy + dy}
stroke="currentColor"
strokeWidth={0.9}
strokeLinecap="round"
/>,
);
}
inner = out;
} else if (hatch.pattern === "crosshatch") {
inner = [
...parallelLines(w, h, angle, spacing, 0.8, "a"),
...parallelLines(w, h, angle + 90, spacing, 0.8, "b"),
];
} else if (hatch.pattern === "insulation") {
// Dämmung: eine gewellte Schar quer über die Box (angenähert als Zickzack).
const rows = Math.max(2, Math.round(h / Math.max(6, spacing)));
const out: React.ReactNode[] = [];
for (let r = 0; r < rows; r++) {
const y = ((r + 0.5) / rows) * h;
const amp = Math.min(h / rows / 2, 3);
const waves = Math.max(2, Math.round(w / 6));
let d = `M 0 ${y}`;
for (let i = 1; i <= waves; i++) {
const x = (i / waves) * w;
const yy = y + (i % 2 === 0 ? -amp : amp);
d += ` L ${x.toFixed(1)} ${yy.toFixed(1)}`;
}
out.push(
<path key={r} d={d} fill="none" stroke="currentColor" strokeWidth={0.8} />,
);
}
inner = out;
} else {
// "diagonal" (Default): eine parallele Schar.
inner = parallelLines(w, h, angle, spacing, 0.8, "d");
}
return (
<svg
className="res-swatch"
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>
</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})`}>{inner}</g>
</svg>
);
}
/**
* Live-SVG-Swatch eines Linienstils. `kind==="zigzag"` zeichnet eine Zickzack-/
* Wellenlinie aus `amplitude`/`wavelength` (mm Papier, optisch skaliert); sonst
* eine gerade Linie mit `dash`-Strichmuster. `weight` (mm) wird auf eine
* sichtbare Pixelstärke abgebildet. Farbe über `currentColor`.
*/
export function LineSwatch({
style,
width = 120,
height = 22,
}: {
style: LineStyle;
width?: number;
height?: number;
}) {
const px = Math.max(1, Math.min(6, style.weight * 6));
const midY = height / 2;
const x0 = 4;
const x1 = width - 4;
let path: React.ReactNode;
if (style.kind === "zigzag") {
const amp = Math.max(1, Math.min(height / 2 - 2, (style.zigzag?.amplitude ?? 1) * 4));
const wl = Math.max(4, (style.zigzag?.wavelength ?? 4) * 4);
let d = `M ${x0} ${midY}`;
// Halbperioden-Segmente: abwechselnd nach oben/unten.
let x = x0;
let up = true;
let seg = 0;
while (x < x1) {
const nx = Math.min(x + wl / 2, x1);
const ny = midY + (up ? -amp : amp);
d += ` L ${nx.toFixed(1)} ${ny.toFixed(1)}`;
x = nx;
up = !up;
seg++;
if (seg > 200) break;
}
path = (
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth={px}
strokeLinejoin="round"
strokeLinecap="round"
/>
);
} else {
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
path = (
<line
x1={x0}
y1={midY}
x2={x1}
y2={midY}
stroke="currentColor"
strokeWidth={px}
strokeDasharray={dasharray}
strokeLinecap="butt"
/>
);
}
return (
<svg
className="res-line-preview"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
>
{path}
</svg>
);
}