// 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 { contoursToDrawings, textsToDrawings } from "./dxfToDrawings"; import type { Contour, ContourSet, 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); }); }); // ── SPLINE ──────────────────────────────────────────────────────────────────── /** LINE-Entity (10/20/30 Start, 11/21/31 Ende). */ function line(x1: number, y1: number, x2: number, y2: number): string[] { return ["0", "LINE", "8", "0", "10", `${x1}`, "20", `${y1}`, "30", "0", "11", `${x2}`, "21", `${y2}`, "31", "0"]; } /** SPLINE mit Kontrollpunkten + Knoten (71 Grad, 40 Knoten, 10/20/30 CPs). */ function spline(degree: number, knots: number[], cps: Array<[number, number]>): string[] { const g = ["0", "SPLINE", "8", "0", "71", `${degree}`]; for (const k of knots) g.push("40", `${k}`); for (const [x, y] of cps) g.push("10", `${x}`, "20", `${y}`, "30", "0"); return g; } /** SPLINE nur mit Stützpunkten (11/21/31 fitPoints), ohne gültige Knoten. */ function splineFit(fps: Array<[number, number]>): string[] { const g = ["0", "SPLINE", "8", "0", "71", "3"]; for (const [x, y] of fps) g.push("11", `${x}`, "21", `${y}`, "31", "0"); return g; } describe("parseDxf — SPLINE", () => { it("Grad-1-Spline zeichnet exakt das Kontrollpolygon nach", () => { // Clamped-Knoten für 2 CPs, Grad 1: |U| = 2+1+1 = 4, Domain [0,1]. const c = onlyContour(dxf(spline(1, [0, 0, 1, 1], [[0, 0], [10, 0]]))); expect(c.closed).toBe(false); 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); // Linear: alle Punkte auf y=0, x monoton steigend. for (let i = 1; i < c.pts.length; i++) { expect(c.pts[i].y).toBeCloseTo(0, 9); expect(c.pts[i].x).toBeGreaterThanOrEqual(c.pts[i - 1].x - 1e-9); } }); it("Grad-2-Spline bleibt in der konvexen Hülle und endet auf den Rand-CPs", () => { // 3 CPs, Grad 2, clamped: |U| = 3+2+1 = 6 → [0,0,0,1,1,1], Domain [0,1]. const c = onlyContour( dxf(spline(2, [0, 0, 0, 1, 1, 1], [[0, 0], [5, 10], [10, 0]])), ); // Endpunkte = erster/letzter Kontrollpunkt (clamped). 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); // Konvexe Hülle: y nie über 10, x in [0,10]; Scheitel bei x≈5 unter 10. for (const p of c.pts) { expect(p.y).toBeLessThanOrEqual(10 + 1e-9); expect(p.y).toBeGreaterThanOrEqual(-1e-9); expect(p.x).toBeGreaterThanOrEqual(-1e-9); expect(p.x).toBeLessThanOrEqual(10 + 1e-9); } // Mittelpunkt (t=0.5) einer quadratischen Bézier: 0.25·P0+0.5·P1+0.25·P2 = (5,5). const mid = c.pts[Math.floor(c.pts.length / 2)]; expect(mid.x).toBeCloseTo(5, 6); expect(mid.y).toBeCloseTo(5, 6); }); it("fällt ohne gültige Knoten auf die Stützpunkte zurück", () => { const c = onlyContour(dxf(splineFit([[0, 0], [1, 2], [3, 4]]))); expect(c.pts).toHaveLength(3); expect(c.pts[1].x).toBeCloseTo(1, 9); expect(c.pts[1].y).toBeCloseTo(2, 9); }); }); // ── INSERT (Block-Referenzen) ───────────────────────────────────────────────── /** BLOCK-Definition: Name, Basispunkt, enthaltene Entities. */ function block(name: string, bx: number, by: number, ...ents: string[][]): string[] { const g = ["0", "BLOCK", "8", "0", "2", name, "10", `${bx}`, "20", `${by}`, "30", "0", "70", "0"]; for (const e of ents) g.push(...e); g.push("0", "ENDBLK"); return g; } interface InsOpts { rot?: number; sx?: number; sy?: number; nc?: number; nr?: number; dc?: number; dr?: number; } /** INSERT-Referenz auf einen Block. */ function insert(name: string, x: number, y: number, o: InsOpts = {}): string[] { const g = ["0", "INSERT", "2", name, "10", `${x}`, "20", `${y}`, "30", "0"]; if (o.sx !== undefined) g.push("41", `${o.sx}`); if (o.sy !== undefined) g.push("42", `${o.sy}`); if (o.rot !== undefined) g.push("50", `${o.rot}`); if (o.nc !== undefined) g.push("70", `${o.nc}`); if (o.nr !== undefined) g.push("71", `${o.nr}`); if (o.dc !== undefined) g.push("44", `${o.dc}`); if (o.dr !== undefined) g.push("45", `${o.dr}`); return g; } /** DXF mit BLOCKS- und ENTITIES-Sektion. */ function dxfFull(blocks: string[][], entities: string[][]): string { const lines: string[] = []; if (blocks.length) { lines.push("0", "SECTION", "2", "BLOCKS"); for (const b of blocks) lines.push(...b); lines.push("0", "ENDSEC"); } lines.push("0", "SECTION", "2", "ENTITIES"); for (const e of entities) lines.push(...e); lines.push("0", "ENDSEC", "0", "EOF"); return lines.join("\n"); } describe("parseDxf — INSERT", () => { it("verschiebt eine Block-Linie an den Einfügepunkt", () => { const c = onlyContour( dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 5, 5)]), ); expect(c.pts[0].x).toBeCloseTo(5, 9); expect(c.pts[0].y).toBeCloseTo(5, 9); expect(c.pts[1].x).toBeCloseTo(6, 9); expect(c.pts[1].y).toBeCloseTo(5, 9); }); it("rotiert um 90° und skaliert", () => { const rot = onlyContour( dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 0, 0, { rot: 90 })]), ); // (1,0) um 90° CCW → (0,1). expect(rot.pts[1].x).toBeCloseTo(0, 6); expect(rot.pts[1].y).toBeCloseTo(1, 6); const scl = onlyContour( dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 0, 0, { sx: 2, sy: 3 })]), ); expect(scl.pts[1].x).toBeCloseTo(2, 9); expect(scl.pts[1].y).toBeCloseTo(0, 9); }); it("expandiert ein 2×1-Array zu zwei Konturen", () => { const res = parseDxf( dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 0, 0, { nc: 2, dc: 10 })]), ); const cs = res.contours[0].contours; expect(cs).toHaveLength(2); // Zweite Spalte um columnSpacing 10 versetzt. const xs = cs.map((c) => c.pts[0].x).sort((a, b) => a - b); expect(xs[0]).toBeCloseTo(0, 9); expect(xs[1]).toBeCloseTo(10, 9); }); it("löst verschachtelte Blockreferenzen auf (Transform-Komposition)", () => { const c = onlyContour( dxfFull( [ block("inner", 0, 0, line(0, 0, 1, 0)), block("outer", 0, 0, insert("inner", 2, 0)), ], [insert("outer", 0, 3)], ), ); // inner (0,0)-(1,0) → +(2,0) durch outer → +(0,3) durch top = (2,3)-(3,3). expect(c.pts[0].x).toBeCloseTo(2, 9); expect(c.pts[0].y).toBeCloseTo(3, 9); expect(c.pts[1].x).toBeCloseTo(3, 9); expect(c.pts[1].y).toBeCloseTo(3, 9); }); }); // ── HATCH (gefüllte Flächen) ────────────────────────────────────────────────── /** HATCH mit Polyline-Randpfad (Flag 3 = external+polyline). */ function hatchPoly(verts: Array<[number, number]>): string[] { const g = ["0", "HATCH", "8", "0", "2", "SOLID", "70", "1", "91", "1", "92", "3", "72", "0", "73", "1", "93", `${verts.length}`]; for (const [x, y] of verts) g.push("10", `${x}`, "20", `${y}`); g.push("97", "0"); return g; } /** HATCH mit Kanten-Randpfad aus Linienkanten (Flag 1 = external, kein Polyline-Bit). */ function hatchLineEdges(verts: Array<[number, number]>): string[] { const g = ["0", "HATCH", "8", "0", "2", "SOLID", "70", "1", "91", "1", "92", "1", "93", `${verts.length}`]; for (let k = 0; k < verts.length; k++) { const a = verts[k]; const b = verts[(k + 1) % verts.length]; g.push("72", "1", "10", `${a[0]}`, "20", `${a[1]}`, "11", `${b[0]}`, "21", `${b[1]}`); } g.push("97", "0"); return g; } /** HATCH mit einer Bogenkante (Kantentyp 2), Winkel in Grad. */ function hatchArc(cx: number, cy: number, r: number, s: number, e: number): string[] { return [ "0", "HATCH", "8", "0", "2", "SOLID", "70", "1", "91", "1", "92", "1", "93", "1", "72", "2", "10", `${cx}`, "20", `${cy}`, "40", `${r}`, "50", `${s}`, "51", `${e}`, "73", "1", "97", "0", ]; } describe("parseDxf — HATCH", () => { it("Polyline-Rand → geschlossene gefüllte Kontur", () => { const c = onlyContour(dxf(hatchPoly([[0, 0], [10, 0], [10, 10], [0, 10]]))); expect(c.closed).toBe(true); expect(c.filled).toBe(true); expect(c.pts).toHaveLength(4); expect(c.pts[1].x).toBeCloseTo(10, 9); expect(c.pts[1].y).toBeCloseTo(0, 9); expect(c.pts[2].x).toBeCloseTo(10, 9); expect(c.pts[2].y).toBeCloseTo(10, 9); }); it("Linienkanten-Rand → geschlossene gefüllte Kontur (Eckpunkte)", () => { const c = onlyContour(dxf(hatchLineEdges([[0, 0], [4, 0], [4, 4], [0, 4]]))); expect(c.closed).toBe(true); expect(c.filled).toBe(true); expect(c.pts).toHaveLength(4); const xs = c.pts.map((p) => p.x).sort((a, b) => a - b); expect(xs[0]).toBeCloseTo(0, 9); expect(xs[3]).toBeCloseTo(4, 9); }); it("Bogenkante → tessellierter Halbkreis auf Radius", () => { const c = onlyContour(dxf(hatchArc(0, 0, 5, 0, 180))); expect(c.filled).toBe(true); for (const p of c.pts) expect(Math.hypot(p.x, p.y)).toBeCloseTo(5, 6); expect(c.pts[0].x).toBeCloseTo(5, 6); expect(c.pts[0].y).toBeCloseTo(0, 6); const last = c.pts[c.pts.length - 1]; expect(last.x).toBeCloseTo(-5, 6); 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 = [ "0", "HATCH", "8", "0", "2", "SOLID", "70", "1", "91", "2", "92", "3", "72", "0", "73", "1", "93", "4", "10", "0", "20", "0", "10", "10", "20", "0", "10", "10", "20", "10", "10", "0", "20", "10", "97", "0", "92", "3", "72", "0", "73", "1", "93", "4", "10", "3", "20", "3", "10", "7", "20", "3", "10", "7", "20", "7", "10", "3", "20", "7", "97", "0", ]; const res = parseDxf(dxf(g)); expect(res.contours[0].contours).toHaveLength(2); expect(res.contours[0].contours.every((c) => c.filled && c.closed)).toBe(true); }); }); // ── TEXT / MTEXT ────────────────────────────────────────────────────────────── /** TEXT-Entity: Position, Höhe, Rotation (Grad), String. */ function textEnt(x: number, y: number, h: number, rotDeg: number, s: string): string[] { return ["0", "TEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", `${rotDeg}`, "1", s]; } /** MTEXT-Entity: Position, Höhe, String (Rotation 0). */ function mtextEnt(x: number, y: number, h: number, s: string): string[] { return ["0", "MTEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", "0", "1", s]; } describe("parseDxf — TEXT/MTEXT", () => { it("TEXT → ImportedText mit Position/Höhe/Winkel (Grad→Radiant)", () => { const res = parseDxf(dxf(textEnt(5, 3, 0.5, 90, "Hallo"))); expect(res.texts).toHaveLength(1); const t0 = res.texts![0]; expect(t0.at.x).toBeCloseTo(5, 9); expect(t0.at.y).toBeCloseTo(3, 9); expect(t0.height).toBeCloseTo(0.5, 9); expect(t0.angle).toBeCloseTo(Math.PI / 2, 9); expect(t0.text).toBe("Hallo"); }); it("MTEXT → Formatcodes gesäubert (\\P → Leerzeichen)", () => { const res = parseDxf(dxf(mtextEnt(1, 2, 0.3, "Welt\\Pzeile2"))); expect(res.texts![0].text).toBe("Welt zeile2"); expect(res.texts![0].at.x).toBeCloseTo(1, 9); expect(res.texts![0].height).toBeCloseTo(0.3, 9); }); it("leerer Text wird übersprungen", () => { const res = parseDxf(dxf(textEnt(0, 0, 0.2, 0, ""))); expect(res.texts ?? []).toHaveLength(0); }); it("fehlende Höhe → positiver Default", () => { const g = ["0", "TEXT", "8", "0", "10", "0", "20", "0", "30", "0", "1", "X"]; const res = parseDxf(dxf(g)); expect(res.texts![0].height).toBeGreaterThan(0); }); }); describe("textsToDrawings", () => { it("ImportedText → Drawing2D {shape:\"text\"}", () => { const drawings = textsToDrawings( [{ at: { x: 1, y: 2 }, text: "Hi", height: 0.4, angle: 0, layer: "L" }], "lvl", "active", "CODE", (s) => s, ); expect(drawings).toHaveLength(1); const g = drawings[0].geom; expect(g.shape).toBe("text"); if (g.shape === "text") { expect(g.text).toBe("Hi"); expect(g.height).toBeCloseTo(0.4, 9); expect(g.at.x).toBeCloseTo(1, 9); } }); }); describe("contoursToDrawings — HATCH-Füllung", () => { it("gefüllte Kontur → Drawing2D mit fillColor und polyline-Form", () => { const set: ContourSet = { id: "s", type: "contourSet", name: "h", contours: [{ z: 0, closed: true, filled: true, pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }] }], }; const [d] = contoursToDrawings([set], "lvl", "active", "CODE", (s) => s); expect(d.geom.shape).toBe("polyline"); expect(d.fillColor).toBeTruthy(); }); it("ungefüllte Kontur → Drawing2D ohne fillColor", () => { const set: ContourSet = { id: "s", type: "contourSet", name: "h", contours: [{ z: 0, closed: true, pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }] }], }; const [d] = contoursToDrawings([set], "lvl", "active", "CODE", (s) => s); expect(d.fillColor).toBeUndefined(); }); });