/** * DXF Writer – converts CADElement[] to DXF string * Zielversionen: AC1009 (R12, Default) und AC1015 (AutoCAD 2000). * * Task F6: BLOCKS+INSERTS mit Name-Mapping/Sanitizing, ATTDEF in * Blockdefinitionen, ATTRIB/SEQEND an Instanzen mit attrValues, * R12-korrektes POLYLINE+VERTEX+SEQEND statt LWPOLYLINE sowie die * Center-/Winkel-Konventionen der App (el.x/el.y = Zentrum, Grad-Winkel). */ import type { CADElement, CADLayer, ProjectData } from '../types/cad.types'; export interface DXFWriterOptions { /** Zielversion: AC1009 (R12, Default) oder AC1015. */ version?: 'AC1009' | 'AC1015'; } interface Pt { x: number; y: number } type Write = (code: number, value: string | number) => void; /** * Generate a DXF string from project data (R12-kompatibel). */ export function writeDXF(data: ProjectData, options: DXFWriterOptions = {}): string { const version = options.version ?? 'AC1009'; const lines: string[] = []; const w: Write = (code, value) => { lines.push(String(code)); lines.push(String(value)); }; // ─── Name-Mapping: Block-ID → sanitierter DXF-Blockname ─── const usedNames = new Set(['0']); // '0' ist in DXF reserviert const nameMap = new Map(); for (const block of data.blocks) { nameMap.set(block.id, sanitizeBlockName(block.name || block.id, usedNames)); } // Header w(0, 'SECTION'); w(2, 'HEADER'); w(9, '$ACADVER'); w(1, version); w(9, '$INSBASE'); w(10, 0); w(20, 0); w(30, 0); w(9, '$EXTMIN'); w(10, 0); w(20, 0); w(9, '$EXTMAX'); w(10, 1000); w(20, 1000); w(0, 'ENDSEC'); // Tables section w(0, 'SECTION'); w(2, 'TABLES'); w(0, 'TABLE'); w(2, 'LAYER'); w(70, data.layers.length); for (const layer of data.layers) { w(0, 'LAYER'); w(2, layer.name); w(70, 0); w(62, hexToACI(layer.color)); w(6, lineTypeToDXF(layer.lineType)); } w(0, 'ENDTAB'); w(0, 'ENDSEC'); // ─── BLOCKS section (Task F6) ─── w(0, 'SECTION'); w(2, 'BLOCKS'); for (const block of data.blocks) { const dxfName = nameMap.get(block.id)!; w(0, 'BLOCK'); w(2, dxfName); w(70, 0); w(10, 0); w(20, 0); w(30, 0); w(3, dxfName); w(1, block.description || ''); for (const el of block.elements) { writeEntity(w, el, getLayerName(data.layers, el.layerId), nameMap, true); } w(0, 'ENDBLK'); } w(0, 'ENDSEC'); // Entities section w(0, 'SECTION'); w(2, 'ENTITIES'); for (const el of data.elements) { const layerName = getLayerName(data.layers, el.layerId); writeEntity(w, el, layerName, nameMap, false); } w(0, 'ENDSEC'); // EOF w(0, 'EOF'); return lines.join('\n'); } /** Blocknamen für DXF sanitizen: nur [A-Za-z0-9_$-], Kollisionen deduplizieren. */ function sanitizeBlockName(name: string, used: Set): string { const base = (name.replace(/[^\w$-]/g, '_') || 'BLOCK'); let candidate = base; let i = 2; while (used.has(candidate.toUpperCase())) { candidate = `${base}_${i++}`; } used.add(candidate.toUpperCase()); return candidate; } /** R12-korrektes POLYLINE+VERTEX+SEQEND (LWPOLYLINE erst ab R13). */ function writePolylineR12( w: Write, layerName: string, aci: number, pts: Pt[], closed: boolean, ): void { w(0, 'POLYLINE'); w(8, layerName); w(62, aci); w(66, 1); // Vertices folgen w(70, closed ? 1 : 0); w(10, 0); w(20, 0); w(30, 0); for (const pt of pts) { w(0, 'VERTEX'); w(8, layerName); w(10, pt.x); w(20, pt.y); w(30, 0); } w(0, 'SEQEND'); } /** BBox-Fallback für Elemente ohne native DXF-Repräsentation. */ function writeBBoxPolyline(w: Write, el: CADElement, layerName: string, aci: number): void { const x0 = el.x; const y0 = el.y; writePolylineR12(w, layerName, aci, [ { x: x0, y: y0 }, { x: x0 + el.width, y: y0 }, { x: x0 + el.width, y: y0 + el.height }, { x: x0, y: y0 + el.height }, ], true); } function writeEntity( w: Write, el: CADElement, layerName: string, nameMap: Map, inBlockDefinition: boolean, ): void { const p = el.properties; const stroke = (p.stroke as string) || '#ffffff'; const aci = hexToACI(stroke); switch (el.type) { case 'line': { w(0, 'LINE'); w(8, layerName); w(62, aci); w(10, p.x1 ?? el.x); w(20, p.y1 ?? el.y); w(30, 0); w(11, p.x2 ?? el.x + el.width); w(21, p.y2 ?? el.y + el.height); w(31, 0); break; } case 'circle': { // App-Konvention: el.x/el.y IST das Zentrum (beide Renderer lesen es direkt) w(0, 'CIRCLE'); w(8, layerName); w(62, aci); w(10, el.x); w(20, el.y); w(30, 0); w(40, p.radius ?? el.width / 2); break; } case 'arc': { w(0, 'ARC'); w(8, layerName); w(62, aci); w(10, el.x); w(20, el.y); w(30, 0); w(40, p.radius ?? el.width / 2); // App-Konvention: Winkel in Grad (0° = 3 Uhr, CCW) — direkt schreiben w(50, p.startAngle ?? 0); w(51, p.endAngle ?? 360); break; } case 'rect': { // App-Konvention: rect x/y = Zentrum der BBox const x0 = el.x - el.width / 2; const y0 = el.y - el.height / 2; writePolylineR12(w, layerName, aci, [ { x: x0, y: y0 }, { x: x0 + el.width, y: y0 }, { x: x0 + el.width, y: y0 + el.height }, { x: x0, y: y0 + el.height }, ], true); break; } case 'polygon': case 'polyline': { const pts = (p.points as Pt[] | undefined) ?? []; if (pts.length >= 2) { writePolylineR12(w, layerName, aci, pts.map((pt) => ({ x: pt.x, y: pt.y })), el.type === 'polygon'); } break; } case 'text': { // ATTDEF: Text-Element als Attribut-Definition in Blockdefinitionen (Task F6) if (inBlockDefinition && p.attdef === true && typeof p.tag === 'string' && p.tag) { w(0, 'ATTDEF'); w(8, layerName); w(10, el.x); w(20, el.y); w(30, 0); w(40, p.fontSize ?? 12); w(1, (p.text as string) ?? ''); w(2, p.tag); w(3, (p.prompt as string) ?? ''); w(70, 0); break; } w(0, 'TEXT'); w(8, layerName); w(62, aci); w(10, el.x); w(20, el.y); w(30, 0); w(40, p.fontSize ?? 12); w(1, (p.text as string) ?? ''); break; } case 'dimension': { const pts = (p.points as Pt[] | undefined) ?? []; if (pts.length >= 2) { w(0, 'LINE'); w(8, layerName); w(62, aci); w(10, pts[0].x); w(20, pts[0].y); w(30, 0); w(11, pts[1].x); w(21, pts[1].y); w(31, 0); const midX = (pts[0].x + pts[1].x) / 2; const midY = (pts[0].y + pts[1].y) / 2; const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); w(0, 'TEXT'); w(8, layerName); w(62, aci); w(10, midX); w(20, midY); w(30, 0); w(40, 12); w(1, dist.toFixed(2)); } break; } case 'block_instance': { const mapped = nameMap.get(String(p.blockId ?? '')); if (!mapped) { writeBBoxPolyline(w, el, layerName, aci); break; } w(0, 'INSERT'); w(8, layerName); w(62, aci); w(2, mapped); w(10, el.x); w(20, el.y); w(30, 0); const sx = typeof p.scaleX === 'number' ? p.scaleX : (typeof p.scale === 'number' ? p.scale : 1); const sy = typeof p.scaleY === 'number' ? p.scaleY : (typeof p.scale === 'number' ? p.scale : 1); w(41, sx); w(42, sy); w(50, p.rotation ?? 0); // Grad, direkt (keine radToDeg-Doppelkonvertierung) // Attributwerte der Instanz → ATTRIB/SEQEND (Task F6) const attrValues = p.attrValues as Record | undefined; if (attrValues && Object.keys(attrValues).length > 0) { w(66, 1); // Attribute folgen for (const [tag, value] of Object.entries(attrValues)) { w(0, 'ATTRIB'); w(8, layerName); w(10, el.x); w(20, el.y); w(30, 0); w(40, 12); w(1, value); w(2, tag); w(70, 0); } w(0, 'SEQEND'); } break; } default: // chair, seating-row, table, stage, revcloud, leader, image, … → BBox writeBBoxPolyline(w, el, layerName, aci); break; } } function getLayerName(layers: CADLayer[], layerId: string): string { return layers.find(l => l.id === layerId)?.name ?? '0'; } function hexToACI(hex: string): number { const h = hex.replace('#', ''); const r = parseInt(h.substring(0, 2), 16); const g = parseInt(h.substring(2, 4), 16); const b = parseInt(h.substring(4, 6), 16); if (r > 200 && g < 100 && b < 100) return 1; // red if (r > 200 && g > 200 && b < 100) return 2; // yellow if (r < 100 && g > 200 && b < 100) return 3; // green if (r < 100 && g > 200 && b > 200) return 4; // cyan if (r < 100 && g < 100 && b > 200) return 5; // blue if (r > 200 && g < 100 && b > 200) return 6; // magenta if (r > 200 && g > 200 && b > 200) return 7; // white if (r < 100 && g < 100 && b < 100) return 8; // gray return 7; } function lineTypeToDXF(lt: string): string { if (lt === 'dashed') return 'DASHED'; if (lt === 'dotted') return 'DOT'; return 'CONTINUOUS'; }