/** * DXF Parser – converts DXF entities to CADElement[] * Uses dxf-parser library. * * Task F5 erweitert die Entity-Abdeckung: ELLIPSE (Polyline-Approx), SPLINE * (Grad<=3 → de-Boor-Approx), MTEXT (Formatcode-Cleanup), POINT, ATTDEF und * INSERT mit MINSERT-Arrays. HATCH wird defensiv mapping-fähig gehalten * (dxf-parser 1.1.x schickt echte HATCH-Entities noch nicht durch). * * Neben `warnings` liefert parseDXF einen strukturierten Fehlerreport * (`skippedEntities`: type + reason je übersprungener Entity). */ import DxfParser from 'dxf-parser'; import type { CADElement, CADLayer, CADProperties, ElementType } from '../types/cad.types'; export interface SkippedEntity { type: string; reason: string; } export interface DXFImportResult { elements: CADElement[]; layers: CADLayer[]; warnings: string[]; skippedEntities: SkippedEntity[]; } let idCounter = 0; const nextId = () => `dxf-${Date.now()}-${idCounter++}`; interface Pt { x: number; y: number } function bboxOf(pts: Pt[]): { x: number; y: number; width: number; height: number } { const minX = Math.min(...pts.map((p) => p.x)); const minY = Math.min(...pts.map((p) => p.y)); const maxX = Math.max(...pts.map((p) => p.x)); const maxY = Math.max(...pts.map((p) => p.y)); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } /** * Parse a DXF string into CAD elements and layers. */ export function parseDXF(dxfString: string): DXFImportResult { const parser = new DxfParser(); const dxf = parser.parseSync(dxfString) as any; const empty: DXFImportResult = { elements: [], layers: [], warnings: [], skippedEntities: [] }; if (!dxf) return { ...empty, warnings: ['DXF parse returned null'] }; const warnings: string[] = []; const skippedEntities: SkippedEntity[] = []; const layerMap = new Map(); const elements: CADElement[] = []; // Build layers from DXF tables if (dxf.tables?.layer?.layers) { let sortOrder = 0; for (const [layerName, layerData] of Object.entries(dxf.tables.layer.layers) as [string, any][]) { const layer: CADLayer = { id: `dxf-layer-${layerName}`, name: layerName, visible: true, locked: false, color: dxfColorToHex(layerData.color) ?? '#ffffff', lineType: mapLineType(layerData.lineType), transparency: 0, sortOrder: sortOrder++, parentId: null, }; layerMap.set(layerName, layer); } } // Ensure a default layer exists if (layerMap.size === 0) { layerMap.set('0', { id: 'dxf-layer-0', name: '0', visible: true, locked: false, color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null, }); } // Parse entities (INSERT expanded for MINSERT arrays) if (dxf.entities) { for (const entity of dxf.entities) { if (entity?.type === 'INSERT') { elements.push(...expandInsert(entity, layerMap)); continue; } const el = entityToCADElement(entity, layerMap); if (el) { elements.push(el); } else { const type = String(entity?.type ?? 'UNKNOWN'); const reason = skipReason(entity); skippedEntities.push({ type, reason }); warnings.push(`Unsupported entity type: ${type} — ${reason}`); } } } return { elements, layers: Array.from(layerMap.values()), warnings, skippedEntities }; } /** Grund, warum eine Entity übersprungen wurde (für den Fehlerreport). */ export function skipReason(entity: any): string { switch (entity?.type) { case 'ELLIPSE': return 'Ellipse ohne center/majorAxisEndPoint nicht darstellbar'; case 'SPLINE': if ((entity?.degreeOfSplineCurve ?? 0) > 3) return 'Spline-Grad > 3 (Approx limitiert auf Grad <= 3)'; return 'Spline ohne verwertbare Kontrollpunkte'; case 'LINE': return 'Line unvollständig (Start-/Endpunkt fehlt)'; case 'HATCH': return 'HATCH-Grenzen nicht lesbar'; case 'ATTDEF': return 'ATTDEF ohne tag'; default: return 'Nicht unterstützter Entity-Typ'; } } /** * INSERT → block_instance(s). MINSERT-Arrays (columnCount/rowCount > 1) * erzeugen n-fache Instanzen mit Spacing-Offset. */ export function expandInsert(entity: any, layerMap?: Map): CADElement[] { const layerName = entity.layer || '0'; const layer = layerMap?.get(layerName) ?? layerMap?.get('0'); const layerId = layer?.id ?? `dxf-layer-${layerName}`; const pos = entity.position; if (!pos) return []; const baseProps: CADProperties = { stroke: entity.color ? (dxfColorToHex(entity.color) ?? layer?.color) : (layer?.color ?? '#ffffff'), strokeWidth: 1, blockId: entity.name, scale: entity.scale || entity.xScale || 1, scaleX: entity.xScale ?? 1, scaleY: entity.yScale ?? 1, rotation: entity.rotation || 0, }; const cols = Math.max(1, Math.floor(entity.columnCount || 1)); const rows = Math.max(1, Math.floor(entity.rowCount || 1)); const colSpacing = entity.columnSpacing || 0; const rowSpacing = entity.rowSpacing || 0; const out: CADElement[] = []; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { out.push({ id: nextId(), type: 'block_instance', layerId, x: pos.x + c * colSpacing, y: pos.y + r * rowSpacing, width: 0, height: 0, properties: { ...baseProps }, }); } } return out; } /** ELLIPSE → Polyline-Approximation (64 Segmente bei Voll-Ellipse). */ function ellipseToPoints(entity: any): Pt[] | null { const c = entity.center; const major = entity.majorAxisEndPoint; if (!c || !major) return null; const a = Math.hypot(major.x ?? 0, major.y ?? 0); if (!(a > 0)) return null; const ratio = typeof entity.axisRatio === 'number' && entity.axisRatio > 0 ? entity.axisRatio : 1; const b = a * ratio; const phi = Math.atan2(major.y ?? 0, major.x ?? 0); const cosP = Math.cos(phi), sinP = Math.sin(phi); const FULL = Math.PI * 2; let start = typeof entity.startAngle === 'number' ? entity.startAngle : 0; let end = typeof entity.endAngle === 'number' ? entity.endAngle : start + FULL; let span = end - start; const closed = !(span > 0.000001) || Math.abs(span - FULL) < 1e-6; if (span <= 0) { start = 0; span = FULL; } const n = Math.max(2, Math.round(64 * (span / FULL))); const pts: Pt[] = []; for (let i = 0; i < n; i++) { const t = closed ? start + (i * span) / n : start + (i * span) / (n - 1); const ct = Math.cos(t), st = Math.sin(t); pts.push({ x: c.x + a * ct * cosP - b * st * sinP, y: c.y + a * ct * sinP + b * st * cosP, }); } return pts; } /** Cox-de-Boor: Punkt auf B-Spline (Grad <= 3). */ function bsplinePoint(degree: number, knots: number[], ctrl: Pt[], t: number): Pt { const n = ctrl.length - 1; let k = degree; for (let i = degree; i <= n; i++) { if (t >= knots[i] && t <= knots[i + 1]) { k = i; break; } } const d: Pt[] = []; for (let j = 0; j <= degree; j++) d.push({ ...ctrl[k - degree + j] }); for (let r = 1; r <= degree; r++) { for (let j = degree; j >= r; j--) { const i = k - degree + j; const denom = knots[i + degree - r + 1] - knots[i]; const alpha = denom === 0 ? 0 : (t - knots[i]) / denom; d[j] = { x: (1 - alpha) * d[j - 1].x + alpha * d[j].x, y: (1 - alpha) * d[j - 1].y + alpha * d[j].y, }; } } return d[degree]; } /** SPLINE → Polyline-Approximation via de Boor (Grad <= 3). */ function splineToPoints(entity: any): Pt[] | null { const degree = entity.degreeOfSplineCurve ?? 3; const ctrl = (entity.controlPoints || []) as Pt[]; if (!Array.isArray(ctrl) || ctrl.length < degree + 1) return null; const m = ctrl.length; const total = m + degree + 1; const knots: number[] = entity.knotValues && entity.knotValues.length === total ? entity.knotValues.slice() : (() => { const ks: number[] = []; for (let j = 0; j < total; j++) { if (j <= degree) ks.push(0); else if (j >= total - 1 - degree) ks.push(1); else ks.push((j - degree) / (total - 1 - 2 * degree)); } return ks; })(); const N = 40; const t0 = knots[degree]; const t1 = knots[m]; const span = t1 - t0; const pts: Pt[] = []; for (let i = 0; i < N; i++) { const t = t0 + (i * span) / (N - 1); pts.push(bsplinePoint(degree, knots, ctrl, t)); } return pts; } /** MTEXT-Formatcodes zerlegen: \P → Umbruch, Inline-Codes entfernen. */ function cleanMText(raw: string): string { return raw .replace(/\\P/gi, '\n') .replace(/\\~/g, ' ') .replace(/\\[A-Za-z][^;{}\\]{0,12};/g, '') .replace(/[{}]/g, ''); } /** * Convert a single DXF entity into a CADElement (exported for unit tests). */ export function entityToCADElement( entity: any, layerMap: Map, ): CADElement | null { const layerName = entity.layer || '0'; const layer = layerMap.get(layerName) ?? layerMap.get('0')!; const color = entity.color ? (dxfColorToHex(entity.color) ?? layer.color) : layer.color; const baseProps: CADProperties = { stroke: color, strokeWidth: 1, }; switch (entity.type) { case 'LINE': { const s = entity.vertices?.[0]; const e = entity.vertices?.[1]; if (!s || !e) return null; return { id: nextId(), type: 'line', layerId: layer.id, x: Math.min(s.x, e.x), y: Math.min(s.y, e.y), width: Math.abs(e.x - s.x), height: Math.abs(e.y - s.y), properties: { ...baseProps, x1: s.x, y1: s.y, x2: e.x, y2: e.y }, }; } case 'CIRCLE': { const c = entity.center; const r = entity.radius; if (!c || r == null) return null; return { id: nextId(), type: 'circle', layerId: layer.id, x: c.x - r, y: c.y - r, width: r * 2, height: r * 2, properties: { ...baseProps, radius: r }, }; } case 'ARC': { const c = entity.center; const r = entity.radius; if (!c || r == null) return null; return { id: nextId(), type: 'arc', layerId: layer.id, x: c.x - r, y: c.y - r, width: r * 2, height: r * 2, properties: { ...baseProps, radius: r, startAngle: entity.startAngle, endAngle: entity.endAngle }, }; } case 'ELLIPSE': { const pts = ellipseToPoints(entity); if (!pts || pts.length < 2) return null; const bbox = bboxOf(pts); return { id: nextId(), type: 'polyline', layerId: layer.id, ...bbox, properties: { ...baseProps, points: pts }, }; } case 'SPLINE': { const degree = entity.degreeOfSplineCurve ?? 3; const ctrl = (entity.controlPoints || []) as Pt[]; if (degree < 1 || degree > 3) return null; if (!Array.isArray(ctrl) || ctrl.length < degree + 1) return null; const pts = splineToPoints(entity); if (!pts || pts.length < 2) return null; const bbox = bboxOf(pts); return { id: nextId(), type: 'polyline', layerId: layer.id, ...bbox, properties: { ...baseProps, points: pts }, }; } case 'LWPOLYLINE': case 'POLYLINE': { const pts = (entity.vertices || []).map((v: any) => ({ x: v.x, y: v.y })); if (pts.length < 2) return null; const bbox = bboxOf(pts); const isClosed = entity.shape === true; const type: ElementType = isClosed ? 'polygon' : 'polyline'; return { id: nextId(), type, layerId: layer.id, ...bbox, properties: { ...baseProps, points: pts }, }; } case 'TEXT': { const s = entity.startPoint; if (!s) return null; return { id: nextId(), type: 'text', layerId: layer.id, x: s.x, y: s.y, width: 0, height: 0, properties: { ...baseProps, text: entity.text || '', fontSize: entity.height || 12 }, }; } case 'MTEXT': { const pos = entity.position; if (!pos) return null; const text = cleanMText(String(entity.text ?? '')); if (!text) return null; return { id: nextId(), type: 'text', layerId: layer.id, x: pos.x, y: pos.y, width: entity.width || 0, height: entity.height || 0, properties: { ...baseProps, text, fontSize: entity.height || 12, rotation: entity.rotation || 0 }, }; } case 'POINT': { const pos = entity.position; if (!pos) return null; const r = 1.5; return { id: nextId(), type: 'circle', layerId: layer.id, x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2, properties: { ...baseProps, radius: r, fill: color, point: true }, }; } case 'ATTDEF': { const s = entity.startPoint; if (!entity.tag || !s) return null; return { id: nextId(), type: 'text', layerId: layer.id, x: s.x, y: s.y, width: 0, height: 0, properties: { ...baseProps, text: `${entity.tag}=${entity.text ?? ''}`, fontSize: entity.textHeight || 12, tag: entity.tag, prompt: entity.prompt ?? '', attdef: true, }, }; } case 'HATCH': { const pts = (entity.boundaryPoints || []) as Pt[]; if (pts.length < 3) return null; const bbox = bboxOf(pts); return { id: nextId(), type: 'polygon', layerId: layer.id, ...bbox, properties: { ...baseProps, points: pts, hatchPattern: entity.patternName || 'SOLID', fill: entity.isSolid ? color : undefined, }, }; } case 'INSERT': { const [inst] = expandInsert(entity, layerMap); return inst ?? null; } case 'DIMENSION': { // Basic dimension support const pts = entity.definitionPoint || entity.points; if (!pts) return null; return { id: nextId(), type: 'dimension', layerId: layer.id, x: 0, y: 0, width: 0, height: 0, properties: { ...baseProps, points: Array.isArray(pts) ? pts.map((p: any) => ({ x: p.x, y: p.y })) : [] }, }; } default: return null; } } /** * DXF ACI color to hex string */ function dxfColorToHex(aci: number | undefined): string | null { if (aci == null) return null; const colors: Record = { 0: '#ffffff', 1: '#ff0000', 2: '#ffff00', 3: '#00ff00', 4: '#00ffff', 5: '#0000ff', 6: '#ff00ff', 7: '#ffffff', 8: '#808080', 9: '#c0c0c0', }; return colors[aci] ?? null; } function mapLineType(lt: string | undefined): 'solid' | 'dashed' | 'dotted' { if (!lt) return 'solid'; const u = lt.toUpperCase(); if (u.includes('DASH')) return 'dashed'; if (u.includes('DOT')) return 'dotted'; return 'solid'; }