task(F6): DXF writer - BLOCKS+INSERTS with name mapping, ATTDEF/ATTRIB/SEQEND, AC1009/AC1015 versions, R12 POLYLINE, center/angle convention fixes

This commit is contained in:
Agent Zero
2026-08-29 01:32:36 +02:00
parent 3c2f015acf
commit 75de6dd1f7
3 changed files with 351 additions and 67 deletions
+7 -3
View File
@@ -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': {
+157 -64
View File
@@ -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<string>(['0']); // '0' ist in DXF reserviert
const nameMap = new Map<string, string>();
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>): 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<string, string>,
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<string, string> | 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;
}