From e6da2a9b2f1d63cee1895a85aeafe3f2efe312ce Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Mon, 22 Jun 2026 23:12:58 +0000 Subject: [PATCH] T20: Add importService.ts (JSON round-trip + DXF import) --- frontend/src/services/importService.ts | 484 +++++++++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 frontend/src/services/importService.ts diff --git a/frontend/src/services/importService.ts b/frontend/src/services/importService.ts new file mode 100644 index 0000000..da502b9 --- /dev/null +++ b/frontend/src/services/importService.ts @@ -0,0 +1,484 @@ +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 ImportData { + version: string; + exportedAt: number; + projectMeta: Record; + layers: CADLayer[]; + elements: CADElement[]; +} + +export interface ImportResult { + success: boolean; + elementsImported: number; + layersImported: number; + errors: string[]; + warnings: string[]; +} + +// ─── JSON Import ───────────────────────────────────────────────────────────── + +/** + * Import a JSON string (from exportJSON) into a YjsDocument. + * This is the round-trip counterpart to exportJSON. + * Clears existing elements and layers before importing. + */ +export function importJSON(jsonString: string, yjsDoc: YjsDocument): ImportResult { + const errors: string[] = []; + const warnings: string[] = []; + + let data: ImportData; + try { + data = JSON.parse(jsonString) as ImportData; + } catch (e) { + return { + success: false, + elementsImported: 0, + layersImported: 0, + errors: [`JSON parse error: ${(e as Error).message}`], + warnings: [], + }; + } + + if (!data.version || !data.layers || !data.elements) { + return { + success: false, + elementsImported: 0, + layersImported: 0, + errors: ['Invalid export format: missing version, layers, or elements'], + warnings: [], + }; + } + + // Clear existing data + yjsDoc.doc.transact(() => { + // Clear elements + const currentElements = yjsDoc.getElements(); + if (currentElements.length > 0) { + yjsDoc.elements.delete(0, currentElements.length); + } + + // Clear layers + const layerIds: string[] = []; + yjsDoc.layers.forEach((_, id) => { + layerIds.push(id); + }); + for (const id of layerIds) { + yjsDoc.layers.delete(id); + } + + // Clear projectMeta + const metaKeys: string[] = []; + yjsDoc.projectMeta.forEach((_, key) => { + metaKeys.push(key); + }); + for (const key of metaKeys) { + yjsDoc.projectMeta.delete(key); + } + }); + + // Import layers + let layersImported = 0; + yjsDoc.doc.transact(() => { + for (const layer of data.layers) { + if (!layer.id) { + warnings.push(`Layer without id skipped: ${layer.name || 'unnamed'}`); + continue; + } + const { id, ...layerData } = layer; + yjsDoc.layers.set(id, layerData as never); + layersImported++; + } + }); + + // Import elements + let elementsImported = 0; + yjsDoc.doc.transact(() => { + for (const element of data.elements) { + if (!element.id || !element.type) { + warnings.push(`Element without id or type skipped`); + continue; + } + yjsDoc.createElement(element as never); + elementsImported++; + } + }); + + // Import projectMeta + if (data.projectMeta) { + yjsDoc.doc.transact(() => { + for (const [key, value] of Object.entries(data.projectMeta)) { + yjsDoc.projectMeta.set(key, value as never); + } + }); + } + + return { + success: true, + elementsImported, + layersImported, + errors, + warnings, + }; +} + +/** + * Import a JSON file from a File object (e.g., from ). + */ +export async function importJSONFile(file: File, yjsDoc: YjsDocument): Promise { + const text = await file.text(); + return importJSON(text, yjsDoc); +} + +// ─── DXF Import (basic) ────────────────────────────────────────────────────── + +interface DxfEntity { + type: string; + layer: string; + color?: number; + data: Record; +} + +/** + * Parse a DXF string into entities. + * Supports: LINE, CIRCLE, TEXT, LWPOLYLINE, ARC + */ +function parseDxf(dxfContent: string): { entities: DxfEntity[]; layers: Map } { + const lines = dxfContent.split(/\r?\n/); + const entities: DxfEntity[] = []; + const layers = new Map(); + + let i = 0; + let inEntities = false; + let inTables = false; + let inLayerTable = false; + + while (i < lines.length) { + const code = lines[i]?.trim(); + const value = lines[i + 1]?.trim() ?? ''; + + // Section detection + if (code === '0' && value === 'SECTION') { + const sectionName = lines[i + 3]?.trim() ?? ''; + inEntities = sectionName === 'ENTITIES'; + inTables = sectionName === 'TABLES'; + i += 4; + continue; + } + + if (code === '0' && value === 'ENDSEC') { + inEntities = false; + inTables = false; + inLayerTable = false; + i += 2; + continue; + } + + // Parse LAYER table entries + if (inTables && code === '0' && value === 'TABLE') { + const tableName = lines[i + 3]?.trim() ?? ''; + inLayerTable = tableName === 'LAYER'; + i += 4; + continue; + } + + if (inTables && code === '0' && value === 'LAYER' && inLayerTable) { + // Read layer properties + let layerName = ''; + let layerColor = ''; + let j = i + 2; + while (j < lines.length && lines[j]?.trim() !== '0') { + const c = lines[j]?.trim(); + const v = lines[j + 1]?.trim() ?? ''; + if (c === '2') layerName = v; + if (c === '62') layerColor = v; + j += 2; + } + if (layerName) { + layers.set(layerName, { name: layerName, color: layerColor }); + } + i = j; + continue; + } + + // Parse entities + if (inEntities && code === '0' && value !== 'ENDSEC') { + const entityType = value; + const entityData: Record = {}; + let entityLayer = '0'; + let entityColor: number | undefined; + + let j = i + 2; + while (j < lines.length && lines[j]?.trim() !== '0') { + const c = lines[j]?.trim(); + const v = lines[j + 1]?.trim() ?? ''; + if (c === '8') entityLayer = v; + if (c === '62') entityColor = parseInt(v, 10); + entityData[parseInt(c, 10)] = v; + j += 2; + } + + entities.push({ + type: entityType, + layer: entityLayer, + color: entityColor, + data: entityData, + }); + i = j; + continue; + } + + i += 2; + } + + return { entities, layers }; +} + +/** + * Import a DXF string into a YjsDocument. + * Supports basic LINE, CIRCLE, TEXT, LWPOLYLINE, ARC entities. + */ +export function importDXF(dxfContent: string, yjsDoc: YjsDocument): ImportResult { + const errors: string[] = []; + const warnings: string[] = []; + + const { entities, layers: dxfLayers } = parseDxf(dxfContent); + + // Import layers + let layersImported = 0; + yjsDoc.doc.transact(() => { + for (const [name, info] of dxfLayers) { + const layerId = name.replace(/[^a-zA-Z0-9_-]/g, '_'); + if (!yjsDoc.layers.has(layerId)) { + yjsDoc.layers.set(layerId, { + id: layerId, + name, + visible: true, + locked: false, + color: info.color ? aciToHex(parseInt(info.color, 10)) : undefined, + } as never); + layersImported++; + } + } + + // Ensure default layer exists + if (!yjsDoc.layers.has('default')) { + yjsDoc.layers.set('default', { + id: 'default', + name: 'Default', + visible: true, + locked: false, + } as never); + layersImported++; + } + }); + + // Import entities + let elementsImported = 0; + yjsDoc.doc.transact(() => { + for (const entity of entities) { + const layerId = entity.layer.replace(/[^a-zA-Z0-9_-]/g, '_') || 'default'; + const stroke = entity.color ? aciToHex(entity.color) : '#000000'; + const id = `dxf-${Date.now()}-${elementsImported}`; + + switch (entity.type) { + case 'LINE': { + const x1 = parseFloat(entity.data[10] || '0'); + const y1 = parseFloat(entity.data[20] || '0'); + const x2 = parseFloat(entity.data[11] || '0'); + const y2 = parseFloat(entity.data[21] || '0'); + yjsDoc.createElement({ + id, + type: 'line', + layerId, + x: Math.min(x1, x2), + y: Math.min(y1, y2), + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + properties: { x1, y1, x2, y2, stroke, strokeWidth: 1 }, + } as never); + elementsImported++; + break; + } + case 'CIRCLE': { + const cx = parseFloat(entity.data[10] || '0'); + const cy = parseFloat(entity.data[20] || '0'); + const radius = parseFloat(entity.data[40] || '0'); + yjsDoc.createElement({ + id, + type: 'circle', + layerId, + x: cx - radius, + y: cy - radius, + width: radius * 2, + height: radius * 2, + properties: { radius, stroke, strokeWidth: 1, fill: 'transparent' }, + } as never); + elementsImported++; + break; + } + case 'TEXT': { + const x = parseFloat(entity.data[10] || '0'); + const y = parseFloat(entity.data[20] || '0'); + const fontSize = parseFloat(entity.data[40] || '14'); + const text = entity.data[1] || ''; + yjsDoc.createElement({ + id, + type: 'text', + layerId, + x, + y, + width: text.length * fontSize * 0.6, + height: fontSize, + properties: { text, fontSize, stroke }, + } as never); + elementsImported++; + break; + } + case 'LWPOLYLINE': { + const numVerts = parseInt(entity.data[90] || '0', 10); + const closed = parseInt(entity.data[70] || '0', 10) === 1; + const points: Array<{ x: number; y: number }> = []; + for (let v = 0; v < numVerts; v++) { + const px = parseFloat(entity.data[10 + v * 2] || '0'); + const py = parseFloat(entity.data[20 + v * 2] || '0'); + points.push({ x: px, y: py }); + } + if (points.length > 0) { + const xs = points.map(p => p.x); + const ys = points.map(p => p.y); + yjsDoc.createElement({ + id, + type: closed ? 'polygon' : 'polyline', + layerId, + x: Math.min(...xs), + y: Math.min(...ys), + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + properties: { points, stroke, strokeWidth: 1, fill: closed ? 'transparent' : undefined }, + } as never); + elementsImported++; + } + break; + } + case 'ARC': { + const cx = parseFloat(entity.data[10] || '0'); + const cy = parseFloat(entity.data[20] || '0'); + const radius = parseFloat(entity.data[40] || '0'); + yjsDoc.createElement({ + id, + type: 'arc', + layerId, + x: cx - radius, + y: cy - radius, + width: radius * 2, + height: radius * 2, + properties: { radius, stroke, strokeWidth: 1, fill: 'transparent' }, + } as never); + elementsImported++; + break; + } + default: + warnings.push(`Unsupported DXF entity type: ${entity.type}`); + break; + } + } + }); + + return { + success: errors.length === 0, + elementsImported, + layersImported, + errors, + warnings, + }; +} + +/** + * Import a DXF file from a File object. + */ +export async function importDXFFile(file: File, yjsDoc: YjsDocument): Promise { + const text = await file.text(); + return importDXF(text, yjsDoc); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function aciToHex(aci: number): string { + const aciMap: Record = { + 1: '#ff0000', // red + 2: '#ffff00', // yellow + 3: '#00ff00', // green + 4: '#00ffff', // cyan + 5: '#0000ff', // blue + 6: '#ff00ff', // magenta + 7: '#ffffff', // white + }; + return aciMap[aci] || '#000000'; +} + +// ─── Unified Import ────────────────────────────────────────────────────────── + +export type ImportFormat = 'json' | 'dxf'; + +export async function importDrawing( + file: File, + format: ImportFormat, + yjsDoc: YjsDocument +): Promise { + switch (format) { + case 'json': + return importJSONFile(file, yjsDoc); + case 'dxf': + return importDXFFile(file, yjsDoc); + default: + return { + success: false, + elementsImported: 0, + layersImported: 0, + errors: [`Unsupported import format: ${format}`], + warnings: [], + }; + } +}