Files
web-cad/frontend/src/services/exportService.ts
T

288 lines
9.2 KiB
TypeScript
Raw Normal View History

/**
* 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<ExportFileResult> {
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<ExportFileResult> {
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<ExportFileResult> {
// 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<Blob>((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(`<?xml version="1.0" encoding="UTF-8"?>`);
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${w} ${h}" width="${w}" height="${h}">`);
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(`</svg>`);
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 `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ${style}/>`;
}
case 'circle': {
const cx = el.x + el.width / 2;
const cy = el.y + el.height / 2;
const r = p.radius ?? el.width / 2;
return `<circle cx="${cx}" cy="${cy}" r="${r}" ${style}/>`;
}
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 `<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}" ${style}/>`;
}
case 'rect': {
return `<rect x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" ${style}/>`;
}
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 `<text x="${el.x}" y="${el.y}" font-size="${size}" fill="${stroke}" font-family="sans-serif">${escapeXml(p.text ?? '')}</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 `<line x1="${pts[0].x}" y1="${pts[0].y}" x2="${pts[1].x}" y2="${pts[1].y}" ${style}/>\n<text x="${midX}" y="${midY}" font-size="10" fill="${stroke}">${dist.toFixed(2)}</text>`;
}
default: {
return `<rect x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" ${style}/>`;
}
}
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* 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);
}