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

288 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}