Files
web-cad/frontend/src/services/dxfParser.ts
T

435 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, CADLayer>();
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<string, CADLayer>): 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<string, CADLayer>,
): 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<number, string> = {
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';
}