From 75de6dd1f7152e7ce0b45e97c505d464f66f1add Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 29 Aug 2026 01:32:36 +0200 Subject: [PATCH] task(F6): DXF writer - BLOCKS+INSERTS with name mapping, ATTDEF/ATTRIB/SEQEND, AC1009/AC1015 versions, R12 POLYLINE, center/angle convention fixes --- frontend/src/services/dxfParser.ts | 10 +- frontend/src/services/dxfWriter.ts | 221 ++++++++++++++++++++--------- frontend/tests/dxfWriterF6.test.ts | 187 ++++++++++++++++++++++++ 3 files changed, 351 insertions(+), 67 deletions(-) create mode 100644 frontend/tests/dxfWriterF6.test.ts diff --git a/frontend/src/services/dxfParser.ts b/frontend/src/services/dxfParser.ts index 2640a9c..67703d9 100644 --- a/frontend/src/services/dxfParser.ts +++ b/frontend/src/services/dxfParser.ts @@ -279,9 +279,10 @@ export function entityToCADElement( const c = entity.center; const r = entity.radius; if (!c || r == null) return null; + // Center-Konvention wie beide Renderer: el.x/el.y IST das Zentrum return { id: nextId(), type: 'circle', layerId: layer.id, - x: c.x - r, y: c.y - r, width: r * 2, height: r * 2, + x: c.x, y: c.y, width: r * 2, height: r * 2, properties: { ...baseProps, radius: r }, }; } @@ -289,10 +290,13 @@ export function entityToCADElement( const c = entity.center; const r = entity.radius; if (!c || r == null) return null; + // dxf-parser konvertiert ARC-Winkel intern nach Radiant (getestet); + // App-Konvention ist Grad (elementDrawers, Arc-Tool, angleBetween). + const toDeg = (rad: number | undefined): number => ((rad ?? 0) * 180) / Math.PI; 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 }, + x: c.x, y: c.y, width: r * 2, height: r * 2, + properties: { ...baseProps, radius: r, startAngle: toDeg(entity.startAngle), endAngle: toDeg(entity.endAngle) }, }; } case 'ELLIPSE': { diff --git a/frontend/src/services/dxfWriter.ts b/frontend/src/services/dxfWriter.ts index 139ca6f..71dea77 100644 --- a/frontend/src/services/dxfWriter.ts +++ b/frontend/src/services/dxfWriter.ts @@ -1,23 +1,44 @@ /** * DXF Writer – converts CADElement[] to DXF string - * Minimal DXF R12 format for compatibility + * 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 R12 string from project data. + * Generate a DXF string from project data (R12-kompatibel). */ -export function writeDXF(data: ProjectData): string { +export function writeDXF(data: ProjectData, options: DXFWriterOptions = {}): string { + const version = options.version ?? 'AC1009'; const lines: string[] = []; - const w = (code: number, value: string | number) => { + 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, 'AC1009'); // R12 + 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); @@ -26,8 +47,6 @@ export function writeDXF(data: ProjectData): string { // Tables section w(0, 'SECTION'); w(2, 'TABLES'); - - // Layer table w(0, 'TABLE'); w(2, 'LAYER'); w(70, data.layers.length); @@ -41,15 +60,31 @@ export function writeDXF(data: ProjectData): string { 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); + writeEntity(w, el, layerName, nameMap, false); } - w(0, 'ENDSEC'); // EOF @@ -58,10 +93,58 @@ export function writeDXF(data: ProjectData): string { 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: (code: number, value: string | number) => void, + w: Write, el: CADElement, layerName: string, + nameMap: Map, + inBlockDefinition: boolean, ): void { const p = el.properties; const stroke = (p.stroke as string) || '#ffffff'; @@ -77,68 +160,69 @@ function writeEntity( break; } case 'circle': { - const cx = el.x + el.width / 2; - const cy = el.y + el.height / 2; - const r = p.radius ?? el.width / 2; + // 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, cx); w(20, cy); w(30, 0); - w(40, r); + w(10, el.x); w(20, el.y); w(30, 0); + w(40, p.radius ?? el.width / 2); break; } case 'arc': { - const cx = el.x + el.width / 2; - const cy = el.y + el.height / 2; - const r = p.radius ?? el.width / 2; w(0, 'ARC'); w(8, layerName); w(62, aci); - w(10, cx); w(20, cy); w(30, 0); - w(40, r); - w(50, radToDeg(p.startAngle ?? 0)); - w(51, radToDeg(p.endAngle ?? 360)); + 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': { - // Rectangle as LWPOLYLINE - w(0, 'LWPOLYLINE'); - w(8, layerName); - w(62, aci); - w(90, 4); - w(70, 1); // closed - w(10, el.x); w(20, el.y); - w(10, el.x + el.width); w(20, el.y); - w(10, el.x + el.width); w(20, el.y + el.height); - w(10, el.x); w(20, el.y + el.height); + // 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 ?? []; - w(0, 'LWPOLYLINE'); - w(8, layerName); - w(62, aci); - w(90, pts.length); - w(70, el.type === 'polygon' ? 1 : 0); - for (const pt of pts) { - w(10, pt.x); w(20, pt.y); + 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 ?? ''); + w(1, (p.text as string) ?? ''); break; } case 'dimension': { - const pts = p.points ?? []; + const pts = (p.points as Pt[] | undefined) ?? []; if (pts.length >= 2) { - // Draw dimension as LINE + TEXT w(0, 'LINE'); w(8, layerName); w(62, aci); @@ -157,27 +241,41 @@ function writeEntity( 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, p.blockId ?? 'BLOCK'); + w(2, mapped); w(10, el.x); w(20, el.y); w(30, 0); - w(41, p.scale ?? 1); - w(42, p.scale ?? 1); - w(50, radToDeg(p.rotation ?? 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: - // For chair, seating-row, etc. — export as LWPOLYLINE bounding box - w(0, 'LWPOLYLINE'); - w(8, layerName); - w(62, aci); - w(90, 4); - w(70, 1); - w(10, el.x); w(20, el.y); - w(10, el.x + el.width); w(20, el.y); - w(10, el.x + el.width); w(20, el.y + el.height); - w(10, el.x); w(20, el.y + el.height); + // chair, seating-row, table, stage, revcloud, leader, image, … → BBox + writeBBoxPolyline(w, el, layerName, aci); break; } } @@ -191,7 +289,6 @@ function hexToACI(hex: string): number { const r = parseInt(h.substring(0, 2), 16); const g = parseInt(h.substring(2, 4), 16); const b = parseInt(h.substring(4, 6), 16); - // Map common colors to ACI 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 @@ -200,7 +297,7 @@ function hexToACI(hex: string): number { 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; // default white + return 7; } function lineTypeToDXF(lt: string): string { @@ -208,7 +305,3 @@ function lineTypeToDXF(lt: string): string { if (lt === 'dotted') return 'DOT'; return 'CONTINUOUS'; } - -function radToDeg(rad: number): number { - return (rad * 180) / Math.PI; -} diff --git a/frontend/tests/dxfWriterF6.test.ts b/frontend/tests/dxfWriterF6.test.ts new file mode 100644 index 0000000..3fc42f4 --- /dev/null +++ b/frontend/tests/dxfWriterF6.test.ts @@ -0,0 +1,187 @@ +/** + * Task F6 – DXF-Writer-Erweiterungstests. + * + * Deckt ab: BLOCKS+INSERTS mit Name-Mapping/Sanitizing, ATTRIB/SEQEND, + * Versionsoption AC1009/AC1015, R12-korrektes POLYLINE statt LWPOLYLINE, + * Center-Konvention (circle/arc el.x/el.y = Zentrum) und Grad-Winkel ohne + * Doppelkonvertierung — abgesichert über echte write→parse-Roundtrips. + */ +import { describe, it, expect } from 'vitest'; +import { writeDXF } from '../src/services/dxfWriter'; +import { parseDXF } from '../src/services/dxfParser'; +import type { CADElement, CADLayer, ProjectData, BlockDefinition } from '../src/types/cad.types'; + +const layer: CADLayer = { + id: 'layer-1', name: 'L1', visible: true, locked: false, + color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null, +}; + +function mkEl(over: Partial = {}): CADElement { + return { + id: 'e1', type: 'line', layerId: 'layer-1', + x: 0, y: 0, width: 0, height: 0, properties: {}, + ...over, + } as CADElement; +} + +function mkBlock(over: Partial = {}): BlockDefinition { + return { + id: 'blk-1', name: 'Stuhl Reihe', description: '', category: 'event', + elements: [mkEl({ type: 'line', properties: { x1: 0, y1: 0, x2: 100, y2: 0 } })], + ...over, + }; +} + +function mkProj(elements: CADElement[], blocks: BlockDefinition[] = []): ProjectData { + return { version: '1', name: 'T', layers: [layer], elements, blocks } as ProjectData; +} + +describe('F6: Version', () => { + it('Default ist AC1009 (R12)', () => { + expect(writeDXF(mkProj([]))).toContain('AC1009'); + }); + it('Option AC1015 setzt Header-Version', () => { + expect(writeDXF(mkProj([]), { version: 'AC1015' })).toContain('AC1015'); + }); +}); + +describe('F6: BLOCKS + INSERTS', () => { + it('BLOCKS-Sektion emittiert sanitisierten Blocknamen, INSERT referenziert gemappten Namen', () => { + const out = writeDXF(mkProj([ + mkEl({ type: 'block_instance', properties: { blockId: 'blk-1' } }), + ], [mkBlock()])); + expect(out).toContain('BLOCKS'); + expect(out).toContain('BLOCK'); + expect(out).toContain('ENDBLK'); + // Name 'Stuhl Reihe' → 'Stuhl_Reihe' (Leerzeichen sanitizing) + expect(out.split('Stuhl_Reihe').length).toBeGreaterThanOrEqual(3); // BLOCK 2/3 + INSERT 2 + expect(out).toContain('INSERT'); + }); + + it('block_instance ohne Definition bleibt BBox-Fallback (kein INSERT)', () => { + const out = writeDXF(mkProj([ + mkEl({ type: 'block_instance', x: 0, y: 0, width: 100, height: 50, properties: { blockId: 'unknown-block' } }), + ])); + expect(out).not.toContain('INSERT'); + expect(out).toContain('POLYLINE'); + }); + + it('Roundtrip: INSERT landet als block_instance mit gemapptem Namen', () => { + const out = writeDXF(mkProj([ + mkEl({ type: 'block_instance', x: 10, y: 20, properties: { blockId: 'blk-1' } }), + ], [mkBlock()])); + const res = parseDXF(out); + const inst = res.elements.find((e) => e.type === 'block_instance'); + expect(inst).toBeDefined(); + expect(inst!.properties.blockId).toBe('Stuhl_Reihe'); + expect(inst!.x).toBe(10); + expect(inst!.y).toBe(20); + }); + + it('ATTDEF aus Blockelement (attdef:true + tag) wird in der BLOCK-Definition emittiert', () => { + const blk = mkBlock({ + elements: [ + mkEl({ type: 'line', properties: { x1: 0, y1: 0, x2: 50, y2: 0 } }), + mkEl({ type: 'text', x: 5, y: 5, properties: { text: 'Buehne', tag: 'NAME', attdef: true, fontSize: 12 } }), + ], + }); + const out = writeDXF(mkProj([], [blk])); + expect(out).toContain('ATTDEF'); + expect(out).toContain('NAME'); + }); + + it('Instanz mit attrValues emittiert 66/ATTRIB/SEQEND', () => { + const out = writeDXF(mkProj([ + mkEl({ + type: 'block_instance', x: 0, y: 0, + properties: { blockId: 'blk-1', attrValues: { NAME: 'Halle A' } }, + }), + ], [mkBlock()])); + expect(out).toContain('ATTRIB'); + expect(out).toContain('SEQEND'); + expect(out).toContain('Halle A'); + expect(out).toContain('66'); + }); +}); + +describe('F6: R12-korrektes POLYLINE', () => { + it('polygon → POLYLINE+VERTEX+SEQEND, kein LWPOLYLINE', () => { + const out = writeDXF(mkProj([ + mkEl({ + type: 'polygon', + properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }] }, + }), + ])); + expect(out).toContain('POLYLINE'); + expect(out).toContain('VERTEX'); + expect(out).toContain('SEQEND'); + expect(out).not.toContain('LWPOLYLINE'); + }); + + it('Roundtrip: polygon bleibt geschlossen (shape-Flag 70=1)', () => { + const out = writeDXF(mkProj([ + mkEl({ + type: 'polygon', + properties: { points: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }] }, + }), + ])); + const res = parseDXF(out); + const poly = res.elements.find((e) => e.type === 'polygon'); + expect(poly).toBeDefined(); + expect(res.elements.find((e) => e.type === 'polyline')).toBeUndefined(); + }); +}); + +describe('F6: Center- & Winkel-Konvention', () => { + it('Writer: circle Zentrum el.x/el.y direkt (kein +width/2)', () => { + const out = writeDXF(mkProj([ + mkEl({ type: 'circle', x: 100, y: 50, width: 60, height: 60, properties: { radius: 30 } }), + ])); + const res = parseDXF(out); + const circ = res.elements.find((e) => e.type === 'circle'); + expect(circ).toBeDefined(); + expect(circ!.x).toBeCloseTo(100, 5); + expect(circ!.y).toBeCloseTo(50, 5); + expect(circ!.properties.radius).toBeCloseTo(30, 5); + }); + + it('Parser: CIRCLE center landet direkt auf el.x/el.y (Renderer-Konvention)', () => { + const dxf = [ + '0', 'SECTION', '2', 'ENTITIES', + '0', 'CIRCLE', '8', '0', '10', '10', '20', '20', '30', '0', '40', '5', + '0', 'ENDSEC', '0', 'EOF', + ].join(String.fromCharCode(10)); + const res = parseDXF(dxf); + const circ = res.elements.find((e) => e.type === 'circle'); + expect(circ).toBeDefined(); + expect(circ!.x).toBe(10); + expect(circ!.y).toBe(20); + expect(circ!.properties.radius).toBe(5); + expect(circ!.width).toBe(10); + }); + + it('arc/rotation Winkel in Grad ohne Doppelkonvertierung', () => { + const out = writeDXF(mkProj([ + mkEl({ type: 'arc', x: 0, y: 0, width: 20, height: 20, properties: { radius: 10, startAngle: 30, endAngle: 120 } }), + mkEl({ type: 'block_instance', x: 0, y: 0, properties: { blockId: 'blk-1', rotation: 45 } }), + ], [mkBlock()])); + expect(out).toContain('50' + String.fromCharCode(10) + '30'); + expect(out).toContain('51' + String.fromCharCode(10) + '120'); + expect(out).toContain('50' + String.fromCharCode(10) + '45'); + const res = parseDXF(out); + const arc = res.elements.find((e) => e.type === 'arc'); + expect(arc!.properties.startAngle).toBeCloseTo(30, 5); + }); + + it('rect als geschlossene POLYLINE mit Ecken aus Zentrumskonvention', () => { + const out = writeDXF(mkProj([ + mkEl({ type: 'rect', x: 0, y: 0, width: 100, height: 50, properties: {} }), + ])); + const res = parseDXF(out); + const poly = res.elements[0]; + const pts = (poly!.properties.points ?? []) as Array<{ x: number; y: number }>; + expect(pts.length).toBe(4); + expect(pts[0].x).toBeCloseTo(-50, 5); + expect(pts[0].y).toBeCloseTo(-25, 5); + }); +});