From 90448c12144fab9ea95ac2d4da8ac374303a7f9d Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Tue, 23 Jun 2026 01:41:28 +0200 Subject: [PATCH] T22: Fix DXF LWPOLYLINE vertex parsing (M1), clear existing data on import (M2), preserve arc start/end angles (M4) --- frontend/src/services/exportService.ts | 23 +- frontend/src/services/importService.ts | 533 ++++++++++++++++++++++++- 2 files changed, 546 insertions(+), 10 deletions(-) diff --git a/frontend/src/services/exportService.ts b/frontend/src/services/exportService.ts index 7480b4c..5f5e102 100644 --- a/frontend/src/services/exportService.ts +++ b/frontend/src/services/exportService.ts @@ -1,6 +1,6 @@ import type { YjsDocument } from '../crdt/YjsDocument'; -// ─── Types ─────────────────────────────────────────────────────────────────── +// ── Types ──────────────────────────────────────────────────────────────────── interface CADLayer { id: string; @@ -27,6 +27,8 @@ interface CADProperties { points?: Array<{ x: number; y: number }>; text?: string; fontSize?: number; + startAngle?: number; + endAngle?: number; [key: string]: unknown; } @@ -56,7 +58,7 @@ interface ExportOptions { landscape?: boolean; } -// ─── Helpers ───────────────────────────────────────────────────────────────── +// ── Helpers ────────────────────────────────────────────────────────────────── function hexToRgb(hex: string): { r: number; g: number; b: number } { const clean = hex.replace('#', ''); @@ -148,7 +150,7 @@ function downloadBlob(content: string | Blob, filename: string, mimeType: string URL.revokeObjectURL(url); } -// ─── JSON Export ───────────────────────────────────────────────────────────── +// ── JSON Export ────────────────────────────────────────────────────────────── export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): void { const data = collectExportData(yjsDoc, {}); @@ -161,7 +163,7 @@ export function exportJSONString(yjsDoc: YjsDocument): string { return JSON.stringify(data, null, 2); } -// ─── SVG Export ────────────────────────────────────────────────────────────── +// ── SVG Export ─────────────────────────────────────────────────────────────── export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): void { const data = collectExportData(yjsDoc, options); @@ -262,7 +264,7 @@ export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', opti downloadBlob(svg, filename, 'image/svg+xml'); } -// ─── DXF Export ────────────────────────────────────────────────────────────── +// ── DXF Export ─────────────────────────────────────────────────────────────── export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): void { const data = collectExportData(yjsDoc, options); @@ -398,6 +400,9 @@ export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', opti const radius = el.properties.radius ?? el.width / 2; const cx = el.x + radius; const cy = el.y + radius; + // M4 fix: Export actual startAngle (group code 50) and endAngle (group code 51) + const startAngle = el.properties.startAngle ?? 0; + const endAngle = el.properties.endAngle ?? 360; lines.push('0'); lines.push('ARC'); lines.push('8'); lines.push(layerName); lines.push('62'); lines.push(String(aci)); @@ -405,8 +410,8 @@ export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', opti 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'); + lines.push('50'); lines.push(String(startAngle)); + lines.push('51'); lines.push(String(endAngle)); break; } default: @@ -421,7 +426,7 @@ export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', opti downloadBlob(dxf, filename, 'application/dxf'); } -// ─── PDF Export ────────────────────────────────────────────────────────────── +// ── PDF Export ─────────────────────────────────────────────────────────────── export async function exportPDF( yjsDoc: YjsDocument, @@ -550,7 +555,7 @@ export async function exportPDF( pdf.save(filename); } -// ─── Unified Export ────────────────────────────────────────────────────────── +// ── Unified Export ─────────────────────────────────────────────────────────── export type ExportFormat = 'json' | 'svg' | 'dxf' | 'pdf'; diff --git a/frontend/src/services/importService.ts b/frontend/src/services/importService.ts index dc9c1e2..0257d23 100644 --- a/frontend/src/services/importService.ts +++ b/frontend/src/services/importService.ts @@ -1 +1,532 @@ -§§include(/tmp/t22-fix/importService.ts) \ No newline at end of file +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; + startAngle?: number; + endAngle?: 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; + dataArrays: 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 = {}; + const entityDataArrays: 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() ?? ''; + const codeNum = parseInt(c, 10); + if (c === '8') entityLayer = v; + if (c === '62') entityColor = parseInt(v, 10); + // Store last value in entityData (for non-repeated codes) + entityData[codeNum] = v; + // Collect all values per group code into arrays (for repeated codes like 10/20) + if (!entityDataArrays[codeNum]) { + entityDataArrays[codeNum] = []; + } + entityDataArrays[codeNum].push(v); + j += 2; + } + + entities.push({ + type: entityType, + layer: entityLayer, + color: entityColor, + data: entityData, + dataArrays: entityDataArrays, + }); + i = j; + continue; + } + + i += 2; + } + + return { entities, layers }; +} + +/** + * Import a DXF string into a YjsDocument. + * Supports basic LINE, CIRCLE, TEXT, LWPOLYLINE, ARC entities. + * Clears existing elements and layers before importing. + */ +export function importDXF(dxfContent: string, yjsDoc: YjsDocument): ImportResult { + const errors: string[] = []; + const warnings: string[] = []; + + const { entities, layers: dxfLayers } = parseDxf(dxfContent); + + // Clear existing data (M2 fix: clear canvas and Yjs document before import) + 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 [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': { + // M1 fix: Use dataArrays to collect repeated group codes 10/20 + // instead of overwriting single values in entityData + const numVerts = parseInt(entity.data[90] || '0', 10); + const closed = parseInt(entity.data[70] || '0', 10) === 1; + const xs = entity.dataArrays[10] || []; + const ys = entity.dataArrays[20] || []; + const vertexCount = numVerts > 0 ? numVerts : Math.min(xs.length, ys.length); + const points: Array<{ x: number; y: number }> = []; + for (let v = 0; v < vertexCount; v++) { + const px = parseFloat(xs[v] || '0'); + const py = parseFloat(ys[v] || '0'); + points.push({ x: px, y: py }); + } + if (points.length > 0) { + const allXs = points.map(p => p.x); + const allYs = points.map(p => p.y); + yjsDoc.createElement({ + id, + type: closed ? 'polygon' : 'polyline', + layerId, + x: Math.min(...allXs), + y: Math.min(...allYs), + width: Math.max(...allXs) - Math.min(...allXs), + height: Math.max(...allYs) - Math.min(...allYs), + 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'); + // M4 fix: Read startAngle (group code 50) and endAngle (group code 51) + const startAngle = entity.data[50] !== undefined ? parseFloat(entity.data[50]) : 0; + const endAngle = entity.data[51] !== undefined ? parseFloat(entity.data[51]) : 360; + 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', startAngle, endAngle }, + } 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: [], + }; + } +}