DXF-Import: HATCH via Custom-Handler -> gefuellte geschlossene Drawing2D-Flaeche

This commit is contained in:
2026-07-05 14:12:59 +02:00
parent 79ea6b0b3b
commit a44572bf7b
4 changed files with 312 additions and 3 deletions
+105 -1
View File
@@ -3,7 +3,8 @@
import { describe, it, expect } from "vitest";
import { parseDxf } from "./dxfParser";
import type { Contour, Vec2 } from "../model/types";
import { contoursToDrawings } 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 {
@@ -286,3 +287,106 @@ describe("parseDxf — INSERT", () => {
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("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);
});
});
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();
});
});
+191
View File
@@ -22,6 +22,8 @@
// • ELLIPSE → Kontur (Ellipsenbogen/-umlauf, tesselliert).
// • 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`).
import DxfParser from "dxf-parser";
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
@@ -93,8 +95,15 @@ interface DxfEntity {
rowCount?: number;
columnSpacing?: number;
rowSpacing?: number;
// HATCH: rohe Gruppencodes (vom Custom-Handler gesammelt, siehe HatchHandler).
rawCodes?: RawCode[];
[k: string]: unknown;
}
/** Ein rohes DXF-Gruppencode/Wert-Paar (HATCH-Randpfad-Auswertung). */
interface RawCode {
code: number;
value: number | string;
}
/** Ein Block (BLOCKS-Tabelle): Basispunkt `position` + eigene Entity-Liste. */
interface DxfBlock {
name?: string;
@@ -117,6 +126,12 @@ const nextId = (prefix: string) =>
*/
export function parseDxf(text: string): DxfImportResult {
const parser = new DxfParser();
// dxf-parser bringt keinen HATCH-Handler mit (verwirft HATCH sonst stumm).
// Eigenen registrieren, der die rohen Gruppencodes einsammelt; die Auswertung
// der Randpfade läuft separat und testbar in `hatchContours`.
(parser as unknown as {
registerEntityHandler: (h: new () => unknown) => void;
}).registerEntityHandler(HatchHandler);
// parseSync wirft bei strukturell kaputtem DXF; das soll nach oben.
const doc = parser.parseSync(text) as unknown as DxfDocument;
const entities = doc?.entities ?? [];
@@ -668,6 +683,9 @@ function collectContours(
if (c) out.push(c);
break;
}
case "HATCH":
for (const c of hatchContours(e)) out.push(c);
break;
case "INSERT":
expandInsert(e, blocks, out, depth);
break;
@@ -676,3 +694,176 @@ function collectContours(
break;
}
}
// ── HATCH (Custom-Handler + Randpfad-Auswertung → gefüllte Konturen) ──────────
/**
* Custom dxf-parser-Handler für HATCH: sammelt die rohen Gruppencodes der Entity
* (bis zum nächsten Code 0), OHNE zu interpretieren. Muster wie die eingebauten
* Handler (Schleife bricht bei Code 0; `lastReadGroup` bleibt darauf stehen).
* Die eigentliche Randpfad-Logik liegt testbar in `hatchContours`.
*/
class HatchHandler {
ForEntityName = "HATCH";
parseEntity(
scanner: {
next: () => { code: number; value: number | string };
isEOF: () => boolean;
},
curr: { code: number; value: number | string },
): DxfEntity {
const raw: RawCode[] = [];
const entity: DxfEntity = { type: String(curr.value), rawCodes: raw };
curr = scanner.next();
while (!scanner.isEOF()) {
if (curr.code === 0) break;
if (curr.code === 8) entity.layer = String(curr.value);
raw.push({ code: curr.code, value: curr.value });
curr = scanner.next();
}
return entity;
}
}
/** Zahlenwert eines Gruppencodes (tolerant). */
function rc(v: number | string | undefined): number {
if (typeof v === "number") return Number.isFinite(v) ? v : 0;
const n = typeof v === "string" ? Number.parseFloat(v) : NaN;
return Number.isFinite(n) ? n : 0;
}
/** Erster Wert eines Codes aus der Kanten-Code-Sammlung. */
function firstOf(m: Map<number, number[]>, code: number): number | undefined {
const a = m.get(code);
return a && a.length > 0 ? a[0] : undefined;
}
/** Bogenkante (Kantentyp 2): Zentrum/Radius, Winkel in GRAD, ggf. im Uhrzeigersinn. */
function pushArcEdge(pts: Vec2[], m: Map<number, number[]>): void {
const cx = firstOf(m, 10);
const cy = firstOf(m, 20);
const r = firstOf(m, 40);
if (cx === undefined || cy === undefined || r === undefined || r <= 0) return;
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;
// Spanne in Laufrichtung auf (0, 2π] normieren.
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);
// Startpunkt weglassen, wenn er den vorherigen Kanten-Endpunkt dupliziert.
for (let i = 0; i <= segs; i++) {
const t = s + dir * (sweep * i) / segs;
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
}
}
/** Punkte einer HATCH-Randkante an die Loop-Punkte anhängen (Typ 1 Linie, 2 Bogen). */
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.
const x = firstOf(m, 10);
const y = firstOf(m, 20);
if (x !== undefined && y !== undefined) pts.push({ x, y });
// 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.
}
/**
* 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).
*/
function hatchContours(e: DxfEntity): Contour[] {
const raw = e.rawCodes;
if (!raw || raw.length === 0) return [];
const n = raw.length;
const layer = e.layer;
// Zum ersten Code 91 (Anzahl Randpfade).
let i = 0;
while (i < n && raw[i].code !== 91) i++;
if (i >= n) return [];
const numPaths = rc(raw[i].value);
i++;
const loops: Contour[] = [];
for (let p = 0; p < numPaths && i < n; p++) {
// Zum Pfadtyp-Flag (92) des nächsten Pfads.
while (i < n && raw[i].code !== 92) i++;
if (i >= n) break;
const flag = rc(raw[i].value);
i++;
const isPolyline = (flag & 2) !== 0;
const pts: Vec2[] = [];
if (isPolyline) {
// Optionale 72/73 überspringen, bis 93 (Vertex-Anzahl).
while (i < n && raw[i].code !== 93) i++;
if (i >= n) break;
const numVerts = rc(raw[i].value);
i++;
let cx: number | null = null;
let got = 0;
while (i < n && got < numVerts) {
const c = raw[i];
if (c.code === 91 || c.code === 92) break; // Sicherheitsnetz
if (c.code === 10) {
cx = rc(c.value);
} else if (c.code === 20 && cx !== null) {
pts.push({ x: cx, y: rc(c.value) });
cx = null;
got++;
}
i++;
}
} else {
// Kanten-Pfad: 93 = Anzahl Kanten.
while (i < n && raw[i].code !== 93) i++;
if (i >= n) break;
const numEdges = rc(raw[i].value);
i++;
for (let ed = 0; ed < numEdges && i < n; ed++) {
// Zum Kantentyp (72), aber nicht über den Pfad/Kanten-Block hinaus.
while (
i < n &&
raw[i].code !== 72 &&
raw[i].code !== 97 &&
raw[i].code !== 92 &&
raw[i].code !== 91
) {
i++;
}
if (i >= n || raw[i].code !== 72) break;
const edgeType = rc(raw[i].value);
i++;
// Kanten-Codes bis zur nächsten Kante/Blockgrenze sammeln.
const m = new Map<number, number[]>();
while (i < n) {
const c = raw[i];
if (c.code === 72 || c.code === 97 || c.code === 92 || c.code === 91) break;
const arr = m.get(c.code) ?? [];
arr.push(rc(c.value));
m.set(c.code, arr);
i++;
}
appendEdge(pts, edgeType, m);
}
}
if (pts.length >= 2) {
loops.push({ z: 0, pts, closed: true, layer, filled: true });
}
}
return loops;
}
+11 -2
View File
@@ -23,6 +23,9 @@ export type CategoryMode = "active" | "byLayer";
let drawingSeq = 0;
const nextId = (): string => `dxf2d-${Date.now()}-${drawingSeq++}`;
/** Default-Füllfarbe für aus HATCH importierte Flächen (neutral, restylebar). */
const DXF_HATCH_FILL = "#c8c8c8";
/**
* Wandelt einen Satz Konturen-Sätze in Drawing2D-Elemente für die Ziel-Ebene
* `levelId` um. Bei `categoryMode === "byLayer"` liefert `layerToCode` den
@@ -45,13 +48,19 @@ export function contoursToDrawings(
categoryMode === "byLayer" && layerName
? layerToCode(layerName)
: fallbackCode;
out.push({
const drawing: Drawing2D = {
id: nextId(),
type: "drawing2d",
levelId,
categoryCode,
geom,
});
};
// Aus einer HATCH stammende Kontur → geschlossene, gefüllte Form. Vollton-
// Füllung als restylebarer Default (neutrales Grau); Umriss bleibt Kategorie.
if (contour.filled && geom.shape === "polyline") {
drawing.fillColor = DXF_HATCH_FILL;
}
out.push(drawing);
}
}
return out;
+5
View File
@@ -1093,6 +1093,11 @@ export interface Contour {
closed: boolean;
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
layer?: string;
/**
* Gefüllte Fläche (aus einer DXF-HATCH). Beim Drawing-Import wird daraus eine
* geschlossene, gefüllte 2D-Form (Vollton-Füllung); sonst nur ein Umriss.
*/
filled?: boolean;
}
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */