From e7ea1eedddfc05c18422ee06ad05deeaec35628e Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 2 Jul 2026 00:54:41 +0200 Subject: [PATCH] =?UTF-8?q?2D-Plan-Qualitaet:=20saubere=20Wandecken=20+=20?= =?UTF-8?q?glatte=20D=C3=A4mmschraffur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wandecken (generatePlan/glPlanCompile/PlanView): die diagonale Miter-Stirnkante am L-Stoss wurde als Umriss gestrokt -> Barb-Ueberstand am Aussenapex + 45deg- Naht zwischen gleichen Schichten. Fix: interne Join-Stirnkanten per neuem noStrokeEdges vom Umriss ausnehmen (Fuellung bleibt volle Flaeche, laengs laufende Materialfugen bleiben). Ecke = sauberer Miter ohne Ueberstand, gleiche Schichten verschmelzen nahtlos ueber die Ecke. GPU- und SVG-Pfad teilen sich noStrokeEdges. Dämmschraffur (glPlanHatch): die Sinuswelle wurde pro Segment einzeln aufs Polygon geclippt und mit Butt-Caps gestrokt -> fransige Fragmente an jeder Wellenbiegung. Fix: kontinuierliche Punkt-Laeufe (clipPolylineToPolygon) als EIN gehrter Streifen; Dash laeuft ueber die Laeufe. STEPS 12->22. Gerade Scharen (diagonal/crosshatch) unveraendert. --- src/plan/PlanView.tsx | 91 +++++++++--- src/plan/generatePlan.corner.test.ts | 116 +++++++++++++++ src/plan/generatePlan.ts | 34 ++++- src/plan/glPlan/glPlanCompile.ts | 54 ++++++- src/plan/glPlan/glPlanHatch.test.ts | 202 ++++++++++++++++++++++----- src/plan/glPlan/glPlanHatch.ts | 199 +++++++++++++++++++------- 6 files changed, 582 insertions(+), 114 deletions(-) create mode 100644 src/plan/generatePlan.corner.test.ts diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 311e971..1a5191a 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -34,6 +34,33 @@ function toScreen(p: Vec2): Vec2 { return { x: p.x * PX_PER_M, y: -p.y * PX_PER_M }; } +/** + * Zerlegt den Rand eines Polygons in zusammenhängende OFFENE Linienzüge, wobei die + * Kanten in `noStroke` ausgelassen werden (Kante `i` = pts[i]→pts[i+1]). Genutzt, + * um die inneren Gehrungs-Stirnkanten an einer Wandecke NICHT zu stricheln (keine + * 45°-Naht, keine über den Apex schießende Barbe), während die Füllung das volle + * Polygon bleibt. Jeder Lauf beginnt an einer sichtbaren Kante, deren Vorgänger + * ausgelassen ist. + */ +function visibleEdgeRuns(scr: Vec2[], noStroke: number[]): Vec2[][] { + const n = scr.length; + const skip = new Set(noStroke.map((i) => ((i % n) + n) % n)); + if (skip.size >= n || n < 2) return []; + const visible = (i: number) => !skip.has(((i % n) + n) % n); + const runs: Vec2[][] = []; + for (let s = 0; s < n; s++) { + if (!visible(s) || visible(s - 1 + n)) continue; // kein Lauf-Anfang + const run: Vec2[] = [scr[s]]; + let j = s; + while (visible(j) && j - s < n) { + run.push(scr[(j + 1) % n]); + j++; + } + runs.push(run); + } + return runs; +} + /** * Papier-Massstab (docs/design/plans-output.md §3): Der Plan IST Papier-Space. * dpi = 96·devicePixelRatio (CSS definiert 1px = 1/96 inch). Strichstärken sind @@ -2565,44 +2592,62 @@ function renderPrimitive( }; switch (p.kind) { case "polygon": { - const pts = p.pts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" "); + const scr = p.pts.map(toScreen); + const pts = scr.map((s) => `${s.x},${s.y}`).join(" "); // Umriss-Strichstärke in mm Papier (Display: konstante px / Print: skaliert). const sw = weight(p.strokeWidthMm); + // Innere Gehrungs-Stirnkanten am Wandknoten (noStrokeEdges) NICHT stricheln: + // die Füllung bleibt das volle Polygon, aber die Diagonale am Knoten (Naht) + // und die überschießende Eck-Barbe entfallen. Sichtbare Kanten werden als + // zusammenhängende offene -Läufe gezeichnet (keine Gehrung über + // die weggelassene Kante). Leeres/kein noStrokeEdges ⇒ voller Umriss. + const noStroke = p.noStrokeEdges; + const outline = () => { + if (!noStroke || noStroke.length === 0) { + return ( + + ); + } + const runs = visibleEdgeRuns(scr, noStroke); + return ( + <> + {runs.map((run, ri) => ( + `${s.x},${s.y}`).join(" ")} + fill="none" + stroke={p.stroke} + strokeWidth={sw} + vectorEffect={vfx} + /> + ))} + + ); + }; // Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss). if (p.hatch.pattern === "none") { return ( - + + + {outline()} + ); } // Vollfüllung: Schraffurfarbe deckt die Component-Füllung (Poché). if (p.hatch.pattern === "solid") { return ( - + + + {outline()} + ); } - // Linien-/Kreuz-Schraffur: erst Grundfüllung, dann Muster darüber. + // Linien-/Kreuz-Schraffur: erst Grundfüllung, dann Muster, dann Umriss. return ( - + + {outline()} ); } diff --git a/src/plan/generatePlan.corner.test.ts b/src/plan/generatePlan.corner.test.ts new file mode 100644 index 0000000..a39fee9 --- /dev/null +++ b/src/plan/generatePlan.corner.test.ts @@ -0,0 +1,116 @@ +/** + * Unit-Tests für die Wandecken-Gehrung im Grundriss (generatePlan): + * • KEINE 45°-Naht zwischen gleichfarbigen Schichten zweier Wände am Knoten + * (die inneren Gehrungs-Stirnkanten tragen `noStrokeEdges`). + * • KEIN überschießender Umriss-Barb: die Gehrungs-Diagonale ist der Umriss + * nicht mehr gestrichen; die Cut-Ecken sitzen exakt auf den Fläche×Fläche- + * Apexen (sauberer Miter, kein Overshoot). + * • Freie (rechtwinklige) Wandenden bleiben voll umrissen (kein noStrokeEdges). + */ + +import { describe, it, expect } from "vitest"; +import { generatePlan } from "./generatePlan"; +import type { Project, Wall } from "../model/types"; + +/** Minimalprojekt: zwei Wände, die in (5,0) eine 90°-L-Ecke bilden. */ +function cornerProject(): Project { + const wall = (id: string, start: Wall["start"], end: Wall["end"]): Wall => ({ + id, + type: "wall", + floorId: "eg", + categoryCode: "20", + start, + end, + wallTypeId: "aw", + height: 2.6, + }); + return { + id: "t", + name: "T", + lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }], + hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }], + components: [ + { id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }, + { id: "b", name: "B", color: "#8c5544", hatchId: "none", joinPriority: 50 }, + ], + wallTypes: [ + { + id: "aw", + name: "AW", + layers: [ + { componentId: "a", thickness: 0.1 }, + { componentId: "b", thickness: 0.245 }, + ], + }, + ], + drawingLevels: [ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 }, + ], + layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }], + walls: [ + // W1 endet in (5,0); W2 startet in (5,0) → geteilter Knoten (L-Ecke). + wall("W1", { x: 0, y: 0 }, { x: 5, y: 0 }), + wall("W2", { x: 5, y: 0 }, { x: 5, y: 4 }), + ], + doors: [], + openings: [], + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + }; +} + +const visible = new Set(["20"]); + +describe("generatePlan — Wandecken-Gehrung", () => { + it("markiert die Gehrungs-Stirnkante am Knoten als nicht zu stricheln (keine Naht/Barbe)", () => { + const plan = generatePlan(cornerProject(), "eg", visible, undefined, "mittel"); + const wallPolys = plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.wallId != null, + ); + expect(wallPolys.length).toBeGreaterThan(0); + // JEDES Wandpolygon (Schichtbänder + Umriss) trägt an genau EINEM Ende die + // Gehrungs-Stirnkante: W1 am Endcut (Kante 1), W2 am Startcut (Kante 3). + const w1 = wallPolys.filter((p) => p.wallId === "W1"); + const w2 = wallPolys.filter((p) => p.wallId === "W2"); + expect(w1.length).toBeGreaterThan(0); + expect(w2.length).toBeGreaterThan(0); + for (const p of w1) expect(p.noStrokeEdges).toEqual([1]); + for (const p of w2) expect(p.noStrokeEdges).toEqual([3]); + }); + + it("legt die Umriss-Cut-Ecken exakt auf die Fläche×Fläche-Apexe (sauberer Miter, kein Overshoot)", () => { + const plan = generatePlan(cornerProject(), "eg", visible, undefined, "mittel"); + // Der Wand-Umriss ist das letzte Wandpolygon je Wand (fill:"none"). + const outlineW1 = plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.wallId === "W1" && p.fill === "none", + ); + expect(outlineW1.length).toBe(1); + const o = outlineW1[0]; + // clippedBand-Reihenfolge [A.start, A.end, B.end, B.start]; A.end/B.end sind + // die getrimmten Enden am Knoten. T=0.345, half=0.1725. Aussen-Apex=(5.1725, + // -0.1725), Innen-Apex=(4.8275, 0.1725) — der EXAKTE 90°-Miter ohne Überstand. + const half = 0.345 / 2; + expect(o.pts[1].x).toBeCloseTo(5 + half, 4); // A.end (Aussenfläche) + expect(o.pts[1].y).toBeCloseTo(-half, 4); + expect(o.pts[2].x).toBeCloseTo(5 - half, 4); // B.end (Innenfläche) + expect(o.pts[2].y).toBeCloseTo(half, 4); + }); + + it("lässt freie (rechtwinklige) Wandenden voll umrissen (kein noStrokeEdges dort)", () => { + // Nur W1 allein → beide Enden frei, kein Knoten. + const proj = cornerProject(); + proj.walls = [proj.walls[0]]; + const plan = generatePlan(proj, "eg", visible, undefined, "mittel"); + const wallPolys = plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.wallId === "W1", + ); + expect(wallPolys.length).toBeGreaterThan(0); + for (const p of wallPolys) expect(p.noStrokeEdges).toEqual([]); + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 1e2f927..c690fe0 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -46,6 +46,7 @@ import { sub, wallCorners, } from "../model/geometry"; +import type { Line } from "../model/geometry"; /** * Detailgrad der Plan-Darstellung (Oberleiste „grob/mittel/fein"). Steuert hier @@ -142,6 +143,17 @@ export type Primitive = hatch: HatchRender; /** Gedimmt zeichnen (Darstellungsmodus „andere grau"). */ greyed?: boolean; + /** + * Kanten-Indizes, die NICHT umrissen (gestrichen) werden dürfen — die Füllung + * bleibt das volle Polygon. Kante `i` läuft von `pts[i]` nach `pts[(i+1)%n]`. + * Damit werden die inneren Gehrungs-/Stoß-Stirnflächen an einer Wandecke + * unterdrückt: die Diagonale, an der GLEICHFARBIGE Schichten zweier Wände + * aneinanderstoßen, würde sonst als sichtbare 45°-Naht erscheinen und der + * Umriss würde als Barbe über den Eck-Apex hinausschießen (Gehrungs-Spitze). + * Längskanten (Schichtfugen zwischen VERSCHIEDENEN Materialien) bleiben + * gestrichen — nur die Stoß-Stirnkanten am Knoten entfallen. + */ + noStrokeEdges?: number[]; /** * ID der Wand, zu der dieses Schicht-/Umriss-Polygon gehört (für die * Links-Klick-Auswahl: die PlanView markiert ALLE Polygone derselben @@ -638,6 +650,19 @@ function addWallPoche( // Vereinfachte Füllung (grob): Farbe des tragenden Bauteils (höchste Priorität). const simpleFill = backboneColor(project, wt); + // Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten). + // clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end) + // ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche. + // Diese werden NICHT umrissen: sonst 45°-Naht zwischen gleichfarbigen Schichten + // + überschießende Gehrungs-Barbe am Eck-Apex. Freie Enden (kein Cut) bleiben + // rechtwinklig gestrichen. Die Längskanten (Schichtfugen) bleiben stets sichtbar. + const joinEdges = (startCut: Line | null, endCut: Line | null): number[] => { + const e: number[] = []; + if (endCut) e.push(1); + if (startCut) e.push(3); + return e; + }; + for (const [s, e] of segments) { const p1 = along(wall.start, wall.end, s); const p2 = along(wall.start, wall.end, e); @@ -656,6 +681,7 @@ function addWallPoche( strokeWidthMm: outlineMm, hatch: NO_HATCH, greyed, + noStrokeEdges: joinEdges(startCut, endCut), wallId, }); continue; @@ -664,6 +690,7 @@ function addWallPoche( // mittel/fein: Schichten von außen (-T/2) nach innen (+T/2) stapeln. Jede // Schicht löst Bauteil (Füllfarbe) + Schraffur (Muster/Maßstab/Winkel/Farbe) // auf; die Schichtfugen werden dünn gestrichen. + const bandJoinEdges = joinEdges(startCut, endCut); let off = refOff - total / 2; for (const layer of wt.layers) { const comp = getComponent(project, layer.componentId); @@ -675,12 +702,16 @@ function addWallPoche( strokeWidthMm: layerLineMm, hatch: resolveHatch(project, comp.hatchId), greyed, + // Die Diagonale am Knoten NICHT stricheln → keine 45°-Naht zwischen + // gleichfarbigen Schichten der beiden Wände. + noStrokeEdges: bandJoinEdges, wallId, }); off += layer.thickness; } // Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung), - // damit die Wand sich klar von den dünnen Schichtfugen abhebt. + // damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs- + // Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe. out.push({ kind: "polygon", pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), @@ -689,6 +720,7 @@ function addWallPoche( strokeWidthMm: outlineMm, hatch: NO_HATCH, greyed, + noStrokeEdges: bandJoinEdges, wallId, }); } diff --git a/src/plan/glPlan/glPlanCompile.ts b/src/plan/glPlan/glPlanCompile.ts index ee94d86..822d04e 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 { applyDash, buildHatchSegments } from './glPlanHatch'; +import { applyDashRuns, buildHatchRuns } from './glPlanHatch'; const PX_PER_M = 90; @@ -247,6 +247,44 @@ export function compilePrimitivesToGpu( const pushLine = (aM: Vec2, bM: Vec2, color: Rgba, strokeMm: number) => strokePolyline([aM, bM], false, color, strokeMm); + /** + * Strichelt den Umriss eines Polygons, wobei die Kanten in `noStroke` + * ÜBERSPRUNGEN werden (Kante `i` = pts[i]→pts[i+1]). Die Füllung bleibt das + * volle Polygon; nur innere Gehrungs-Stirnkanten am Wandknoten entfallen. Die + * sichtbaren Kanten werden als zusammenhängende OFFENE Züge gestrichelt → an + * den weggelassenen Kanten laufen die Enden gerade aus (keine Gehrung über die + * Naht = keine überschießende Barbe). Leeres `noStroke` ⇒ geschlossener, + * gehrter Umriss wie bisher. + */ + const strokePolygonOutline = ( + ptsM: Vec2[], + color: Rgba, + strokeMm: number, + noStroke: number[] | undefined, + ) => { + const n = ptsM.length; + if (!noStroke || noStroke.length === 0 || n < 2) { + strokePolyline(ptsM, true, color, strokeMm); + return; + } + const skip = new Set(noStroke.map((i) => ((i % n) + n) % n)); + if (skip.size >= n) return; // alle Kanten unterdrückt → nichts zeichnen + // Sichtbare Kanten zu zusammenhängenden Läufen bündeln; ein Lauf beginnt an + // einer sichtbaren Kante, deren Vorgänger übersprungen ist. + const visible = (i: number) => !skip.has(((i % n) + n) % n); + for (let s = 0; s < n; s++) { + if (!visible(s) || visible(s - 1 + n)) continue; // kein Lauf-Anfang + const run: Vec2[] = [ptsM[s]]; + let j = s; + while (visible(j)) { + run.push(ptsM[(j + 1) % n]); + j++; + if (j - s >= n) break; // voller Umlauf (kann bei skip.size≥1 nicht sein) + } + strokePolyline(run, false, color, strokeMm); + } + }; + for (const prim of primitives) { if (prim.kind === 'polygon') { // Füllung (falls vorhanden). @@ -266,21 +304,23 @@ export function compilePrimitivesToGpu( } // Schraffur (nach der Füllung, vor/passend zum Umriss — wie der SVG stapelt): // das Muster wird in echte Linien-Geometrie (Modell-Meter) tesselliert und - // aufs Polygon geclippt (konkav-fähig, even-odd). Musterlinien in derselben - // Papier-mm-Breite wie der SVG-``; `hatch.dash` wird geometrisch - // (Strich/Lücke) aufgelöst, da die GL-Linien-Pipeline kein Dash kennt. + // aufs Polygon geclippt (konkav-fähig, even-odd). Musterlinien fließen als + // zusammenhängende Läufe in `strokePolyline` → GEHRTE, glatte Übergänge + // (insb. die Dämmungswelle bleibt spitzenfrei). Breite = dieselbe Papier-mm + // wie der SVG-``; `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') { 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 segs = applyDash(buildHatchSegments(prim.pts, prim.hatch), prim.hatch.dash); - for (const s of segs) pushLine(s.a, s.b, hatchColor, hatchMm); + const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash); + for (const run of runs) strokePolyline(run, false, hatchColor, hatchMm); } // Umriss (crispe Kante) — geschlossener Ring, GEHRT (kein Stufen-Cap an // Ecken). Breite = ECHTE Papier-mm; der Renderer rechnet massstabs-/ // zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG). const stroke = parseColor(prim.stroke); if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) { - strokePolyline(prim.pts, true, stroke, prim.strokeWidthMm); + strokePolygonOutline(prim.pts, stroke, prim.strokeWidthMm, prim.noStrokeEdges); } } else if (prim.kind === 'line') { const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1]; diff --git a/src/plan/glPlan/glPlanHatch.test.ts b/src/plan/glPlan/glPlanHatch.test.ts index 6fa2bf3..6828a90 100644 --- a/src/plan/glPlan/glPlanHatch.test.ts +++ b/src/plan/glPlan/glPlanHatch.test.ts @@ -6,11 +6,13 @@ import { describe, it, expect } from 'vitest'; import { - applyDash, - buildHatchSegments, + applyDashRuns, + buildHatchRuns, clipLineToPolygon, + clipPolylineToPolygon, clipSegmentToPolygon, hatchLineFamily, + type HatchRun, type HatchSegment, } from './glPlanHatch'; import type { Vec2 } from '../../model/types'; @@ -23,6 +25,17 @@ function totalLen(segs: HatchSegment[]): number { return l; } +/** Gesamt-Länge einer Lauf-Schar (Polylinien). */ +function totalRunLen(runs: HatchRun[]): number { + let l = 0; + for (const run of runs) { + for (let i = 0; i + 1 < run.length; i++) { + l += Math.hypot(run[i + 1].x - run[i].x, run[i + 1].y - run[i].y); + } + } + return l; +} + /** Basis-Hatch mit Überschreibungen. */ function hatch(over: Partial): HatchRender { return { pattern: 'diagonal', scale: 1, angle: 0, color: '#000', lineWeight: 0.13, dash: null, ...over }; @@ -136,6 +149,106 @@ describe('clipSegmentToPolygon', () => { }); }); +describe('clipPolylineToPolygon', () => { + const sq: Vec2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 4 }, + { x: 0, y: 4 }, + ]; + + it('KONVEX: eine durchgehende Polylinie bleibt EIN Lauf (keine Zerreißung an inneren Vertices)', () => { + // Polylinie mit mehreren inneren Stützpunkten, komplett im Quadrat. + const pts: Vec2[] = [ + { x: 1, y: 1 }, + { x: 2, y: 2 }, + { x: 3, y: 1 }, + { x: 3.5, y: 2 }, + ]; + const runs = clipPolylineToPolygon(pts, sq); + expect(runs.length).toBe(1); + // Alle Original-Stützpunkte bleiben erhalten → ein Lauf mit ≥4 Punkten. + expect(runs[0].length).toBe(4); + expect(totalRunLen(runs)).toBeCloseTo(totalRunLen([pts]), 9); + }); + + it('überstehende Polylinie wird an den Grenzen geklemmt, bleibt aber EIN Lauf', () => { + // Waagerechte Polylinie von x=-3..7 durch die Mitte → innen [0,4]. + const pts: Vec2[] = [ + { x: -3, y: 2 }, + { x: 0, y: 2 }, + { x: 7, y: 2 }, + ]; + const runs = clipPolylineToPolygon(pts, sq); + expect(runs.length).toBe(1); + expect(totalRunLen(runs)).toBeCloseTo(4, 9); // x=0..4 + }); + + it('KONKAV (C-Form): eine Linie durch beide Arme → ZWEI getrennte Läufe', () => { + // Gleiche C-Form wie beim Linien-Clipping; eine durchgehende horizontale + // Polylinie auf Höhe y=4 tritt in der Kerbe aus → zwei Läufe. + const c: Vec2[] = [ + { x: 0, y: 0 }, + { x: 6, y: 0 }, + { x: 6, y: 6 }, + { x: 4, y: 6 }, + { x: 4, y: 2 }, + { x: 2, y: 2 }, + { x: 2, y: 6 }, + { x: 0, y: 6 }, + ]; + const pts: Vec2[] = [ + { x: -1, y: 4 }, + { x: 3, y: 4 }, + { x: 7, y: 4 }, + ]; + const runs = clipPolylineToPolygon(pts, c); + expect(runs.length).toBe(2); + const spans = runs + .map((r) => { + const xs = r.map((p) => p.x); + return [Math.min(...xs), Math.max(...xs)]; + }) + .sort((p, q) => p[0] - q[0]); + expect(spans[0][0]).toBeCloseTo(0, 9); + expect(spans[0][1]).toBeCloseTo(2, 9); + expect(spans[1][0]).toBeCloseTo(4, 9); + expect(spans[1][1]).toBeCloseTo(6, 9); + }); + + it('Polylinie komplett AUSSERHALB → keine Läufe', () => { + const pts: Vec2[] = [ + { x: -3, y: 10 }, + { x: 2, y: 10 }, + { x: 7, y: 10 }, + ]; + expect(clipPolylineToPolygon(pts, sq)).toEqual([]); + }); + + it('Ecken-Durchquerung: ein L-förmiger Zug ins und wieder aus dem Polygon bleibt zusammenhängend', () => { + // Zug tritt bei x=0 ein, geht durch eine INNERE Ecke (2,2) und wieder raus: + // (-1,2)-(2,2)-(2,5) → innen: (0,2)-(2,2)-(2,4). Ein Lauf mit der Ecke drin. + const pts: Vec2[] = [ + { x: -1, y: 2 }, + { x: 2, y: 2 }, + { x: 2, y: 5 }, + ]; + const runs = clipPolylineToPolygon(pts, sq); + expect(runs.length).toBe(1); + // Der innere Eckpunkt (2,2) bleibt im Lauf → ≥3 Punkte. + expect(runs[0].length).toBeGreaterThanOrEqual(3); + // Enthält die Ecke (2,2). + const hasCorner = runs[0].some((p) => Math.abs(p.x - 2) < 1e-6 && Math.abs(p.y - 2) < 1e-6); + expect(hasCorner).toBe(true); + expect(totalRunLen(runs)).toBeCloseTo(2 + 2, 9); // (0,2)->(2,2)=2, (2,2)->(2,4)=2 + }); + + it('degeneriert (<2 Punkte / <3 Ecken) → leer', () => { + expect(clipPolylineToPolygon([{ x: 0, y: 0 }], sq)).toEqual([]); + expect(clipPolylineToPolygon([{ x: 0, y: 0 }, { x: 1, y: 1 }], [{ x: 0, y: 0 }])).toEqual([]); + }); +}); + describe('hatchLineFamily', () => { const sq: Vec2[] = [ { x: 0, y: 0 }, @@ -158,7 +271,7 @@ describe('hatchLineFamily', () => { }); }); -describe('buildHatchSegments', () => { +describe('buildHatchRuns', () => { const sq: Vec2[] = [ { x: 0, y: 0 }, { x: 1, y: 0 }, @@ -166,20 +279,22 @@ describe('buildHatchSegments', () => { { x: 0, y: 1 }, ]; - it('solid/none erzeugen KEINE Musterlinien (Füllung/Umriss übernimmt der Aufrufer)', () => { - expect(buildHatchSegments(sq, hatch({ pattern: 'none' }))).toEqual([]); - expect(buildHatchSegments(sq, hatch({ pattern: 'solid' }))).toEqual([]); + it('solid/none erzeugen KEINE Läufe (Füllung/Umriss übernimmt der Aufrufer)', () => { + expect(buildHatchRuns(sq, hatch({ pattern: 'none' }))).toEqual([]); + expect(buildHatchRuns(sq, hatch({ pattern: 'solid' }))).toEqual([]); }); - it('diagonal erzeugt eine einzelne Schar; crosshatch mehr (zwei Scharen)', () => { - const diag = buildHatchSegments(sq, hatch({ pattern: 'diagonal' })); - const cross = buildHatchSegments(sq, hatch({ pattern: 'crosshatch' })); + it('diagonal erzeugt eine einzelne Schar 2-Punkt-Läufe; crosshatch mehr (zwei Scharen)', () => { + const diag = buildHatchRuns(sq, hatch({ pattern: 'diagonal' })); + const cross = buildHatchRuns(sq, hatch({ pattern: 'crosshatch' })); expect(diag.length).toBeGreaterThan(0); - // Zwei Scharen → mindestens so viele Segmente wie eine Schar, praktisch mehr. + // Gerade Musterlinien sind 2-Punkt-Läufe. + for (const r of diag) expect(r.length).toBe(2); + // Zwei Scharen → mindestens so viele Läufe wie eine Schar, praktisch mehr. expect(cross.length).toBeGreaterThan(diag.length); - // Alle Segmente liegen im Einheitsquadrat (Clipping wirkt). - for (const s of cross) { - for (const p of [s.a, s.b]) { + // Alle Lauf-Punkte liegen im Einheitsquadrat (Clipping wirkt). + for (const r of cross) { + for (const p of r) { expect(p.x).toBeGreaterThanOrEqual(-1e-6); expect(p.x).toBeLessThanOrEqual(1 + 1e-6); expect(p.y).toBeGreaterThanOrEqual(-1e-6); @@ -188,11 +303,14 @@ describe('buildHatchSegments', () => { } }); - it('insulation erzeugt tessellierte Wellen-Segmente innerhalb des Polygons', () => { - const segs = buildHatchSegments(sq, hatch({ pattern: 'insulation' })); - expect(segs.length).toBeGreaterThan(0); - for (const s of segs) { - for (const p of [s.a, s.b]) { + it('insulation erzeugt DURCHGEHENDE Wellen-Läufe (≥3 Punkte) innerhalb des Polygons', () => { + const runs = buildHatchRuns(sq, hatch({ pattern: 'insulation' })); + expect(runs.length).toBeGreaterThan(0); + // Mindestens ein Lauf ist eine echte Polylinie (nicht zerrissen in 2-Punkt-Stücke). + expect(runs.some((r) => r.length >= 3)).toBe(true); + for (const r of runs) { + expect(r.length).toBeGreaterThanOrEqual(2); + for (const p of r) { expect(p.x).toBeGreaterThanOrEqual(-1e-6); expect(p.x).toBeLessThanOrEqual(1 + 1e-6); } @@ -200,30 +318,50 @@ describe('buildHatchSegments', () => { }); it('größerer scale → weniger Musterlinien (weitere Teilung)', () => { - const fine = buildHatchSegments(sq, hatch({ pattern: 'diagonal', scale: 1 })); - const coarse = buildHatchSegments(sq, hatch({ pattern: 'diagonal', scale: 4 })); + const fine = buildHatchRuns(sq, hatch({ pattern: 'diagonal', scale: 1 })); + const coarse = buildHatchRuns(sq, hatch({ pattern: 'diagonal', scale: 4 })); expect(coarse.length).toBeLessThan(fine.length); }); it('scale ≤ 0 wird auf 1 geklemmt (keine leere/entartete Kachel)', () => { - const segs = buildHatchSegments(sq, hatch({ pattern: 'diagonal', scale: 0 })); - expect(segs.length).toBeGreaterThan(0); + const runs = buildHatchRuns(sq, hatch({ pattern: 'diagonal', scale: 0 })); + expect(runs.length).toBeGreaterThan(0); }); }); -describe('applyDash', () => { - it('ohne Muster (null/leer) bleiben Segmente unverändert', () => { - const segs: HatchSegment[] = [{ a: { x: 0, y: 0 }, b: { x: 10, y: 0 } }]; - expect(applyDash(segs, null)).toBe(segs); - expect(applyDash(segs, [])).toBe(segs); +describe('applyDashRuns', () => { + it('ohne Muster (null/leer) bleiben Läufe unverändert', () => { + const runs: HatchRun[] = [[{ x: 0, y: 0 }, { x: 10, y: 0 }]]; + expect(applyDashRuns(runs, null)).toBe(runs); + expect(applyDashRuns(runs, [])).toBe(runs); }); - it('zerlegt ein Segment in Strich/Lücke (Summe der Striche < Gesamtlänge)', () => { - const segs: HatchSegment[] = [{ a: { x: 0, y: 0 }, b: { x: 10, y: 0 } }]; - const dashed = applyDash(segs, [0.13, 0.13]); // gleich lang an/aus + it('zerlegt einen Lauf in Strich/Lücke (Summe der Striche < Gesamtlänge)', () => { + const runs: HatchRun[] = [[{ x: 0, y: 0 }, { x: 10, y: 0 }]]; + const dashed = applyDashRuns(runs, [0.13, 0.13]); // gleich lang an/aus expect(dashed.length).toBeGreaterThan(1); // Nur die „an"-Stücke bleiben → Gesamtlänge kleiner als das Original (10). - expect(totalLen(dashed)).toBeGreaterThan(0); - expect(totalLen(dashed)).toBeLessThan(totalLen(segs)); + expect(totalRunLen(dashed)).toBeGreaterThan(0); + expect(totalRunLen(dashed)).toBeLessThan(totalRunLen(runs)); + }); + + it('läuft die Dash-Phase über Stützpunkt-Ecken durch (gekrümmter Lauf)', () => { + // Ein rechtwinkliger Lauf (zwei Segmente à 10 m). Die Phase darf nicht je + // Segment zurückgesetzt werden → einzelne „an"-Stücke dürfen die Ecke queren. + const runs: HatchRun[] = [ + [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ], + ]; + const dashed = applyDashRuns(runs, [50, 5]); // langer Strich (>1 Segment), kurze Lücke + expect(dashed.length).toBeGreaterThan(0); + // Mindestens ein „an"-Stück überspannt die Ecke (enthält einen 90°-Knick → + // ≥3 Punkte, wobei der mittlere die Ecke (10,0) ist). + const spansCorner = dashed.some( + (r) => r.length >= 3 && r.some((p) => Math.abs(p.x - 10) < 1e-6 && Math.abs(p.y) < 1e-6), + ); + expect(spansCorner).toBe(true); }); }); diff --git a/src/plan/glPlan/glPlanHatch.ts b/src/plan/glPlan/glPlanHatch.ts index fc94e24..60945a7 100644 --- a/src/plan/glPlan/glPlanHatch.ts +++ b/src/plan/glPlan/glPlanHatch.ts @@ -34,6 +34,14 @@ export interface HatchSegment { b: Vec2; } +/** + * Ein zusammenhängender Linienzug in Modell-Metern (≥2 Punkte). Gerade + * Musterlinien (diagonal/crosshatch) sind 2-Punkt-Läufe; die Dämmungs-Welle ist + * ein langer, an den Polygonkanten geclippter Lauf. Läufe fließen als EIN gehrter + * Streifen in `strokePolyline` → glatte Übergänge statt Butt-Cap-Stufen. + */ +export type HatchRun = Vec2[]; + /** * Achsen-ausgerichtetes Bounding-Rechteck eines Polygons (Modell-Meter). * Leeres Polygon → degeneriert (min>max), der Aufrufer erzeugt dann keine Linien. @@ -151,6 +159,67 @@ export function clipSegmentToPolygon(a: Vec2, b: Vec2, poly: Vec2[]): HatchSegme return out; } +/** + * Schneidet eine ZUSAMMENHÄNGENDE Polylinie `pts` gegen ein (auch KONKAVES) + * einfaches Polygon und liefert die innenliegenden Teile als CONTINUOUS runs + * (jeder Lauf = Vec2[] mit ≥2 Punkten). Anders als segmentweises Clippen bleiben + * aufeinanderfolgende Innen-Segmente in EINEM Lauf verbunden — die interne + * Tessellierung (z. B. Sinus-Stützpunkte) wird NICHT an jedem Vertex zerrissen. + * Nur ein echtes Austreten aus dem Polygon (bzw. eine Lücke) beginnt einen neuen + * Lauf. Nutzt dieselbe Even-Odd-/halb-offene-Kanten-Konvention wie + * {@link clipLineToPolygon} via {@link clipSegmentToPolygon}. + * + * Verfahren: jede Polylinien-Kante wird geclippt; die (in Laufrichtung + * sortierten) Innenstücke werden an den laufenden Lauf angehängt, solange ihr + * Anfang bündig ans bisherige Lauf-Ende anschließt. Sobald ein Innenstück nicht + * am Lauf-Ende beginnt (Wiedereintritt nach einem Austritt) oder die Kante gar + * kein Innenstück liefert (komplett außen), wird der Lauf abgeschlossen. + */ +export function clipPolylineToPolygon(pts: Vec2[], poly: Vec2[]): HatchRun[] { + if (pts.length < 2 || poly.length < 3) return []; + const EPS = 1e-9; + const near = (p: Vec2, q: Vec2) => Math.abs(p.x - q.x) < EPS && Math.abs(p.y - q.y) < EPS; + + const runs: HatchRun[] = []; + let cur: HatchRun | null = null; + + const closeRun = () => { + if (cur && cur.length >= 2) runs.push(cur); + cur = null; + }; + + for (let i = 0; i + 1 < pts.length; i++) { + const a = pts[i]; + const b = pts[i + 1]; + // Innenstücke dieser Kante, in Laufrichtung a→b sortiert (clipSegment liefert + // sie bereits entlang der Trägergeraden geordnet; a→b ist die positive Richtung). + const pieces = clipSegmentToPolygon(a, b, poly); + for (const seg of pieces) { + // Kante in Laufrichtung orientieren (clipSegment kann a/b vertauschen). + const dx = b.x - a.x, dy = b.y - a.y; + const ta = (seg.a.x - a.x) * dx + (seg.a.y - a.y) * dy; + const tb = (seg.b.x - a.x) * dx + (seg.b.y - a.y) * dy; + const s = ta <= tb ? seg.a : seg.b; + const e = ta <= tb ? seg.b : seg.a; + + if (cur && near(cur[cur.length - 1], s)) { + // Nahtloser Anschluss ans Lauf-Ende → nur den neuen Endpunkt anhängen. + cur.push(e); + } else { + // Neuer Lauf (erster Innenteil oder Wiedereintritt nach Austritt). + closeRun(); + cur = [s, e]; + } + } + // Endete diese Kante NICHT genau am Polygonrand-Innenstück bis b (d. h. das + // letzte Innenstück reicht nicht bis zum Kantenende b), so tritt der Lauf hier + // aus dem Polygon → beim nächsten Innenteil beginnt ein neuer Lauf. + if (cur && !near(cur[cur.length - 1], b)) closeRun(); + } + closeRun(); + return runs; +} + /** * Erzeugt eine Schar paralleler, aufs Polygon geclippter Linien-Segmente: * Richtung `angleRad` (Modell-Raum, gegen den Uhrzeigersinn), Abstand `spacingM` @@ -209,43 +278,68 @@ function rotate(v: Vec2, rad: number): Vec2 { const DASH_MM_TO_M = (1 / 0.13) * PX_TO_M; /** - * Zerlegt eine Segment-Schar entlang eines Strichmusters (`dash`, in mm-Papier) - * in die durchgezogenen Teilstücke. `dash` ist eine Sequenz [an, aus, an, …]; - * ungerade Längen werden (wie SVG/Canvas) verdoppelt. Leeres/ungültiges Muster - * → Segmente unverändert (durchgezogen). Die Phase läuft je Segment neu bei 0 - * (Segmente sind bereits kurze Musterlinien-Stücke → sichtbar konsistent). + * Zerlegt zusammenhängende Läufe entlang eines Strichmusters (`dash`, in mm- + * Papier) in die durchgezogenen Teil-Läufe. `dash` ist eine Sequenz + * [an, aus, an, …]; ungerade Längen werden (wie SVG/Canvas) verdoppelt. Leeres/ + * ungültiges Muster → Läufe unverändert (durchgezogen). Die Phase läuft ENTLANG + * DES GESAMTEN LAUFS durch (über Stützpunkt-Ecken hinweg), sodass die Striche + * einer gekrümmten Musterlinie (Dämmungswelle) gleichmäßig und ecken-übergreifend + * verteilt sind. Jedes „an"-Stück wird als eigener (kurzer) Lauf ausgegeben und + * kann selbst über mehrere Stützpunkt-Segmente reichen. */ -export function applyDash(segs: HatchSegment[], dash: number[] | null): HatchSegment[] { - if (!dash || dash.length === 0) return segs; +export function applyDashRuns(runs: HatchRun[], dash: number[] | null): HatchRun[] { + if (!dash || dash.length === 0) return runs; // Musterlängen in Meter; nicht-positive/ungültige Werte verwerfen. const pat = dash.map((d) => Math.max(0, d) * DASH_MM_TO_M).filter((d) => d > 0); - if (pat.length === 0) return segs; + if (pat.length === 0) return runs; // Ungerade Länge → verdoppeln (Standard-Dash-Semantik). const cycle = pat.length % 2 === 0 ? pat : pat.concat(pat); const total = cycle.reduce((s, d) => s + d, 0); - if (total <= 1e-9) return segs; + if (total <= 1e-9) return runs; - const out: HatchSegment[] = []; - for (const seg of segs) { - const dx = seg.b.x - seg.a.x; - const dy = seg.b.y - seg.a.y; - const len = Math.hypot(dx, dy); - if (len < 1e-12) continue; - const ux = dx / len, uy = dy / len; - let pos = 0; + const out: HatchRun[] = []; + for (const run of runs) { + if (run.length < 2) continue; + // Bogenlänge je Stützpunkt vorab, um Dash-Positionen auf Segmente abzubilden. let idx = 0; // gerade Index = „an", ungerade = „aus" - while (pos < len - 1e-9) { - const remain = cycle[idx % cycle.length]; - const end = Math.min(len, pos + remain); - if (idx % 2 === 0 && end - pos > 1e-9) { - out.push({ - a: { x: seg.a.x + ux * pos, y: seg.a.y + uy * pos }, - b: { x: seg.a.x + ux * end, y: seg.a.y + uy * end }, - }); + let dashLeft = cycle[0]; // verbleibende Länge im aktuellen Dash-Zustand + let on = true; + let piece: Vec2[] | null = [run[0]]; // aktuell offenes „an"-Stück + + const flush = () => { + if (piece && piece.length >= 2) out.push(piece); + piece = null; + }; + + for (let i = 0; i + 1 < run.length; i++) { + let a = run[i]; + const b = run[i + 1]; + let segLen = Math.hypot(b.x - a.x, b.y - a.y); + if (segLen < 1e-12) continue; + let ux = (b.x - a.x) / segLen, uy = (b.y - a.y) / segLen; + // Segment ggf. an Dash-Grenzen zerschneiden. + while (segLen > 1e-9) { + const step = Math.min(segLen, dashLeft); + const mid: Vec2 = { x: a.x + ux * step, y: a.y + uy * step }; + if (on) { + if (!piece) piece = [a]; + piece.push(mid); + } + // Vorrücken. + a = mid; + segLen -= step; + dashLeft -= step; + if (dashLeft <= 1e-9) { + // Dash-Zustand wechseln. + if (on) flush(); + on = !on; + idx++; + dashLeft = cycle[idx % cycle.length]; + if (on) piece = [a]; // neues „an"-Stück beginnt hier + } } - pos = end; - idx++; } + flush(); } return out; } @@ -262,19 +356,21 @@ function toModelAngleRad(screenDeg: number): number { } /** - * Baut die Schraffur eines Polygons als Linien-Segmente (Modell-Meter). Bildet - * die SVG-``-Geometrie 1:1 nach: + * Baut die Schraffur eines Polygons als zusammenhängende Läufe ({@link HatchRun}, + * Modell-Meter). Bildet die SVG-``-Geometrie 1:1 nach: * • diagonal — eine Schar; SVG-Kachel 8·scale px mit senkrechter Musterlinie - * (x-konstant), gedreht um `angle`. Abstand = 8·scale px. + * (x-konstant), gedreht um `angle`. Abstand = 8·scale px. Jede + * Musterlinie ist ein gerader 2-Punkt-Lauf. * • crosshatch — zwei Scharen (90° versetzt), gleicher Abstand. * • insulation — weiche Wellenlinie, Kachel 14×10·scale px, gedreht um `angle`; - * als segmentierte Polylinie tesselliert und geclippt. + * als DURCHGEHENDE Polylinie tesselliert und in kontinuierliche + * Läufe geclippt (glatte gehrte Übergänge statt Butt-Cap-Stufen). * • solid/none — keine Linien (Vollfüllung/Umriss übernimmt der Aufrufer). * * Skala 0/negativ wird auf 1 geklemmt (kein Division-durch-Null, keine leere * Kachel), damit degeneriert konfigurierte Hatches trotzdem sichtbar bleiben. */ -export function buildHatchSegments(poly: Vec2[], hatch: HatchRender): HatchSegment[] { +export function buildHatchRuns(poly: Vec2[], hatch: HatchRender): HatchRun[] { if (poly.length < 3) return []; const pattern = hatch.pattern; if (pattern === 'none' || pattern === 'solid') return []; @@ -282,7 +378,7 @@ export function buildHatchSegments(poly: Vec2[], hatch: HatchRender): HatchSegme const scale = hatch.scale > 1e-6 ? hatch.scale : 1; if (pattern === 'insulation') { - return insulationSegments(poly, hatch, scale); + return insulationRuns(poly, hatch, scale); } // diagonal / crosshatch: parallele Geradenscharen. @@ -296,14 +392,15 @@ export function buildHatchSegments(poly: Vec2[], hatch: HatchRender): HatchSegme const rot = toModelAngleRad(hatch.angle); const dir1 = rotate({ x: 0, y: -1 }, rot); const a1 = Math.atan2(dir1.y, dir1.x); - const segs = hatchLineFamily(poly, a1, spacingM, 0); + // Gerade Musterlinien → 2-Punkt-Läufe (visuell identisch zu Butt-Cap-Segmenten). + const runs: HatchRun[] = hatchLineFamily(poly, a1, spacingM, 0).map((s) => [s.a, s.b]); if (pattern === 'crosshatch') { // Zweite Schar: die SVG-Kachel trägt zusätzlich eine horizontale Linie // (x-Achse der Kachel) → 90° zur ersten, gleicher Abstand. - for (const s of hatchLineFamily(poly, a1 + Math.PI / 2, spacingM, 0)) segs.push(s); + for (const s of hatchLineFamily(poly, a1 + Math.PI / 2, spacingM, 0)) runs.push([s.a, s.b]); } - return segs; + return runs; } /** @@ -311,11 +408,13 @@ export function buildHatchSegments(poly: Vec2[], hatch: HatchRender): HatchSegme * pro Kachel eine weiche Bézier-Welle. Wir bilden sie als Scharwellen nach: eine * Schar paralleler „Musterachsen" im Abstand der Kachelhöhe (10·scale px), auf * jeder Achse eine tessellierte Sinuswelle mit Wellenlänge = Kachelbreite - * (14·scale px) und weicher Amplitude. Jede Wellen-Polylinie wird Segment für - * Segment aufs Polygon geclippt (even-odd), sodass die Welle exakt an den - * Polygonkanten endet. + * (14·scale px) und weicher Amplitude. Jede Wellen-Polylinie wird als + * DURCHGEHENDER Zug aufs Polygon geclippt ({@link clipPolylineToPolygon}), sodass + * benachbarte Wellen-Stützpunkte in EINEM Lauf verbunden bleiben. Der Compiler + * strokt den Lauf als gehrten Streifen → glatte, spitzenfreie Welle. Die Welle + * endet exakt an den Polygonkanten (even-odd-Clipping, konkav-fähig). */ -function insulationSegments(poly: Vec2[], hatch: HatchRender, scale: number): HatchSegment[] { +function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchRun[] { const bb = bbox(poly); const rot = toModelAngleRad(hatch.angle); @@ -341,11 +440,12 @@ function insulationSegments(poly: Vec2[], hatch: HatchRender, scale: number): Ha } if (!(uMax > uMin) || !(vMax > vMin)) return []; - // Feinheit der Wellen-Tessellierung (Segmente je Wellenlänge). - const STEPS = 12; + // Feinheit der Wellen-Tessellierung (Segmente je Wellenlänge). Höher als beim + // segmentweisen Ansatz, da die Läufe jetzt glatt gehrt werden → weichere Welle. + const STEPS = 22; const du = wavelength / STEPS; - const out: HatchSegment[] = []; + const runs: HatchRun[] = []; const toWorld = (u: number, v: number): Vec2 => ({ x: center.x + uDir.x * u + vDir.x * v, y: center.y + uDir.y * u + vDir.y * v, @@ -358,16 +458,13 @@ function insulationSegments(poly: Vec2[], hatch: HatchRender, scale: number): Ha for (let r = firstRow; r <= lastRow; r++) { const vBase = r * rowGap; - // Eine Welle als Polylinie tessellieren, dann JEDES Segment clippen. - let prev: Vec2 | null = null; + // Ganze Welle als EINE Polylinie tessellieren, dann DURCHGEHEND clippen. + const wavePts: Vec2[] = []; for (let u = uStart; u <= uEnd + 1e-9; u += du) { const wave = amplitude * Math.sin((2 * Math.PI * u) / wavelength); - const pt = toWorld(u, vBase + wave); - if (prev) { - for (const s of clipSegmentToPolygon(prev, pt, poly)) out.push(s); - } - prev = pt; + wavePts.push(toWorld(u, vBase + wave)); } + for (const run of clipPolylineToPolygon(wavePts, poly)) runs.push(run); } - return out; + return runs; }