From b7551d4930b2d5ed90be37c75d6f58917dd56ea9 Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 13:51:08 +0200 Subject: [PATCH] DXF-Import: ARC/CIRCLE/ELLIPSE als tessellierte Konturen (Abdeckungsluecke geschlossen) --- src/io/dxfParser.test.ts | 121 ++++++++++++++++++++++++++++++++ src/io/dxfParser.ts | 148 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/io/dxfParser.test.ts diff --git a/src/io/dxfParser.test.ts b/src/io/dxfParser.test.ts new file mode 100644 index 0000000..5817369 --- /dev/null +++ b/src/io/dxfParser.test.ts @@ -0,0 +1,121 @@ +// Tests für den DXF-Import der Kurven-Entities ARC / CIRCLE / ELLIPSE: +// belegt Tessellierung, Winkeleinheit (Radiant), Schließung und Z-Höhe. + +import { describe, it, expect } from "vitest"; +import { parseDxf } from "./dxfParser"; +import type { Contour, Vec2 } from "../model/types"; + +/** Minimales DXF aus Gruppencode/Wert-Paaren; nur eine ENTITIES-Sektion. */ +function dxf(...entities: string[][]): string { + const lines = ["0", "SECTION", "2", "ENTITIES"]; + for (const pairs of entities) lines.push(...pairs); + lines.push("0", "ENDSEC", "0", "EOF"); + return lines.join("\n"); +} + +/** Ein CIRCLE-Entity: Zentrum (cx,cy,cz), Radius r. */ +function circle(cx: number, cy: number, cz: number, r: number): string[] { + return ["0", "CIRCLE", "8", "0", "10", `${cx}`, "20", `${cy}`, "30", `${cz}`, "40", `${r}`]; +} + +/** Ein ARC-Entity: Zentrum, Radius, Start/End in GRAD (DXF-Konvention). */ +function arc(cx: number, cy: number, r: number, startDeg: number, endDeg: number): string[] { + return [ + "0", "ARC", "8", "0", + "10", `${cx}`, "20", `${cy}`, "30", "0", + "40", `${r}`, "50", `${startDeg}`, "51", `${endDeg}`, + ]; +} + +/** Ein ELLIPSE-Entity: Zentrum, Hauptachsen-Endpunkt (rel.), Verhältnis, Start/End (Radiant). */ +function ellipse( + cx: number, cy: number, majX: number, majY: number, ratio: number, start: number, end: number, +): string[] { + return [ + "0", "ELLIPSE", "8", "0", + "10", `${cx}`, "20", `${cy}`, "30", "0", + "11", `${majX}`, "21", `${majY}`, "31", "0", + "40", `${ratio}`, "41", `${start}`, "42", `${end}`, + ]; +} + +const dist = (p: Vec2, cx: number, cy: number) => Math.hypot(p.x - cx, p.y - cy); + +/** Einzige Kontur des Imports (Test-Bequemlichkeit). */ +function onlyContour(text: string): Contour { + const res = parseDxf(text); + expect(res.contours).toHaveLength(1); + expect(res.contours[0].contours).toHaveLength(1); + return res.contours[0].contours[0]; +} + +describe("parseDxf — CIRCLE", () => { + it("erzeugt einen geschlossenen, tessellierten Ring auf konstantem Radius", () => { + const c = onlyContour(dxf(circle(10, 20, 5, 4))); + expect(c.closed).toBe(true); + expect(c.z).toBe(5); + // Voller Kreis ohne Schluss-Duplikat: 2π / (π/32) = 64 Segmente → 64 Punkte. + expect(c.pts).toHaveLength(64); + for (const p of c.pts) expect(dist(p, 10, 20)).toBeCloseTo(4, 9); + // Erster Punkt bei Winkel 0: (cx+r, cy). + expect(c.pts[0].x).toBeCloseTo(14, 9); + expect(c.pts[0].y).toBeCloseTo(20, 9); + // Kein Duplikat des Startpunkts am Ende. + expect(dist(c.pts[c.pts.length - 1], 14, 20)).toBeGreaterThan(0.01); + }); +}); + +describe("parseDxf — ARC", () => { + it("tesselliert einen Viertelbogen 0°→90° CCW (offen)", () => { + const c = onlyContour(dxf(arc(0, 0, 10, 0, 90))); + expect(c.closed).toBe(false); + // Spanne π/2 → 16 Segmente → 17 Punkte. + expect(c.pts).toHaveLength(17); + expect(c.pts[0].x).toBeCloseTo(10, 9); + expect(c.pts[0].y).toBeCloseTo(0, 9); + const end = c.pts[c.pts.length - 1]; + expect(end.x).toBeCloseTo(0, 9); + expect(end.y).toBeCloseTo(10, 9); + for (const p of c.pts) expect(dist(p, 0, 0)).toBeCloseTo(10, 9); + }); + + it("ergänzt eine umlaufende Spanne (270°→90°) um 2π statt negativ", () => { + const c = onlyContour(dxf(arc(0, 0, 5, 270, 90))); + // Spanne 180° = π → 32 Segmente → 33 Punkte, CCW von unten (−y) nach oben (+y). + expect(c.pts).toHaveLength(33); + expect(c.pts[0].y).toBeCloseTo(-5, 9); + expect(c.pts[c.pts.length - 1].y).toBeCloseTo(5, 9); + // Mittelpunkt der Spanne (bei 0°) liegt bei (+r, 0), nicht (−r, 0). + expect(c.pts[16].x).toBeCloseTo(5, 6); + }); +}); + +describe("parseDxf — ELLIPSE", () => { + it("tesselliert einen vollen Umlauf (geschlossen) mit korrekten Halbachsen", () => { + const c = onlyContour(ellipse2Dxf()); + expect(c.closed).toBe(true); + // Voller Umlauf → Schluss-Duplikat weggelassen. + expect(dist(c.pts[c.pts.length - 1], c.pts[0].x, c.pts[0].y)).toBeGreaterThan(0.01); + // Hauptachse 10 entlang x, Nebenachse 5 entlang y (ratio 0.5). + const maxX = Math.max(...c.pts.map((p) => p.x)); + const maxY = Math.max(...c.pts.map((p) => p.y)); + expect(maxX).toBeCloseTo(10, 6); + expect(maxY).toBeCloseTo(5, 6); + // Parameter t=0 → Center + Hauptachse = (10, 0). + expect(c.pts[0].x).toBeCloseTo(10, 9); + expect(c.pts[0].y).toBeCloseTo(0, 9); + }); +}); + +/** Volle Ellipse: Center (0,0), Hauptachse (10,0), ratio 0.5, 0..2π. */ +function ellipse2Dxf(): string { + return dxf(ellipse(0, 0, 10, 0, 0.5, 0, Math.PI * 2)); +} + +describe("parseDxf — gemischt", () => { + it("liest mehrere Kurven-Entities in EINEN Konturensatz", () => { + const res = parseDxf(dxf(circle(0, 0, 0, 1), arc(0, 0, 2, 0, 90))); + expect(res.contours).toHaveLength(1); + expect(res.contours[0].contours).toHaveLength(2); + }); +}); diff --git a/src/io/dxfParser.ts b/src/io/dxfParser.ts index 78ab4d3..6ce38f9 100644 --- a/src/io/dxfParser.ts +++ b/src/io/dxfParser.ts @@ -17,6 +17,9 @@ // • MESH → Dreiecks-Mesh (Vertices + Face-Liste). // • LWPOLYLINE / POLYLINE (2D/3D) → Kontur (z aus elevation/Vertex-Z). // • LINE → Kontur (zwei-Punkt-Linienzug). +// • ARC → Kontur (offener Bogen, tesselliert). +// • CIRCLE → Kontur (geschlossener Kreis, tesselliert). +// • ELLIPSE → Kontur (Ellipsenbogen/-umlauf, tesselliert). import DxfParser from "dxf-parser"; import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types"; @@ -61,6 +64,16 @@ interface DxfEntity { faces?: number[][]; isPolyfaceMesh?: boolean; is3dPolygonMesh?: boolean; + // Kurven-Entities (ARC/CIRCLE/ELLIPSE). Winkel liefert dxf-parser in RADIANT + // (ARC/CIRCLE Grad→rad umgerechnet; ELLIPSE-Parameterwinkel roh in Radiant). + center?: DxfVertex; + radius?: number; + startAngle?: number; + endAngle?: number; + /** ELLIPSE: Hauptachsen-Endpunkt RELATIV zum Center. */ + majorAxisEndPoint?: DxfVertex; + /** ELLIPSE: Verhältnis Neben-/Hauptachse (b/a). */ + axisRatio?: number; [k: string]: unknown; } interface DxfDocument { @@ -114,6 +127,21 @@ export function parseDxf(text: string): DxfImportResult { if (ct) contours.push(ct); break; } + case "ARC": { + const ct = arcContour(e); + if (ct) contours.push(ct); + break; + } + case "CIRCLE": { + const ct = circleContour(e); + if (ct) contours.push(ct); + break; + } + case "ELLIPSE": { + const ct = ellipseContour(e); + if (ct) contours.push(ct); + break; + } default: // Unbekannte/irrelevante Entity → ignorieren (tolerant). break; @@ -308,3 +336,123 @@ function lineContour(e: DxfEntity): Contour | null { layer: e.layer, }; } + +// ── Kurven-Entities (ARC/CIRCLE/ELLIPSE) → tessellierte Konturen ────────────── + +/** Winkelauflösung der Tessellierung (~5.6° pro Segment). */ +const CURVE_STEP = Math.PI / 32; +/** Obergrenze der Segmentzahl (Schutz gegen entartete Eingaben). */ +const CURVE_MAX_SEG = 256; + +/** Segmentzahl für eine Winkelspanne (Radiant): mind. 2, gedeckelt. */ +function segmentsFor(sweep: number): number { + return Math.max(2, Math.min(CURVE_MAX_SEG, Math.ceil(Math.abs(sweep) / CURVE_STEP))); +} + +/** + * ARC → offener Bogen-Linienzug. `startAngle`/`endAngle` in Radiant (dxf-parser + * rechnet Grad→rad). Bögen laufen CCW; eine nicht-positive Spanne wird um 2π + * ergänzt (voller-Kreis-Fall bleibt 2π). + */ +function arcContour(e: DxfEntity): Contour | null { + const c = e.center; + const r = e.radius; + if ( + !c || + !Number.isFinite(c.x) || + !Number.isFinite(c.y) || + typeof r !== "number" || + !Number.isFinite(r) || + r <= 0 + ) { + return null; + } + const start = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0; + const end = Number.isFinite(e.endAngle) ? (e.endAngle as number) : Math.PI * 2; + let sweep = end - start; + if (sweep <= 0) sweep += Math.PI * 2; + const cx = c.x ?? 0; + const cy = c.y ?? 0; + const z = Number.isFinite(c.z) ? (c.z as number) : 0; + const segs = segmentsFor(sweep); + const pts: Vec2[] = []; + for (let i = 0; i <= segs; i++) { + const t = start + (sweep * i) / segs; + pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) }); + } + return { z, pts, closed: false, layer: e.layer }; +} + +/** CIRCLE → geschlossener Kreis-Linienzug (voller Umlauf, letzter Punkt weggelassen). */ +function circleContour(e: DxfEntity): Contour | null { + const c = e.center; + const r = e.radius; + if ( + !c || + !Number.isFinite(c.x) || + !Number.isFinite(c.y) || + typeof r !== "number" || + !Number.isFinite(r) || + r <= 0 + ) { + return null; + } + const cx = c.x ?? 0; + const cy = c.y ?? 0; + const z = Number.isFinite(c.z) ? (c.z as number) : 0; + const segs = segmentsFor(Math.PI * 2); + const pts: Vec2[] = []; + // 0..2π ohne Schluss-Duplikat (closed schließt den Ring). + for (let i = 0; i < segs; i++) { + const t = (Math.PI * 2 * i) / segs; + pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) }); + } + return { z, pts, closed: true, layer: e.layer }; +} + +/** + * ELLIPSE → Linienzug. Hauptachse = `majorAxisEndPoint` (Vektor relativ zum + * Center), Nebenachse = ⟂ dazu · `axisRatio`. `startAngle`/`endAngle` sind + * PARAMETERwinkel in Radiant; ein voller Umlauf (Spanne ≈ 2π) wird geschlossen. + * Punkt(t) = Center + cos t · Haupt + sin t · Neben. + */ +function ellipseContour(e: DxfEntity): Contour | null { + const c = e.center; + const maj = e.majorAxisEndPoint; + const ratio = e.axisRatio; + if ( + !c || + !maj || + !Number.isFinite(c.x) || + !Number.isFinite(c.y) || + !Number.isFinite(maj.x) || + !Number.isFinite(maj.y) || + typeof ratio !== "number" || + !Number.isFinite(ratio) + ) { + return null; + } + const cx = c.x ?? 0; + const cy = c.y ?? 0; + const ax = maj.x ?? 0; // Hauptachsen-Vektor (relativ Center) + const ay = maj.y ?? 0; + const bx = -ay * ratio; // Nebenachse = Linksnormale der Hauptachse · Verhältnis + const by = ax * ratio; + const start = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0; + const end = Number.isFinite(e.endAngle) ? (e.endAngle as number) : Math.PI * 2; + let sweep = end - start; + if (sweep <= 0) sweep += Math.PI * 2; + const full = Math.abs(sweep - Math.PI * 2) < 1e-9; + const z = Number.isFinite(c.z) ? (c.z as number) : 0; + const segs = segmentsFor(sweep); + const pts: Vec2[] = []; + // Bei vollem Umlauf Schluss-Duplikat weglassen (closed schließt den Ring). + const last = full ? segs - 1 : segs; + for (let i = 0; i <= last; i++) { + const t = start + (sweep * i) / segs; + const ct = Math.cos(t); + const st = Math.sin(t); + pts.push({ x: cx + ax * ct + bx * st, y: cy + ay * ct + by * st }); + } + return { z, pts, closed: full, layer: e.layer }; +}