diff --git a/src/io/dxfParser.test.ts b/src/io/dxfParser.test.ts index 36aaf2c..5c4209e 100644 --- a/src/io/dxfParser.test.ts +++ b/src/io/dxfParser.test.ts @@ -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(); + }); +}); diff --git a/src/io/dxfParser.ts b/src/io/dxfParser.ts index 857fd31..dade51f 100644 --- a/src/io/dxfParser.ts +++ b/src/io/dxfParser.ts @@ -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, 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): 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): 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(); + 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; +} diff --git a/src/io/dxfToDrawings.ts b/src/io/dxfToDrawings.ts index 88a5d60..2606c5f 100644 --- a/src/io/dxfToDrawings.ts +++ b/src/io/dxfToDrawings.ts @@ -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; diff --git a/src/model/types.ts b/src/model/types.ts index d00849c..9ddd0a8 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -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). */