/** * Export Service – handles DXF, SVG, PDF, PNG, JSON exports */ import { writeDXF } from './dxfWriter'; import { exportPDF } from './pdfExport'; import type { CADElement, CADLayer, ProjectData } from '../types/cad.types'; export type ExportFormat = 'dxf' | 'svg' | 'pdf' | 'png' | 'json'; export interface ExportOptions { format: ExportFormat; filename?: string; } export interface ExportFileResult { success: boolean; blob: Blob | null; filename: string; error?: string; } /** * Export project data to the specified format and trigger download. */ export async function exportProject( data: ProjectData, options: ExportOptions, ): Promise { const filename = options.filename || `${data.name || 'cad-export'}.${options.format}`; try { switch (options.format) { case 'json': return exportJSON(data, filename); case 'dxf': return exportDXF(data, filename); case 'svg': return exportSVG(data, filename); case 'pdf': return await exportPDFFile(data, filename); case 'png': return await exportPNG(data, filename); default: return { success: false, blob: null, filename, error: `Unsupported format: ${options.format}` }; } } catch (err) { return { success: false, blob: null, filename, error: `Export error: ${err instanceof Error ? err.message : String(err)}`, }; } } /** * Export as JSON (full project data). */ function exportJSON(data: ProjectData, filename: string): ExportFileResult { const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: 'application/json' }); return { success: true, blob, filename }; } /** * Export as DXF. */ function exportDXF(data: ProjectData, filename: string): ExportFileResult { const dxf = writeDXF(data); const blob = new Blob([dxf], { type: 'application/dxf' }); return { success: true, blob, filename }; } /** * Export as SVG. */ function exportSVG(data: ProjectData, filename: string): ExportFileResult { const svg = generateSVG(data); const blob = new Blob([svg], { type: 'image/svg+xml' }); return { success: true, blob, filename }; } /** * Export as PDF. */ async function exportPDFFile(data: ProjectData, filename: string): Promise { const bytes = await exportPDF(data); const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' }); return { success: true, blob, filename }; } /** * Export as PNG — renders canvas to image. * Requires a canvas element reference. */ async function exportPNG(data: ProjectData, filename: string): Promise { // Create an offscreen canvas and render elements const canvas = document.createElement('canvas'); const bbox = calculateBoundingBox(data.elements); const padding = 20; const w = (bbox.maxX - bbox.minX) + padding * 2; const h = (bbox.maxY - bbox.minY) + padding * 2; canvas.width = Math.max(w, 800); canvas.height = Math.max(h, 600); const ctx = canvas.getContext('2d'); if (!ctx) return { success: false, blob: null, filename, error: 'Canvas context unavailable' }; // White background ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Translate to content origin ctx.save(); ctx.translate(-bbox.minX + padding, -bbox.minY + padding); // Draw elements for (const el of data.elements) { const layer = data.layers.find(l => l.id === el.layerId); if (layer && !layer.visible) continue; drawElementToCanvas(ctx, el, layer?.color); } ctx.restore(); const blob = await new Promise((resolve) => { canvas.toBlob((b) => resolve(b!), 'image/png'); }); return { success: true, blob, filename }; } /** * Generate SVG string from project data. */ function generateSVG(data: ProjectData): string { const bbox = calculateBoundingBox(data.elements); const padding = 20; const minX = bbox.minX - padding; const minY = bbox.minY - padding; const w = (bbox.maxX - bbox.minX) + padding * 2; const h = (bbox.maxY - bbox.minY) + padding * 2; const lines: string[] = []; lines.push(``); lines.push(``); for (const el of data.elements) { const layer = data.layers.find(l => l.id === el.layerId); if (layer && !layer.visible) continue; const svgEl = elementToSVG(el, layer?.color); if (svgEl) lines.push(svgEl); } lines.push(``); return lines.join('\n'); } function elementToSVG(el: CADElement, layerColor?: string): string | null { const p = el.properties; const stroke = (p.stroke as string) || layerColor || '#000000'; const sw = p.strokeWidth ?? 1; const fill = p.fill as string || 'none'; const style = `stroke="${stroke}" strokeWidth="${sw}" fill="${fill}"`; switch (el.type) { case 'line': { const x1 = p.x1 ?? el.x; const y1 = p.y1 ?? el.y; const x2 = p.x2 ?? el.x + el.width; const y2 = p.y2 ?? el.y + el.height; return ``; } case 'circle': { const cx = el.x + el.width / 2; const cy = el.y + el.height / 2; const r = p.radius ?? el.width / 2; return ``; } case 'arc': { const cx = el.x + el.width / 2; const cy = el.y + el.height / 2; const r = p.radius ?? el.width / 2; const start = p.startAngle ?? 0; const end = p.endAngle ?? Math.PI * 2; const x1 = cx + Math.cos(start) * r; const y1 = cy + Math.sin(start) * r; const x2 = cx + Math.cos(end) * r; const y2 = cy + Math.sin(end) * r; const largeArc = (end - start) > Math.PI ? 1 : 0; return ``; } case 'rect': { return ``; } case 'polygon': case 'polyline': { const pts = (p.points ?? []).map(pt => `${pt.x},${pt.y}`).join(' '); const tag = el.type === 'polygon' ? 'polygon' : 'polyline'; return `<${tag} points="${pts}" ${style}/>`; } case 'text': { const size = p.fontSize ?? 12; return `${escapeXml(p.text ?? '')}`; } case 'dimension': { const pts = p.points ?? []; if (pts.length < 2) return null; const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); const midX = (pts[0].x + pts[1].x) / 2; const midY = (pts[0].y + pts[1].y) / 2; return `\n${dist.toFixed(2)}`; } default: { return ``; } } } function drawElementToCanvas(ctx: CanvasRenderingContext2D, el: CADElement, layerColor?: string): void { const p = el.properties; const stroke = (p.stroke as string) || layerColor || '#000000'; ctx.strokeStyle = stroke; ctx.lineWidth = p.strokeWidth ?? 1; ctx.fillStyle = (p.fill as string) || 'transparent'; switch (el.type) { case 'line': ctx.beginPath(); ctx.moveTo(p.x1 ?? el.x, p.y1 ?? el.y); ctx.lineTo(p.x2 ?? el.x + el.width, p.y2 ?? el.y + el.height); ctx.stroke(); break; case 'circle': ctx.beginPath(); ctx.arc(el.x + el.width / 2, el.y + el.height / 2, p.radius ?? el.width / 2, 0, Math.PI * 2); ctx.stroke(); break; case 'rect': ctx.strokeRect(el.x, el.y, el.width, el.height); break; case 'polygon': case 'polyline': { const pts = p.points ?? []; if (pts.length < 2) break; ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); if (el.type === 'polygon') ctx.closePath(); ctx.stroke(); break; } case 'text': ctx.font = `${p.fontSize ?? 12}px sans-serif`; ctx.fillStyle = stroke; ctx.fillText(p.text ?? '', el.x, el.y); break; default: ctx.strokeRect(el.x, el.y, el.width, el.height); break; } } function calculateBoundingBox(elements: CADElement[]) { if (elements.length === 0) return { minX: 0, minY: 0, maxX: 1000, maxY: 1000 }; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const el of 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); } return { minX, minY, maxX, maxY }; } function escapeXml(text: string): string { return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /** * Trigger a browser download from a blob. */ export function downloadBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }