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
+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>
);
}