From 05bc5aa6e139cf52089bc45619b10516b19eac34 Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 14:18:49 +0200 Subject: [PATCH] DXF-HATCH: Ellipsen- + Spline-Randkanten tesselliert (B-Spline-Sampling extrahiert) --- src/io/dxfParser.test.ts | 41 +++++++++++++++ src/io/dxfParser.ts | 106 ++++++++++++++++++++++++++++++--------- 2 files changed, 124 insertions(+), 23 deletions(-) diff --git a/src/io/dxfParser.test.ts b/src/io/dxfParser.test.ts index 5c4209e..ec36c0d 100644 --- a/src/io/dxfParser.test.ts +++ b/src/io/dxfParser.test.ts @@ -353,6 +353,47 @@ describe("parseDxf — HATCH", () => { expect(last.y).toBeCloseTo(0, 6); }); + it("Ellipsenkante → tessellierter Halbumlauf mit korrekten Halbachsen", () => { + // Center (0,0), Hauptachse (10,0), Verhältnis 0.5, 0°→180° ccw. + const g = [ + "0", "HATCH", "8", "0", "2", "SOLID", "70", "1", + "91", "1", "92", "1", "93", "1", + "72", "3", "10", "0", "20", "0", "11", "10", "21", "0", "40", "0.5", "50", "0", "51", "180", "73", "1", + "97", "0", + ]; + const c = onlyContour(dxf(g)); + expect(c.filled).toBe(true); + // t=0 → Center + Hauptachse = (10,0). + expect(c.pts[0].x).toBeCloseTo(10, 6); + expect(c.pts[0].y).toBeCloseTo(0, 6); + // Scheitel bei t=90° → Nebenachse (0,5). + const maxY = Math.max(...c.pts.map((p) => p.y)); + expect(maxY).toBeCloseTo(5, 6); + const last = c.pts[c.pts.length - 1]; + expect(last.x).toBeCloseTo(-10, 6); + expect(last.y).toBeCloseTo(0, 6); + }); + + it("Spline-Kante (Grad 1) → Kontrollpolygon nachgezeichnet", () => { + // 2 CPs (0,0)-(10,0), Grad 1, clamped-Knoten [0,0,1,1]. + const g = [ + "0", "HATCH", "8", "0", "2", "SOLID", "70", "1", + "91", "1", "92", "1", "93", "1", + "72", "4", "94", "1", "95", "4", "96", "2", + "40", "0", "40", "0", "40", "1", "40", "1", + "10", "0", "20", "0", "10", "10", "20", "0", + "97", "0", + ]; + const c = onlyContour(dxf(g)); + expect(c.filled).toBe(true); + expect(c.pts[0].x).toBeCloseTo(0, 9); + expect(c.pts[0].y).toBeCloseTo(0, 9); + const last = c.pts[c.pts.length - 1]; + expect(last.x).toBeCloseTo(10, 9); + expect(last.y).toBeCloseTo(0, 9); + for (const p of c.pts) expect(p.y).toBeCloseTo(0, 9); + }); + it("mehrere Randpfade → mehrere Loops (Insel als eigener Ring)", () => { // Zwei Polyline-Pfade in EINER HATCH: numPaths = 2. const g = [ diff --git a/src/io/dxfParser.ts b/src/io/dxfParser.ts index dade51f..3f87692 100644 --- a/src/io/dxfParser.ts +++ b/src/io/dxfParser.ts @@ -23,7 +23,7 @@ // • SPLINE → Kontur (B-Spline via De Boor; Fallback fitPoints). // • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt). // • HATCH → gefüllte Kontur(en) je Randpfad (Custom-Handler, -// Polyline- + Linien-/Bogen-Kanten; Fill via `Contour.filled`). +// Polyline- + Linien-/Bogen-/Ellipsen-/Spline-Kanten; Fill via `Contour.filled`). import DxfParser from "dxf-parser"; import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types"; @@ -527,6 +527,27 @@ function deBoor(cps: Vec2[], U: number[], p: number, t: number): Vec2 { return d[p]; } +/** + * Nicht-rationale B-Spline über die Domäne [U[p], U[n+1]] abtasten (De Boor), + * wenn Kontrollpunkte/Knoten/Grad konsistent sind (|U| = |P| + Grad + 1). Sonst + * null. ~8 Abtastpunkte je Kontrollpunkt, gedeckelt. + */ +function sampleBSpline(cps: Vec2[], knots: number[], degree: number): Vec2[] | null { + if (cps.length < 2 || degree < 1 || knots.length !== cps.length + degree + 1) { + return null; + } + const samples = Math.max(16, Math.min(CURVE_MAX_SEG, cps.length * 8)); + const n = cps.length - 1; + const t0 = knots[degree]; + const t1 = knots[n + 1]; + const pts: Vec2[] = []; + for (let i = 0; i <= samples; i++) { + const t = i === samples ? t1 : t0 + ((t1 - t0) * i) / samples; + pts.push(deBoor(cps, knots, degree, t)); + } + return pts; +} + /** * SPLINE → Linienzug. Primär echte B-Spline-Auswertung (Kontrollpunkte + Knoten * + Grad, wenn der Knotenvektor konsistent ist: |U| = |P| + Grad + 1). Sonst @@ -543,19 +564,8 @@ function splineContour(e: DxfEntity): Contour | null { const closed = e.closed === true; const z = cps.length > 0 ? firstZ(e.controlPoints) : firstZ(e.fitPoints); - if (cps.length >= 2 && degree >= 1 && knots.length === cps.length + degree + 1) { - // ~8 Abtastpunkte je Kontrollpunkt, gedeckelt. - const samples = Math.max(16, Math.min(CURVE_MAX_SEG, cps.length * 8)); - const n = cps.length - 1; - const t0 = knots[degree]; - const t1 = knots[n + 1]; - const pts: Vec2[] = []; - for (let i = 0; i <= samples; i++) { - const t = i === samples ? t1 : t0 + ((t1 - t0) * i) / samples; - pts.push(deBoor(cps, knots, degree, t)); - } - return { z, pts, closed, layer: e.layer }; - } + const sampled = sampleBSpline(cps, knots, degree); + if (sampled) return { z, pts: sampled, closed, layer: e.layer }; if (fps.length >= 2) return { z, pts: fps, closed, layer: e.layer }; if (cps.length >= 2) return { z, pts: cps, closed, layer: e.layer }; return null; @@ -760,7 +770,59 @@ function pushArcEdge(pts: Vec2[], m: Map): void { } } -/** Punkte einer HATCH-Randkante an die Loop-Punkte anhängen (Typ 1 Linie, 2 Bogen). */ +/** + * Ellipsenkante (Kantentyp 3): Zentrum (10/20), Hauptachsen-Endpunkt relativ + * (11/21), Achsverhältnis (40), Start/End-PARAMETERwinkel in GRAD (50/51), ggf. + * im Uhrzeigersinn (73). Punkt(t) = Center + cos t·Haupt + sin t·Neben. + */ +function pushEllipseEdge(pts: Vec2[], m: Map): void { + const cx = firstOf(m, 10); + const cy = firstOf(m, 20); + const mx = firstOf(m, 11); + const my = firstOf(m, 21); + const ratio = firstOf(m, 40); + if (cx === undefined || cy === undefined || mx === undefined || my === undefined || ratio === undefined) { + return; + } + const bx = -my * ratio; // Nebenachse = Linksnormale der Hauptachse · Verhältnis + const by = mx * ratio; + const s = (firstOf(m, 50) ?? 0) * (Math.PI / 180); + const e = (firstOf(m, 51) ?? 360) * (Math.PI / 180); + const ccw = (firstOf(m, 73) ?? 1) !== 0; + let sweep = ccw ? e - s : -(e - s); + sweep = ((sweep % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + if (sweep === 0) sweep = Math.PI * 2; + const dir = ccw ? 1 : -1; + const segs = segmentsFor(sweep); + for (let i = 0; i <= segs; i++) { + const t = s + (dir * (sweep * i)) / segs; + const ct = Math.cos(t); + const st = Math.sin(t); + pts.push({ x: cx + mx * ct + bx * st, y: cy + my * ct + by * st }); + } +} + +/** + * Spline-Kante (Kantentyp 4): Grad (94), Knoten (40×), Kontrollpunkte (10/20×). + * Über De Boor abgetastet (rationale Gewichte 42 ignoriert). Fallback: rohe + * Kontrollpunkte als Polygonzug. + */ +function pushSplineEdge(pts: Vec2[], m: Map): void { + const xs = m.get(10) ?? []; + const ys = m.get(20) ?? []; + const cps: Vec2[] = []; + for (let i = 0; i < Math.min(xs.length, ys.length); i++) cps.push({ x: xs[i], y: ys[i] }); + const knots = m.get(40) ?? []; + const degree = firstOf(m, 94) ?? 3; + const sampled = sampleBSpline(cps, knots, degree); + if (sampled) { + for (const p of sampled) pts.push(p); + } else { + for (const p of cps) pts.push(p); + } +} + +/** Punkte einer HATCH-Randkante an die Loop-Punkte anhängen (Typ 1/2/3/4). */ function appendEdge(pts: Vec2[], edgeType: number, m: Map): void { if (edgeType === 1) { // Linie: Start (10/20) — der Endpunkt ist der Start der nächsten Kante. @@ -770,19 +832,17 @@ function appendEdge(pts: Vec2[], edgeType: number, m: Map): vo // Bei der LETZTEN Kante fehlt sonst der Endpunkt; der closed-Ring schließt ihn. return; } - if (edgeType === 2) { - pushArcEdge(pts, m); - return; - } - // Kantentyp 3 (Ellipse) / 4 (Spline): im ersten Wurf nicht unterstützt. + if (edgeType === 2) pushArcEdge(pts, m); + else if (edgeType === 3) pushEllipseEdge(pts, m); + else if (edgeType === 4) pushSplineEdge(pts, m); } /** * HATCH-Entity (rohe Gruppencodes) → gefüllte, geschlossene Konturen (ein Loop * je Randpfad). Unterstützt Polyline-Randpfade (Flag-Bit 2) sowie Kanten-Pfade - * mit Linien- und Bogenkanten. Bulges an Polyline-Rändern und Ellipse-/Spline- - * Kanten werden im ersten Wurf ignoriert (als Sehne bzw. übersprungen). Insel- - * Loops entstehen als eigene geschlossene Ringe (keine echten Löcher). + * mit Linien-, Bogen-, Ellipsen- und Spline-Kanten (alle tesselliert). Bulges an + * Polyline-Rändern werden ignoriert (als Sehne); Insel-Loops entstehen als eigene + * geschlossene Ringe (keine echten Löcher). */ function hatchContours(e: DxfEntity): Contour[] { const raw = e.rawCodes;