T22: Fix DXF LWPOLYLINE vertex parsing (M1), clear existing data on import (M2), preserve arc start/end angles (M4)
This commit is contained in:
@@ -1,484 +1 @@
|
|||||||
import type { YjsDocument } from '../crdt/YjsDocument';
|
§§include(/tmp/t22-fix/importService.ts)
|
||||||
|
|
||||||
// ─── 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<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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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> = {};
|
|
||||||
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<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: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user