diff --git a/frontend/src/services/exportService.ts b/frontend/src/services/exportService.ts index 5f5e102..0e4635c 100644 --- a/frontend/src/services/exportService.ts +++ b/frontend/src/services/exportService.ts @@ -1,64 +1,14 @@ import type { YjsDocument } from '../crdt/YjsDocument'; +import type { + CADLayer, + CADProperties, + CADElement, + ExportData, + ExportOptions, + ExportResult, +} from '../types/cad.types'; -// ── Types ──────────────────────────────────────────────────────────────────── - -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; - layers: CADLayer[]; - elements: CADElement[]; -} - -interface ExportOptions { - includeHiddenLayers?: boolean; - includeInvisibleElements?: boolean; - pageSize?: 'a4' | 'a3' | 'letter'; - landscape?: boolean; -} - -// ── Helpers ────────────────────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────────────────────── function hexToRgb(hex: string): { r: number; g: number; b: number } { const clean = hex.replace('#', ''); @@ -150,412 +100,452 @@ function downloadBlob(content: string | Blob, filename: string, mimeType: string URL.revokeObjectURL(url); } -// ── JSON Export ────────────────────────────────────────────────────────────── +// ── JSON Export ─────────────────────────────────────────────────────────────── -export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): void { - const data = collectExportData(yjsDoc, {}); - const json = JSON.stringify(data, null, 2); - downloadBlob(json, filename, 'application/json'); +export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): ExportResult { + try { + const data = collectExportData(yjsDoc, {}); + 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 { - const data = collectExportData(yjsDoc, {}); - return JSON.stringify(data, null, 2); +export function exportJSONString(yjsDoc: YjsDocument): ExportResult { + try { + 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 { - const data = collectExportData(yjsDoc, options); +export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): ExportResult { + try { + const data = collectExportData(yjsDoc, options); - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const el of data.elements) { - minX = Math.min(minX, el.x); - minY = Math.min(minY, el.y); - maxX = Math.max(maxX, el.x + el.width); - maxY = Math.max(maxY, el.y + el.height); - } - if (data.elements.length === 0) { - 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(``); - svgParts.push(``); - - svgParts.push(''); - - 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(``); - break; - } - case 'circle': { - const radius = el.properties.radius ?? el.width / 2; - const cx = el.x + radius; - const cy = svgY - radius; - svgParts.push(``); - break; - } - case 'rect': { - svgParts.push(``); - 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(``); - } - 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(``); - } - break; - } - case 'text': { - const text = escapeXml(el.properties.text || ''); - const fontSize = el.properties.fontSize || 14; - svgParts.push(`${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(``); - break; - } - default: - break; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const el of data.elements) { + minX = Math.min(minX, el.x); + minY = Math.min(minY, el.y); + maxX = Math.max(maxX, el.x + el.width); + maxY = Math.max(maxY, el.y + el.height); + } + if (data.elements.length === 0) { + minX = 0; minY = 0; maxX = 100; maxY = 100; } - } - svgParts.push(''); - const svg = svgParts.join('\n'); - downloadBlob(svg, filename, 'image/svg+xml'); + 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(``); + svgParts.push(``); + + svgParts.push(''); + + 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(``); + break; + } + case 'circle': { + const radius = el.properties.radius ?? el.width / 2; + const cx = el.x + radius; + const cy = svgY - radius; + svgParts.push(``); + break; + } + case 'rect': { + svgParts.push(``); + 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(``); + } + 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(``); + } + break; + } + case 'text': { + const text = escapeXml(el.properties.text || ''); + const fontSize = el.properties.fontSize || 14; + svgParts.push(`${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(``); + break; + } + default: + break; + } + } + + svgParts.push(''); + 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 { - const data = collectExportData(yjsDoc, options); - const lines: string[] = []; +export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): ExportResult { + try { + const data = collectExportData(yjsDoc, options); + const lines: string[] = []; - lines.push('0'); lines.push('SECTION'); - lines.push('2'); lines.push('HEADER'); - lines.push('9'); lines.push('$ACADVER'); - lines.push('1'); lines.push('AC1009'); - lines.push('9'); lines.push('$INSBASE'); - lines.push('10'); lines.push('0.0'); - lines.push('20'); lines.push('0.0'); - lines.push('30'); lines.push('0.0'); - lines.push('0'); lines.push('ENDSEC'); + lines.push('0'); lines.push('SECTION'); + lines.push('2'); lines.push('HEADER'); + lines.push('9'); lines.push('$ACADVER'); + lines.push('1'); lines.push('AC1009'); + lines.push('9'); lines.push('$INSBASE'); + lines.push('10'); lines.push('0.0'); + lines.push('20'); lines.push('0.0'); + lines.push('30'); lines.push('0.0'); + lines.push('0'); lines.push('ENDSEC'); - lines.push('0'); lines.push('SECTION'); - lines.push('2'); lines.push('TABLES'); - lines.push('0'); lines.push('TABLE'); - lines.push('2'); lines.push('LAYER'); - lines.push('70'); lines.push(String(data.layers.length)); + lines.push('0'); lines.push('SECTION'); + lines.push('2'); lines.push('TABLES'); + lines.push('0'); lines.push('TABLE'); + lines.push('2'); lines.push('LAYER'); + lines.push('70'); lines.push(String(data.layers.length)); - for (const layer of data.layers) { - const color = layer.color || '#000000'; - const rgb = hexToRgb(color); - const aci = rgbToAci(rgb.r, rgb.g, rgb.b); - lines.push('0'); lines.push('LAYER'); - lines.push('2'); lines.push(layer.name || layer.id); - lines.push('70'); lines.push('0'); - lines.push('62'); lines.push(String(aci)); - lines.push('6'); lines.push(layer.lineType || 'CONTINUOUS'); - } + for (const layer of data.layers) { + const color = layer.color || '#000000'; + const rgb = hexToRgb(color); + const aci = rgbToAci(rgb.r, rgb.g, rgb.b); + lines.push('0'); lines.push('LAYER'); + lines.push('2'); lines.push(layer.name || layer.id); + lines.push('70'); lines.push('0'); + lines.push('62'); lines.push(String(aci)); + lines.push('6'); lines.push(layer.lineType || 'CONTINUOUS'); + } - lines.push('0'); lines.push('ENDTAB'); - lines.push('0'); lines.push('ENDSEC'); + lines.push('0'); lines.push('ENDTAB'); + lines.push('0'); lines.push('ENDSEC'); - lines.push('0'); lines.push('SECTION'); - lines.push('2'); lines.push('ENTITIES'); + lines.push('0'); lines.push('SECTION'); + lines.push('2'); lines.push('ENTITIES'); - for (const el of data.elements) { - const layer = getLayerById(data.layers, el.layerId); - const layerName = layer?.name || layer?.id || '0'; - const color = el.properties.stroke || layer?.color || '#000000'; - const rgb = hexToRgb(color); - const aci = rgbToAci(rgb.r, rgb.g, rgb.b); + for (const el of data.elements) { + const layer = getLayerById(data.layers, el.layerId); + const layerName = layer?.name || layer?.id || '0'; + const color = el.properties.stroke || layer?.color || '#000000'; + const rgb = hexToRgb(color); + const aci = rgbToAci(rgb.r, rgb.g, rgb.b); - switch (el.type) { - case 'line': { - const x1 = el.properties.x1 ?? el.x; - const y1 = el.properties.y1 ?? el.y; - const x2 = el.properties.x2 ?? (el.x + el.width); - const y2 = el.properties.y2 ?? (el.y + el.height); - 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'); + switch (el.type) { + case 'line': { + const x1 = el.properties.x1 ?? el.x; + const y1 = el.properties.y1 ?? el.y; + const x2 = el.properties.x2 ?? (el.x + el.width); + const y2 = el.properties.y2 ?? (el.y + el.height); + lines.push('0'); lines.push('LINE'); 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)); - } + 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; } - break; - } - case 'polygon': { - if (el.properties.points && el.properties.points.length > 0) { - const pts = el.properties.points; + 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(String(pts.length)); + lines.push('90'); lines.push('4'); 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)); - } + 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; } - 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( yjsDoc: YjsDocument, filename = 'cad-export.pdf', options: ExportOptions = {} -): Promise { - const data = collectExportData(yjsDoc, options); +): Promise { + try { + const data = collectExportData(yjsDoc, options); - const { jsPDF } = await import('jspdf'); + const { jsPDF } = await import('jspdf'); - const pageSize = options.pageSize || 'a4'; - const orientation = options.landscape ? 'landscape' : 'portrait'; - const pdf = new jsPDF({ - orientation, - unit: 'mm', - format: pageSize, - }); + const pageSize = options.pageSize || 'a4'; + const orientation = options.landscape ? 'landscape' : 'portrait'; + const pdf = new jsPDF({ + orientation, + unit: 'mm', + format: pageSize, + }); - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - const margin = 10; - const drawableW = pageWidth - margin * 2; - const drawableH = pageHeight - margin * 2; + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 10; + const drawableW = pageWidth - margin * 2; + const drawableH = pageHeight - margin * 2; - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const el of data.elements) { - minX = Math.min(minX, el.x); - minY = Math.min(minY, el.y); - maxX = Math.max(maxX, el.x + el.width); - maxY = Math.max(maxY, el.y + el.height); - } - if (data.elements.length === 0) { - 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); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const el of data.elements) { + minX = Math.min(minX, el.x); + minY = Math.min(minY, el.y); + maxX = Math.max(maxX, el.x + el.width); + maxY = Math.max(maxY, el.y + el.height); + } + if (data.elements.length === 0) { + minX = 0; minY = 0; maxX = 100; maxY = 100; } - switch (el.type) { - case 'line': { - const x1 = el.properties.x1 ?? el.x; - const y1 = el.properties.y1 ?? el.y; - const x2 = el.properties.x2 ?? (el.x + el.width); - const y2 = el.properties.y2 ?? (el.y + el.height); - pdf.line(tx(x1), ty(y1), tx(x2), ty(y2)); - break; + 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); } - case 'circle': { - 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, fillColor && fillColor !== 'transparent' ? 'FD' : 'S'); - 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)); - } + + switch (el.type) { + case 'line': { + const x1 = el.properties.x1 ?? el.x; + const y1 = el.properties.y1 ?? el.y; + const x2 = el.properties.x2 ?? (el.x + el.width); + const y2 = el.properties.y2 ?? (el.y + el.height); + pdf.line(tx(x1), ty(y1), tx(x2), ty(y2)); + break; } - 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)); - } + case 'circle': { + 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, fillColor && fillColor !== 'transparent' ? 'FD' : 'S'); + break; } - 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'; @@ -564,7 +554,7 @@ export async function exportDrawing( format: ExportFormat, filename?: string, options?: ExportOptions -): Promise { +): Promise { const ext = format; const name = filename || `cad-export.${ext}`; @@ -578,6 +568,9 @@ export async function exportDrawing( case 'pdf': return exportPDF(yjsDoc, name, options); default: - throw new Error(`Unsupported export format: ${format}`); + return { + success: false, + error: `Unsupported export format: ${format}`, + }; } }