598 lines
21 KiB
TypeScript
598 lines
21 KiB
TypeScript
|
|
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}`);
|
||
|
|
}
|
||
|
|
}
|