// 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"; import { dashHasDot } from "./lineSegments"; // ── 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 -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( , ); } return out; } /** * 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 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 = ( <> ); } else { inner = ( ? ); } } else if (hatch.pattern === "solid") { inner = ; } else if (hatch.pattern === "none") { inner = null; } else if (hatch.lines === "random") { // Zufällig verteilte kurze Striche (deterministisch geseedet). Dichte, Länge // und der explizite Seed spiegeln die Plan-Parameter (HatchStyle) — so ändert // „Neu würfeln"/Dichte/Länge die Vorschau wie im Plan. Rein schematisch (px), // NICHT deckungsgleich mit der Modell-Streuung, aber qualitativ konsistent. const density = hatch.density != null && hatch.density > 0 ? hatch.density : 1; const seed = (Math.round(scale * 1000) + Math.round(angle) + Math.round(hatch.seed ?? 0)) >>> 0; const rnd = mulberry32(seed); const count = Math.min(400, Math.max(4, Math.round((18 * Math.min(2, scale) + 6) * density))); // mm-Papier → Vorschau-px (wie das Dash-Muster im LineSwatch: ·4). const PX_PER_MM = 4; const hasRange = hatch.lengthMin != null && hatch.lengthMax != null && hatch.lengthMin > 0 && hatch.lengthMax >= hatch.lengthMin; const baseLen = 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 lr = rnd(); const len = hasRange ? (hatch.lengthMin! + lr * (hatch.lengthMax! - hatch.lengthMin!)) * PX_PER_MM : baseLen; const dx = (Math.cos(ang) * len) / 2; const dy = (Math.sin(ang) * len) / 2; out.push( , ); } 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( , ); } inner = out; } else { // "diagonal" (Default): eine parallele Schar. inner = parallelLines(w, h, angle, spacing, 0.8, "d"); } return ( {inner} ); } /** * 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 = ( ); } else if (style.kind === "custom" && style.motif && style.motif.points.length >= 2) { // Custom-Motiv: die Einheitszelle (mm) wird entlang der Linie gekachelt // (mm-Papier → px ·4, wie das Dash-Muster). Deckungsgleich zur motifPoints- // Kachelung im Plan-Renderer, hier nur schematisch/px. const PX_PER_MM = 4; const motif = style.motif; const cellPx = Math.max(2, motif.length * PX_PER_MM); let d = ""; let started = false; let lastX = -1; let lastY = 0; let x = x0; let rep = 0; outer: while (x <= x1 && rep < 400) { const base = x0 + rep * cellPx; for (const p of motif.points) { const px2 = base + p.x * PX_PER_MM; if (px2 > x1 + 0.5) break outer; const py2 = midY - p.y * PX_PER_MM; if (Math.abs(px2 - lastX) < 0.01 && Math.abs(py2 - lastY) < 0.01) continue; d += `${started ? " L" : "M"} ${px2.toFixed(1)} ${py2.toFixed(1)}`; started = true; lastX = px2; lastY = py2; } x = base + cellPx; rep++; } path = ( ); } else if (!style.dash || !style.dash.length) { // Volllinie: durchgezogen, eine dunkle Linie. path = ( ); } else { // Muster-Linie (Strich/Punkt/Lücke, loopend): den ERSTEN Loop in Ink-Farbe // (currentColor), danach 2 weitere Loops in Grau (var(--muted)) — so sieht man // den fortgesetzten Loop. Punkte (0-Längen-Segmente) brauchen runde Kappen. const PX_PER_MM = 4; const dpx = style.dash.map((d) => d * PX_PER_MM); const dasharray = dpx.join(" "); const loopLen = dpx.reduce((a, b) => a + b, 0) || PX_PER_MM; const cap = dashHasDot(style.dash) ? "round" : "butt"; // Der graue Teil beginnt exakt an der Loop-Grenze (x0 + loopLen) → das Muster // läuft phasengleich weiter (dash startet bei jedem Pfadanfang neu). const darkEnd = Math.min(x1, x0 + loopLen); const greyEnd = Math.min(x1, x0 + loopLen * 3); path = ( <> {greyEnd > darkEnd && ( )} ); } return ( {path} ); }