T20: Add exportService.ts (JSON/SVG/DXF/PDF exporters)
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
import type { YjsDocument } from '../crdt/YjsDocument';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CADLayer {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
color?: string;
|
||||
lineType?: string;
|
||||
transparency?: number;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
interface CADProperties {
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
rotation?: number;
|
||||
lineType?: 'solid' | 'dashed' | 'dotted';
|
||||
radius?: number;
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
points?: Array<{ x: number; y: number }>;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CADElement {
|
||||
id: string;
|
||||
type: string;
|
||||
layerId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
properties: CADProperties;
|
||||
}
|
||||
|
||||
interface ExportData {
|
||||
version: string;
|
||||
exportedAt: number;
|
||||
projectMeta: Record<string, unknown>;
|
||||
layers: CADLayer[];
|
||||
elements: CADElement[];
|
||||
}
|
||||
|
||||
interface ExportOptions {
|
||||
includeHiddenLayers?: boolean;
|
||||
includeInvisibleElements?: boolean;
|
||||
pageSize?: 'a4' | 'a3' | 'letter';
|
||||
landscape?: boolean;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const clean = hex.replace('#', '');
|
||||
if (clean.length === 3) {
|
||||
return {
|
||||
r: parseInt(clean[0] + clean[0], 16),
|
||||
g: parseInt(clean[1] + clean[1], 16),
|
||||
b: parseInt(clean[2] + clean[2], 16),
|
||||
};
|
||||
}
|
||||
return {
|
||||
r: parseInt(clean.substring(0, 2), 16) || 0,
|
||||
g: parseInt(clean.substring(2, 4), 16) || 0,
|
||||
b: parseInt(clean.substring(4, 6), 16) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToAci(r: number, g: number, b: number): number {
|
||||
// Simplified ACI color mapping
|
||||
if (r > 200 && g > 200 && b > 200) return 7; // white
|
||||
if (r < 50 && g < 50 && b < 50) return 7; // black → white in DXF
|
||||
if (r > 200 && g < 100 && b < 100) return 1; // red
|
||||
if (r < 100 && g > 200 && b < 100) return 3; // green
|
||||
if (r < 100 && g < 100 && b > 200) return 5; // blue
|
||||
if (r > 200 && g > 200 && b < 100) return 2; // yellow
|
||||
if (r > 200 && g < 100 && b > 200) return 6; // magenta
|
||||
if (r < 100 && g > 200 && b > 200) return 4; // cyan
|
||||
return 7;
|
||||
}
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function getLayerById(layers: CADLayer[], id: string): CADLayer | undefined {
|
||||
return layers.find((l) => l.id === id);
|
||||
}
|
||||
|
||||
function shouldExportElement(
|
||||
element: CADElement,
|
||||
layers: CADLayer[],
|
||||
options: ExportOptions
|
||||
): boolean {
|
||||
if (options.includeInvisibleElements) return true;
|
||||
const layer = getLayerById(layers, element.layerId);
|
||||
if (layer && !layer.visible) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectExportData(yjsDoc: YjsDocument, options: ExportOptions): ExportData {
|
||||
const layers: CADLayer[] = [];
|
||||
yjsDoc.layers.forEach((layer, id) => {
|
||||
if (!options.includeHiddenLayers && !layer.visible) return;
|
||||
layers.push({ ...layer, id });
|
||||
});
|
||||
layers.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
|
||||
const elements = yjsDoc.getElements().filter((el) =>
|
||||
shouldExportElement(el, layers, options)
|
||||
);
|
||||
|
||||
const projectMeta: Record<string, unknown> = {};
|
||||
yjsDoc.projectMeta.forEach((value, key) => {
|
||||
projectMeta[key] = value;
|
||||
});
|
||||
|
||||
return {
|
||||
version: '1.0.0',
|
||||
exportedAt: Date.now(),
|
||||
projectMeta,
|
||||
layers,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
function downloadBlob(content: string | Blob, filename: string, mimeType: string): void {
|
||||
const blob = typeof content === 'string' ? new Blob([content], { type: mimeType }) : content;
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── JSON Export ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): void {
|
||||
const data = collectExportData(yjsDoc, {});
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
downloadBlob(json, filename, 'application/json');
|
||||
}
|
||||
|
||||
export function exportJSONString(yjsDoc: YjsDocument): string {
|
||||
const data = collectExportData(yjsDoc, {});
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
// ─── SVG Export ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): void {
|
||||
const data = collectExportData(yjsDoc, options);
|
||||
|
||||
// Calculate bounding box
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of data.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);
|
||||
}
|
||||
if (data.elements.length === 0) {
|
||||
minX = 0; minY = 0; maxX = 100; maxY = 100;
|
||||
}
|
||||
|
||||
const padding = 10;
|
||||
const vbX = minX - padding;
|
||||
const vbY = minY - padding;
|
||||
const vbW = (maxX - minX) + padding * 2;
|
||||
const vbH = (maxY - minY) + padding * 2;
|
||||
|
||||
const svgParts: string[] = [];
|
||||
svgParts.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||
svgParts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vbX} ${vbY} ${vbW} ${vbH}" width="${vbW}" height="${vbH}">`);
|
||||
|
||||
// Layer styles
|
||||
svgParts.push('<defs><style>');
|
||||
for (const layer of data.layers) {
|
||||
const color = layer.color || '#000000';
|
||||
const dash = layer.lineType === 'dashed' ? 'stroke-dasharray:8,4;' : layer.lineType === 'dotted' ? 'stroke-dasharray:2,3;' : '';
|
||||
svgParts.push(`.layer-${layer.id} { stroke: ${color}; ${dash} }`);
|
||||
}
|
||||
svgParts.push('</style></defs>');
|
||||
|
||||
// Elements (Y-axis flipped for SVG coordinate system)
|
||||
for (const el of data.elements) {
|
||||
const layer = getLayerById(data.layers, el.layerId);
|
||||
const layerClass = layer ? `class="layer-${layer.id}"` : '';
|
||||
const stroke = el.properties.stroke || layer?.color || '#000000';
|
||||
const fill = el.properties.fill || 'none';
|
||||
const strokeWidth = el.properties.strokeWidth || 1;
|
||||
const transform = el.properties.rotation ? ` transform="rotate(${el.properties.rotation} ${el.x + el.width / 2} ${el.y + el.height / 2})"` : '';
|
||||
|
||||
// SVG Y-axis is inverted compared to CAD
|
||||
const svgY = (vbY + vbH) - el.y;
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = el.properties.x1 ?? el.x;
|
||||
const y1 = (vbY + vbH) - (el.properties.y1 ?? el.y);
|
||||
const x2 = el.properties.x2 ?? (el.x + el.width);
|
||||
const y2 = (vbY + vbH) - (el.properties.y2 ?? (el.y + el.height));
|
||||
svgParts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const radius = el.properties.radius ?? el.width / 2;
|
||||
const cx = el.x + radius;
|
||||
const cy = svgY - radius;
|
||||
svgParts.push(`<circle cx="${cx}" cy="${cy}" r="${radius}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
svgParts.push(`<rect x="${el.x}" y="${svgY - el.height}" width="${el.width}" height="${el.height}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
break;
|
||||
}
|
||||
case 'polyline': {
|
||||
if (el.properties.points && el.properties.points.length > 0) {
|
||||
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
|
||||
svgParts.push(`<polyline points="${pts}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polygon': {
|
||||
if (el.properties.points && el.properties.points.length > 0) {
|
||||
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
|
||||
svgParts.push(`<polygon points="${pts}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const text = escapeXml(el.properties.text || '');
|
||||
const fontSize = el.properties.fontSize || 14;
|
||||
svgParts.push(`<text x="${el.x}" y="${svgY}" font-size="${fontSize}" fill="${stroke}"${transform}>${text}</text>`);
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
// Approximate arc with path (simplified: use bounding box)
|
||||
const rx = el.width / 2;
|
||||
const ry = el.height / 2;
|
||||
const cx = el.x + rx;
|
||||
const cy = svgY - ry;
|
||||
svgParts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unknown type: skip
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
svgParts.push('</svg>');
|
||||
const svg = svgParts.join('\n');
|
||||
downloadBlob(svg, filename, 'image/svg+xml');
|
||||
}
|
||||
|
||||
// ─── DXF Export ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): void {
|
||||
const data = collectExportData(yjsDoc, options);
|
||||
const lines: string[] = [];
|
||||
|
||||
// HEADER section
|
||||
lines.push('0'); lines.push('SECTION');
|
||||
lines.push('2'); lines.push('HEADER');
|
||||
lines.push('9'); lines.push('$ACADVER');
|
||||
lines.push('1'); lines.push('AC1009');
|
||||
lines.push('9'); lines.push('$INSBASE');
|
||||
lines.push('10'); lines.push('0.0');
|
||||
lines.push('20'); lines.push('0.0');
|
||||
lines.push('30'); lines.push('0.0');
|
||||
lines.push('0'); lines.push('ENDSEC');
|
||||
|
||||
// TABLES section — LAYER table
|
||||
lines.push('0'); lines.push('SECTION');
|
||||
lines.push('2'); lines.push('TABLES');
|
||||
lines.push('0'); lines.push('TABLE');
|
||||
lines.push('2'); lines.push('LAYER');
|
||||
lines.push('70'); lines.push(String(data.layers.length));
|
||||
|
||||
for (const layer of data.layers) {
|
||||
const color = layer.color || '#000000';
|
||||
const rgb = hexToRgb(color);
|
||||
const aci = rgbToAci(rgb.r, rgb.g, rgb.b);
|
||||
lines.push('0'); lines.push('LAYER');
|
||||
lines.push('2'); lines.push(layer.name || layer.id);
|
||||
lines.push('70'); lines.push('0');
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('6'); lines.push(layer.lineType || 'CONTINUOUS');
|
||||
}
|
||||
|
||||
lines.push('0'); lines.push('ENDTAB');
|
||||
lines.push('0'); lines.push('ENDSEC');
|
||||
|
||||
// ENTITIES section
|
||||
lines.push('0'); lines.push('SECTION');
|
||||
lines.push('2'); lines.push('ENTITIES');
|
||||
|
||||
for (const el of data.elements) {
|
||||
const layer = getLayerById(data.layers, el.layerId);
|
||||
const layerName = layer?.name || layer?.id || '0';
|
||||
const color = el.properties.stroke || layer?.color || '#000000';
|
||||
const rgb = hexToRgb(color);
|
||||
const aci = rgbToAci(rgb.r, rgb.g, rgb.b);
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = el.properties.x1 ?? el.x;
|
||||
const y1 = el.properties.y1 ?? el.y;
|
||||
const x2 = el.properties.x2 ?? (el.x + el.width);
|
||||
const y2 = el.properties.y2 ?? (el.y + el.height);
|
||||
lines.push('0'); lines.push('LINE');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('10'); lines.push(String(x1));
|
||||
lines.push('20'); lines.push(String(y1));
|
||||
lines.push('30'); lines.push('0.0');
|
||||
lines.push('11'); lines.push(String(x2));
|
||||
lines.push('21'); lines.push(String(y2));
|
||||
lines.push('31'); lines.push('0.0');
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const radius = el.properties.radius ?? el.width / 2;
|
||||
const cx = el.x + radius;
|
||||
const cy = el.y + radius;
|
||||
lines.push('0'); lines.push('CIRCLE');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('10'); lines.push(String(cx));
|
||||
lines.push('20'); lines.push(String(cy));
|
||||
lines.push('30'); lines.push('0.0');
|
||||
lines.push('40'); lines.push(String(radius));
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
// Export as LWPOLYLINE (4 corners)
|
||||
const x = el.x, y = el.y, w = el.width, h = el.height;
|
||||
lines.push('0'); lines.push('LWPOLYLINE');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('90'); lines.push('4');
|
||||
lines.push('70'); lines.push('1'); // closed
|
||||
lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y));
|
||||
lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y));
|
||||
lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y + h));
|
||||
lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y + h));
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const text = el.properties.text || '';
|
||||
const fontSize = el.properties.fontSize || 14;
|
||||
lines.push('0'); lines.push('TEXT');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('10'); lines.push(String(el.x));
|
||||
lines.push('20'); lines.push(String(el.y));
|
||||
lines.push('30'); lines.push('0.0');
|
||||
lines.push('40'); lines.push(String(fontSize));
|
||||
lines.push('1'); lines.push(text);
|
||||
break;
|
||||
}
|
||||
case 'polyline': {
|
||||
if (el.properties.points && el.properties.points.length > 0) {
|
||||
const pts = el.properties.points;
|
||||
lines.push('0'); lines.push('LWPOLYLINE');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('90'); lines.push(String(pts.length));
|
||||
lines.push('70'); lines.push('0'); // open
|
||||
for (const p of pts) {
|
||||
lines.push('10'); lines.push(String(p.x));
|
||||
lines.push('20'); lines.push(String(p.y));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polygon': {
|
||||
if (el.properties.points && el.properties.points.length > 0) {
|
||||
const pts = el.properties.points;
|
||||
lines.push('0'); lines.push('LWPOLYLINE');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('90'); lines.push(String(pts.length));
|
||||
lines.push('70'); lines.push('1'); // closed
|
||||
for (const p of pts) {
|
||||
lines.push('10'); lines.push(String(p.x));
|
||||
lines.push('20'); lines.push(String(p.y));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
const radius = el.properties.radius ?? el.width / 2;
|
||||
const cx = el.x + radius;
|
||||
const cy = el.y + radius;
|
||||
lines.push('0'); lines.push('ARC');
|
||||
lines.push('8'); lines.push(layerName);
|
||||
lines.push('62'); lines.push(String(aci));
|
||||
lines.push('10'); lines.push(String(cx));
|
||||
lines.push('20'); lines.push(String(cy));
|
||||
lines.push('30'); lines.push('0.0');
|
||||
lines.push('40'); lines.push(String(radius));
|
||||
lines.push('50'); lines.push('0.0');
|
||||
lines.push('51'); lines.push('360.0');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('0'); lines.push('ENDSEC');
|
||||
|
||||
// EOF
|
||||
lines.push('0'); lines.push('EOF');
|
||||
|
||||
const dxf = lines.join('\n');
|
||||
downloadBlob(dxf, filename, 'application/dxf');
|
||||
}
|
||||
|
||||
// ─── PDF Export ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function exportPDF(
|
||||
yjsDoc: YjsDocument,
|
||||
filename = 'cad-export.pdf',
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const data = collectExportData(yjsDoc, options);
|
||||
|
||||
// Dynamic import to avoid bundling jsPDF if not used
|
||||
const { jsPDF } = await import('jspdf');
|
||||
|
||||
const pageSize = options.pageSize || 'a4';
|
||||
const orientation = options.landscape ? 'landscape' : 'portrait';
|
||||
const pdf = new jsPDF({
|
||||
orientation,
|
||||
unit: 'mm',
|
||||
format: pageSize,
|
||||
});
|
||||
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
const margin = 10;
|
||||
const drawableW = pageWidth - margin * 2;
|
||||
const drawableH = pageHeight - margin * 2;
|
||||
|
||||
// Calculate bounding box and scale to fit
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of data.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);
|
||||
}
|
||||
if (data.elements.length === 0) {
|
||||
minX = 0; minY = 0; maxX = 100; maxY = 100;
|
||||
}
|
||||
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const scale = Math.min(drawableW / contentW, drawableH / contentH, 1);
|
||||
const offsetX = margin + (drawableW - contentW * scale) / 2;
|
||||
const offsetY = margin + (drawableH - contentH * scale) / 2;
|
||||
|
||||
// Transform CAD coords to PDF coords (Y-axis flip)
|
||||
const tx = (x: number) => offsetX + (x - minX) * scale;
|
||||
const ty = (y: number) => pageHeight - offsetY - (y - minY) * scale;
|
||||
|
||||
// Draw elements
|
||||
for (const el of data.elements) {
|
||||
const layer = getLayerById(data.layers, el.layerId);
|
||||
const strokeColor = el.properties.stroke || layer?.color || '#000000';
|
||||
const rgb = hexToRgb(strokeColor);
|
||||
pdf.setDrawColor(rgb.r, rgb.g, rgb.b);
|
||||
pdf.setLineWidth((el.properties.strokeWidth || 1) * scale);
|
||||
|
||||
const fillColor = el.properties.fill;
|
||||
if (fillColor && fillColor !== 'transparent') {
|
||||
const fRgb = hexToRgb(fillColor);
|
||||
pdf.setFillColor(fRgb.r, fRgb.g, fRgb.b);
|
||||
}
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = el.properties.x1 ?? el.x;
|
||||
const y1 = el.properties.y1 ?? el.y;
|
||||
const x2 = el.properties.x2 ?? (el.x + el.width);
|
||||
const y2 = el.properties.y2 ?? (el.y + el.height);
|
||||
pdf.line(tx(x1), ty(y1), tx(x2), ty(y2));
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const radius = el.properties.radius ?? el.width / 2;
|
||||
const cx = tx(el.x + radius);
|
||||
const cy = ty(el.y + radius);
|
||||
pdf.circle(cx, cy, radius * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const x = tx(el.x);
|
||||
const y = ty(el.y + el.height); // flip
|
||||
pdf.rect(x, y, el.width * scale, el.height * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
|
||||
break;
|
||||
}
|
||||
case 'polyline': {
|
||||
if (el.properties.points && el.properties.points.length > 1) {
|
||||
const pts = el.properties.points;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[i + 1].x), ty(pts[i + 1].y));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'polygon': {
|
||||
if (el.properties.points && el.properties.points.length > 2) {
|
||||
const pts = el.properties.points;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const next = (i + 1) % pts.length;
|
||||
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[next].x), ty(pts[next].y));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const text = el.properties.text || '';
|
||||
const fontSize = (el.properties.fontSize || 14) * scale;
|
||||
pdf.setFontSize(fontSize);
|
||||
pdf.text(text, tx(el.x), ty(el.y));
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
const radius = el.properties.radius ?? el.width / 2;
|
||||
const cx = tx(el.x + radius);
|
||||
const cy = ty(el.y + radius);
|
||||
pdf.circle(cx, cy, radius * scale, 'S');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Plan header
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(128, 128, 128);
|
||||
pdf.text(
|
||||
`Exported: ${new Date().toISOString()} | Elements: ${data.elements.length} | Layers: ${data.layers.length}`,
|
||||
margin,
|
||||
pageHeight - 5
|
||||
);
|
||||
|
||||
pdf.save(filename);
|
||||
}
|
||||
|
||||
// ─── Unified Export ──────────────────────────────────────────────────────────
|
||||
|
||||
export type ExportFormat = 'json' | 'svg' | 'dxf' | 'pdf';
|
||||
|
||||
export async function exportDrawing(
|
||||
yjsDoc: YjsDocument,
|
||||
format: ExportFormat,
|
||||
filename?: string,
|
||||
options?: ExportOptions
|
||||
): Promise<void> {
|
||||
const ext = format;
|
||||
const name = filename || `cad-export.${ext}`;
|
||||
|
||||
switch (format) {
|
||||
case 'json':
|
||||
return exportJSON(yjsDoc, name);
|
||||
case 'svg':
|
||||
return exportSVG(yjsDoc, name, options);
|
||||
case 'dxf':
|
||||
return exportDXF(yjsDoc, name, options);
|
||||
case 'pdf':
|
||||
return exportPDF(yjsDoc, name, options);
|
||||
default:
|
||||
throw new Error(`Unsupported export format: ${format}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user