T20: Add importService.ts (JSON round-trip + DXF import)

This commit is contained in:
2026-06-22 23:12:58 +00:00
parent 68687d3557
commit e6da2a9b2f
+484
View File
@@ -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: [],
};
}
}