f3966f99e9
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.
502 lines
19 KiB
TypeScript
502 lines
19 KiB
TypeScript
// ERSETZT durch `sceneToPrintSvg.ts` (liest die render2d-Szene aus
|
||
// `toRenderScene.ts` — dieselbe Quelle wie der Viewport — statt den Plan
|
||
// eigenständig zu re-interpretieren). Dieser Serializer wird vom PDF-Export
|
||
// (`exportPdf.ts`) NICHT MEHR verwendet und bleibt nur als Referenz/Altpfad
|
||
// stehen (z. B. für einen Vergleichs-Screenshot). Neue Änderungen bitte in
|
||
// `sceneToPrintSvg.ts` vornehmen.
|
||
//
|
||
// Druck-SVG aus einem Plan (generatePlan → Primitive). Eigenständiges,
|
||
// massstäbliches SVG für den VEKTOR-PDF-Export — NICHT vom Bildschirm-DOM
|
||
// abgeleitet, sondern direkt aus den Plan-Primitiven (Polygon/Linie/Bogen).
|
||
//
|
||
// Kern-Idee (vgl. CONVENTIONS.md, docs/design/plans-output.md §3): Der Plan IST
|
||
// Papier-Space. Welt-Meter werden im gewählten Massstab 1:N nach Papier-
|
||
// Millimeter abgebildet (1 m = 1000/N mm). Das SVG-Benutzerkoordinatensystem ist
|
||
// damit „mm": die viewBox ist in mm, Strichstärken (`stroke-width`) sind echte
|
||
// physische mm — KEIN non-scaling-stroke (anders als die Bildschirm-PlanView).
|
||
//
|
||
// Schraffuren werden NICHT als <pattern> geschrieben (PDF-Konverter übernehmen
|
||
// Pattern oft nicht sauber), sondern als echte, auf das Polygon geklippte
|
||
// Vektorlinien direkt ins SVG. So bleibt das PDF rein vektoriell und scharf.
|
||
//
|
||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||
|
||
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 `<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
|
||
* NÄCHSTHÖHERE dieser ISO-nahen Stufen gequantelt, damit der Plan saubere,
|
||
* unterscheidbare Linienstärken trägt (dünne Hilfslinien ≠ dicke Umrisse).
|
||
*/
|
||
const PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0] as const;
|
||
|
||
/** Untergrenze einer sichtbaren Druck-Strichstärke (mm) — vermeidet 0-Linien. */
|
||
const MIN_PEN_MM = 0.13;
|
||
|
||
/** Quantelt eine mm-Strichstärke auf die nächste Stift-Stufe (≥ MIN_PEN_MM). */
|
||
function quantizePen(mm: number): number {
|
||
const w = Math.max(MIN_PEN_MM, mm);
|
||
for (const step of PEN_STEPS) if (w <= step + 1e-6) return step;
|
||
return PEN_STEPS[PEN_STEPS.length - 1];
|
||
}
|
||
|
||
/** Ergebnis des Print-SVG-Aufbaus: das Element + Blattmasse (mm). */
|
||
export interface PrintSvg {
|
||
/** Das fertige, in mm bemaßte <svg>-Element (für svg2pdf). */
|
||
svg: SVGSVGElement;
|
||
/** Blattbreite in mm (= viewBox-Breite). */
|
||
widthMm: number;
|
||
/** Blatthöhe in mm (= viewBox-Höhe). */
|
||
heightMm: number;
|
||
/** Massstäblicher Inhalts-Rahmen (mm) innerhalb des Blattes (Plan-Bounds). */
|
||
content: { x: number; y: number; w: number; h: number };
|
||
}
|
||
|
||
export interface PrintSvgOptions {
|
||
/** Massstab-Nenner N (1:N). 1 m = 1000/N mm auf dem Papier. */
|
||
scaleDenominator: number;
|
||
/** Blattbreite in mm (Papierformat, schon Hoch/Quer berücksichtigt). */
|
||
pageWidthMm: number;
|
||
/** Blatthöhe in mm. */
|
||
pageHeightMm: number;
|
||
/** Rand um den Plan-Inhalt in mm (weisser Blattrand). */
|
||
marginMm?: number;
|
||
}
|
||
|
||
/** Modell-Meter → Papier-Millimeter im Massstab 1:N. */
|
||
function mmPerMeter(n: number): number {
|
||
return 1000 / n;
|
||
}
|
||
|
||
/**
|
||
* Bildet einen Welt-Punkt (Meter) auf Papier-Millimeter ab. Der Inhalt wird so
|
||
* verschoben, dass die Plan-Bounds (in mm) zentriert auf dem Blatt liegen.
|
||
* Plan-Y zeigt nach oben → SVG-Y nach unten (Spiegelung), daher das Minus.
|
||
*/
|
||
interface Mapper {
|
||
(p: Vec2): Vec2;
|
||
}
|
||
|
||
/**
|
||
* Erzeugt das Druck-SVG aus einem Plan. Reihenfolge der Primitive bleibt
|
||
* erhalten (Zeichenreihenfolge = Stapelung); Schraffuren werden als geklippte
|
||
* Linien zwischen Grundfüllung und Umriss eingefügt.
|
||
*/
|
||
export function planToPrintSvg(plan: Plan, opts: PrintSvgOptions): PrintSvg {
|
||
const n = opts.scaleDenominator;
|
||
const k = mmPerMeter(n); // mm je Meter
|
||
// `marginMm` ist Teil des Vertrags (Aufrufer kann einen Rand wünschen); da der
|
||
// Inhalt zentriert wird, liegt er ohnehin innerhalb des Blattes. Wir lesen ihn
|
||
// bewusst, ohne ihn aktuell für einen Versatz zu nutzen (zentrierte Lage).
|
||
void (opts.marginMm ?? 10);
|
||
|
||
const b = plan.bounds;
|
||
// Inhalts-Maße in mm (massstäblich), unverschoben.
|
||
const contentWmm = (b.maxX - b.minX) * k;
|
||
const contentHmm = (b.maxY - b.minY) * k;
|
||
|
||
// Inhalt mittig aufs Blatt setzen. Der nutzbare Bereich ist Blatt minus Rand;
|
||
// wir zentrieren den Plan-Block darin (clippen aber NICHT — bei zu grossem
|
||
// Inhalt läuft er über das Blatt hinaus, was der Aufrufer per Format/Massstab
|
||
// vermeidet; ein Warnhinweis liegt in der UI).
|
||
const offsetXmm = (opts.pageWidthMm - contentWmm) / 2;
|
||
// SVG-Y zeigt nach unten: der oberste Welt-Punkt (maxY) liegt oben auf dem
|
||
// Blatt. Wir mappen maxY → offsetYmm.
|
||
const offsetYmm = (opts.pageHeightMm - contentHmm) / 2;
|
||
|
||
const map: Mapper = (p) => ({
|
||
x: offsetXmm + (p.x - b.minX) * k,
|
||
y: offsetYmm + (b.maxY - p.y) * k,
|
||
});
|
||
|
||
const svg = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
|
||
svg.setAttribute("xmlns", SVG_NS);
|
||
// Breite/Höhe als REINE Zahlen (= viewBox-Einheiten), KEINE „mm"-Einheit: so
|
||
// bildet svg2pdf die viewBox (in mm-Benutzereinheiten) 1:1 auf das mm-Ziel
|
||
// (doc.svg {width,height}) ab. Eine „297mm"-Einheit würde svg2pdf als
|
||
// physische Größe lesen und zusätzlich pt-skalieren (Faktor 25.4/72) → falsch.
|
||
svg.setAttribute("width", String(opts.pageWidthMm));
|
||
svg.setAttribute("height", String(opts.pageHeightMm));
|
||
svg.setAttribute("viewBox", `0 0 ${opts.pageWidthMm} ${opts.pageHeightMm}`);
|
||
|
||
// Weisses Blatt (deckt die volle Seite).
|
||
const bg = document.createElementNS(SVG_NS, "rect");
|
||
bg.setAttribute("x", "0");
|
||
bg.setAttribute("y", "0");
|
||
bg.setAttribute("width", String(opts.pageWidthMm));
|
||
bg.setAttribute("height", String(opts.pageHeightMm));
|
||
bg.setAttribute("fill", "#ffffff");
|
||
svg.appendChild(bg);
|
||
|
||
// Jedes Primitiv in mm-Knoten übersetzen.
|
||
for (const p of plan.primitives) {
|
||
appendPrimitive(svg, p, map, k);
|
||
}
|
||
|
||
return {
|
||
svg,
|
||
widthMm: opts.pageWidthMm,
|
||
heightMm: opts.pageHeightMm,
|
||
content: { x: offsetXmm, y: offsetYmm, w: contentWmm, h: contentHmm },
|
||
};
|
||
}
|
||
|
||
/** Schreibt ein einzelnes Primitiv (mit ggf. Schraffur) in das SVG. */
|
||
function appendPrimitive(
|
||
svg: SVGSVGElement,
|
||
p: Primitive,
|
||
map: Mapper,
|
||
mmPerM: number,
|
||
): void {
|
||
const opacity = p.greyed ? "0.3" : undefined;
|
||
switch (p.kind) {
|
||
case "polygon":
|
||
appendPolygon(svg, p, map, mmPerM, opacity);
|
||
break;
|
||
case "line": {
|
||
const a = map(p.a);
|
||
const b = map(p.b);
|
||
// 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;
|
||
}
|
||
case "arc": {
|
||
const node = arcNode(p, map, mmPerM);
|
||
if (opacity) node.setAttribute("opacity", opacity);
|
||
svg.appendChild(node);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Polygon mit Füllung/Umriss/Schraffur. Reihenfolge wie in der PlanView:
|
||
* • pattern "none" → reine Füllung (oder „none") + Umriss.
|
||
* • pattern "solid" → Vollfüllung in der Hatch-Farbe + Umriss.
|
||
* • Linien/Kreuz/Dämmung → Grundfüllung, dann geklippte Schraffurlinien, dann Umriss.
|
||
*/
|
||
function appendPolygon(
|
||
svg: SVGSVGElement,
|
||
p: Extract<Primitive, { kind: "polygon" }>,
|
||
map: Mapper,
|
||
mmPerM: number,
|
||
opacity: string | undefined,
|
||
): void {
|
||
const ptsMm = p.pts.map(map);
|
||
const ptsAttr = ptsMm.map((s) => `${round(s.x)},${round(s.y)}`).join(" ");
|
||
const strokeMm = p.strokeWidthMm > 0 ? quantizePen(p.strokeWidthMm) : 0;
|
||
|
||
const g = document.createElementNS(SVG_NS, "g");
|
||
if (opacity) g.setAttribute("opacity", opacity);
|
||
|
||
const pattern = p.hatch.pattern;
|
||
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));
|
||
} else if (pattern === "solid") {
|
||
g.appendChild(polyNode(ptsAttr, p.hatch.color, p.stroke, strokeMm));
|
||
} else {
|
||
// Grundfüllung (ohne Umriss), dann die geklippten Schraffurlinien, dann der
|
||
// Umriss obenauf (separat, damit er die Schraffur-Enden überdeckt).
|
||
if (p.fill !== "none") {
|
||
g.appendChild(polyNode(ptsAttr, p.fill, "none", 0));
|
||
}
|
||
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));
|
||
}
|
||
}
|
||
|
||
svg.appendChild(g);
|
||
}
|
||
|
||
/** Erzeugt einen <polygon>-Knoten (mm-Koordinaten als Attribut-String). */
|
||
function polyNode(
|
||
ptsAttr: string,
|
||
fill: string,
|
||
stroke: string,
|
||
strokeMm: number,
|
||
): SVGElement {
|
||
const el = document.createElementNS(SVG_NS, "polygon");
|
||
el.setAttribute("points", ptsAttr);
|
||
el.setAttribute("fill", fill);
|
||
if (stroke !== "none" && strokeMm > 0) {
|
||
el.setAttribute("stroke", stroke);
|
||
el.setAttribute("stroke-width", String(strokeMm));
|
||
el.setAttribute("stroke-linejoin", "round");
|
||
} else {
|
||
el.setAttribute("stroke", "none");
|
||
}
|
||
return el;
|
||
}
|
||
|
||
/** Erzeugt einen <line>-Knoten in mm; Strichmuster (mm Welt → mm Papier). */
|
||
function lineNode(
|
||
a: Vec2,
|
||
b: Vec2,
|
||
color: string,
|
||
strokeMm: number,
|
||
dash: number[] | null | undefined,
|
||
_mmPerM: number,
|
||
): SVGElement {
|
||
const el = document.createElementNS(SVG_NS, "line");
|
||
el.setAttribute("x1", String(round(a.x)));
|
||
el.setAttribute("y1", String(round(a.y)));
|
||
el.setAttribute("x2", String(round(b.x)));
|
||
el.setAttribute("y2", String(round(b.y)));
|
||
el.setAttribute("stroke", color);
|
||
el.setAttribute("stroke-width", String(strokeMm));
|
||
el.setAttribute("stroke-linecap", "round");
|
||
if (dash && dash.length) {
|
||
// Strichmuster ist in mm Papier definiert (wie weightMm), daher direkt.
|
||
el.setAttribute("stroke-dasharray", dash.map((d) => round(d)).join(" "));
|
||
}
|
||
return el;
|
||
}
|
||
|
||
/**
|
||
* Erzeugt einen Bogen als <path> (A-Kommando). Der Bogen-Radius ist in Metern →
|
||
* Papier-mm. Drehrichtung aus dem Kreuzprodukt im Papier-Raum (Y nach unten).
|
||
*/
|
||
function arcNode(
|
||
p: Extract<Primitive, { kind: "arc" }>,
|
||
map: Mapper,
|
||
mmPerM: number,
|
||
): SVGElement {
|
||
const c = map(p.center);
|
||
const from = map(p.from);
|
||
const to = map(p.to);
|
||
const r = p.r * mmPerM;
|
||
const v1 = { x: from.x - c.x, y: from.y - c.y };
|
||
const v2 = { x: to.x - c.x, y: to.y - c.y };
|
||
const cross = v1.x * v2.y - v1.y * v2.x;
|
||
const sweep = cross > 0 ? 1 : 0;
|
||
const d = `M ${round(from.x)} ${round(from.y)} A ${round(r)} ${round(r)} 0 0 ${sweep} ${round(to.x)} ${round(to.y)}`;
|
||
const el = document.createElementNS(SVG_NS, "path");
|
||
el.setAttribute("d", d);
|
||
el.setAttribute("fill", "none");
|
||
el.setAttribute("stroke", "#111111");
|
||
el.setAttribute("stroke-width", String(quantizePen(p.weightMm)));
|
||
if (p.dash && p.dash.length) {
|
||
el.setAttribute("stroke-dasharray", p.dash.map((x) => round(x)).join(" "));
|
||
}
|
||
return el;
|
||
}
|
||
|
||
/** Auf 3 Nachkommastellen runden (kompaktes, exaktes SVG). */
|
||
function round(v: number): number {
|
||
return Math.round(v * 1000) / 1000;
|
||
}
|
||
|
||
// ── Schraffur als geklippte Vektorlinien ─────────────────────────────────────
|
||
|
||
interface Seg {
|
||
a: Vec2;
|
||
b: Vec2;
|
||
}
|
||
|
||
/**
|
||
* Erzeugt die Schraffurlinien eines Polygons (in Papier-mm), auf das Polygon
|
||
* geklippt. Die Kachelgrösse der Bildschirm-PlanView (8 px bzw. 14 px bei
|
||
* „insulation", × Maßstab) wird hier in einen mm-Linienabstand übersetzt, damit
|
||
* der Druck der Bildschirm-Dichte nahekommt:
|
||
* • diagonal/crosshatch: parallele Linienscharen im Winkel `angle`.
|
||
* • crosshatch: zusätzlich die um 90° gedrehte Schar.
|
||
* • insulation: vereinfacht als eine dichtere diagonale Schar (echte Wellen-
|
||
* linie wäre als Pfad möglich; eine Linienschar liest sich im Druck sauber
|
||
* und bleibt rein vektoriell — offener Punkt im Report).
|
||
*/
|
||
function hatchLines(polyMm: Vec2[], hatch: HatchRender, mmPerM: number): Seg[] {
|
||
// Kachel der PlanView: 8 viewBox-Einheiten (= px bei dpi-Bezug) × scale. In der
|
||
// PlanView ist 1 viewBox-Einheit = 1/PX_PER_M m = 1/90 m. Auf Papier sind das
|
||
// (1/90)·mmPerM mm. So bleibt die Strich-Dichte massstäblich konsistent.
|
||
const unitMm = (1 / 90) * mmPerM;
|
||
const tileMm = (hatch.pattern === "insulation" ? 10 : 8) * hatch.scale * unitMm;
|
||
const spacing = Math.max(0.5, tileMm); // Mindestabstand, sonst zu dicht
|
||
|
||
const angle = (hatch.angle * Math.PI) / 180;
|
||
const segs: Seg[] = [];
|
||
fillParallel(polyMm, angle, spacing, segs);
|
||
if (hatch.pattern === "crosshatch") {
|
||
fillParallel(polyMm, angle + Math.PI / 2, spacing, segs);
|
||
}
|
||
if (hatch.pattern === "insulation") {
|
||
// Zweite, gegenläufige Schar deutet die Dämmungs-Textur an (dichter Filz).
|
||
fillParallel(polyMm, angle - Math.PI / 4, spacing, segs);
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
/**
|
||
* Füllt das Polygon mit parallelen Linien im gegebenen Winkel/Abstand und klippt
|
||
* jede Linie auf das Polygon (Scanline über die Polygon-Schnittpunkte). Robust
|
||
* für konvexe wie konkave (einfache) Polygone.
|
||
*/
|
||
function fillParallel(
|
||
poly: Vec2[],
|
||
angle: number,
|
||
spacing: number,
|
||
out: Seg[],
|
||
): void {
|
||
if (poly.length < 3 || spacing <= 0) return;
|
||
// Linienrichtung d und Normale nrm (Projektionsachse).
|
||
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
|
||
const nrm = { x: -dir.y, y: dir.x };
|
||
|
||
// Projektion der Polygonpunkte auf die Normale → [tMin, tMax]-Band der Linien.
|
||
let tMin = Infinity;
|
||
let tMax = -Infinity;
|
||
for (const p of poly) {
|
||
const t = p.x * nrm.x + p.y * nrm.y;
|
||
if (t < tMin) tMin = t;
|
||
if (t > tMax) tMax = t;
|
||
}
|
||
// Linien an t = tMin + i·spacing; für jede die Schnitt-Intervalle ermitteln.
|
||
const start = Math.ceil(tMin / spacing) * spacing;
|
||
for (let t = start; t <= tMax; t += spacing) {
|
||
const xs: number[] = []; // Parameter entlang dir der Schnittpunkte
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const a = poly[j];
|
||
const b = poly[i];
|
||
const ta = a.x * nrm.x + a.y * nrm.y;
|
||
const tb = b.x * nrm.x + b.y * nrm.y;
|
||
// Schneidet die Kante das Niveau t?
|
||
if (ta === tb) continue;
|
||
const f = (t - ta) / (tb - ta);
|
||
if (f < 0 || f > 1) continue;
|
||
const px = a.x + (b.x - a.x) * f;
|
||
const py = a.y + (b.y - a.y) * f;
|
||
xs.push(px * dir.x + py * dir.y); // Position entlang dir
|
||
}
|
||
if (xs.length < 2) continue;
|
||
xs.sort((u, v) => u - v);
|
||
// Paarweise Intervalle = Polygon-Inneres entlang der Linie.
|
||
for (let k = 0; k + 1 < xs.length; k += 2) {
|
||
const u0 = xs[k];
|
||
const u1 = xs[k + 1];
|
||
if (u1 - u0 < 1e-6) continue;
|
||
// Punkt = t·nrm + u·dir.
|
||
out.push({
|
||
a: { x: nrm.x * t + dir.x * u0, y: nrm.y * t + dir.y * u0 },
|
||
b: { x: nrm.x * t + dir.x * u1, y: nrm.y * t + dir.y * u1 },
|
||
});
|
||
}
|
||
}
|
||
}
|