From 6d4b4e66c1f184830c5a93fd74c7f11c081779f4 Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 2 Jul 2026 00:28:11 +0200 Subject: [PATCH] GPU-Schraffuren im WebGL2-Grundriss: diagonal/crosshatch/insulation + Dash Muster in Modell-Metern erzeugt (massstabstreu wie SVG-), konkav- faehig auf das Fuellpolygon geclippt (even-odd-Scanline, halb-offene Kanten- Konvention). Strichbreite in Papier-mm; Dash geometrisch aufgeloest. Emittiert nach der Fuellung, vor dem Umriss (Stapelreihenfolge wie SVG). 12 Unit-Tests. --- src/plan/glPlan/glPlanCompile.ts | 12 + src/plan/glPlan/glPlanHatch.test.ts | 229 +++++++++++++++++ src/plan/glPlan/glPlanHatch.ts | 373 ++++++++++++++++++++++++++++ 3 files changed, 614 insertions(+) create mode 100644 src/plan/glPlan/glPlanHatch.test.ts create mode 100644 src/plan/glPlan/glPlanHatch.ts diff --git a/src/plan/glPlan/glPlanCompile.ts b/src/plan/glPlan/glPlanCompile.ts index a4cdb36..ee94d86 100644 --- a/src/plan/glPlan/glPlanCompile.ts +++ b/src/plan/glPlan/glPlanCompile.ts @@ -11,6 +11,7 @@ import type { Primitive } from '../generatePlan'; import type { Vec2 } from '../../model/types'; import type { GpuGeometry, Rgba } from './glPlanTypes'; +import { applyDash, buildHatchSegments } from './glPlanHatch'; const PX_PER_M = 90; @@ -263,6 +264,17 @@ export function compilePrimitivesToGpu( addFillBatch(tris.length, fill); } } + // 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. + 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); + } // 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). diff --git a/src/plan/glPlan/glPlanHatch.test.ts b/src/plan/glPlan/glPlanHatch.test.ts new file mode 100644 index 0000000..6fa2bf3 --- /dev/null +++ b/src/plan/glPlan/glPlanHatch.test.ts @@ -0,0 +1,229 @@ +/** + * Unit-Tests für die Schraffur-Tessellierung: Linie↔Polygon-Clipping (konvex + + * konkav + Linie außerhalb), Segment-Clipping, Muster-Aufbau (diagonal/cross/ + * insulation/solid/none) und die geometrische Dash-Auflösung. + */ + +import { describe, it, expect } from 'vitest'; +import { + applyDash, + buildHatchSegments, + clipLineToPolygon, + clipSegmentToPolygon, + hatchLineFamily, + type HatchSegment, +} from './glPlanHatch'; +import type { Vec2 } from '../../model/types'; +import type { HatchRender } from '../generatePlan'; + +/** Gesamt-Länge einer Segment-Schar. */ +function totalLen(segs: HatchSegment[]): number { + let l = 0; + for (const s of segs) l += Math.hypot(s.b.x - s.a.x, s.b.y - s.a.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 }; +} + +// Einheits-Richtungen. +const RIGHT: Vec2 = { x: 1, y: 0 }; + +describe('clipLineToPolygon', () => { + it('konvex: horizontale Linie durch die Mitte eines Quadrats → volle Breite', () => { + const sq: Vec2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 4 }, + { x: 0, y: 4 }, + ]; + const segs = clipLineToPolygon({ x: -1, y: 2 }, RIGHT, sq); + expect(segs.length).toBe(1); + // Innen-Intervall von x=0 bis x=4. + const s = segs[0]; + expect(Math.min(s.a.x, s.b.x)).toBeCloseTo(0, 9); + expect(Math.max(s.a.x, s.b.x)).toBeCloseTo(4, 9); + expect(totalLen(segs)).toBeCloseTo(4, 9); + }); + + it('KONKAV: horizontale Linie durch beide Arme einer C-Form → ZWEI Segmente', () => { + // C-/U-Form mit Kerbe: eine horizontale Linie auf mittlerer Höhe quert vier + // Kanten → zwei getrennte Innen-Intervalle. + // (0,0)-(6,0)-(6,6)-(4,6)-(4,2)-(2,2)-(2,6)-(0,6) + 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 }, + ]; + // Höhe y=4 liegt in den beiden Armen (x∈[0,2] und x∈[4,6]), nicht in der Kerbe. + const segs = clipLineToPolygon({ x: -1, y: 4 }, RIGHT, c); + expect(segs.length).toBe(2); + const xs = segs + .map((s) => [Math.min(s.a.x, s.b.x), Math.max(s.a.x, s.b.x)]) + .sort((p, q) => p[0] - q[0]); + expect(xs[0][0]).toBeCloseTo(0, 9); + expect(xs[0][1]).toBeCloseTo(2, 9); + expect(xs[1][0]).toBeCloseTo(4, 9); + expect(xs[1][1]).toBeCloseTo(6, 9); + // Unterhalb der Kerbe (y=1) ist die Form durchgehend → EIN Segment [0,6]. + const low = clipLineToPolygon({ x: -1, y: 1 }, RIGHT, c); + expect(low.length).toBe(1); + expect(totalLen(low)).toBeCloseTo(6, 9); + }); + + it('Linie AUSSERHALB des Polygons → leer', () => { + const sq: Vec2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 4 }, + { x: 0, y: 4 }, + ]; + expect(clipLineToPolygon({ x: -1, y: 10 }, RIGHT, sq)).toEqual([]); + expect(clipLineToPolygon({ x: -1, y: -5 }, RIGHT, sq)).toEqual([]); + }); + + it('degeneriert (<3 Ecken) → leer', () => { + expect(clipLineToPolygon({ x: 0, y: 0 }, RIGHT, [])).toEqual([]); + expect(clipLineToPolygon({ x: 0, y: 0 }, RIGHT, [{ x: 0, y: 0 }, { x: 1, y: 1 }])).toEqual([]); + }); + + it('robuste Eckdurchquerung: Linie genau durch einen Eckpunkt zählt EINMAL', () => { + // Diagonale Linie durch die Ecke (0,0) eines Rauten-Polygons; die halb-offene + // Kanten-Konvention darf keinen Doppel-/Nullschnitt erzeugen. + const diamond: Vec2[] = [ + { x: 0, y: -2 }, + { x: 2, y: 0 }, + { x: 0, y: 2 }, + { x: -2, y: 0 }, + ]; + // Horizontale Linie exakt durch die beiden Seitenecken (y=0): ein Segment. + const segs = clipLineToPolygon({ x: -5, y: 0 }, RIGHT, diamond); + expect(segs.length).toBe(1); + expect(totalLen(segs)).toBeCloseTo(4, 9); + }); +}); + +describe('clipSegmentToPolygon', () => { + const sq: Vec2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 4 }, + { x: 0, y: 4 }, + ]; + + it('klemmt ein überstehendes Segment auf die Polygon-Grenzen', () => { + const segs = clipSegmentToPolygon({ x: -3, y: 2 }, { x: 7, y: 2 }, sq); + expect(segs.length).toBe(1); + expect(Math.min(segs[0].a.x, segs[0].b.x)).toBeCloseTo(0, 9); + expect(Math.max(segs[0].a.x, segs[0].b.x)).toBeCloseTo(4, 9); + }); + + it('ein komplett außenliegendes Segment → leer', () => { + expect(clipSegmentToPolygon({ x: 5, y: 2 }, { x: 9, y: 2 }, sq)).toEqual([]); + }); + + it('behält nur den innenliegenden Teil eines halb überstehenden Segments', () => { + const segs = clipSegmentToPolygon({ x: 2, y: 2 }, { x: 9, y: 2 }, sq); + expect(segs.length).toBe(1); + expect(totalLen(segs)).toBeCloseTo(2, 9); // von x=2 bis x=4 + }); +}); + +describe('hatchLineFamily', () => { + const sq: Vec2[] = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, + ]; + + it('deckt das Polygon mit parallelen Linien im gegebenen Abstand ab', () => { + // Horizontale Scharen (Richtung entlang x), Abstand 2 m → ~5–6 Linien à 10 m. + const segs = hatchLineFamily(sq, 0, 2, 0); + expect(segs.length).toBeGreaterThanOrEqual(4); + // Jede Linie überspannt die volle Breite (10 m) innerhalb des Quadrats. + for (const s of segs) expect(Math.hypot(s.b.x - s.a.x, s.b.y - s.a.y)).toBeCloseTo(10, 6); + }); + + it('Abstand ≤ 0 oder <3 Ecken → leer', () => { + expect(hatchLineFamily(sq, 0, 0, 0)).toEqual([]); + expect(hatchLineFamily([{ x: 0, y: 0 }], 0, 1, 0)).toEqual([]); + }); +}); + +describe('buildHatchSegments', () => { + const sq: Vec2[] = [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { 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('diagonal erzeugt eine einzelne Schar; crosshatch mehr (zwei Scharen)', () => { + const diag = buildHatchSegments(sq, hatch({ pattern: 'diagonal' })); + const cross = buildHatchSegments(sq, hatch({ pattern: 'crosshatch' })); + expect(diag.length).toBeGreaterThan(0); + // Zwei Scharen → mindestens so viele Segmente 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]) { + expect(p.x).toBeGreaterThanOrEqual(-1e-6); + expect(p.x).toBeLessThanOrEqual(1 + 1e-6); + expect(p.y).toBeGreaterThanOrEqual(-1e-6); + expect(p.y).toBeLessThanOrEqual(1 + 1e-6); + } + } + }); + + 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]) { + expect(p.x).toBeGreaterThanOrEqual(-1e-6); + expect(p.x).toBeLessThanOrEqual(1 + 1e-6); + } + } + }); + + 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 })); + 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); + }); +}); + +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); + }); + + 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 + 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)); + }); +}); diff --git a/src/plan/glPlan/glPlanHatch.ts b/src/plan/glPlan/glPlanHatch.ts new file mode 100644 index 0000000..fc94e24 --- /dev/null +++ b/src/plan/glPlan/glPlanHatch.ts @@ -0,0 +1,373 @@ +/** + * Schraffur-Tessellierung für den GPU-Grundriss: wandelt ein Polygon mit + * aufgelöster {@link HatchRender} in eine Schar echter Linien-Segmente (Modell- + * Meter), die anschließend über `strokePolyline`/`pushLine` in die Linien-Puffer + * fließen. Damit rendert die GPU-Ebene dieselben Muster wie der SVG-``- + * Renderer, nur als tessellierte Geometrie statt als Kachel-Füllung. + * + * KOORDINATEN & MASSSTAB (muss deckungsgleich zum SVG sein): + * • Der SVG-`` ist in BILDSCHIRM-Raum (viewBox-px, `sx = mx·PX_PER_M`) + * definiert; Kachelgröße = `8·scale` px (diagonal/cross) bzw. `14×10·scale` px + * (insulation), gedreht per `rotate(angle)` (im Uhrzeigersinn, Bildschirm-Y + * nach unten). Die Muster-GEOMETRIE skaliert also mit dem Zoom (Papier). + * • Wir erzeugen die Muster direkt in MODELL-Metern: ein px in Bildschirm-Raum + * entspricht `1/PX_PER_M` m. So passen Abstand/Winkel exakt zum SVG, und die + * Segmente lassen sich unverändert an `strokePolyline` (Papier-mm-Breite) + * übergeben. + * • Die STRICHBREITE bleibt in Papier-mm (`hatch.lineWeight`) — genau wie beim + * SVG-Muster, das `hatchStrokePx(lineWeight)` als non-scaling-artige Breite + * zeichnet. + */ + +import type { Vec2 } from '../../model/types'; +import type { HatchRender } from '../generatePlan'; + +/** Bildschirm-Einheiten (viewBox-px) je Meter — identisch zu PlanView.toScreen. */ +const PX_PER_M = 90; + +/** px (Bildschirm-Raum) → Meter (Modell-Raum). */ +const PX_TO_M = 1 / PX_PER_M; + +/** Ein Linien-Segment in Modell-Metern. */ +export interface HatchSegment { + a: Vec2; + b: Vec2; +} + +/** + * Achsen-ausgerichtetes Bounding-Rechteck eines Polygons (Modell-Meter). + * Leeres Polygon → degeneriert (min>max), der Aufrufer erzeugt dann keine Linien. + */ +function bbox(poly: Vec2[]): { minX: number; minY: number; maxX: number; maxY: number } { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of poly) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + return { minX, minY, maxX, maxY }; +} + +/** + * Schneidet eine UNENDLICHE Linie (Aufpunkt `p0`, Einheits-Richtung `dir`) mit + * einem einfachen (auch KONKAVEN) Polygon und liefert die INNERHALB liegenden + * Segmente. Vorgehen (robustes Even-Odd-Scanline-Prinzip): + * 1. Für jede Polygon-Kante den Schnitt-Parameter `t` (entlang `dir`) berechnen, + * bei dem die Linie die Kante quert. + * 2. Alle `t` sortieren; aufeinanderfolgende Paare (t0,t1),(t2,t3),… liegen im + * Inneren (even-odd) → als Segmente zurückgeben. + * Das ist für beliebige konkave Polygone korrekt, weil eine Gerade ein einfaches + * Polygon stets in eine gerade Anzahl Kanten schneidet und die Innen-Intervalle + * sich mit jeder Kreuzung ein-/ausschalten. + * + * Robustheit an Ecken: Ein Schnitt wird nur gezählt, wenn die Linie die Kante + * ECHT quert (die beiden Endpunkte liegen auf verschiedenen Seiten). Berührungen + * genau am Eckpunkt werden über eine halb-offene Kanten-Konvention (das untere + * Kanten-Ende zählt, das obere nicht) vermieden, sodass keine doppelten oder + * fehlenden Kreuzungen entstehen. + */ +export function clipLineToPolygon(p0: Vec2, dir: Vec2, poly: Vec2[]): HatchSegment[] { + const n = poly.length; + if (n < 3) return []; + + // Senkrechte zur Richtung: side(p) = vorzeichenbehaftete Distanz zur Linie. + const nx = -dir.y; + const ny = dir.x; + const side = (p: Vec2) => (p.x - p0.x) * nx + (p.y - p0.y) * ny; + + const ts: number[] = []; + for (let i = 0; i < n; i++) { + const a = poly[i]; + const b = poly[(i + 1) % n]; + const sa = side(a); + const sb = side(b); + + // Beide echt auf derselben Seite → keine Kreuzung. + if (sa > 0 && sb > 0) continue; + if (sa < 0 && sb < 0) continue; + + // Halb-offene Konvention gegen Doppel-/Fehlzählung an Eckpunkten: + // • Kante liegt exakt AUF der Linie → ignorieren. + // • Berührung genau an einem Endpunkt (side==0) zählt nur, wenn der ANDERE + // Endpunkt echt oberhalb (>0) liegt. So gehört ein Eckpunkt eindeutig zur + // Kante darunter und wird genau EINMAL gezählt. + if (sa === 0 && sb === 0) continue; + if (sa === 0 && sb < 0) continue; + if (sb === 0 && sa < 0) continue; + + // Parameter `t` entlang `dir` am Schnittpunkt: Nullstelle von side auf der + // Kante interpolieren, dann auf `dir` projizieren. + const denom = sa - sb; + const u = denom !== 0 ? sa / denom : 0; // Anteil a→b bis side==0 + const ix = a.x + (b.x - a.x) * u; + const iy = a.y + (b.y - a.y) * u; + const t = (ix - p0.x) * dir.x + (iy - p0.y) * dir.y; + ts.push(t); + } + + if (ts.length < 2) return []; + ts.sort((x, y) => x - y); + + const segs: HatchSegment[] = []; + for (let i = 0; i + 1 < ts.length; i += 2) { + const t0 = ts[i]; + const t1 = ts[i + 1]; + if (t1 - t0 < 1e-9) continue; // entartetes Intervall (Tangente an Ecke) + segs.push({ + a: { x: p0.x + dir.x * t0, y: p0.y + dir.y * t0 }, + b: { x: p0.x + dir.x * t1, y: p0.y + dir.y * t1 }, + }); + } + return segs; +} + +/** + * Schneidet ein ENDLICHES Segment [a,b] gegen ein (konkaves) Polygon und liefert + * die innenliegenden Teilstücke. Baut auf {@link clipLineToPolygon} auf: erst die + * Innen-Intervalle der Trägergeraden bestimmen, dann mit [a,b] (Parameterbereich + * [0,len]) verschneiden. + */ +export function clipSegmentToPolygon(a: Vec2, b: Vec2, poly: Vec2[]): HatchSegment[] { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy); + if (len < 1e-12) return []; + const dir: Vec2 = { x: dx / len, y: dy / len }; + const full = clipLineToPolygon(a, dir, poly); + const out: HatchSegment[] = []; + for (const seg of full) { + // Parameter der Innen-Intervall-Enden entlang `dir` (ab Aufpunkt a). + const t0 = (seg.a.x - a.x) * dir.x + (seg.a.y - a.y) * dir.y; + const t1 = (seg.b.x - a.x) * dir.x + (seg.b.y - a.y) * dir.y; + const lo = Math.max(0, Math.min(t0, t1)); + const hi = Math.min(len, Math.max(t0, t1)); + if (hi - lo < 1e-9) continue; + out.push({ + a: { x: a.x + dir.x * lo, y: a.y + dir.y * lo }, + b: { x: a.x + dir.x * hi, y: a.y + dir.y * hi }, + }); + } + return out; +} + +/** + * Erzeugt eine Schar paralleler, aufs Polygon geclippter Linien-Segmente: + * Richtung `angleRad` (Modell-Raum, gegen den Uhrzeigersinn), Abstand `spacingM` + * (Meter) senkrecht zur Richtung, Phasen-Versatz `offsetM` (Meter, entlang der + * Normalen). Deckt das Polygon vollständig ab (über dessen projizierte Ausdehnung). + */ +export function hatchLineFamily( + poly: Vec2[], + angleRad: number, + spacingM: number, + offsetM = 0, +): HatchSegment[] { + if (poly.length < 3 || spacingM <= 1e-9) return []; + const bb = bbox(poly); + if (!(bb.maxX > bb.minX) && !(bb.maxY > bb.minY)) return []; + + const dir: Vec2 = { x: Math.cos(angleRad), y: Math.sin(angleRad) }; + const nrm: Vec2 = { x: -dir.y, y: dir.x }; // Linien-Normale (Scan-Richtung) + + // Rechtwinklige Projektion aller Ecken auf die Normale → Abdeckungsbereich. + const center: Vec2 = { + x: (bb.minX + bb.maxX) / 2, + y: (bb.minY + bb.maxY) / 2, + }; + let dMin = Infinity, dMax = -Infinity; + for (const p of poly) { + const d = (p.x - center.x) * nrm.x + (p.y - center.y) * nrm.y; + if (d < dMin) dMin = d; + if (d > dMax) dMax = d; + } + + const segs: HatchSegment[] = []; + // Raster so wählen, dass es den Bereich (inkl. Versatz) sicher deckt. + const first = Math.floor((dMin - offsetM) / spacingM) - 1; + const last = Math.ceil((dMax - offsetM) / spacingM) + 1; + for (let k = first; k <= last; k++) { + const d = offsetM + k * spacingM; + const p0: Vec2 = { x: center.x + nrm.x * d, y: center.y + nrm.y * d }; + for (const s of clipLineToPolygon(p0, dir, poly)) segs.push(s); + } + return segs; +} + +/** 2D-Vektor um `rad` drehen (gegen den Uhrzeigersinn, Modell-Konvention). */ +function rotate(v: Vec2, rad: number): Vec2 { + const c = Math.cos(rad), s = Math.sin(rad); + return { x: v.x * c - v.y * s, y: v.x * s + v.y * c }; +} + +/** + * Faktor mm-Papier → px-Musterraum, identisch zum SVG-`dashArray` (dort + * `dash·(1/0.13)`). Ein 0.13-mm-Strich entspricht ~1 px Kachelmaß; dividiert man + * mit `PX_TO_M`, ergeben sich die Strichlängen in Modell-Metern (skalieren mit + * dem Zoom wie die Kachel selbst — genau wie die SVG-Schraffur). + */ +const DASH_MM_TO_M = (1 / 0.13) * PX_TO_M; + +/** + * 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). + */ +export function applyDash(segs: HatchSegment[], dash: number[] | null): HatchSegment[] { + if (!dash || dash.length === 0) return segs; + // 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; + // 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; + + 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; + 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 }, + }); + } + pos = end; + idx++; + } + } + return out; +} + +/** + * SVG-`rotate(angleDeg)` (im Uhrzeigersinn, Bildschirm-Y nach unten) → Winkel im + * MODELL-Raum (gegen den Uhrzeigersinn, Modell-Y nach oben). Da die Bildschirm- + * Abbildung Y spiegelt (`sy=-my`), kehrt sich der Drehsinn um: eine im Bildschirm + * um +angle (CW) gedrehte Musterrichtung entspricht im Modell −angle relativ zur + * gewählten Basisrichtung. + */ +function toModelAngleRad(screenDeg: number): number { + return (-screenDeg * Math.PI) / 180; +} + +/** + * Baut die Schraffur eines Polygons als Linien-Segmente (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. + * • crosshatch — zwei Scharen (90° versetzt), gleicher Abstand. + * • insulation — weiche Wellenlinie, Kachel 14×10·scale px, gedreht um `angle`; + * als segmentierte Polylinie tesselliert und geclippt. + * • 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[] { + if (poly.length < 3) return []; + const pattern = hatch.pattern; + if (pattern === 'none' || pattern === 'solid') return []; + + const scale = hatch.scale > 1e-6 ? hatch.scale : 1; + + if (pattern === 'insulation') { + return insulationSegments(poly, hatch, scale); + } + + // diagonal / crosshatch: parallele Geradenscharen. + // Die SVG-Kachel ist 8·scale px; die Musterlinie läuft senkrecht (y-Achse der + // Kachel), Abstand also 8·scale px in x. In Metern: (8·scale)/PX_PER_M. + const spacingM = 8 * scale * PX_TO_M; + + // Kachel-Basisrichtung: die Linie läuft in der SVG-Kachel entlang +Y + // (Bildschirm, nach unten). Im Modell (Y gespiegelt) entspricht das (0,-1). + // Zusätzlich um `angle` (SVG-CW) gedreht → Modell-CCW um −angle. + 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); + + 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); + } + return segs; +} + +/** + * Dämmungs-Schraffur (Wellenlinie): die SVG-Kachel ist 14×10·scale px und trägt + * 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. + */ +function insulationSegments(poly: Vec2[], hatch: HatchRender, scale: number): HatchSegment[] { + const bb = bbox(poly); + const rot = toModelAngleRad(hatch.angle); + + // Muster-Parameter in Metern (aus den SVG-px-Werten der Kachel). + const wavelength = 14 * scale * PX_TO_M; // Kachelbreite + const rowGap = 10 * scale * PX_TO_M; // Kachelhöhe → Zeilenabstand + const amplitude = 3 * scale * PX_TO_M; // Wellen-Amplitude (weich) + + // Lokales Muster-Koordinatensystem: u entlang der Welle, v quer (Zeilen). + const uDir = rotate({ x: 1, y: 0 }, rot); + const vDir = rotate({ x: 0, y: 1 }, rot); + + // Abdeckungsbereich in (u,v) über die Polygon-Ecken bestimmen. + const center: Vec2 = { x: (bb.minX + bb.maxX) / 2, y: (bb.minY + bb.maxY) / 2 }; + let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity; + for (const p of poly) { + const u = (p.x - center.x) * uDir.x + (p.y - center.y) * uDir.y; + const v = (p.x - center.x) * vDir.x + (p.y - center.y) * vDir.y; + if (u < uMin) uMin = u; + if (u > uMax) uMax = u; + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + if (!(uMax > uMin) || !(vMax > vMin)) return []; + + // Feinheit der Wellen-Tessellierung (Segmente je Wellenlänge). + const STEPS = 12; + const du = wavelength / STEPS; + + const out: HatchSegment[] = []; + 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, + }); + + const firstRow = Math.floor(vMin / rowGap) - 1; + const lastRow = Math.ceil(vMax / rowGap) + 1; + const uStart = Math.floor((uMin - wavelength) / du) * du; + const uEnd = uMax + wavelength; + + 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; + 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; + } + } + return out; +}