T20: Add exportService.ts (JSON/SVG/DXF/PDF exporters)

This commit is contained in:
2026-06-22 23:12:11 +00:00
parent f6e1b7f690
commit 68687d3557
+597
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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}`);
}
}