DXF-HATCH: Ellipsen- + Spline-Randkanten tesselliert (B-Spline-Sampling extrahiert)

This commit is contained in:
2026-07-05 14:18:49 +02:00
parent d36c84689f
commit 05bc5aa6e1
2 changed files with 124 additions and 23 deletions
+41
View File
@@ -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 = [
+83 -23
View File
@@ -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<number, number[]>): 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<number, number[]>): 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<number, number[]>): 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<number, number[]>): 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<number, number[]>): 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;