T24: Add try/catch to export functions, use shared types, return ExportResult

This commit is contained in:
2026-06-22 23:49:18 +00:00
parent 4f0d750741
commit 056e552ef4
+409 -416
View File
@@ -1,64 +1,14 @@
import type { YjsDocument } from '../crdt/YjsDocument'; import type { YjsDocument } from '../crdt/YjsDocument';
import type {
CADLayer,
CADProperties,
CADElement,
ExportData,
ExportOptions,
ExportResult,
} from '../types/cad.types';
// ── Types ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
interface CADLayer {
id: string;
name: string;
visible: boolean;
locked: boolean;
color?: string;
lineType?: string;
transparency?: number;
sortOrder?: number;
}
interface CADProperties {
fill?: string;
stroke?: string;
strokeWidth?: number;
rotation?: number;
lineType?: 'solid' | 'dashed' | 'dotted';
radius?: number;
x1?: number;
y1?: number;
x2?: number;
y2?: number;
points?: Array<{ x: number; y: number }>;
text?: string;
fontSize?: number;
startAngle?: number;
endAngle?: number;
[key: string]: unknown;
}
interface CADElement {
id: string;
type: string;
layerId: string;
x: number;
y: number;
width: number;
height: number;
properties: CADProperties;
}
interface ExportData {
version: string;
exportedAt: number;
projectMeta: Record<string, unknown>;
layers: CADLayer[];
elements: CADElement[];
}
interface ExportOptions {
includeHiddenLayers?: boolean;
includeInvisibleElements?: boolean;
pageSize?: 'a4' | 'a3' | 'letter';
landscape?: boolean;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function hexToRgb(hex: string): { r: number; g: number; b: number } { function hexToRgb(hex: string): { r: number; g: number; b: number } {
const clean = hex.replace('#', ''); const clean = hex.replace('#', '');
@@ -150,412 +100,452 @@ function downloadBlob(content: string | Blob, filename: string, mimeType: string
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
// ── JSON Export ────────────────────────────────────────────────────────────── // ── JSON Export ──────────────────────────────────────────────────────────────
export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): void { export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): ExportResult {
const data = collectExportData(yjsDoc, {}); try {
const json = JSON.stringify(data, null, 2); const data = collectExportData(yjsDoc, {});
downloadBlob(json, filename, 'application/json'); const json = JSON.stringify(data, null, 2);
downloadBlob(json, filename, 'application/json');
return { success: true, data: json };
} catch (e) {
return {
success: false,
error: `exportJSON failed: ${(e as Error).message}`,
};
}
} }
export function exportJSONString(yjsDoc: YjsDocument): string { export function exportJSONString(yjsDoc: YjsDocument): ExportResult {
const data = collectExportData(yjsDoc, {}); try {
return JSON.stringify(data, null, 2); const data = collectExportData(yjsDoc, {});
const json = JSON.stringify(data, null, 2);
return { success: true, data: json };
} catch (e) {
return {
success: false,
error: `exportJSONString failed: ${(e as Error).message}`,
};
}
} }
// ── SVG Export ─────────────────────────────────────────────────────────────── // ── SVG Export ───────────────────────────────────────────────────────────────
export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): void { export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): ExportResult {
const data = collectExportData(yjsDoc, options); try {
const data = collectExportData(yjsDoc, options);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of data.elements) { for (const el of data.elements) {
minX = Math.min(minX, el.x); minX = Math.min(minX, el.x);
minY = Math.min(minY, el.y); minY = Math.min(minY, el.y);
maxX = Math.max(maxX, el.x + el.width); maxX = Math.max(maxX, el.x + el.width);
maxY = Math.max(maxY, el.y + el.height); maxY = Math.max(maxY, el.y + el.height);
} }
if (data.elements.length === 0) { if (data.elements.length === 0) {
minX = 0; minY = 0; maxX = 100; maxY = 100; minX = 0; minY = 0; maxX = 100; maxY = 100;
}
const padding = 10;
const vbX = minX - padding;
const vbY = minY - padding;
const vbW = (maxX - minX) + padding * 2;
const vbH = (maxY - minY) + padding * 2;
const svgParts: string[] = [];
svgParts.push(`<?xml version="1.0" encoding="UTF-8"?>`);
svgParts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vbX} ${vbY} ${vbW} ${vbH}" width="${vbW}" height="${vbH}">`);
svgParts.push('<defs><style>');
for (const layer of data.layers) {
const color = layer.color || '#000000';
const dash = layer.lineType === 'dashed' ? 'stroke-dasharray:8,4;' : layer.lineType === 'dotted' ? 'stroke-dasharray:2,3;' : '';
svgParts.push(`.layer-${layer.id} { stroke: ${color}; ${dash} }`);
}
svgParts.push('</style></defs>');
for (const el of data.elements) {
const layer = getLayerById(data.layers, el.layerId);
const stroke = el.properties.stroke || layer?.color || '#000000';
const fill = el.properties.fill || 'none';
const strokeWidth = el.properties.strokeWidth || 1;
const transform = el.properties.rotation ? ` transform="rotate(${el.properties.rotation} ${el.x + el.width / 2} ${el.y + el.height / 2})"` : '';
const svgY = (vbY + vbH) - el.y;
switch (el.type) {
case 'line': {
const x1 = el.properties.x1 ?? el.x;
const y1 = (vbY + vbH) - (el.properties.y1 ?? el.y);
const x2 = el.properties.x2 ?? (el.x + el.width);
const y2 = (vbY + vbH) - (el.properties.y2 ?? (el.y + el.height));
svgParts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
break;
}
case 'circle': {
const radius = el.properties.radius ?? el.width / 2;
const cx = el.x + radius;
const cy = svgY - radius;
svgParts.push(`<circle cx="${cx}" cy="${cy}" r="${radius}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
break;
}
case 'rect': {
svgParts.push(`<rect x="${el.x}" y="${svgY - el.height}" width="${el.width}" height="${el.height}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
break;
}
case 'polyline': {
if (el.properties.points && el.properties.points.length > 0) {
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
svgParts.push(`<polyline points="${pts}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
}
break;
}
case 'polygon': {
if (el.properties.points && el.properties.points.length > 0) {
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
svgParts.push(`<polygon points="${pts}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
}
break;
}
case 'text': {
const text = escapeXml(el.properties.text || '');
const fontSize = el.properties.fontSize || 14;
svgParts.push(`<text x="${el.x}" y="${svgY}" font-size="${fontSize}" fill="${stroke}"${transform}>${text}</text>`);
break;
}
case 'arc': {
const rx = el.width / 2;
const ry = el.height / 2;
const cx = el.x + rx;
const cy = svgY - ry;
svgParts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
break;
}
default:
break;
} }
}
svgParts.push('</svg>'); const padding = 10;
const svg = svgParts.join('\n'); const vbX = minX - padding;
downloadBlob(svg, filename, 'image/svg+xml'); const vbY = minY - padding;
const vbW = (maxX - minX) + padding * 2;
const vbH = (maxY - minY) + padding * 2;
const svgParts: string[] = [];
svgParts.push(`<?xml version="1.0" encoding="UTF-8"?>`);
svgParts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vbX} ${vbY} ${vbW} ${vbH}" width="${vbW}" height="${vbH}">`);
svgParts.push('<defs><style>');
for (const layer of data.layers) {
const color = layer.color || '#000000';
const dash = layer.lineType === 'dashed' ? 'stroke-dasharray:8,4;' : layer.lineType === 'dotted' ? 'stroke-dasharray:2,3;' : '';
svgParts.push(`.layer-${layer.id} { stroke: ${color}; ${dash} }`);
}
svgParts.push('</style></defs>');
for (const el of data.elements) {
const layer = getLayerById(data.layers, el.layerId);
const stroke = el.properties.stroke || layer?.color || '#000000';
const fill = el.properties.fill || 'none';
const strokeWidth = el.properties.strokeWidth || 1;
const transform = el.properties.rotation ? ` transform="rotate(${el.properties.rotation} ${el.x + el.width / 2} ${el.y + el.height / 2})"` : '';
const svgY = (vbY + vbH) - el.y;
switch (el.type) {
case 'line': {
const x1 = el.properties.x1 ?? el.x;
const y1 = (vbY + vbH) - (el.properties.y1 ?? el.y);
const x2 = el.properties.x2 ?? (el.x + el.width);
const y2 = (vbY + vbH) - (el.properties.y2 ?? (el.y + el.height));
svgParts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
break;
}
case 'circle': {
const radius = el.properties.radius ?? el.width / 2;
const cx = el.x + radius;
const cy = svgY - radius;
svgParts.push(`<circle cx="${cx}" cy="${cy}" r="${radius}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
break;
}
case 'rect': {
svgParts.push(`<rect x="${el.x}" y="${svgY - el.height}" width="${el.width}" height="${el.height}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
break;
}
case 'polyline': {
if (el.properties.points && el.properties.points.length > 0) {
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
svgParts.push(`<polyline points="${pts}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
}
break;
}
case 'polygon': {
if (el.properties.points && el.properties.points.length > 0) {
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
svgParts.push(`<polygon points="${pts}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
}
break;
}
case 'text': {
const text = escapeXml(el.properties.text || '');
const fontSize = el.properties.fontSize || 14;
svgParts.push(`<text x="${el.x}" y="${svgY}" font-size="${fontSize}" fill="${stroke}"${transform}>${text}</text>`);
break;
}
case 'arc': {
const rx = el.width / 2;
const ry = el.height / 2;
const cx = el.x + rx;
const cy = svgY - ry;
svgParts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
break;
}
default:
break;
}
}
svgParts.push('</svg>');
const svg = svgParts.join('\n');
downloadBlob(svg, filename, 'image/svg+xml');
return { success: true, data: svg };
} catch (e) {
return {
success: false,
error: `exportSVG failed: ${(e as Error).message}`,
};
}
} }
// ── DXF Export ─────────────────────────────────────────────────────────────── // ── DXF Export ───────────────────────────────────────────────────────────────
export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): void { export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): ExportResult {
const data = collectExportData(yjsDoc, options); try {
const lines: string[] = []; const data = collectExportData(yjsDoc, options);
const lines: string[] = [];
lines.push('0'); lines.push('SECTION'); lines.push('0'); lines.push('SECTION');
lines.push('2'); lines.push('HEADER'); lines.push('2'); lines.push('HEADER');
lines.push('9'); lines.push('$ACADVER'); lines.push('9'); lines.push('$ACADVER');
lines.push('1'); lines.push('AC1009'); lines.push('1'); lines.push('AC1009');
lines.push('9'); lines.push('$INSBASE'); lines.push('9'); lines.push('$INSBASE');
lines.push('10'); lines.push('0.0'); lines.push('10'); lines.push('0.0');
lines.push('20'); lines.push('0.0'); lines.push('20'); lines.push('0.0');
lines.push('30'); lines.push('0.0'); lines.push('30'); lines.push('0.0');
lines.push('0'); lines.push('ENDSEC'); lines.push('0'); lines.push('ENDSEC');
lines.push('0'); lines.push('SECTION'); lines.push('0'); lines.push('SECTION');
lines.push('2'); lines.push('TABLES'); lines.push('2'); lines.push('TABLES');
lines.push('0'); lines.push('TABLE'); lines.push('0'); lines.push('TABLE');
lines.push('2'); lines.push('LAYER'); lines.push('2'); lines.push('LAYER');
lines.push('70'); lines.push(String(data.layers.length)); lines.push('70'); lines.push(String(data.layers.length));
for (const layer of data.layers) { for (const layer of data.layers) {
const color = layer.color || '#000000'; const color = layer.color || '#000000';
const rgb = hexToRgb(color); const rgb = hexToRgb(color);
const aci = rgbToAci(rgb.r, rgb.g, rgb.b); const aci = rgbToAci(rgb.r, rgb.g, rgb.b);
lines.push('0'); lines.push('LAYER'); lines.push('0'); lines.push('LAYER');
lines.push('2'); lines.push(layer.name || layer.id); lines.push('2'); lines.push(layer.name || layer.id);
lines.push('70'); lines.push('0'); lines.push('70'); lines.push('0');
lines.push('62'); lines.push(String(aci)); lines.push('62'); lines.push(String(aci));
lines.push('6'); lines.push(layer.lineType || 'CONTINUOUS'); lines.push('6'); lines.push(layer.lineType || 'CONTINUOUS');
} }
lines.push('0'); lines.push('ENDTAB'); lines.push('0'); lines.push('ENDTAB');
lines.push('0'); lines.push('ENDSEC'); lines.push('0'); lines.push('ENDSEC');
lines.push('0'); lines.push('SECTION'); lines.push('0'); lines.push('SECTION');
lines.push('2'); lines.push('ENTITIES'); lines.push('2'); lines.push('ENTITIES');
for (const el of data.elements) { for (const el of data.elements) {
const layer = getLayerById(data.layers, el.layerId); const layer = getLayerById(data.layers, el.layerId);
const layerName = layer?.name || layer?.id || '0'; const layerName = layer?.name || layer?.id || '0';
const color = el.properties.stroke || layer?.color || '#000000'; const color = el.properties.stroke || layer?.color || '#000000';
const rgb = hexToRgb(color); const rgb = hexToRgb(color);
const aci = rgbToAci(rgb.r, rgb.g, rgb.b); const aci = rgbToAci(rgb.r, rgb.g, rgb.b);
switch (el.type) { switch (el.type) {
case 'line': { case 'line': {
const x1 = el.properties.x1 ?? el.x; const x1 = el.properties.x1 ?? el.x;
const y1 = el.properties.y1 ?? el.y; const y1 = el.properties.y1 ?? el.y;
const x2 = el.properties.x2 ?? (el.x + el.width); const x2 = el.properties.x2 ?? (el.x + el.width);
const y2 = el.properties.y2 ?? (el.y + el.height); const y2 = el.properties.y2 ?? (el.y + el.height);
lines.push('0'); lines.push('LINE'); lines.push('0'); lines.push('LINE');
lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci));
lines.push('10'); lines.push(String(x1));
lines.push('20'); lines.push(String(y1));
lines.push('30'); lines.push('0.0');
lines.push('11'); lines.push(String(x2));
lines.push('21'); lines.push(String(y2));
lines.push('31'); lines.push('0.0');
break;
}
case 'circle': {
const radius = el.properties.radius ?? el.width / 2;
const cx = el.x + radius;
const cy = el.y + radius;
lines.push('0'); lines.push('CIRCLE');
lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci));
lines.push('10'); lines.push(String(cx));
lines.push('20'); lines.push(String(cy));
lines.push('30'); lines.push('0.0');
lines.push('40'); lines.push(String(radius));
break;
}
case 'rect': {
const x = el.x, y = el.y, w = el.width, h = el.height;
lines.push('0'); lines.push('LWPOLYLINE');
lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci));
lines.push('90'); lines.push('4');
lines.push('70'); lines.push('1');
lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y));
lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y));
lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y + h));
lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y + h));
break;
}
case 'text': {
const text = el.properties.text || '';
const fontSize = el.properties.fontSize || 14;
lines.push('0'); lines.push('TEXT');
lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci));
lines.push('10'); lines.push(String(el.x));
lines.push('20'); lines.push(String(el.y));
lines.push('30'); lines.push('0.0');
lines.push('40'); lines.push(String(fontSize));
lines.push('1'); lines.push(text);
break;
}
case 'polyline': {
if (el.properties.points && el.properties.points.length > 0) {
const pts = el.properties.points;
lines.push('0'); lines.push('LWPOLYLINE');
lines.push('8'); lines.push(layerName); lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci)); lines.push('62'); lines.push(String(aci));
lines.push('90'); lines.push(String(pts.length)); lines.push('10'); lines.push(String(x1));
lines.push('70'); lines.push('0'); lines.push('20'); lines.push(String(y1));
for (const p of pts) { lines.push('30'); lines.push('0.0');
lines.push('10'); lines.push(String(p.x)); lines.push('11'); lines.push(String(x2));
lines.push('20'); lines.push(String(p.y)); lines.push('21'); lines.push(String(y2));
} lines.push('31'); lines.push('0.0');
break;
} }
break; case 'circle': {
} const radius = el.properties.radius ?? el.width / 2;
case 'polygon': { const cx = el.x + radius;
if (el.properties.points && el.properties.points.length > 0) { const cy = el.y + radius;
const pts = el.properties.points; lines.push('0'); lines.push('CIRCLE');
lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci));
lines.push('10'); lines.push(String(cx));
lines.push('20'); lines.push(String(cy));
lines.push('30'); lines.push('0.0');
lines.push('40'); lines.push(String(radius));
break;
}
case 'rect': {
const x = el.x, y = el.y, w = el.width, h = el.height;
lines.push('0'); lines.push('LWPOLYLINE'); lines.push('0'); lines.push('LWPOLYLINE');
lines.push('8'); lines.push(layerName); lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci)); lines.push('62'); lines.push(String(aci));
lines.push('90'); lines.push(String(pts.length)); lines.push('90'); lines.push('4');
lines.push('70'); lines.push('1'); lines.push('70'); lines.push('1');
for (const p of pts) { lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y));
lines.push('10'); lines.push(String(p.x)); lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y));
lines.push('20'); lines.push(String(p.y)); lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y + h));
} lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y + h));
break;
} }
break; case 'text': {
const text = el.properties.text || '';
const fontSize = el.properties.fontSize || 14;
lines.push('0'); lines.push('TEXT');
lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci));
lines.push('10'); lines.push(String(el.x));
lines.push('20'); lines.push(String(el.y));
lines.push('30'); lines.push('0.0');
lines.push('40'); lines.push(String(fontSize));
lines.push('1'); lines.push(text);
break;
}
case 'polyline': {
if (el.properties.points && el.properties.points.length > 0) {
const pts = el.properties.points;
lines.push('0'); lines.push('LWPOLYLINE');
lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci));
lines.push('90'); lines.push(String(pts.length));
lines.push('70'); lines.push('0');
for (const p of pts) {
lines.push('10'); lines.push(String(p.x));
lines.push('20'); lines.push(String(p.y));
}
}
break;
}
case 'polygon': {
if (el.properties.points && el.properties.points.length > 0) {
const pts = el.properties.points;
lines.push('0'); lines.push('LWPOLYLINE');
lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci));
lines.push('90'); lines.push(String(pts.length));
lines.push('70'); lines.push('1');
for (const p of pts) {
lines.push('10'); lines.push(String(p.x));
lines.push('20'); lines.push(String(p.y));
}
}
break;
}
case 'arc': {
const radius = el.properties.radius ?? el.width / 2;
const cx = el.x + radius;
const cy = el.y + radius;
// M4 fix: Export actual startAngle (group code 50) and endAngle (group code 51)
const startAngle = el.properties.startAngle ?? 0;
const endAngle = el.properties.endAngle ?? 360;
lines.push('0'); lines.push('ARC');
lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci));
lines.push('10'); lines.push(String(cx));
lines.push('20'); lines.push(String(cy));
lines.push('30'); lines.push('0.0');
lines.push('40'); lines.push(String(radius));
lines.push('50'); lines.push(String(startAngle));
lines.push('51'); lines.push(String(endAngle));
break;
}
default:
break;
} }
case 'arc': {
const radius = el.properties.radius ?? el.width / 2;
const cx = el.x + radius;
const cy = el.y + radius;
// M4 fix: Export actual startAngle (group code 50) and endAngle (group code 51)
const startAngle = el.properties.startAngle ?? 0;
const endAngle = el.properties.endAngle ?? 360;
lines.push('0'); lines.push('ARC');
lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci));
lines.push('10'); lines.push(String(cx));
lines.push('20'); lines.push(String(cy));
lines.push('30'); lines.push('0.0');
lines.push('40'); lines.push(String(radius));
lines.push('50'); lines.push(String(startAngle));
lines.push('51'); lines.push(String(endAngle));
break;
}
default:
break;
} }
lines.push('0'); lines.push('ENDSEC');
lines.push('0'); lines.push('EOF');
const dxf = lines.join('\n');
downloadBlob(dxf, filename, 'application/dxf');
return { success: true, data: dxf };
} catch (e) {
return {
success: false,
error: `exportDXF failed: ${(e as Error).message}`,
};
} }
lines.push('0'); lines.push('ENDSEC');
lines.push('0'); lines.push('EOF');
const dxf = lines.join('\n');
downloadBlob(dxf, filename, 'application/dxf');
} }
// ── PDF Export ─────────────────────────────────────────────────────────────── // ── PDF Export ───────────────────────────────────────────────────────────────
export async function exportPDF( export async function exportPDF(
yjsDoc: YjsDocument, yjsDoc: YjsDocument,
filename = 'cad-export.pdf', filename = 'cad-export.pdf',
options: ExportOptions = {} options: ExportOptions = {}
): Promise<void> { ): Promise<ExportResult> {
const data = collectExportData(yjsDoc, options); try {
const data = collectExportData(yjsDoc, options);
const { jsPDF } = await import('jspdf'); const { jsPDF } = await import('jspdf');
const pageSize = options.pageSize || 'a4'; const pageSize = options.pageSize || 'a4';
const orientation = options.landscape ? 'landscape' : 'portrait'; const orientation = options.landscape ? 'landscape' : 'portrait';
const pdf = new jsPDF({ const pdf = new jsPDF({
orientation, orientation,
unit: 'mm', unit: 'mm',
format: pageSize, format: pageSize,
}); });
const pageWidth = pdf.internal.pageSize.getWidth(); const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight(); const pageHeight = pdf.internal.pageSize.getHeight();
const margin = 10; const margin = 10;
const drawableW = pageWidth - margin * 2; const drawableW = pageWidth - margin * 2;
const drawableH = pageHeight - margin * 2; const drawableH = pageHeight - margin * 2;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of data.elements) { for (const el of data.elements) {
minX = Math.min(minX, el.x); minX = Math.min(minX, el.x);
minY = Math.min(minY, el.y); minY = Math.min(minY, el.y);
maxX = Math.max(maxX, el.x + el.width); maxX = Math.max(maxX, el.x + el.width);
maxY = Math.max(maxY, el.y + el.height); maxY = Math.max(maxY, el.y + el.height);
} }
if (data.elements.length === 0) { if (data.elements.length === 0) {
minX = 0; minY = 0; maxX = 100; maxY = 100; minX = 0; minY = 0; maxX = 100; maxY = 100;
}
const contentW = maxX - minX;
const contentH = maxY - minY;
const scale = Math.min(drawableW / contentW, drawableH / contentH, 1);
const offsetX = margin + (drawableW - contentW * scale) / 2;
const offsetY = margin + (drawableH - contentH * scale) / 2;
const tx = (x: number) => offsetX + (x - minX) * scale;
const ty = (y: number) => pageHeight - offsetY - (y - minY) * scale;
for (const el of data.elements) {
const layer = getLayerById(data.layers, el.layerId);
const strokeColor = el.properties.stroke || layer?.color || '#000000';
const rgb = hexToRgb(strokeColor);
pdf.setDrawColor(rgb.r, rgb.g, rgb.b);
pdf.setLineWidth((el.properties.strokeWidth || 1) * scale);
const fillColor = el.properties.fill;
if (fillColor && fillColor !== 'transparent') {
const fRgb = hexToRgb(fillColor);
pdf.setFillColor(fRgb.r, fRgb.g, fRgb.b);
} }
switch (el.type) { const contentW = maxX - minX;
case 'line': { const contentH = maxY - minY;
const x1 = el.properties.x1 ?? el.x; const scale = Math.min(drawableW / contentW, drawableH / contentH, 1);
const y1 = el.properties.y1 ?? el.y; const offsetX = margin + (drawableW - contentW * scale) / 2;
const x2 = el.properties.x2 ?? (el.x + el.width); const offsetY = margin + (drawableH - contentH * scale) / 2;
const y2 = el.properties.y2 ?? (el.y + el.height);
pdf.line(tx(x1), ty(y1), tx(x2), ty(y2)); const tx = (x: number) => offsetX + (x - minX) * scale;
break; const ty = (y: number) => pageHeight - offsetY - (y - minY) * scale;
for (const el of data.elements) {
const layer = getLayerById(data.layers, el.layerId);
const strokeColor = el.properties.stroke || layer?.color || '#000000';
const rgb = hexToRgb(strokeColor);
pdf.setDrawColor(rgb.r, rgb.g, rgb.b);
pdf.setLineWidth((el.properties.strokeWidth || 1) * scale);
const fillColor = el.properties.fill;
if (fillColor && fillColor !== 'transparent') {
const fRgb = hexToRgb(fillColor);
pdf.setFillColor(fRgb.r, fRgb.g, fRgb.b);
} }
case 'circle': {
const radius = el.properties.radius ?? el.width / 2; switch (el.type) {
const cx = tx(el.x + radius); case 'line': {
const cy = ty(el.y + radius); const x1 = el.properties.x1 ?? el.x;
pdf.circle(cx, cy, radius * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S'); const y1 = el.properties.y1 ?? el.y;
break; const x2 = el.properties.x2 ?? (el.x + el.width);
} const y2 = el.properties.y2 ?? (el.y + el.height);
case 'rect': { pdf.line(tx(x1), ty(y1), tx(x2), ty(y2));
const x = tx(el.x); break;
const y = ty(el.y + el.height);
pdf.rect(x, y, el.width * scale, el.height * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
break;
}
case 'polyline': {
if (el.properties.points && el.properties.points.length > 1) {
const pts = el.properties.points;
for (let i = 0; i < pts.length - 1; i++) {
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[i + 1].x), ty(pts[i + 1].y));
}
} }
break; case 'circle': {
} const radius = el.properties.radius ?? el.width / 2;
case 'polygon': { const cx = tx(el.x + radius);
if (el.properties.points && el.properties.points.length > 2) { const cy = ty(el.y + radius);
const pts = el.properties.points; pdf.circle(cx, cy, radius * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
for (let i = 0; i < pts.length; i++) { break;
const next = (i + 1) % pts.length;
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[next].x), ty(pts[next].y));
}
} }
break; case 'rect': {
const x = tx(el.x);
const y = ty(el.y + el.height);
pdf.rect(x, y, el.width * scale, el.height * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
break;
}
case 'polyline': {
if (el.properties.points && el.properties.points.length > 1) {
const pts = el.properties.points;
for (let i = 0; i < pts.length - 1; i++) {
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[i + 1].x), ty(pts[i + 1].y));
}
}
break;
}
case 'polygon': {
if (el.properties.points && el.properties.points.length > 2) {
const pts = el.properties.points;
for (let i = 0; i < pts.length; i++) {
const next = (i + 1) % pts.length;
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[next].x), ty(pts[next].y));
}
}
break;
}
case 'text': {
const text = el.properties.text || '';
const fontSize = (el.properties.fontSize || 14) * scale;
pdf.setFontSize(fontSize);
pdf.text(text, tx(el.x), ty(el.y));
break;
}
case 'arc': {
const radius = el.properties.radius ?? el.width / 2;
const cx = tx(el.x + radius);
const cy = ty(el.y + radius);
pdf.circle(cx, cy, radius * scale, 'S');
break;
}
default:
break;
} }
case 'text': {
const text = el.properties.text || '';
const fontSize = (el.properties.fontSize || 14) * scale;
pdf.setFontSize(fontSize);
pdf.text(text, tx(el.x), ty(el.y));
break;
}
case 'arc': {
const radius = el.properties.radius ?? el.width / 2;
const cx = tx(el.x + radius);
const cy = ty(el.y + radius);
pdf.circle(cx, cy, radius * scale, 'S');
break;
}
default:
break;
} }
pdf.setFontSize(8);
pdf.setTextColor(128, 128, 128);
pdf.text(
`Exported: ${new Date().toISOString()} | Elements: ${data.elements.length} | Layers: ${data.layers.length}`,
margin,
pageHeight - 5
);
pdf.save(filename);
return { success: true };
} catch (e) {
return {
success: false,
error: `exportPDF failed: ${(e as Error).message}`,
};
} }
pdf.setFontSize(8);
pdf.setTextColor(128, 128, 128);
pdf.text(
`Exported: ${new Date().toISOString()} | Elements: ${data.elements.length} | Layers: ${data.layers.length}`,
margin,
pageHeight - 5
);
pdf.save(filename);
} }
// ── Unified Export ─────────────────────────────────────────────────────────── // ── Unified Export ─────────────────────────────────────────────────────────────
export type ExportFormat = 'json' | 'svg' | 'dxf' | 'pdf'; export type ExportFormat = 'json' | 'svg' | 'dxf' | 'pdf';
@@ -564,7 +554,7 @@ export async function exportDrawing(
format: ExportFormat, format: ExportFormat,
filename?: string, filename?: string,
options?: ExportOptions options?: ExportOptions
): Promise<void> { ): Promise<ExportResult> {
const ext = format; const ext = format;
const name = filename || `cad-export.${ext}`; const name = filename || `cad-export.${ext}`;
@@ -578,6 +568,9 @@ export async function exportDrawing(
case 'pdf': case 'pdf':
return exportPDF(yjsDoc, name, options); return exportPDF(yjsDoc, name, options);
default: default:
throw new Error(`Unsupported export format: ${format}`); return {
success: false,
error: `Unsupported export format: ${format}`,
};
} }
} }