From 4ac99d37cb868b10034ad54a5ac03a8a76006afe Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 15:06:25 +0200 Subject: [PATCH] DXF-Import: CIRCLE/ARC als echte glatte Kreis-/Bogen-Formen (statt tesselliertem Vieleck) --- src/io/dxfParser.test.ts | 46 +++++++++++++++++++++++++ src/io/dxfParser.ts | 16 +++++++-- src/io/dxfToDrawings.ts | 8 +++++ src/model/types.ts | 13 +++++++ src/plan/PlanView.tsx | 46 +++++++++++++++++++++++++ src/plan/generatePlan.ts | 71 ++++++++++++++++++++++++++++++++------- src/plan/toRenderScene.ts | 15 +++++++++ 7 files changed, 200 insertions(+), 15 deletions(-) diff --git a/src/io/dxfParser.test.ts b/src/io/dxfParser.test.ts index 35491bb..9bb8daf 100644 --- a/src/io/dxfParser.test.ts +++ b/src/io/dxfParser.test.ts @@ -471,6 +471,52 @@ describe("textsToDrawings", () => { }); }); +describe("parseDxf — Kurven behalten echte Geometrie (curve)", () => { + it("CIRCLE trägt curve {kind:circle}", () => { + const c = onlyContour(dxf(circle(10, 20, 5, 4))); + expect(c.curve?.kind).toBe("circle"); + expect(c.curve?.cx).toBeCloseTo(10, 9); + expect(c.curve?.cy).toBeCloseTo(20, 9); + expect(c.curve?.r).toBeCloseTo(4, 9); + }); + + it("ARC trägt curve {kind:arc} mit Winkeln (Radiant)", () => { + const c = onlyContour(dxf(arc(0, 0, 10, 0, 90))); + expect(c.curve?.kind).toBe("arc"); + expect(c.curve?.r).toBeCloseTo(10, 9); + expect(c.curve?.a0).toBeCloseTo(0, 9); + expect(c.curve?.a1).toBeCloseTo(Math.PI / 2, 9); + }); +}); + +describe("contoursToDrawings — Kurven → glatte Formen", () => { + it("curve circle → {shape:\"circle\"}", () => { + const set: ContourSet = { + id: "s", type: "contourSet", name: "c", + contours: [{ z: 0, closed: true, pts: [{ x: 0, y: 0 }], curve: { kind: "circle", cx: 1, cy: 2, r: 3, a0: 0, a1: Math.PI * 2 } }], + }; + const [d] = contoursToDrawings([set], "lvl", "active", "C", (s) => s); + expect(d.geom.shape).toBe("circle"); + if (d.geom.shape === "circle") { + expect(d.geom.r).toBeCloseTo(3, 9); + expect(d.geom.center.x).toBeCloseTo(1, 9); + } + }); + + it("curve arc → {shape:\"arc\"}", () => { + const set: ContourSet = { + id: "s", type: "contourSet", name: "c", + contours: [{ z: 0, closed: false, pts: [{ x: 0, y: 0 }], curve: { kind: "arc", cx: 0, cy: 0, r: 5, a0: 0, a1: 1 } }], + }; + const [d] = contoursToDrawings([set], "lvl", "active", "C", (s) => s); + expect(d.geom.shape).toBe("arc"); + if (d.geom.shape === "arc") { + expect(d.geom.r).toBeCloseTo(5, 9); + expect(d.geom.a1).toBeCloseTo(1, 9); + } + }); +}); + describe("contoursToDrawings — HATCH-Füllung", () => { it("gefüllte Kontur → Drawing2D mit fillColor und polyline-Form", () => { const set: ContourSet = { diff --git a/src/io/dxfParser.ts b/src/io/dxfParser.ts index a451a8e..a0ad161 100644 --- a/src/io/dxfParser.ts +++ b/src/io/dxfParser.ts @@ -444,7 +444,13 @@ function arcContour(e: DxfEntity): Contour | null { const t = start + (sweep * i) / segs; pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) }); } - return { z, pts, closed: false, layer: e.layer }; + return { + z, + pts, + closed: false, + layer: e.layer, + curve: { kind: "arc", cx, cy, r, a0: start, a1: start + sweep }, + }; } /** CIRCLE → geschlossener Kreis-Linienzug (voller Umlauf, letzter Punkt weggelassen). */ @@ -471,7 +477,13 @@ function circleContour(e: DxfEntity): Contour | null { const t = (Math.PI * 2 * i) / segs; pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) }); } - return { z, pts, closed: true, layer: e.layer }; + return { + z, + pts, + closed: true, + layer: e.layer, + curve: { kind: "circle", cx, cy, r, a0: 0, a1: Math.PI * 2 }, + }; } /** diff --git a/src/io/dxfToDrawings.ts b/src/io/dxfToDrawings.ts index caf1ef9..8e0eb35 100644 --- a/src/io/dxfToDrawings.ts +++ b/src/io/dxfToDrawings.ts @@ -102,6 +102,14 @@ export function textsToDrawings( /** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */ function contourGeom(contour: Contour): Drawing2DGeom | null { + // Echte Kurve (CIRCLE/ARC) → GLATTE Kreis-/Bogen-Form statt tesselliertem Vieleck. + const c = contour.curve; + if (c) { + if (c.kind === "circle") { + return { shape: "circle", center: { x: c.cx, y: c.cy }, r: c.r }; + } + return { shape: "arc", center: { x: c.cx, y: c.cy }, r: c.r, a0: c.a0, a1: c.a1 }; + } const pts = contour.pts; if (pts.length < 2) return null; if (pts.length === 2 && !contour.closed) { diff --git a/src/model/types.ts b/src/model/types.ts index cc7325b..68cf01c 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1098,6 +1098,19 @@ export interface Contour { * geschlossene, gefüllte 2D-Form (Vollton-Füllung); sonst nur ein Umriss. */ filled?: boolean; + /** + * Wahre Kurvengeometrie (aus CIRCLE/ARC). `pts` bleibt tesselliert (Kontext/ + * 3D), aber der Drawing-Import baut daraus eine GLATTE `{shape:"circle"|"arc"}`- + * Form statt eines Vielecks. Winkel in RADIANT (CCW); Kreis = a0..a1 über 2π. + */ + curve?: { + kind: "circle" | "arc"; + cx: number; + cy: number; + r: number; + a0: number; + a1: number; + }; } /** diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 8b7f80e..85a834b 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -3096,5 +3096,51 @@ function renderPrimitive( ); } + case "drawingCircle": { + // Echtes `` — rund bei jedem Zoom (kein Vieleck). Füllung optional. + const c = toScreen(p.center); + const r = p.r * PX_PER_M; + return ( + + ); + } + case "drawingArc": { + // Glatter SVG-Bogen. Winkel in Radiant (CCW im Modell); Screen-Y ist + // gespiegelt → ein modell-CCW-Bogen läuft am Bildschirm im Uhrzeigersinn + // (SVG sweep-flag 1). large-arc aus der Spannweite (>π). + const TAU = Math.PI * 2; + const rPx = p.r * PX_PER_M; + const from = toScreen({ + x: p.center.x + p.r * Math.cos(p.a0), + y: p.center.y + p.r * Math.sin(p.a0), + }); + const to = toScreen({ + x: p.center.x + p.r * Math.cos(p.a1), + y: p.center.y + p.r * Math.sin(p.a1), + }); + const sweep = (((p.a1 - p.a0) % TAU) + TAU) % TAU; + const largeArc = sweep > Math.PI ? 1 : 0; + const d = `M ${from.x} ${from.y} A ${rPx} ${rPx} 0 ${largeArc} 1 ${to.x} ${to.y}`; + return ( + + ); + } } } diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 5d2e131..8d6836b 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -311,6 +311,35 @@ export type Primitive = /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */ drawingId?: string; greyed?: boolean; + } + | { + // Glatter Kreis (aus Drawing2D `{shape:"circle"}`, z. B. DXF-CIRCLE) — als + // echtes `` gerendert (rund bei jedem Zoom), nicht als Vieleck. + kind: "drawingCircle"; + center: Vec2; + /** Radius in Modell-Metern. */ + r: number; + /** Vollton-Füllfarbe oder "none". */ + fill: string; + stroke: string; + weightMm: number; + dash?: number[] | null; + drawingId?: string; + greyed?: boolean; + } + | { + // Glatter Bogen (aus Drawing2D `{shape:"arc"}`, z. B. DXF-ARC) — als + // SVG-Bogenpfad gerendert; Winkel in RADIANT (CCW im Modell). + kind: "drawingArc"; + center: Vec2; + r: number; + a0: number; + a1: number; + stroke: string; + weightMm: number; + dash?: number[] | null; + drawingId?: string; + greyed?: boolean; }; /** @@ -1017,19 +1046,36 @@ function addDrawing2D( seg(c4, c1); break; } - case "circle": { - // Kreis als geschlossene Polygon-Approximation (N Segmente) — nutzt die - // vorhandene Füll-/Strich-Pipeline, ohne ein eigenes Kreis-Primitiv. - const N = 64; - const pts: Vec2[] = []; - for (let i = 0; i < N; i++) { - const t = (i / N) * Math.PI * 2; - pts.push({ x: g.center.x + g.r * Math.cos(t), y: g.center.y + g.r * Math.sin(t) }); - } - fillPoly(pts); - for (let i = 0; i < N; i++) seg(pts[i], pts[(i + 1) % N]); + case "circle": + // Echtes Kreis-Primitiv → glattes `` (rund bei jedem Zoom) mit + // optionaler Vollton-Füllung. Ersetzt die frühere 64-Ecken-Approximation. + out.push({ + kind: "drawingCircle", + center: g.center, + r: g.r, + fill: background ?? d.fillColor ?? "none", + stroke: color, + weightMm, + dash, + greyed, + drawingId: d.id, + }); + break; + case "arc": + // Echtes Bogen-Primitiv → glatter SVG-Bogenpfad (Winkel in Radiant). + out.push({ + kind: "drawingArc", + center: g.center, + r: g.r, + a0: g.a0, + a1: g.a1, + stroke: color, + weightMm, + dash, + greyed, + drawingId: d.id, + }); break; - } case "text": out.push({ kind: "drawingText", @@ -1042,7 +1088,6 @@ function addDrawing2D( drawingId: d.id, }); break; - // arc: Primitive-Erweiterung folgt später. default: break; } diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index 5c0635c..d0d7a43 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -486,6 +486,21 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL widthMm: p.weightMm, dash: p.dash ?? null, }); + } else if (p.kind === "drawingCircle" || p.kind === "drawingArc") { + // Der native Renderer hat kein Kreis-/Bogen-Primitiv → hier tessellieren + // (der SVG-Pfad zeichnet sie glatt). Kreis = voller Umlauf, Bogen = a0..a1. + flushRun(); + const col = withOpacity(toRgba(p.stroke, 1) ?? [0.1, 0.1, 0.1, 1], undefined, p.greyed); + const TAU = Math.PI * 2; + const a0 = p.kind === "drawingArc" ? p.a0 : 0; + const sweep = p.kind === "drawingArc" ? (((p.a1 - p.a0) % TAU) + TAU) % TAU : TAU; + const N = Math.max(8, Math.ceil(sweep / (Math.PI / 32))); + const pts: RPoint[] = []; + for (let i = 0; i <= N; i++) { + const t = a0 + (sweep * i) / N; + pts.push([p.center.x + p.r * Math.cos(t), p.center.y + p.r * Math.sin(t)]); + } + polylines.push({ pts, color: col, widthMm: p.weightMm, dash: p.dash ?? null, z: zc++ }); } else if (p.kind === "text") { // "text": zeilenweise flachen — die ECHTEN Glyphen rastert der Rust- // Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie