// DXF-Export des Grundrisses. Nutzt DIESELBE Quelle wie der PDF-Export — den // Vektor-Plan (generatePlan → Primitive) — schreibt aber MODELL-SPACE-Geometrie // in ECHTEN Metern (kein Papier-mm, keine Y-Spiegelung). So können nachgelagerte // CAD-Nutzer beim Plot beliebig skalieren; die Datei ist masshaltig 1:1 in Metern. // // Layer entstehen aus den Kategorie-Ebenen des Projekts (Code + Name, Farbe, // Linienstärke). Jede Entity liegt auf dem Layer ihrer Kategorie; Primitive ohne // direkten Kategorie-Bezug (Tür-Symbole, Kontext, Schraffuren) landen auf // sprechenden Sammel-Layern. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). import type { Plan, Primitive } from "../plan/generatePlan"; import type { LayerCategory, Project, Vec2 } from "../model/types"; import { flattenCategories } from "../model/types"; import { DxfWriter } from "./dxfWriter"; export interface ExportDxfOptions { /** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */ title?: string; /** Schraffurlinien mit ausgeben (true) oder nur Umrisse/Flächen (false). */ includeHatches?: boolean; /** Dateiname des Downloads (mit/ohne .dxf). */ fileName?: string; } /** Sammel-Layer-Namen für Primitive ohne eigene Kategorie. */ const LAYER_HATCH = "HATCH"; const LAYER_SYMBOLS = "SYMBOLS"; const LAYER_CONTEXT = "CONTEXT"; const LAYER_DEFAULT = "PLAN"; /** * Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space). * Reihenfolge der Primitive bleibt erhalten. Rückgabe ist reiner ASCII-Text. */ export function buildPlanDxf( plan: Plan, project: Project, opts: ExportDxfOptions = {}, ): string { const includeHatches = opts.includeHatches ?? true; const dxf = new DxfWriter(); // 1) Layer aus den Kategorie-Ebenen anlegen (Code+Name als Layer-Name, Farbe, // Linienstärke). So trägt die DXF echte, benannte Layer statt eines Flachs. const cats = flattenCategories(project.layers); const layerNameByCode = new Map(); for (const c of cats) { const name = layerName(c); layerNameByCode.set(c.code, name); dxf.addLayer({ name, color: aciFromHex(c.color), trueColor: rgbFromHex(c.color), lineWeight: mmToLwe(c.lw), }); } // Sammel-Layer für kategorielose Primitive. dxf.addLayer({ name: LAYER_SYMBOLS, color: 7, trueColor: 0x111111, lineWeight: 18 }); dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 }); dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 }); dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 }); // 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im // Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing- // Entity auf dem richtigen Layer. const codeByWall = new Map(); for (const w of project.walls) codeByWall.set(w.id, w.categoryCode); const codeByDrawing = new Map(); for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode); const layerFor = (p: Primitive): string => { if (p.kind === "polygon") { if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT; if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT; return LAYER_DEFAULT; } if (p.kind === "line") { if (p.cls === "context-line") return LAYER_CONTEXT; if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT; // Tür-Blatt/-Rahmen und Wand-Achsen: Symbol-Sammellayer. return LAYER_SYMBOLS; } // Bögen: Tür-Schwenkbogen → Symbole. return LAYER_SYMBOLS; }; // 3) Jedes Primitiv in DXF-Entities übersetzen. for (const p of plan.primitives) { emitPrimitive(dxf, p, layerFor(p), includeHatches); } return dxf.build(); } /** Layer-Name aus einer Kategorie: "Code_Name" (bereinigt der Writer selbst). */ function layerName(c: LayerCategory): string { return `${c.code}_${c.name}`; } /** Schreibt ein Plan-Primitiv als passende DXF-Entity(en). */ function emitPrimitive( dxf: DxfWriter, p: Primitive, layer: string, includeHatches: boolean, ): void { switch (p.kind) { case "line": dxf.addLine(layer, p.a, p.b); break; case "arc": emitArc(dxf, p, layer); break; case "polygon": { const pts = p.pts; if (pts.length < 2) break; // Kreis-Approximationen (viele Segmente) bleiben als geschlossene Polylinie — // masshaltig und robust. Geschlossene Ringe bekommen das Ring-Flag. dxf.addPolyline(layer, pts, true); // Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine // sichtbaren Musterlinien → wir geben nur echte Muster (nicht solid/none) aus. if ( includeHatches && p.hatch.pattern !== "none" && p.hatch.pattern !== "solid" ) { for (const seg of hatchSegments(pts, p.hatch)) { dxf.addLine(LAYER_HATCH, seg.a, seg.b); } } break; } } } /** * Bogen-Primitiv → DXF-ARC. DXF-Bögen laufen gegen den Uhrzeigersinn von * startAngle nach endAngle. Der Plan-Bogen ist über from/to definiert; die * Drehrichtung folgt dem Kreuzprodukt (positiv = CCW). Bei CW werden Start/Ende * getauscht, damit der kurze Bogen in der richtigen Richtung entsteht. */ function emitArc( dxf: DxfWriter, p: Extract, layer: string, ): void { const a1 = angleDeg(p.center, p.from); const a2 = angleDeg(p.center, p.to); const v1 = { x: p.from.x - p.center.x, y: p.from.y - p.center.y }; const v2 = { x: p.to.x - p.center.x, y: p.to.y - p.center.y }; const cross = v1.x * v2.y - v1.y * v2.x; if (cross >= 0) { dxf.addArc(layer, p.center, p.r, a1, a2); } else { dxf.addArc(layer, p.center, p.r, a2, a1); } } /** Winkel (Grad, 0..360) von `c` nach `p`, gegen den Uhrzeigersinn. */ function angleDeg(c: Vec2, p: Vec2): number { const d = (Math.atan2(p.y - c.y, p.x - c.x) * 180) / Math.PI; return d < 0 ? d + 360 : d; } // ── Schraffur als geklippte Vektorlinien (Meter) ──────────────────────────── // Wie planToPrintSvg, aber in WELT-Metern statt Papier-mm. Die Kachelgrösse der // Bildschirm-PlanView (8 bzw. 10 Einheiten × scale, 1 Einheit = 1/90 m) wird // direkt in einen Meter-Linienabstand übersetzt. interface Seg { a: Vec2; b: Vec2; } function hatchSegments( poly: Vec2[], hatch: Extract["hatch"], ): Seg[] { const unitM = 1 / 90; const tileM = (hatch.pattern === "insulation" ? 10 : 8) * hatch.scale * unitM; const spacing = Math.max(0.02, tileM); // Mindestabstand in Metern const angle = (hatch.angle * Math.PI) / 180; const segs: Seg[] = []; fillParallel(poly, angle, spacing, segs); if (hatch.pattern === "crosshatch") fillParallel(poly, angle + Math.PI / 2, spacing, segs); if (hatch.pattern === "insulation") fillParallel(poly, angle - Math.PI / 4, spacing, segs); return segs; } /** * Füllt ein Polygon mit parallelen Linien (Winkel/Abstand) und klippt jede Linie * per Scanline auf das Polygon. Robust für konvexe wie einfache konkave Polygone. */ function fillParallel(poly: Vec2[], angle: number, spacing: number, out: Seg[]): void { if (poly.length < 3 || spacing <= 0) return; const dir = { x: Math.cos(angle), y: Math.sin(angle) }; const nrm = { x: -dir.y, y: dir.x }; let tMin = Infinity; let tMax = -Infinity; for (const p of poly) { const t = p.x * nrm.x + p.y * nrm.y; if (t < tMin) tMin = t; if (t > tMax) tMax = t; } const start = Math.ceil(tMin / spacing) * spacing; for (let t = start; t <= tMax; t += spacing) { const xs: number[] = []; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[j]; const b = poly[i]; const ta = a.x * nrm.x + a.y * nrm.y; const tb = b.x * nrm.x + b.y * nrm.y; if (ta === tb) continue; const f = (t - ta) / (tb - ta); if (f < 0 || f > 1) continue; const px = a.x + (b.x - a.x) * f; const py = a.y + (b.y - a.y) * f; xs.push(px * dir.x + py * dir.y); } if (xs.length < 2) continue; xs.sort((u, v) => u - v); for (let k = 0; k + 1 < xs.length; k += 2) { const u0 = xs[k]; const u1 = xs[k + 1]; if (u1 - u0 < 1e-6) continue; out.push({ a: { x: nrm.x * t + dir.x * u0, y: nrm.y * t + dir.y * u0 }, b: { x: nrm.x * t + dir.x * u1, y: nrm.y * t + dir.y * u1 }, }); } } } // ── Farb-Umrechnung ───────────────────────────────────────────────────────── /** "#rrggbb" → 0xRRGGBB (24-Bit True-Color). Fallback dunkelgrau. */ export function rgbFromHex(hex: string): number { const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); if (!m) return 0x111111; return parseInt(m[1], 16); } /** * Mappt eine Hex-Farbe auf den nächsten AutoCAD-Color-Index (ACI 1..255). Nutzt * die feste ACI-Standardpalette (die ersten 9 Indizes sind die Grundfarben) und * wählt per euklidischem RGB-Abstand den nächstliegenden. Robust genug, damit * Viewer ohne True-Color-Support trotzdem sinnvolle Farben zeigen. */ export function aciFromHex(hex: string): number { const rgb = rgbFromHex(hex); const r = (rgb >> 16) & 0xff; const g = (rgb >> 8) & 0xff; const b = rgb & 0xff; let best = 7; let bestDist = Infinity; for (const [idx, cr, cg, cb] of ACI_PALETTE) { const d = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2; if (d < bestDist) { bestDist = d; best = idx; } } return best; } /** Millimeter → DXF-LWEIGHT (1/100 mm), gerundet auf gültige Stufen ≥ 0. */ export function mmToLwe(mm: number): number { return Math.max(0, Math.round(mm * 100)); } /** * Verkürzte ACI-Standardpalette [index, r, g, b]. Deckt die geläufigen Töne ab; * für die Layer-Farben (meist gedämpfte Grau-/Bautöne) genügt das Nächste-Farbe- * Matching. Index 7 (weiss/schwarz) und die Grautöne 250..255 sind enthalten. */ const ACI_PALETTE: ReadonlyArray = [ [1, 255, 0, 0], [2, 255, 255, 0], [3, 0, 255, 0], [4, 0, 255, 255], [5, 0, 0, 255], [6, 255, 0, 255], [7, 255, 255, 255], [8, 128, 128, 128], [9, 192, 192, 192], [30, 255, 128, 0], [40, 255, 191, 127], [250, 51, 51, 51], [251, 91, 91, 91], [252, 132, 132, 132], [253, 173, 173, 173], [254, 214, 214, 214], [255, 255, 255, 255], ]; /** Baut das DXF und löst den Download aus (Browser). */ export function savePlanDxf( plan: Plan, project: Project, opts: ExportDxfOptions = {}, ): void { const text = buildPlanDxf(plan, project, opts); const name = (opts.fileName ?? "grundriss").replace(/\.dxf$/i, ""); const blob = new Blob([text], { type: "application/dxf" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${name}.dxf`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }