175 lines
5.8 KiB
TypeScript
175 lines
5.8 KiB
TypeScript
/**
|
||
* PDF Export – renders CAD elements to PDF using pdf-lib
|
||
*/
|
||
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
||
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
||
|
||
/**
|
||
* Export project data to a PDF byte array.
|
||
*/
|
||
export async function exportPDF(data: ProjectData): Promise<Uint8Array> {
|
||
const pdfDoc = await PDFDocument.create();
|
||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||
|
||
// Calculate bounding box of all elements
|
||
const bbox = calculateBoundingBox(data.elements);
|
||
const padding = 20;
|
||
const contentW = bbox.maxX - bbox.minX + padding * 2;
|
||
const contentH = bbox.maxY - bbox.minY + padding * 2;
|
||
|
||
// Use A4 landscape or fit to content (whichever is larger)
|
||
const a4W = 842; // A4 landscape in points
|
||
const a4H = 595;
|
||
const pageW = Math.max(a4W, contentW);
|
||
const pageH = Math.max(a4H, contentH);
|
||
|
||
const page = pdfDoc.addPage([pageW, pageH]);
|
||
const { width: pw, height: ph } = page.getSize();
|
||
|
||
// Flip Y axis: PDF origin is bottom-left, CAD origin is top-left
|
||
const flipY = (y: number) => ph - y + bbox.minY - padding;
|
||
const offsetX = -bbox.minX + padding;
|
||
const offsetY = 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;
|
||
drawElement(page, el, font, offsetX, offsetY, flipY, layer?.color);
|
||
}
|
||
|
||
return pdfDoc.save();
|
||
}
|
||
|
||
function drawElement(
|
||
page: any,
|
||
el: CADElement,
|
||
font: any,
|
||
offX: number,
|
||
offY: number,
|
||
flipY: (y: number) => number,
|
||
layerColor?: string,
|
||
): void {
|
||
const p = el.properties;
|
||
const color = hexToRgb(p.stroke as string) ?? hexToRgb(layerColor ?? '#000000') ?? rgb(0, 0, 0);
|
||
const lineWidth = p.strokeWidth ?? 1;
|
||
|
||
switch (el.type) {
|
||
case 'line': {
|
||
const x1 = (p.x1 ?? el.x) + offX;
|
||
const y1 = flipY((p.y1 ?? el.y) - offY);
|
||
const x2 = (p.x2 ?? el.x + el.width) + offX;
|
||
const y2 = flipY((p.y2 ?? el.y + el.height) - offY);
|
||
page.drawLine({ start: { x: x1, y: y1 }, end: { x: x2, y: y2 }, thickness: lineWidth, color });
|
||
break;
|
||
}
|
||
case 'circle': {
|
||
const cx = el.x + el.width / 2 + offX;
|
||
const cy = flipY(el.y + el.height / 2 - offY);
|
||
const r = p.radius ?? el.width / 2;
|
||
page.drawCircle({ x: cx, y: cy, radius: r, borderColor: color, borderWidth: lineWidth });
|
||
break;
|
||
}
|
||
case 'arc': {
|
||
// Approximate arc with line segments
|
||
const cx = el.x + el.width / 2 + offX;
|
||
const cy = flipY(el.y + el.height / 2 - offY);
|
||
const r = p.radius ?? el.width / 2;
|
||
const start = p.startAngle ?? 0;
|
||
const end = p.endAngle ?? Math.PI * 2;
|
||
const segments = 32;
|
||
for (let i = 0; i < segments; i++) {
|
||
const a1 = start + (end - start) * (i / segments);
|
||
const a2 = start + (end - start) * ((i + 1) / segments);
|
||
page.drawLine({
|
||
start: { x: cx + Math.cos(a1) * r, y: cy + Math.sin(a1) * r },
|
||
end: { x: cx + Math.cos(a2) * r, y: cy + Math.sin(a2) * r },
|
||
thickness: lineWidth, color,
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
case 'rect': {
|
||
page.drawRectangle({
|
||
x: el.x + offX, y: flipY(el.y + el.height - offY),
|
||
width: el.width, height: el.height,
|
||
borderColor: color, borderWidth: lineWidth,
|
||
});
|
||
break;
|
||
}
|
||
case 'polygon':
|
||
case 'polyline': {
|
||
const pts = p.points ?? [];
|
||
for (let i = 0; i < pts.length - 1; i++) {
|
||
page.drawLine({
|
||
start: { x: pts[i].x + offX, y: flipY(pts[i].y - offY) },
|
||
end: { x: pts[i + 1].x + offX, y: flipY(pts[i + 1].y - offY) },
|
||
thickness: lineWidth, color,
|
||
});
|
||
}
|
||
if (el.type === 'polygon' && pts.length > 2) {
|
||
page.drawLine({
|
||
start: { x: pts[pts.length - 1].x + offX, y: flipY(pts[pts.length - 1].y - offY) },
|
||
end: { x: pts[0].x + offX, y: flipY(pts[0].y - offY) },
|
||
thickness: lineWidth, color,
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
case 'text': {
|
||
const size = p.fontSize ?? 12;
|
||
page.drawText(p.text ?? '', {
|
||
x: el.x + offX, y: flipY(el.y - offY) - size,
|
||
size, font, color,
|
||
});
|
||
break;
|
||
}
|
||
case 'dimension': {
|
||
const pts = p.points ?? [];
|
||
if (pts.length >= 2) {
|
||
page.drawLine({
|
||
start: { x: pts[0].x + offX, y: flipY(pts[0].y - offY) },
|
||
end: { x: pts[1].x + offX, y: flipY(pts[1].y - offY) },
|
||
thickness: lineWidth, color,
|
||
});
|
||
const midX = (pts[0].x + pts[1].x) / 2 + offX;
|
||
const midY = flipY((pts[0].y + pts[1].y) / 2 - offY);
|
||
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
|
||
page.drawText(dist.toFixed(2), { x: midX, y: midY, size: 10, font, color });
|
||
}
|
||
break;
|
||
}
|
||
default: {
|
||
// Bounding box for other element types
|
||
page.drawRectangle({
|
||
x: el.x + offX, y: flipY(el.y + el.height - offY),
|
||
width: el.width, height: el.height,
|
||
borderColor: color, borderWidth: lineWidth,
|
||
});
|
||
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 hexToRgb(hex: string | undefined): ReturnType<typeof rgb> | null {
|
||
if (!hex) return null;
|
||
const h = hex.replace('#', '');
|
||
if (h.length < 6) return null;
|
||
const r = parseInt(h.substring(0, 2), 16) / 255;
|
||
const g = parseInt(h.substring(2, 4), 16) / 255;
|
||
const b = parseInt(h.substring(4, 6), 16) / 255;
|
||
return rgb(r, g, b);
|
||
}
|