T20: Add importService.ts (JSON round-trip + DXF import)
This commit is contained in:
@@ -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<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