feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 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}" stroke-width="${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);
|
||||
}
|
||||
Reference in New Issue
Block a user