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}`); } }