DXF-Import: SPLINE (De-Boor-B-Spline) + INSERT (Block-Expansion mit Transform/Array/Verschachtelung)

This commit is contained in:
2026-07-05 13:59:15 +02:00
parent 33c2c6c22e
commit 83da278ed2
2 changed files with 431 additions and 44 deletions
+167
View File
@@ -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);
});
});
+264 -44
View File
@@ -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<string, DxfBlock>;
}
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<string, unknown>)[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<string, DxfBlock>,
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<string, DxfBlock>,
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;
}
}