Schraffur/Linien: Rendering der neuen Typen (Bild, Random, Zickzack)

Bild-Schraffur als getiltes <pattern>/<image> (scaleX/scaleY/rotation) im
Live-SVG- und Print-Pfad; GL/WASM/DXF vorerst neutraler Fallback (Folgearbeit,
im Code vermerkt). Random-Vektor-Schraffur als deterministische Streu-Striche
(mulberry32-Seed aus Flaechen-Bounding-Box, kein Math.random) in allen Pfaden.
Zickzack-Linie als getilteter Pfad; LineStyle.kind/zigzag additiv durch die
Linien-Emission (generatePlan) bis zu den Renderern durchgereicht. Geteilte
Geometrie in glPlanHatch (scatterStrokes/buildRandomHatchRuns/zigzagPoints) —
eine Wahrheit fuer Live/Print/GL/DXF. 8 neue Tests.
This commit is contained in:
2026-07-04 00:41:09 +02:00
parent f5923d302d
commit 0613bb596b
8 changed files with 691 additions and 26 deletions
+22 -2
View File
@@ -12,6 +12,7 @@ import type { Plan, Primitive } from "../plan/generatePlan";
import type { LayerCategory, Project, Vec2 } from "../model/types"; import type { LayerCategory, Project, Vec2 } from "../model/types";
import { flattenCategories } from "../model/types"; import { flattenCategories } from "../model/types";
import { DxfWriter } from "./dxfWriter"; import { DxfWriter } from "./dxfWriter";
import { buildRandomHatchRuns, zigzagPoints } from "../plan/glPlan/glPlanHatch";
export interface ExportDxfOptions { export interface ExportDxfOptions {
/** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */ /** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */
@@ -106,7 +107,15 @@ function emitPrimitive(
): void { ): void {
switch (p.kind) { switch (p.kind) {
case "line": 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; break;
case "arc": case "arc":
@@ -121,13 +130,24 @@ function emitPrimitive(
dxf.addPolyline(layer, pts, true); dxf.addPolyline(layer, pts, true);
// Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine // Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine
// sichtbaren Musterlinien → wir geben nur echte Muster (nicht solid/none) aus. // 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 ( if (
includeHatches && includeHatches &&
p.hatch.kind !== "image" &&
p.hatch.pattern !== "none" && p.hatch.pattern !== "none" &&
p.hatch.pattern !== "solid" p.hatch.pattern !== "solid"
) { ) {
const cycle = dashCycleMeters(p.hatch.dash); 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) { if (cycle) {
for (const d of dashSeg(seg, cycle)) dxf.addLine(LAYER_HATCH, d.a, d.b); for (const d of dashSeg(seg, cycle)) dxf.addLine(LAYER_HATCH, d.a, d.b);
} else { } else {
+114 -13
View File
@@ -23,10 +23,75 @@
import type { HatchRender, Plan, Primitive } from "../plan/generatePlan"; import type { HatchRender, Plan, Primitive } from "../plan/generatePlan";
import type { Vec2 } from "../model/types"; import type { Vec2 } from "../model/types";
import { buildRandomHatchRuns, zigzagPoints } from "../plan/glPlan/glPlanHatch";
/** SVG-Namespace für die programmatisch erzeugten Knoten. */ /** SVG-Namespace für die programmatisch erzeugten Knoten. */
const SVG_NS = "http://www.w3.org/2000/svg"; 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 `<pattern>`-IDs im Dokument. */
let patternSeq = 0;
/** Liefert (oder erzeugt) das gemeinsame `<defs>`-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-`<pattern>` 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<HatchRender["image"]>,
): 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;
}
/** `<polyline>`-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 * Standard-Stift-Stufen (mm). Beliebige Kategorie-Strichstärken werden auf die
* NÄCHSTHÖHERE dieser ISO-nahen Stufen gequantelt, damit der Plan saubere, * NÄCHSTHÖHERE dieser ISO-nahen Stufen gequantelt, damit der Plan saubere,
@@ -160,7 +225,16 @@ function appendPrimitive(
case "line": { case "line": {
const a = map(p.a); const a = map(p.a);
const b = map(p.b); 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); if (opacity) ln.setAttribute("opacity", opacity);
svg.appendChild(ln); svg.appendChild(ln);
break; break;
@@ -195,7 +269,15 @@ function appendPolygon(
if (opacity) g.setAttribute("opacity", opacity); if (opacity) g.setAttribute("opacity", opacity);
const pattern = p.hatch.pattern; const pattern = p.hatch.pattern;
if (pattern === "none") { if (p.hatch.kind === "image" && p.hatch.image) {
// Bild-Schraffur: gekacheltes <pattern> mit <image> (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)); g.appendChild(polyNode(ptsAttr, p.fill, p.stroke, strokeMm));
} else if (pattern === "solid") { } else if (pattern === "solid") {
g.appendChild(polyNode(ptsAttr, p.hatch.color, p.stroke, strokeMm)); g.appendChild(polyNode(ptsAttr, p.hatch.color, p.stroke, strokeMm));
@@ -205,17 +287,36 @@ function appendPolygon(
if (p.fill !== "none") { if (p.fill !== "none") {
g.appendChild(polyNode(ptsAttr, p.fill, "none", 0)); g.appendChild(polyNode(ptsAttr, p.fill, "none", 0));
} }
for (const seg of hatchLines(ptsMm, p.hatch, mmPerM)) { if (p.hatch.lines === "random") {
g.appendChild( // Random-Vektor (Kies/Splitt): deterministische Streu-Striche. Wird im
lineNode( // MODELL-Raum erzeugt (identischer Seed/Geometrie wie Bildschirm/GL/PDF)
seg.a, // und dann nach Papier-mm abgebildet.
seg.b, for (const run of buildRandomHatchRuns(p.pts, p.hatch)) {
p.hatch.color, if (run.length < 2) continue;
quantizePen(p.hatch.lineWeight), g.appendChild(
p.hatch.dash, lineNode(
mmPerM, 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) { if (strokeMm > 0) {
g.appendChild(polyNode(ptsAttr, "none", p.stroke, strokeMm)); g.appendChild(polyNode(ptsAttr, "none", p.stroke, strokeMm));
+122 -2
View File
@@ -20,6 +20,7 @@ import type { DraftShape, SnapResult, ToolHandlers, ToolId, ToolMods } from "../
import { useGlPlanRenderer } from "./useGlPlanRenderer"; import { useGlPlanRenderer } from "./useGlPlanRenderer";
import { useWasmPlanRenderer } from "./useWasmPlanRenderer"; import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
import { quantizePen } from "../export/sceneToPrintSvg"; 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 PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum)
const PAD = 60; // Rand in viewBox-Einheiten const PAD = 60; // Rand in viewBox-Einheiten
@@ -1601,7 +1602,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const hatched = useMemo(() => { const hatched = useMemo(() => {
const list: HatchedPoly[] = []; const list: HatchedPoly[] = [];
plan.primitives.forEach((p, i) => { 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 }); 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 <pattern>; solid/none nicht. */ /** Lineare/Kreuz-Muster brauchen ein <pattern>; solid/none nicht. */
function needsPattern(pattern: HatchRender["pattern"]): boolean { function needsPattern(pattern: HatchRender["pattern"]): boolean {
return ( return (
@@ -2375,6 +2379,19 @@ function needsPattern(pattern: HatchRender["pattern"]): boolean {
); );
} }
/**
* Ob ein Polygon ein SVG-`<pattern>` (in `<defs>`) 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-<pattern>. Maßstab skaliert die Kachelgröße, * Ein parametrisiertes Schraffur-<pattern>. Maßstab skaliert die Kachelgröße,
* Winkel dreht das Muster (patternTransform), Farbe/Linienstärke kommen aus dem * Winkel dreht das Muster (patternTransform), Farbe/Linienstärke kommen aus dem
@@ -2407,6 +2424,26 @@ function HatchPattern({
.join(" ") .join(" ")
: undefined; : undefined;
if (hatch.kind === "image" && hatch.image) {
// Bild-Schraffur: gekacheltes <image>-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 (
<pattern
id={id}
patternUnits="userSpaceOnUse"
width={w}
height={h}
patternTransform={img.rotation ? `rotate(${img.rotation})` : undefined}
>
<image href={img.src} x={0} y={0} width={w} height={h} preserveAspectRatio="none" />
</pattern>
);
}
if (hatch.pattern === "insulation") { if (hatch.pattern === "insulation") {
// Weiche Zickzack-/Wellenlinie (SIA-nah). Kachel 14×10 × Maßstab. // Weiche Zickzack-/Wellenlinie (SIA-nah). Kachel 14×10 × Maßstab.
const w = 14 * hatch.scale; const w = 14 * hatch.scale;
@@ -2487,6 +2524,8 @@ interface DrawingRun {
dash: number[] | null | undefined; dash: number[] | null | undefined;
color?: string; color?: string;
greyed?: boolean; greyed?: boolean;
/** Zickzack-Parameter (Papier-mm); gesetzt ⇒ Lauf als Zickzack-Pfad zeichnen. */
zigzag?: { amplitude: number; wavelength: number };
} }
/** Kleiner Toleranz-Test auf Punktgleichheit (Modell-Meter). */ /** Kleiner Toleranz-Test auf Punktgleichheit (Modell-Meter). */
@@ -2541,6 +2580,22 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] {
flush(); flush();
continue; 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)) { if (curPts && curSrc && sameLineStyle(curSrc, p) && samePt(curPts[curPts.length - 1], p.a)) {
// An den laufenden Zug anhängen. // An den laufenden Zug anhängen.
curPts.push(p.b); curPts.push(p.b);
@@ -2583,7 +2638,17 @@ function DrawingRunShape({
.map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm))) .map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm)))
.join(" ") .join(" ")
: undefined; : 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 ? ( const shape = run.closed ? (
<polygon <polygon
points={pts} points={pts}
@@ -2716,6 +2781,40 @@ function renderPrimitive(
</> </>
); );
}; };
// Bild-Schraffur: Grundfüllung + gekacheltes Bild-Muster (<pattern> aus
// <defs>, id=hatch-index) + Umriss — unabhängig vom `pattern`-Feld.
if (p.hatch.kind === "image" && p.hatch.image) {
return (
<g>
<polygon points={pts} fill={p.fill} stroke="none" />
<polygon points={pts} fill={`url(#hatch-${index})`} stroke="none" />
{outline()}
</g>
);
}
// Random-Vektor-Schraffur (Kies/Splitt): Grundfüllung + deterministische
// Streu-Striche (im MODELL-Raum erzeugt → toScreen; identischer Seed wie
// GL/PDF) + Umriss. KEIN <pattern> (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 (
<g>
<polygon points={pts} fill={p.fill} stroke="none" />
{runs.map((run, ri) => (
<polyline
key={`r${ri}`}
points={run.map(toScreen).map((s) => `${s.x},${s.y}`).join(" ")}
fill="none"
stroke={p.hatch.color}
strokeWidth={hsw}
vectorEffect={vfx}
/>
))}
{outline()}
</g>
);
}
// Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss). // Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss).
if (p.hatch.pattern === "none") { if (p.hatch.pattern === "none") {
return ( return (
@@ -2746,6 +2845,27 @@ function renderPrimitive(
case "line": { case "line": {
const a = toScreen(p.a); const a = toScreen(p.a);
const b = toScreen(p.b); 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 (
<polyline
points={zpts.map((s) => `${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 // Strichstärke + Strichmuster in mm Papier. Die Farbe kommt weiterhin aus
// der CSS-Klasse (z. B. .door-leaf / .wall-axis). // der CSS-Klasse (z. B. .door-leaf / .wall-axis).
return ( return (
+13 -1
View File
@@ -210,6 +210,13 @@ export type Primitive =
weightMm: number; weightMm: number;
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */ /** Strichmuster in mm Papier; null/undefined = durchgezogen. */
dash?: number[] | null; 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). */ /** Optionale explizite Strichfarbe (überschreibt die CSS-Klasse). */
color?: string; color?: string;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */ /** 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 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 weightMm = d.weightMm ?? ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM;
const dash = ls?.dash ?? null; 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) => 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 * 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, weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
color: jls ? jls.color : stroke, color: jls ? jls.color : stroke,
dash: jls ? jls.dash : null, dash: jls ? jls.dash : null,
// Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
greyed, greyed,
}); });
} }
+20 -3
View File
@@ -11,7 +11,7 @@
import type { Primitive } from '../generatePlan'; import type { Primitive } from '../generatePlan';
import type { Vec2 } from '../../model/types'; import type { Vec2 } from '../../model/types';
import type { GpuGeometry, Rgba } from './glPlanTypes'; 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; const PX_PER_M = 90;
@@ -405,7 +405,15 @@ export function compilePrimitivesToGpu(
// deckungsgleich mit den Schichtfugen in beiden Modi. // deckungsgleich mit den Schichtfugen in beiden Modi.
// `hatch.dash` wird geometrisch (Strich/Lücke) aufgelöst, da die GL- // `hatch.dash` wird geometrisch (Strich/Lücke) aufgelöst, da die GL-
// Linien-Pipeline kein Dash kennt. // 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 hatchColor = parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1];
const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13; const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13;
const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash); const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
@@ -420,7 +428,16 @@ export function compilePrimitivesToGpu(
} }
} else if (prim.kind === 'line') { } else if (prim.kind === 'line') {
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1]; 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') { } else if (prim.kind === 'arc') {
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen). // Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1]; const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1];
+137 -1
View File
@@ -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 * 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). * 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- * 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; 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') { if (pattern === 'insulation') {
return insulationRuns(poly, hatch, scale); return insulationRuns(poly, hatch, scale);
} }
@@ -486,3 +494,131 @@ function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchR
} }
return runs; 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;
}
+234
View File
@@ -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> = {}): 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-<pattern>/<image> + Zickzack-<polyline> ──
// 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<string, string>;
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 ⇒ <pattern> mit gekacheltem <image>, Zickzack ⇒ <polyline>", 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);
});
});
+29 -4
View File
@@ -20,7 +20,7 @@
// gesamte (ggf. verkettete oder adaptiv tessellierte) Strecke durchläuft. // gesamte (ggf. verkettete oder adaptiv tessellierte) Strecke durchläuft.
import type { Plan, Primitive } from "./generatePlan"; 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"; import { docToLines } from "../text/renderHtml";
type LinePrim = Extract<Primitive, { kind: "line" }>; type LinePrim = Extract<Primitive, { kind: "line" }>;
@@ -371,8 +371,16 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
// Schraffur: die aufs Polygon geclippten Musterlinien wie im Browser // Schraffur: die aufs Polygon geclippten Musterlinien wie im Browser
// (glPlanHatch — identische Geometrie), als Polylinien. "none"/"solid" // (glPlanHatch — identische Geometrie), als Polylinien. "none"/"solid"
// brauchen keine Linien (solid deckt die Füllung farbig ab). // brauchen keine Linien (solid deckt die Füllung farbig ab). Bild-Schraffuren
if (p.hatch && p.hatch.pattern !== "none" && p.hatch.pattern !== "solid") { // (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( const hatchCol = withOpacity(
toRgba(p.hatch.color, 1) ?? [0.1, 0.1, 0.1, 1], toRgba(p.hatch.color, 1) ?? [0.1, 0.1, 0.1, 1],
undefined, undefined,
@@ -414,7 +422,24 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
} }
} }
} else if (p.kind === "line") { } 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. // Aufeinanderfolgende 2D-Zeichenlinien gleicher drawingId/Stil verketten.
const a: RPoint = [p.a.x, p.a.y]; const a: RPoint = [p.a.x, p.a.y];
const b: RPoint = [p.b.x, p.b.y]; const b: RPoint = [p.b.x, p.b.y];