diff --git a/src/export/exportDxf.ts b/src/export/exportDxf.ts index 64e3f76..42bdca8 100644 --- a/src/export/exportDxf.ts +++ b/src/export/exportDxf.ts @@ -12,6 +12,7 @@ import type { Plan, Primitive } from "../plan/generatePlan"; import type { LayerCategory, Project, Vec2 } from "../model/types"; import { flattenCategories } from "../model/types"; import { DxfWriter } from "./dxfWriter"; +import { buildRandomHatchRuns, zigzagPoints } from "../plan/glPlan/glPlanHatch"; export interface ExportDxfOptions { /** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */ @@ -106,7 +107,15 @@ function emitPrimitive( ): void { switch (p.kind) { case "line": - dxf.addLine(layer, p.a, p.b); + if (p.zigzag) { + // Zickzack-Linie → offene Polylinie (amplitude/wavelength Papier-mm → + // Modell-Meter über dieselbe Kopplung wie das Strichmuster oben). + const amp = p.zigzag.amplitude * DASH_MM_TO_M; + const wav = p.zigzag.wavelength * DASH_MM_TO_M; + dxf.addPolyline(layer, zigzagPoints(p.a, p.b, amp, wav), false); + } else { + dxf.addLine(layer, p.a, p.b); + } break; case "arc": @@ -121,13 +130,24 @@ function emitPrimitive( dxf.addPolyline(layer, pts, true); // Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine // sichtbaren Musterlinien → wir geben nur echte Muster (nicht solid/none) aus. + // Bild-Schraffur (kind==="image"): DXF trägt keine Rasterbilder → + // FALLBACK ohne Musterlinien (nur die Umriss-Polylinie oben). Als + // Folge-Arbeit dokumentiert. Random-Vektor: deterministische Streu-Striche + // (Modell-Meter, identischer Seed wie Bildschirm/PDF). if ( includeHatches && + p.hatch.kind !== "image" && p.hatch.pattern !== "none" && p.hatch.pattern !== "solid" ) { const cycle = dashCycleMeters(p.hatch.dash); - for (const seg of hatchSegments(pts, p.hatch)) { + const segs: Seg[] = + p.hatch.lines === "random" + ? buildRandomHatchRuns(pts, p.hatch) + .filter((r) => r.length >= 2) + .map((r) => ({ a: r[0], b: r[r.length - 1] })) + : hatchSegments(pts, p.hatch); + for (const seg of segs) { if (cycle) { for (const d of dashSeg(seg, cycle)) dxf.addLine(LAYER_HATCH, d.a, d.b); } else { diff --git a/src/export/planToPrintSvg.ts b/src/export/planToPrintSvg.ts index 15ecb7d..66fd888 100644 --- a/src/export/planToPrintSvg.ts +++ b/src/export/planToPrintSvg.ts @@ -23,10 +23,75 @@ import type { HatchRender, Plan, Primitive } from "../plan/generatePlan"; import type { Vec2 } from "../model/types"; +import { buildRandomHatchRuns, zigzagPoints } from "../plan/glPlan/glPlanHatch"; /** SVG-Namespace für die programmatisch erzeugten Knoten. */ const SVG_NS = "http://www.w3.org/2000/svg"; +/** Basis-Kachelmaß (mm) einer Bild-Schraffur bei scaleX/scaleY = 1. */ +const IMG_TILE_MM = 20; + +/** Monoton wachsender Zähler für eindeutige ``-IDs im Dokument. */ +let patternSeq = 0; + +/** Liefert (oder erzeugt) das gemeinsame ``-Element des SVG. */ +function ensureDefs(svg: SVGSVGElement): SVGDefsElement { + const existing = svg.querySelector("defs"); + if (existing) return existing as SVGDefsElement; + const defs = document.createElementNS(SVG_NS, "defs") as SVGDefsElement; + // Vor den Inhalt setzen, damit Referenzen (url(#…)) sicher aufgelöst werden. + svg.insertBefore(defs, svg.firstChild); + return defs; +} + +/** + * Legt ein gekacheltes Bild-`` an und liefert dessen ID. `scaleX`/ + * `scaleY` verzerren die Kachel unabhängig in L×B (preserveAspectRatio="none"), + * `rotation` (Grad) dreht das Muster (patternTransform). Einheiten = Papier-mm. + */ +function ensureImagePattern( + svg: SVGSVGElement, + image: NonNullable, +): string { + const id = `imgpat-${patternSeq++}`; + const w = Math.max(0.5, IMG_TILE_MM * (image.scaleX > 0 ? image.scaleX : 1)); + const h = Math.max(0.5, IMG_TILE_MM * (image.scaleY > 0 ? image.scaleY : 1)); + const pattern = document.createElementNS(SVG_NS, "pattern"); + pattern.setAttribute("id", id); + pattern.setAttribute("patternUnits", "userSpaceOnUse"); + pattern.setAttribute("width", String(round(w))); + pattern.setAttribute("height", String(round(h))); + if (image.rotation) pattern.setAttribute("patternTransform", `rotate(${image.rotation})`); + const img = document.createElementNS(SVG_NS, "image"); + img.setAttribute("href", image.src); + img.setAttribute("x", "0"); + img.setAttribute("y", "0"); + img.setAttribute("width", String(round(w))); + img.setAttribute("height", String(round(h))); + img.setAttribute("preserveAspectRatio", "none"); + pattern.appendChild(img); + ensureDefs(svg).appendChild(pattern); + return id; +} + +/** ``-Knoten (mm-Koordinaten) — für Zickzack-Linien/-Läufe. */ +function polylineNode( + pts: Vec2[], + color: string, + strokeMm: number, + dash: number[] | null | undefined, +): SVGElement { + const el = document.createElementNS(SVG_NS, "polyline"); + el.setAttribute("points", pts.map((p) => `${round(p.x)},${round(p.y)}`).join(" ")); + el.setAttribute("fill", "none"); + el.setAttribute("stroke", color); + el.setAttribute("stroke-width", String(strokeMm)); + el.setAttribute("stroke-linecap", "round"); + el.setAttribute("stroke-linejoin", "round"); + if (dash && dash.length) el.setAttribute("stroke-dasharray", dash.map((d) => round(d)).join(" ")); + return el; +} + /** * Standard-Stift-Stufen (mm). Beliebige Kategorie-Strichstärken werden auf die * NÄCHSTHÖHERE dieser ISO-nahen Stufen gequantelt, damit der Plan saubere, @@ -160,7 +225,16 @@ function appendPrimitive( case "line": { const a = map(p.a); const b = map(p.b); - const ln = lineNode(a, b, p.color ?? "#111111", quantizePen(p.weightMm), p.dash, mmPerM); + // Zickzack-Linie (amplitude/wavelength sind Papier-mm = mm-Zielraum) → + // Polylinie; sonst gerade (ggf. gestrichelte) Linie wie bisher. + const ln = p.zigzag + ? polylineNode( + zigzagPoints(a, b, p.zigzag.amplitude, p.zigzag.wavelength), + p.color ?? "#111111", + quantizePen(p.weightMm), + p.dash, + ) + : lineNode(a, b, p.color ?? "#111111", quantizePen(p.weightMm), p.dash, mmPerM); if (opacity) ln.setAttribute("opacity", opacity); svg.appendChild(ln); break; @@ -195,7 +269,15 @@ function appendPolygon( if (opacity) g.setAttribute("opacity", opacity); const pattern = p.hatch.pattern; - if (pattern === "none") { + if (p.hatch.kind === "image" && p.hatch.image) { + // Bild-Schraffur: gekacheltes mit (scaleX/scaleY/rotation) + // über die Fläche, unabhängig vom `pattern`-Feld. Grundfüllung darunter, + // Umriss obenauf. + if (p.fill !== "none") g.appendChild(polyNode(ptsAttr, p.fill, "none", 0)); + const patId = ensureImagePattern(svg, p.hatch.image); + g.appendChild(polyNode(ptsAttr, `url(#${patId})`, "none", 0)); + if (strokeMm > 0) g.appendChild(polyNode(ptsAttr, "none", p.stroke, strokeMm)); + } else if (pattern === "none") { g.appendChild(polyNode(ptsAttr, p.fill, p.stroke, strokeMm)); } else if (pattern === "solid") { g.appendChild(polyNode(ptsAttr, p.hatch.color, p.stroke, strokeMm)); @@ -205,17 +287,36 @@ function appendPolygon( if (p.fill !== "none") { g.appendChild(polyNode(ptsAttr, p.fill, "none", 0)); } - for (const seg of hatchLines(ptsMm, p.hatch, mmPerM)) { - g.appendChild( - lineNode( - seg.a, - seg.b, - p.hatch.color, - quantizePen(p.hatch.lineWeight), - p.hatch.dash, - mmPerM, - ), - ); + if (p.hatch.lines === "random") { + // Random-Vektor (Kies/Splitt): deterministische Streu-Striche. Wird im + // MODELL-Raum erzeugt (identischer Seed/Geometrie wie Bildschirm/GL/PDF) + // und dann nach Papier-mm abgebildet. + for (const run of buildRandomHatchRuns(p.pts, p.hatch)) { + if (run.length < 2) continue; + g.appendChild( + lineNode( + map(run[0]), + map(run[run.length - 1]), + p.hatch.color, + quantizePen(p.hatch.lineWeight), + null, + mmPerM, + ), + ); + } + } else { + for (const seg of hatchLines(ptsMm, p.hatch, mmPerM)) { + g.appendChild( + lineNode( + seg.a, + seg.b, + p.hatch.color, + quantizePen(p.hatch.lineWeight), + p.hatch.dash, + mmPerM, + ), + ); + } } if (strokeMm > 0) { g.appendChild(polyNode(ptsAttr, "none", p.stroke, strokeMm)); diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 739e489..8de7134 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -20,6 +20,7 @@ import type { DraftShape, SnapResult, ToolHandlers, ToolId, ToolMods } from "../ import { useGlPlanRenderer } from "./useGlPlanRenderer"; import { useWasmPlanRenderer } from "./useWasmPlanRenderer"; import { quantizePen } from "../export/sceneToPrintSvg"; +import { buildRandomHatchRuns, zigzagPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch"; const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum) const PAD = 60; // Rand in viewBox-Einheiten @@ -1601,7 +1602,7 @@ export const PlanView = forwardRef( const hatched = useMemo(() => { const list: HatchedPoly[] = []; plan.primitives.forEach((p, i) => { - if (p.kind === "polygon" && needsPattern(p.hatch.pattern)) { + if (p.kind === "polygon" && polyUsesPattern(p.hatch)) { list.push({ patternId: `hatch-${i}`, hatch: p.hatch }); } }); @@ -2366,6 +2367,9 @@ function onSegment(a: Vec2, b: Vec2, p: Vec2): boolean { ); } +/** Basis-Kachelmaß (viewBox-Einheiten) einer Bild-Schraffur bei scaleX/scaleY = 1. */ +const IMG_TILE_VB = 40; + /** Lineare/Kreuz-Muster brauchen ein ; solid/none nicht. */ function needsPattern(pattern: HatchRender["pattern"]): boolean { return ( @@ -2375,6 +2379,19 @@ function needsPattern(pattern: HatchRender["pattern"]): boolean { ); } +/** + * Ob ein Polygon ein SVG-`` (in ``) braucht: + * • Bild-Schraffur (`kind==="image"`) → ja (gekacheltes Bild-Muster). + * • Random-Vektor (`lines==="random"`) → NEIN: die Streu-Striche werden als + * geclippte Polylinien direkt gezeichnet (Determinismus, Parität zu GL/PDF). + * • sonst die klassischen Parallel-/Kreuz-/Dämmungs-Muster. + */ +function polyUsesPattern(h: HatchRender): boolean { + if (h.kind === "image") return !!h.image; + if (h.lines === "random") return false; + return needsPattern(h.pattern); +} + /** * Ein parametrisiertes Schraffur-. Maßstab skaliert die Kachelgröße, * Winkel dreht das Muster (patternTransform), Farbe/Linienstärke kommen aus dem @@ -2407,6 +2424,26 @@ function HatchPattern({ .join(" ") : undefined; + if (hatch.kind === "image" && hatch.image) { + // Bild-Schraffur: gekacheltes -Muster. Kachel-Basismaß in viewBox- + // Einheiten, unabhängig in L×B verzerrt (scaleX/scaleY), um `rotation` gedreht. + // preserveAspectRatio="none" erlaubt die Verzerrung. + const img = hatch.image; + const w = Math.max(1, IMG_TILE_VB * (img.scaleX > 0 ? img.scaleX : 1)); + const h = Math.max(1, IMG_TILE_VB * (img.scaleY > 0 ? img.scaleY : 1)); + return ( + + + + ); + } + if (hatch.pattern === "insulation") { // Weiche Zickzack-/Wellenlinie (SIA-nah). Kachel 14×10 × Maßstab. const w = 14 * hatch.scale; @@ -2487,6 +2524,8 @@ interface DrawingRun { dash: number[] | null | undefined; color?: string; greyed?: boolean; + /** Zickzack-Parameter (Papier-mm); gesetzt ⇒ Lauf als Zickzack-Pfad zeichnen. */ + zigzag?: { amplitude: number; wavelength: number }; } /** Kleiner Toleranz-Test auf Punktgleichheit (Modell-Meter). */ @@ -2541,6 +2580,22 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] { flush(); continue; } + // Zickzack-Linien werden NICHT verkettet (jede als eigener Zickzack-Lauf) — + // sonst würde die Tessellierung über eine Gehrung hinweg zerreißen. + if (p.zigzag) { + flush(); + runs.push({ + pts: [p.a, p.b], + closed: false, + cls: p.cls, + weightMm: p.weightMm, + dash: p.dash, + color: p.color, + greyed: p.greyed, + zigzag: p.zigzag, + }); + continue; + } if (curPts && curSrc && sameLineStyle(curSrc, p) && samePt(curPts[curPts.length - 1], p.a)) { // An den laufenden Zug anhängen. curPts.push(p.b); @@ -2583,7 +2638,17 @@ function DrawingRunShape({ .map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm))) .join(" ") : undefined; - const pts = run.pts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" "); + // Zickzack-Lauf: im MODELL-Raum tessellieren (amplitude/wavelength Papier-mm → + // Modell-Meter via DASH_MM_TO_M), dann toScreen. Sonst die Roh-Stützpunkte. + const modelPts = run.zigzag + ? zigzagPoints( + run.pts[0], + run.pts[run.pts.length - 1], + run.zigzag.amplitude * DASH_MM_TO_M, + run.zigzag.wavelength * DASH_MM_TO_M, + ) + : run.pts; + const pts = modelPts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" "); const shape = run.closed ? ( ); }; + // Bild-Schraffur: Grundfüllung + gekacheltes Bild-Muster ( aus + // , id=hatch-index) + Umriss — unabhängig vom `pattern`-Feld. + if (p.hatch.kind === "image" && p.hatch.image) { + return ( + + + + {outline()} + + ); + } + // Random-Vektor-Schraffur (Kies/Splitt): Grundfüllung + deterministische + // Streu-Striche (im MODELL-Raum erzeugt → toScreen; identischer Seed wie + // GL/PDF) + Umriss. KEIN (die Striche werden direkt gezeichnet). + if (p.hatch.kind !== "image" && p.hatch.lines === "random") { + const hsw = weight(p.hatch.lineWeight > 0 ? p.hatch.lineWeight : 0.13); + const runs = buildRandomHatchRuns(p.pts, p.hatch); + return ( + + + {runs.map((run, ri) => ( + `${s.x},${s.y}`).join(" ")} + fill="none" + stroke={p.hatch.color} + strokeWidth={hsw} + vectorEffect={vfx} + /> + ))} + {outline()} + + ); + } // Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss). if (p.hatch.pattern === "none") { return ( @@ -2746,6 +2845,27 @@ function renderPrimitive( case "line": { const a = toScreen(p.a); const b = toScreen(p.b); + // Zickzack-Linie: als Polylinie (im MODELL-Raum tesselliert → toScreen; + // amplitude/wavelength Papier-mm → Modell-Meter via DASH_MM_TO_M, wie + // GL/PDF). ADDITIV — gerade/gestrichelte Linien bleiben unverändert. + if (p.zigzag) { + const zpts = zigzagPoints( + p.a, + p.b, + p.zigzag.amplitude * DASH_MM_TO_M, + p.zigzag.wavelength * DASH_MM_TO_M, + ).map(toScreen); + return ( + `${s.x},${s.y}`).join(" ")} + className={p.cls} + fill="none" + stroke={p.color} + strokeWidth={weight(p.weightMm)} + vectorEffect={vfx} + /> + ); + } // Strichstärke + Strichmuster in mm Papier. Die Farbe kommt weiterhin aus // der CSS-Klasse (z. B. .door-leaf / .wall-axis). return ( diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 69361e1..9dd1475 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -210,6 +210,13 @@ export type Primitive = weightMm: number; /** Strichmuster in mm Papier; null/undefined = durchgezogen. */ dash?: number[] | null; + /** + * Zickzack-/Wellen-Parameter (aus einem LineStyle mit `kind==="zigzag"`), + * beide in mm Papier. Ist es gesetzt, zeichnen die Renderer die Linie als + * Zickzack-Pfad statt als gerade (ggf. gestrichelte) Linie — ADDITIV, ohne + * die bestehenden Strich/Dash-Linien zu verändern (Feld fehlt ⇒ heute). + */ + zigzag?: { amplitude: number; wavelength: number }; /** Optionale explizite Strichfarbe (überschreibt die CSS-Klasse). */ color?: string; /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */ @@ -773,8 +780,11 @@ function addDrawing2D( const color = d.color ?? ls?.color ?? colorByCode.get(d.categoryCode) ?? MONO_INK; const weightMm = d.weightMm ?? ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM; const dash = ls?.dash ?? null; + // Zickzack-Parameter aus dem LineStyle durchreichen (nur kind==="zigzag"). + // Additiv: fehlt es, bleibt die Linie gerade/gestrichelt wie bisher. + const zigzag = ls?.kind === "zigzag" ? ls.zigzag : undefined; const seg = (a: Vec2, b: Vec2) => - out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, color, greyed, drawingId: d.id }); + out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, zigzag, color, greyed, drawingId: d.id }); /** * Gefüllte Fläche einer GESCHLOSSENEN Form: trägt die Vollton-Füllfarbe @@ -1027,6 +1037,8 @@ function addWallPoche( weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm, color: jls ? jls.color : stroke, dash: jls ? jls.dash : null, + // Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen. + zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined, greyed, }); } diff --git a/src/plan/glPlan/glPlanCompile.ts b/src/plan/glPlan/glPlanCompile.ts index 134e364..5f2a05c 100644 --- a/src/plan/glPlan/glPlanCompile.ts +++ b/src/plan/glPlan/glPlanCompile.ts @@ -11,7 +11,7 @@ import type { Primitive } from '../generatePlan'; import type { Vec2 } from '../../model/types'; import type { GpuGeometry, Rgba } from './glPlanTypes'; -import { applyDashRuns, buildHatchRuns } from './glPlanHatch'; +import { applyDashRuns, buildHatchRuns, zigzagPoints, DASH_MM_TO_M } from './glPlanHatch'; const PX_PER_M = 90; @@ -405,7 +405,15 @@ export function compilePrimitivesToGpu( // deckungsgleich mit den Schichtfugen in beiden Modi. // `hatch.dash` wird geometrisch (Strich/Lücke) aufgelöst, da die GL- // Linien-Pipeline kein Dash kennt. - if (prim.hatch && prim.hatch.pattern !== 'none' && prim.hatch.pattern !== 'solid') { + // Bild-Schraffur (kind==="image"): WebGL2-Ebene kennt keine Muster-Textur → + // FALLBACK auf die neutrale Poché-Füllung (oben), keine Musterlinien. Der + // Random-Untermodus läuft transparent über `buildHatchRuns` (Streu-Striche). + if ( + prim.hatch && + prim.hatch.kind !== 'image' && + prim.hatch.pattern !== 'none' && + prim.hatch.pattern !== 'solid' + ) { const hatchColor = parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1]; const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13; const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash); @@ -420,7 +428,16 @@ export function compilePrimitivesToGpu( } } else if (prim.kind === 'line') { const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1]; - pushLine(prim.a, prim.b, color, prim.weightMm || 0.18); + if (prim.zigzag) { + // Zickzack-Linie → gehrter Polylinienzug (amplitude/wavelength Papier-mm + // → Modell-Meter über dieselbe Kopplung wie Strichmuster). + const amp = prim.zigzag.amplitude * DASH_MM_TO_M; + const wav = prim.zigzag.wavelength * DASH_MM_TO_M; + const zpts = zigzagPoints(prim.a, prim.b, amp, wav); + strokePolyline(zpts, false, color, prim.weightMm || 0.18); + } else { + pushLine(prim.a, prim.b, color, prim.weightMm || 0.18); + } } else if (prim.kind === 'arc') { // Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen). const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1]; diff --git a/src/plan/glPlan/glPlanHatch.ts b/src/plan/glPlan/glPlanHatch.ts index 140ddd1..e6633a0 100644 --- a/src/plan/glPlan/glPlanHatch.ts +++ b/src/plan/glPlan/glPlanHatch.ts @@ -277,7 +277,7 @@ function rotate(v: Vec2, rad: number): Vec2 { * mit `PX_TO_M`, ergeben sich die Strichlängen in Modell-Metern (skalieren mit * dem Zoom wie die Kachel selbst — genau wie die SVG-Schraffur). */ -const DASH_MM_TO_M = (1 / 0.13) * PX_TO_M; +export const DASH_MM_TO_M = (1 / 0.13) * PX_TO_M; /** * Zerlegt zusammenhängende Läufe entlang eines Strichmusters (`dash`, in mm- @@ -379,6 +379,14 @@ export function buildHatchRuns(poly: Vec2[], hatch: HatchRender): HatchRun[] { const scale = hatch.scale > 1e-6 ? hatch.scale : 1; + // Random-Vektor-Untermodus (Kies/Splitt): kurze, deterministisch gestreute + // Striche statt der regelmäßigen Parallel-/Kreuzschar. Bild-Schraffuren + // (kind==="image") werden hier NICHT als Vektorlinien erzeugt — sie fallen im + // Aufrufer auf eine neutrale Füllung zurück (RScene/GL kennen keine Textur). + if (hatch.kind !== 'image' && hatch.lines === 'random') { + return buildRandomHatchRuns(poly, hatch, scale); + } + if (pattern === 'insulation') { return insulationRuns(poly, hatch, scale); } @@ -486,3 +494,131 @@ function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchR } return runs; } + +// ── Random-Vektor-Schraffur (deterministisch) ──────────────────────────────── + +/** + * Deterministischer 32-bit-PRNG (mulberry32). Gleicher Seed ⇒ gleiche Folge — + * KEIN `Math.random()`: die Random-Schraffur muss über Re-Renders/Print + * reproduzierbar sein (sonst flackert der Splitt und der Druck ist nicht + * deterministisch). + */ +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; + }; +} + +/** + * Faltet mehrere Fließkomma-Kennwerte zu einem stabilen 32-bit-Seed. Die Werte + * werden gequantelt (·1000, gerundet), damit numerisches Rauschen den Seed nicht + * verschiebt — gleiche Fläche/gleiche Hatch-Parameter ⇒ gleicher Seed ⇒ gleiche + * Streuung. + */ +function hashSeed(...vals: number[]): number { + let h = 0x811c9dc5; + for (const v of vals) { + const q = Math.round(v * 1000) | 0; + h ^= q & 0xffff; + h = Math.imul(h, 0x01000193); + h ^= (q >>> 16) & 0xffff; + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** + * Streut kurze Striche gleichmäßig-zufällig (aber DETERMINISTISCH per `seed`) über + * ein Polygon: je Zelle des `cell`-Rasters ein Strich zufälliger Lage, Länge + * (~`strokeLen`) und Orientierung (um `baseAngleRad` ± π), auf das Polygon + * geclippt ({@link clipSegmentToPolygon}, konkav-fähig). Einheiten-agnostisch — + * der Aufrufer gibt `strokeLen`/`cell` in der Ziel-Koordinateneinheit an + * (Modell-Meter bzw. Papier-mm). Ausgabe = 2-Punkt-Läufe. + */ +export function scatterStrokes( + poly: Vec2[], + strokeLen: number, + cell: number, + baseAngleRad: number, + seed: number, +): HatchRun[] { + if (poly.length < 3 || strokeLen <= 1e-9 || cell <= 1e-9) return []; + const bb = bbox(poly); + const w = bb.maxX - bb.minX; + const h = bb.maxY - bb.minY; + if (!(w > 0) || !(h > 0)) return []; + const nx = Math.max(1, Math.round(w / cell)); + const ny = Math.max(1, Math.round(h / cell)); + const count = Math.min(20000, Math.max(4, nx * ny)); + const rnd = mulberry32(seed); + const runs: HatchRun[] = []; + for (let i = 0; i < count; i++) { + const cx = bb.minX + rnd() * w; + const cy = bb.minY + rnd() * h; + // Orientierung frei (± π um die Basisrichtung), Länge um ±40 % variiert. + const ang = baseAngleRad + (rnd() * 2 - 1) * Math.PI; + const half = (strokeLen * 0.5) * (0.6 + 0.8 * rnd()); + const dx = Math.cos(ang) * half; + const dy = Math.sin(ang) * half; + const a: Vec2 = { x: cx - dx, y: cy - dy }; + const b: Vec2 = { x: cx + dx, y: cy + dy }; + for (const seg of clipSegmentToPolygon(a, b, poly)) runs.push([seg.a, seg.b]); + } + return runs; +} + +/** + * Random-Vektor-Schraffur eines Polygons in Modell-Metern (Kies/Splitt). Dichte + * und Strichlänge sind — wie die Parallelschar — an die Kachelgröße + * (`8·scale` px) gekoppelt, damit `scale` die Streudichte steuert. Der Seed + * stammt aus der Flächen-Kennung (gerundete Bounding-Box) plus den Hatch- + * Parametern → über Re-Renders/Print reproduzierbar. + */ +export function buildRandomHatchRuns(poly: Vec2[], hatch: HatchRender, scale?: number): HatchRun[] { + const s = scale ?? (hatch.scale > 1e-6 ? hatch.scale : 1); + const cell = 8 * s * PX_TO_M; // Meter, wie der Parallel-Schar-Abstand + const strokeLen = cell * 1.1; // kurzer Strich ~ Kachelmaß + const bb = bbox(poly); + const seed = hashSeed(bb.minX, bb.minY, bb.maxX - bb.minX, bb.maxY - bb.minY, hatch.angle, s); + const base = toModelAngleRad(hatch.angle); + return scatterStrokes(poly, strokeLen, cell, base, seed); +} + +// ── Zickzack-/Wellen-Linie ─────────────────────────────────────────────────── + +/** + * Tesselliert die Strecke [a,b] zu einem Zickzack (Dreieckswelle) mit `amplitude` + * (Ausschlag quer, Einheit wie a/b) und `wavelength` (Periodenlänge längs). Rein + * geometrisch, einheiten-agnostisch: der Aufrufer übergibt amplitude/wavelength + * in der Zielkoordinaten-Einheit. Die Auslenkung folgt dem Muster [0,+A,0,−A] je + * Viertel-Wellenlänge; Start liegt auf der Achse, das Ende exakt auf `b`. + */ +export function zigzagPoints(a: Vec2, b: Vec2, amplitude: number, wavelength: number): Vec2[] { + const dx = b.x - a.x; + const dy = b.y - a.y; + const L = Math.hypot(dx, dy); + if (L < 1e-12 || wavelength <= 1e-9 || amplitude <= 0) return [a, b]; + const ux = dx / L; + const uy = dy / L; + const nx = -uy; // linke Normale (Ausschlagachse) + const ny = ux; + const quarter = wavelength / 4; + const steps = Math.max(1, Math.round(L / quarter)); + const tri = [0, 1, 0, -1]; + const pts: Vec2[] = []; + for (let i = 0; i <= steps; i++) { + const t = Math.min(L, i * quarter); + const off = amplitude * tri[i % 4]; + pts.push({ x: a.x + ux * t + nx * off, y: a.y + uy * t + ny * off }); + } + const last = pts[pts.length - 1]; + if (Math.abs(last.x - b.x) > 1e-9 || Math.abs(last.y - b.y) > 1e-9) { + pts.push({ x: b.x, y: b.y }); + } + return pts; +} diff --git a/src/plan/hatchLineTypes.test.ts b/src/plan/hatchLineTypes.test.ts new file mode 100644 index 0000000..a7abfcc --- /dev/null +++ b/src/plan/hatchLineTypes.test.ts @@ -0,0 +1,234 @@ +// Phase-C-Rendering der NEUEN Schraffur-/Linien-Typen (Bild-Schraffur, +// Random-Vektor, Zickzack-Linie). Prüft die drei Darstellungen an den SVG-nahen +// Pfaden (planToRenderScene = Live/WASM/PDF-Quelle, planToPrintSvg = Print) sowie +// die geteilten Geometrie-Helfer. Der Default-Sample bleibt unberührt. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { Plan, Primitive, HatchRender } from "./generatePlan"; +import { planToRenderScene } from "./toRenderScene"; +import { buildHatchRuns, zigzagPoints } from "./glPlan/glPlanHatch"; + +const SQUARE = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 4 }, + { x: 0, y: 4 }, +]; + +function hatch(over: Partial = {}): HatchRender { + return { + pattern: "diagonal", + scale: 1, + angle: 0, + color: "#123456", + lineWeight: 0.13, + dash: null, + ...over, + }; +} + +function planOf(prims: Primitive[]): Plan { + return { primitives: prims, bounds: { minX: 0, minY: 0, maxX: 4, maxY: 4 } }; +} + +describe("zigzagPoints", () => { + it("erzeugt einen Zickzack-Pfad mit alternierender Auslenkung", () => { + const pts = zigzagPoints({ x: 0, y: 0 }, { x: 8, y: 0 }, 1, 4); + // Mehr Stützpunkte als die reine Gerade (Zickzack), und quer ausgelenkt. + expect(pts.length).toBeGreaterThan(3); + const ys = pts.map((p) => p.y); + expect(Math.max(...ys)).toBeGreaterThan(0.5); // +A + expect(Math.min(...ys)).toBeLessThan(-0.5); // -A + // Beginnt auf der Achse, endet exakt auf b. + expect(pts[0]).toEqual({ x: 0, y: 0 }); + expect(pts[pts.length - 1].x).toBeCloseTo(8, 6); + expect(pts[pts.length - 1].y).toBeCloseTo(0, 6); + }); + + it("degeneriert (amplitude 0 / wavelength 0) auf die Gerade", () => { + expect(zigzagPoints({ x: 0, y: 0 }, { x: 1, y: 0 }, 0, 4)).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]); + }); +}); + +describe("Random-Vektor-Schraffur", () => { + it("liefert Streu-Striche (nicht leer) und ist DETERMINISTISCH bei gleichem Seed", () => { + const h = hatch({ kind: "vector", lines: "random", scale: 1 }); + const a = buildHatchRuns(SQUARE, h); + const b = buildHatchRuns(SQUARE, h); + expect(a.length).toBeGreaterThan(0); + expect(a).toEqual(b); // identische Geometrie bei identischen Eingaben + }); + + it("unterscheidet sich von der regelmäßigen Parallelschar", () => { + const random = buildHatchRuns(SQUARE, hatch({ kind: "vector", lines: "random" })); + const parallel = buildHatchRuns(SQUARE, hatch({ lines: "parallel" })); + expect(JSON.stringify(random)).not.toEqual(JSON.stringify(parallel)); + }); + + it("erscheint als Streu-Polylinien in der RenderScene (deterministisch)", () => { + const poly: Primitive = { + kind: "polygon", + pts: SQUARE, + fill: "#ffffff", + stroke: "none", + strokeWidthMm: 0, + hatch: hatch({ kind: "vector", lines: "random" }), + }; + const s1 = planToRenderScene(planOf([poly])); + const s2 = planToRenderScene(planOf([poly])); + expect(s1.polylines.length).toBeGreaterThan(0); + expect(s1.polylines).toEqual(s2.polylines); + }); +}); + +describe("Zickzack-Linie in der RenderScene", () => { + it("emittiert eine tessellierte Polylinie statt einer geraden Linie", () => { + const line: Primitive = { + kind: "line", + a: { x: 0, y: 0 }, + b: { x: 4, y: 0 }, + cls: "draw2d", + weightMm: 0.18, + zigzag: { amplitude: 2, wavelength: 4 }, + }; + const scene = planToRenderScene(planOf([line])); + expect(scene.lines.length).toBe(0); // KEINE gerade Linie + expect(scene.polylines.length).toBe(1); + expect(scene.polylines[0].pts.length).toBeGreaterThan(3); // Zickzack + }); +}); + +describe("Bild-Schraffur — Fallback in der (texturlosen) RenderScene", () => { + it("erzeugt KEINE Musterlinien (nur neutrale Füllung)", () => { + const poly: Primitive = { + kind: "polygon", + pts: SQUARE, + fill: "#eeeeee", + stroke: "none", + strokeWidthMm: 0, + hatch: hatch({ + kind: "image", + pattern: "diagonal", // würde OHNE image-Fallback Diagonalen zeichnen + image: { src: "data:image/png;base64,AAAA", scaleX: 1, scaleY: 1, rotation: 0 }, + }), + }; + const scene = planToRenderScene(planOf([poly])); + expect(scene.polylines.length).toBe(0); // Fallback: keine Vektor-Musterlinien + expect(scene.fills.length).toBe(1); // neutrale Poché-Füllung bleibt + }); +}); + +// ── Print-SVG (planToPrintSvg): Bild-/ + Zickzack- ── +// Node-Umgebung ohne DOM → minimaler document-Stub (nur die von planToPrintSvg +// genutzten Methoden). So lässt sich der erzeugte SVG-Knotenbaum inspizieren. + +interface StubEl { + tagName: string; + attrs: Map; + childNodes: StubEl[]; + setAttribute(k: string, v: unknown): void; + getAttribute(k: string): string | null; + appendChild(c: StubEl): StubEl; + insertBefore(n: StubEl, ref: StubEl | null): StubEl; + querySelector(sel: string): StubEl | null; + readonly firstChild: StubEl | null; +} + +function makeEl(tag: string): StubEl { + const el: StubEl = { + tagName: tag, + attrs: new Map(), + childNodes: [], + setAttribute(k, v) { + this.attrs.set(k, String(v)); + }, + getAttribute(k) { + return this.attrs.get(k) ?? null; + }, + appendChild(c) { + this.childNodes.push(c); + return c; + }, + insertBefore(n, ref) { + const i = ref ? this.childNodes.indexOf(ref) : -1; + if (i < 0) this.childNodes.unshift(n); + else this.childNodes.splice(i, 0, n); + return n; + }, + querySelector(sel) { + for (const c of this.childNodes) { + if (c.tagName === sel) return c; + const r = c.querySelector(sel); + if (r) return r; + } + return null; + }, + get firstChild() { + return this.childNodes[0] ?? null; + }, + }; + return el; +} + +function walk(el: StubEl, pred: (e: StubEl) => boolean, acc: StubEl[] = []): StubEl[] { + if (pred(el)) acc.push(el); + for (const c of el.childNodes) walk(c, pred, acc); + return acc; +} + +describe("planToPrintSvg — neue Typen", () => { + let prevDoc: unknown; + beforeAll(() => { + prevDoc = (globalThis as { document?: unknown }).document; + (globalThis as { document: unknown }).document = { + createElementNS: (_ns: string, tag: string) => makeEl(tag), + }; + }); + afterAll(() => { + (globalThis as { document?: unknown }).document = prevDoc; + }); + + it("Bild-Schraffur ⇒ mit gekacheltem , Zickzack ⇒ ", async () => { + const { planToPrintSvg } = await import("../export/planToPrintSvg"); + const imgPoly: Primitive = { + kind: "polygon", + pts: SQUARE, + fill: "none", + stroke: "#111111", + strokeWidthMm: 0.25, + hatch: hatch({ + kind: "image", + image: { src: "data:image/png;base64,ZZZ", scaleX: 2, scaleY: 0.5, rotation: 30 }, + }), + }; + const zline: Primitive = { + kind: "line", + a: { x: 0, y: 0 }, + b: { x: 4, y: 0 }, + cls: "draw2d", + weightMm: 0.18, + zigzag: { amplitude: 1, wavelength: 4 }, + }; + const { svg } = planToPrintSvg(planOf([imgPoly, zline]), { + scaleDenominator: 100, + pageWidthMm: 210, + pageHeightMm: 297, + }); + const root = svg as unknown as StubEl; + const patterns = walk(root, (e) => e.tagName === "pattern"); + expect(patterns.length).toBe(1); + const images = walk(patterns[0], (e) => e.tagName === "image"); + expect(images.length).toBe(1); + expect(images[0].getAttribute("href")).toBe("data:image/png;base64,ZZZ"); + // Verzerrte Kachel (scaleX≠scaleY) + Rotation im patternTransform. + expect(patterns[0].getAttribute("width")).not.toBe(patterns[0].getAttribute("height")); + expect(patterns[0].getAttribute("patternTransform")).toContain("rotate(30)"); + // Zickzack-Linie als Polylinie mit mehreren Stützpunkten. + const polylines = walk(root, (e) => e.tagName === "polyline"); + expect(polylines.length).toBeGreaterThan(0); + expect((polylines[0].getAttribute("points") ?? "").trim().split(/\s+/).length).toBeGreaterThan(3); + }); +}); diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index 2e53c6d..885ad24 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -20,7 +20,7 @@ // gesamte (ggf. verkettete oder adaptiv tessellierte) Strecke durchläuft. import type { Plan, Primitive } from "./generatePlan"; -import { applyDashRuns, buildHatchRuns } from "./glPlan/glPlanHatch"; +import { applyDashRuns, buildHatchRuns, zigzagPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch"; import { docToLines } from "../text/renderHtml"; type LinePrim = Extract; @@ -371,8 +371,16 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL // Schraffur: die aufs Polygon geclippten Musterlinien wie im Browser // (glPlanHatch — identische Geometrie), als Polylinien. "none"/"solid" - // brauchen keine Linien (solid deckt die Füllung farbig ab). - if (p.hatch && p.hatch.pattern !== "none" && p.hatch.pattern !== "solid") { + // brauchen keine Linien (solid deckt die Füllung farbig ab). Bild-Schraffuren + // (kind==="image") kann die render2d-Szene nicht tragen (keine Textur) → + // FALLBACK: nur die neutrale Poché-Füllung (oben), keine Musterlinien. Der + // Random-Untermodus läuft transparent über `buildHatchRuns` (Streu-Striche). + if ( + p.hatch && + p.hatch.kind !== "image" && + p.hatch.pattern !== "none" && + p.hatch.pattern !== "solid" + ) { const hatchCol = withOpacity( toRgba(p.hatch.color, 1) ?? [0.1, 0.1, 0.1, 1], undefined, @@ -414,7 +422,24 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL } } } else if (p.kind === "line") { - if (p.drawingId) { + if (p.zigzag) { + // Zickzack-Linie: als Polylinie tesselliert (render2d kennt keinen + // Zickzack-Primitiv). amplitude/wavelength sind Papier-mm → Modell-Meter + // über dieselbe Kopplung wie Strichmuster (DASH_MM_TO_M). Nicht verketten. + flushRun(); + const amp = p.zigzag.amplitude * DASH_MM_TO_M; + const wav = p.zigzag.wavelength * DASH_MM_TO_M; + const zpts = zigzagPoints(p.a, p.b, amp, wav); + const col = withOpacity(strokeColorFor(p.cls, p.color), p.cls, p.greyed); + if (zpts.length >= 2) { + polylines.push({ + pts: zpts.map((v) => [v.x, v.y]), + color: col, + widthMm: p.weightMm, + z: zc++, + }); + } + } else if (p.drawingId) { // Aufeinanderfolgende 2D-Zeichenlinien gleicher drawingId/Stil verketten. const a: RPoint = [p.a.x, p.a.y]; const b: RPoint = [p.b.x, p.b.y];