T22: Fix DXF LWPOLYLINE vertex parsing (M1), clear existing data on import (M2), preserve arc start/end angles (M4)

This commit is contained in:
2026-06-23 01:41:28 +02:00
parent 9ee7223a46
commit 90448c1214
2 changed files with 546 additions and 10 deletions
+14 -9
View File
@@ -1,6 +1,6 @@
import type { YjsDocument } from '../crdt/YjsDocument'; import type { YjsDocument } from '../crdt/YjsDocument';
// ── Types ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
interface CADLayer { interface CADLayer {
id: string; id: string;
@@ -27,6 +27,8 @@ interface CADProperties {
points?: Array<{ x: number; y: number }>; points?: Array<{ x: number; y: number }>;
text?: string; text?: string;
fontSize?: number; fontSize?: number;
startAngle?: number;
endAngle?: number;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -56,7 +58,7 @@ interface ExportOptions {
landscape?: boolean; landscape?: boolean;
} }
// ── Helpers ───────────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────────
function hexToRgb(hex: string): { r: number; g: number; b: number } { function hexToRgb(hex: string): { r: number; g: number; b: number } {
const clean = hex.replace('#', ''); const clean = hex.replace('#', '');
@@ -148,7 +150,7 @@ function downloadBlob(content: string | Blob, filename: string, mimeType: string
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
// ── JSON Export ───────────────────────────────────────────────────────────── // ── JSON Export ─────────────────────────────────────────────────────────────
export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): void { export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): void {
const data = collectExportData(yjsDoc, {}); const data = collectExportData(yjsDoc, {});
@@ -161,7 +163,7 @@ export function exportJSONString(yjsDoc: YjsDocument): string {
return JSON.stringify(data, null, 2); return JSON.stringify(data, null, 2);
} }
// ── SVG Export ────────────────────────────────────────────────────────────── // ── SVG Export ──────────────────────────────────────────────────────────────
export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): void { export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): void {
const data = collectExportData(yjsDoc, options); 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'); downloadBlob(svg, filename, 'image/svg+xml');
} }
// ── DXF Export ────────────────────────────────────────────────────────────── // ── DXF Export ──────────────────────────────────────────────────────────────
export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): void { export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): void {
const data = collectExportData(yjsDoc, options); 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 radius = el.properties.radius ?? el.width / 2;
const cx = el.x + radius; const cx = el.x + radius;
const cy = el.y + 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('0'); lines.push('ARC');
lines.push('8'); lines.push(layerName); lines.push('8'); lines.push(layerName);
lines.push('62'); lines.push(String(aci)); 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('20'); lines.push(String(cy));
lines.push('30'); lines.push('0.0'); lines.push('30'); lines.push('0.0');
lines.push('40'); lines.push(String(radius)); lines.push('40'); lines.push(String(radius));
lines.push('50'); lines.push('0.0'); lines.push('50'); lines.push(String(startAngle));
lines.push('51'); lines.push('360.0'); lines.push('51'); lines.push(String(endAngle));
break; break;
} }
default: default:
@@ -421,7 +426,7 @@ export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', opti
downloadBlob(dxf, filename, 'application/dxf'); downloadBlob(dxf, filename, 'application/dxf');
} }
// ── PDF Export ────────────────────────────────────────────────────────────── // ── PDF Export ──────────────────────────────────────────────────────────────
export async function exportPDF( export async function exportPDF(
yjsDoc: YjsDocument, yjsDoc: YjsDocument,
@@ -550,7 +555,7 @@ export async function exportPDF(
pdf.save(filename); pdf.save(filename);
} }
// ── Unified Export ────────────────────────────────────────────────────────── // ── Unified Export ──────────────────────────────────────────────────────────
export type ExportFormat = 'json' | 'svg' | 'dxf' | 'pdf'; export type ExportFormat = 'json' | 'svg' | 'dxf' | 'pdf';
+532 -1
View File
@@ -1 +1,532 @@
§§include(/tmp/t22-fix/importService.ts) 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<string, unknown>;
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 <input type="file">).
*/
export async function importJSONFile(file: File, yjsDoc: YjsDocument): Promise<ImportResult> {
const text = await file.text();
return importJSON(text, yjsDoc);
}
// ── DXF Import (basic) ───────────────────────────────────────────────────────
interface DxfEntity {
type: string;
layer: string;
color?: number;
data: Record<number, string>;
dataArrays: Record<number, string[]>;
}
/**
* Parse a DXF string into entities.
* Supports: LINE, CIRCLE, TEXT, LWPOLYLINE, ARC
*/
function parseDxf(dxfContent: string): { entities: DxfEntity[]; layers: Map<string, { name: string; color?: string }> } {
const lines = dxfContent.split(/\r?\n/);
const entities: DxfEntity[] = [];
const layers = new Map<string, { name: string; color?: string }>();
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<number, string> = {};
const entityDataArrays: Record<number, string[]> = {};
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<ImportResult> {
const text = await file.text();
return importDXF(text, yjsDoc);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function aciToHex(aci: number): string {
const aciMap: Record<number, string> = {
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<ImportResult> {
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: [],
};
}
}