diff --git a/frontend/src/services/exportService.ts b/frontend/src/services/exportService.ts new file mode 100644 index 0000000..ffce33b --- /dev/null +++ b/frontend/src/services/exportService.ts @@ -0,0 +1,597 @@ +import type { YjsDocument } from '../crdt/YjsDocument'; + +// ─── 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; + [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 ───────────────────────────────────────────────────────────────── + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const clean = hex.replace('#', ''); + if (clean.length === 3) { + return { + r: parseInt(clean[0] + clean[0], 16), + g: parseInt(clean[1] + clean[1], 16), + b: parseInt(clean[2] + clean[2], 16), + }; + } + return { + r: parseInt(clean.substring(0, 2), 16) || 0, + g: parseInt(clean.substring(2, 4), 16) || 0, + b: parseInt(clean.substring(4, 6), 16) || 0, + }; +} + +function rgbToAci(r: number, g: number, b: number): number { + // Simplified ACI color mapping + if (r > 200 && g > 200 && b > 200) return 7; // white + if (r < 50 && g < 50 && b < 50) return 7; // black → white in DXF + if (r > 200 && g < 100 && b < 100) return 1; // red + if (r < 100 && g > 200 && b < 100) return 3; // green + if (r < 100 && g < 100 && b > 200) return 5; // blue + if (r > 200 && g > 200 && b < 100) return 2; // yellow + if (r > 200 && g < 100 && b > 200) return 6; // magenta + if (r < 100 && g > 200 && b > 200) return 4; // cyan + return 7; +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function getLayerById(layers: CADLayer[], id: string): CADLayer | undefined { + return layers.find((l) => l.id === id); +} + +function shouldExportElement( + element: CADElement, + layers: CADLayer[], + options: ExportOptions +): boolean { + if (options.includeInvisibleElements) return true; + const layer = getLayerById(layers, element.layerId); + if (layer && !layer.visible) return false; + return true; +} + +function collectExportData(yjsDoc: YjsDocument, options: ExportOptions): ExportData { + const layers: CADLayer[] = []; + yjsDoc.layers.forEach((layer, id) => { + if (!options.includeHiddenLayers && !layer.visible) return; + layers.push({ ...layer, id }); + }); + layers.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); + + const elements = yjsDoc.getElements().filter((el) => + shouldExportElement(el, layers, options) + ); + + const projectMeta: Record = {}; + yjsDoc.projectMeta.forEach((value, key) => { + projectMeta[key] = value; + }); + + return { + version: '1.0.0', + exportedAt: Date.now(), + projectMeta, + layers, + elements, + }; +} + +function downloadBlob(content: string | Blob, filename: string, mimeType: string): void { + const blob = typeof content === 'string' ? new Blob([content], { type: mimeType }) : content; + 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); +} + +// ─── 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 exportJSONString(yjsDoc: YjsDocument): string { + const data = collectExportData(yjsDoc, {}); + return JSON.stringify(data, null, 2); +} + +// ─── SVG Export ────────────────────────────────────────────────────────────── + +export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): void { + const data = collectExportData(yjsDoc, options); + + // Calculate bounding box + 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(``); + + // Layer styles + svgParts.push(''); + + // Elements (Y-axis flipped for SVG coordinate system) + for (const el of data.elements) { + const layer = getLayerById(data.layers, el.layerId); + const layerClass = layer ? `class="layer-${layer.id}"` : ''; + 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})"` : ''; + + // SVG Y-axis is inverted compared to CAD + 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': { + // Approximate arc with path (simplified: use bounding box) + const rx = el.width / 2; + const ry = el.height / 2; + const cx = el.x + rx; + const cy = svgY - ry; + svgParts.push(``); + break; + } + default: + // Unknown type: skip + break; + } + } + + svgParts.push(''); + const svg = svgParts.join('\n'); + downloadBlob(svg, filename, 'image/svg+xml'); +} + +// ─── DXF Export ────────────────────────────────────────────────────────────── + +export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): void { + const data = collectExportData(yjsDoc, options); + const lines: string[] = []; + + // HEADER section + 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'); + + // TABLES section — LAYER table + 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'); + } + + lines.push('0'); lines.push('ENDTAB'); + lines.push('0'); lines.push('ENDSEC'); + + // ENTITIES section + 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); + + 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': { + // Export as LWPOLYLINE (4 corners) + 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'); // closed + 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('62'); lines.push(String(aci)); + lines.push('90'); lines.push(String(pts.length)); + lines.push('70'); lines.push('0'); // open + 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'); // closed + 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; + 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('0.0'); + lines.push('51'); lines.push('360.0'); + break; + } + default: + break; + } + } + + lines.push('0'); lines.push('ENDSEC'); + + // EOF + lines.push('0'); lines.push('EOF'); + + const dxf = lines.join('\n'); + downloadBlob(dxf, filename, 'application/dxf'); +} + +// ─── PDF Export ────────────────────────────────────────────────────────────── + +export async function exportPDF( + yjsDoc: YjsDocument, + filename = 'cad-export.pdf', + options: ExportOptions = {} +): Promise { + const data = collectExportData(yjsDoc, options); + + // Dynamic import to avoid bundling jsPDF if not used + 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 pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 10; + const drawableW = pageWidth - margin * 2; + const drawableH = pageHeight - margin * 2; + + // Calculate bounding box and scale to fit + 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; + + // Transform CAD coords to PDF coords (Y-axis flip) + const tx = (x: number) => offsetX + (x - minX) * scale; + const ty = (y: number) => pageHeight - offsetY - (y - minY) * scale; + + // Draw elements + 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) { + 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; + } + 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); // flip + 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; + } + } + + // Plan header + 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 ────────────────────────────────────────────────────────── + +export type ExportFormat = 'json' | 'svg' | 'dxf' | 'pdf'; + +export async function exportDrawing( + yjsDoc: YjsDocument, + format: ExportFormat, + filename?: string, + options?: ExportOptions +): Promise { + const ext = format; + const name = filename || `cad-export.${ext}`; + + switch (format) { + case 'json': + return exportJSON(yjsDoc, name); + case 'svg': + return exportSVG(yjsDoc, name, options); + case 'dxf': + return exportDXF(yjsDoc, name, options); + case 'pdf': + return exportPDF(yjsDoc, name, options); + default: + throw new Error(`Unsupported export format: ${format}`); + } +}