diff --git a/src/io/dxfParser.test.ts b/src/io/dxfParser.test.ts index 5817369..36aaf2c 100644 --- a/src/io/dxfParser.test.ts +++ b/src/io/dxfParser.test.ts @@ -119,3 +119,170 @@ describe("parseDxf — gemischt", () => { 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); + }); +}); diff --git a/src/io/dxfParser.ts b/src/io/dxfParser.ts index 6ce38f9..857fd31 100644 --- a/src/io/dxfParser.ts +++ b/src/io/dxfParser.ts @@ -20,6 +20,8 @@ // • ARC → Kontur (offener Bogen, tesselliert). // • CIRCLE → Kontur (geschlossener Kreis, tesselliert). // • ELLIPSE → Kontur (Ellipsenbogen/-umlauf, tesselliert). +// • SPLINE → Kontur (B-Spline via De Boor; Fallback fitPoints). +// • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt). import DxfParser from "dxf-parser"; import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types"; @@ -74,10 +76,34 @@ interface DxfEntity { majorAxisEndPoint?: DxfVertex; /** ELLIPSE: Verhältnis Neben-/Hauptachse (b/a). */ axisRatio?: number; + // SPLINE-Felder. Winkel/Parameter roh; Knoten/Grad definieren die B-Spline. + controlPoints?: DxfVertex[]; + fitPoints?: DxfVertex[]; + knotValues?: number[]; + degreeOfSplineCurve?: number; + closed?: boolean; + // INSERT-Felder (Block-Referenz). `position` (Einfügepunkt) wird tolerant + // gelesen (das Feld ist oben als MESH-Vertexliste getippt) — daher hier NICHT + // erneut deklariert; `rotation` ist in GRAD (anders als ARC/CIRCLE). + name?: string; + xScale?: number; + yScale?: number; + rotation?: number; + columnCount?: number; + rowCount?: number; + columnSpacing?: number; + rowSpacing?: number; [k: string]: unknown; } +/** Ein Block (BLOCKS-Tabelle): Basispunkt `position` + eigene Entity-Liste. */ +interface DxfBlock { + name?: string; + position?: DxfVertex; + entities?: DxfEntity[]; +} interface DxfDocument { entities?: DxfEntity[]; + blocks?: Record; } let importSeq = 0; @@ -94,6 +120,7 @@ export function parseDxf(text: string): DxfImportResult { // parseSync wirft bei strukturell kaputtem DXF; das soll nach oben. const doc = parser.parseSync(text) as unknown as DxfDocument; const entities = doc?.entities ?? []; + const blocks = doc?.blocks ?? {}; const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…) const meshIndices: number[] = []; @@ -101,51 +128,22 @@ export function parseDxf(text: string): DxfImportResult { for (const e of entities) { const type = (e.type ?? "").toUpperCase(); - switch (type) { - case "3DFACE": - addFace(meshTriangles, meshIndices, e); - break; - case "MESH": - addMesh(meshTriangles, meshIndices, e); - break; - case "POLYLINE": - // POLYLINE ist mehrdeutig: Polyface/PolygonMesh → Mesh; sonst → Kontur. - if (isMeshPolyline(e)) { - addPolyfaceMesh(meshTriangles, meshIndices, e); - } else { - const ct = polylineContour(e); - if (ct) contours.push(ct); - } - break; - case "LWPOLYLINE": { - const ct = polylineContour(e); - if (ct) contours.push(ct); - break; - } - case "LINE": { - const ct = lineContour(e); - 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; + // Mesh-Entities in die Dreiecks-Puffer; POLYLINE nur als Mesh-Variante. + if (type === "3DFACE") { + addFace(meshTriangles, meshIndices, e); + continue; } + if (type === "MESH") { + addMesh(meshTriangles, meshIndices, e); + continue; + } + if (type === "POLYLINE" && isMeshPolyline(e)) { + addPolyfaceMesh(meshTriangles, meshIndices, e); + continue; + } + // Alle übrigen Kontur-Entities (inkl. SPLINE + INSERT-Block-Expansion) über + // den gemeinsamen Sammler — dieselbe Logik nutzt die INSERT-Rekursion. + collectContours(e, blocks, contours, 0); } const meshes: ImportedMesh[] = []; @@ -456,3 +454,225 @@ function ellipseContour(e: DxfEntity): Contour | null { } return { z, pts, closed: full, layer: e.layer }; } + +// ── SPLINE (B-Spline via De Boor) ──────────────────────────────────────────── + +/** DxfVertex-Liste → finite Vec2-Stützpunkte. */ +function toVec2s(vs: DxfVertex[] | undefined): Vec2[] { + const out: Vec2[] = []; + for (const v of vs ?? []) { + if (Number.isFinite(v.x) && Number.isFinite(v.y)) { + out.push({ x: v.x ?? 0, y: v.y ?? 0 }); + } + } + return out; +} + +/** Erster finiter Z-Wert einer DxfVertex-Liste (sonst 0). */ +function firstZ(vs: DxfVertex[] | undefined): number { + for (const v of vs ?? []) if (Number.isFinite(v.z)) return v.z as number; + return 0; +} + +/** + * Knoten-Span-Index (The NURBS Book, A2.1): grösstes k mit U[k] ≤ t < U[k+1], + * geklemmt auf [p, n]. `n` = letzter Kontrollpunkt-Index. + */ +function findSpan(n: number, p: number, t: number, U: number[]): number { + if (t >= U[n + 1]) return n; + if (t <= U[p]) return p; + let low = p; + let high = n + 1; + let mid = Math.floor((low + high) / 2); + while (t < U[mid] || t >= U[mid + 1]) { + if (t < U[mid]) high = mid; + else low = mid; + mid = Math.floor((low + high) / 2); + } + return mid; +} + +/** Punkt einer (nicht-rationalen) B-Spline bei Parameter t (De Boor, A2.4). */ +function deBoor(cps: Vec2[], U: number[], p: number, t: number): Vec2 { + const n = cps.length - 1; + const span = findSpan(n, p, t, U); + const d: Vec2[] = []; + for (let j = 0; j <= p; j++) d[j] = { ...cps[span - p + j] }; + for (let r = 1; r <= p; r++) { + for (let j = p; j >= r; j--) { + const i = span - p + j; + const denom = U[i + p - r + 1] - U[i]; + const a = denom === 0 ? 0 : (t - U[i]) / denom; + d[j] = { + x: (1 - a) * d[j - 1].x + a * d[j].x, + y: (1 - a) * d[j - 1].y + a * d[j].y, + }; + } + } + return d[p]; +} + +/** + * SPLINE → Linienzug. Primär echte B-Spline-Auswertung (Kontrollpunkte + Knoten + * + Grad, wenn der Knotenvektor konsistent ist: |U| = |P| + Grad + 1). Sonst + * Rückfall auf Stützpunkte (fitPoints) bzw. — grob — das Kontrollpolygon. + * Rationale Gewichte werden ignoriert (selten; dxf-parser liefert sie nicht). + */ +function splineContour(e: DxfEntity): Contour | null { + const cps = toVec2s(e.controlPoints); + const fps = toVec2s(e.fitPoints); + const knots = (e.knotValues ?? []).filter((k) => Number.isFinite(k)); + const degree = Number.isFinite(e.degreeOfSplineCurve) + ? (e.degreeOfSplineCurve as number) + : 3; + 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 }; + } + 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; +} + +// ── INSERT (Block-Referenz → transformierte Konturen) ───────────────────────── + +/** Tolerantes Zahlenfeld: endliche Zahl oder Default. */ +function numOr(v: unknown, d: number): number { + return typeof v === "number" && Number.isFinite(v) ? v : d; +} + +/** Tolerante Koordinate aus einem (unbekannt getippten) Punktobjekt. */ +function coord(v: unknown, key: "x" | "y"): number { + if (v && typeof v === "object") { + const n = (v as Record)[key]; + if (typeof n === "number" && Number.isFinite(n)) return n; + } + return 0; +} + +/** Schutz gegen zyklische/tief verschachtelte Blockreferenzen. */ +const MAX_INSERT_DEPTH = 8; + +/** + * INSERT expandieren: Block-Entities rekursiv zu LOKALEN Konturen sammeln, dann + * je Array-Zelle mit der 2D-Transform der Referenz nach Welt abbilden. + * Welt(p, off) = Einfügepunkt + Rot(θ) · ( Scale(p − Basispunkt) + off ). + * `off` = (Spalte·Spaltenabstand, Zeile·Zeilenabstand) im rotierten Blockraster. + */ +function expandInsert( + e: DxfEntity, + blocks: Record, + out: Contour[], + depth: number, +): void { + if (depth >= MAX_INSERT_DEPTH) return; + const name = typeof e.name === "string" ? e.name : null; + if (!name) return; + const block = blocks[name]; + if (!block || !Array.isArray(block.entities)) return; + + const sx = numOr(e.xScale, 1); + const sy = numOr(e.yScale, 1); + const rot = (numOr(e.rotation, 0) * Math.PI) / 180; // INSERT-Rotation in GRAD + const cos = Math.cos(rot); + const sin = Math.sin(rot); + const ix = coord(e.position, "x"); // Einfügepunkt (tolerant gelesen) + const iy = coord(e.position, "y"); + const bx = coord(block.position, "x"); // Block-Basispunkt + const by = coord(block.position, "y"); + const nc = Math.max(1, Math.floor(numOr(e.columnCount, 1))); + const nr = Math.max(1, Math.floor(numOr(e.rowCount, 1))); + const dc = numOr(e.columnSpacing, 0); + const dr = numOr(e.rowSpacing, 0); + + // Block-Inhalt EINMAL lokal sammeln (rekursiv), dann je Zelle transformieren. + const local: Contour[] = []; + for (const be of block.entities) collectContours(be, blocks, local, depth + 1); + if (local.length === 0) return; + + for (let row = 0; row < nr; row++) { + for (let col = 0; col < nc; col++) { + const offx = col * dc; + const offy = row * dr; + for (const lc of local) { + const pts = lc.pts.map((p) => { + const vx = (p.x - bx) * sx + offx; + const vy = (p.y - by) * sy + offy; + return { x: ix + (vx * cos - vy * sin), y: iy + (vx * sin + vy * cos) }; + }); + out.push({ z: lc.z, pts, closed: lc.closed, layer: e.layer ?? lc.layer }); + } + } + } +} + +/** + * Gemeinsamer Kontur-Dispatch für eine Entity (Top-Level UND Block-Inhalt). + * Mesh-Entities werden hier NICHT behandelt (nur der Top-Level-Pfad erzeugt + * Meshes; Block-interne Meshes sind im 2D-Import selten und bleiben aussen vor). + */ +function collectContours( + e: DxfEntity, + blocks: Record, + out: Contour[], + depth: number, +): void { + const type = (e.type ?? "").toUpperCase(); + switch (type) { + case "LWPOLYLINE": { + const c = polylineContour(e); + if (c) out.push(c); + break; + } + case "POLYLINE": { + if (!isMeshPolyline(e)) { + const c = polylineContour(e); + if (c) out.push(c); + } + break; + } + case "LINE": { + const c = lineContour(e); + if (c) out.push(c); + break; + } + case "ARC": { + const c = arcContour(e); + if (c) out.push(c); + break; + } + case "CIRCLE": { + const c = circleContour(e); + if (c) out.push(c); + break; + } + case "ELLIPSE": { + const c = ellipseContour(e); + if (c) out.push(c); + break; + } + case "SPLINE": { + const c = splineContour(e); + if (c) out.push(c); + break; + } + case "INSERT": + expandInsert(e, blocks, out, depth); + break; + default: + // Unbekannte/irrelevante Entity → ignorieren (tolerant). + break; + } +}