288 lines
9.2 KiB
TypeScript
288 lines
9.2 KiB
TypeScript
/**
|
||
* 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, '&').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);
|
||
}
|