2026-06-26 10:50:24 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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';
|
2026-07-04 16:43:55 +02:00
|
|
|
|
const style = `stroke="${stroke}" strokeWidth="${sw}" fill="${fill}"`;
|
2026-06-26 10:50:24 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|