/** * Wokwi zip import/export * * Converts between Wokwi's diagram.json format and Velxio's internal * component/wire format, bundling everything into a .zip file. * * Wokwi zip structure: * diagram.json — parts + connections * sketch.ino — main sketch (or projectname.ino) * *.h / *.cpp — additional files * libraries.txt — optional library list * wokwi-project.txt — optional metadata */ import JSZip from 'jszip'; import type { Wire } from '../types/wire'; // ── Type definitions ────────────────────────────────────────────────────────── interface WokwiPart { type: string; id: string; top: number; left: number; rotate?: number; attrs: Record; } interface WokwiDiagram { version: number; author: string; editor: string; parts: WokwiPart[]; connections: [string, string, string, string[]][]; } export interface VelxioComponent { id: string; metadataId: string; x: number; y: number; properties: Record; } export interface ImportResult { boardType: 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico'; boardPosition: { x: number; y: number }; components: VelxioComponent[]; wires: Wire[]; files: Array<{ name: string; content: string }>; /** Library names parsed from libraries.txt. Includes both standard Arduino Library Manager names and Wokwi-hosted entries in the form "LibName@wokwi:hash". */ libraries: string[]; } // ── Board mappings ──────────────────────────────────────────────────────────── // Wokwi board type → Velxio boardType const WOKWI_TYPE_TO_BOARD: Record< string, 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico' > = { 'wokwi-arduino-uno': 'arduino-uno', 'wokwi-arduino-nano': 'arduino-nano', 'wokwi-arduino-mega': 'arduino-mega', 'wokwi-raspberry-pi-pico': 'raspberry-pi-pico', }; // Velxio boardType → Wokwi type const BOARD_TO_WOKWI_TYPE: Record = { 'arduino-uno': 'wokwi-arduino-uno', 'arduino-nano': 'wokwi-arduino-nano', 'arduino-mega': 'wokwi-arduino-mega', 'raspberry-pi-pico': 'wokwi-raspberry-pi-pico', }; // Velxio boardType → default Wokwi part id const BOARD_TO_WOKWI_ID: Record = { 'arduino-uno': 'uno', 'arduino-nano': 'nano', 'arduino-mega': 'mega', 'raspberry-pi-pico': 'pico', }; // ── Pin name aliases ───────────────────────────────────────────────────────── // Maps Wokwi connection "signal" pin names to wokwi-element physical pin names. // Wokwi boards (e.g. board-ssd1306) use different naming than the bare elements. const COMPONENT_PIN_ALIASES: Record> = { ssd1306: { SDA: 'DATA', SCL: 'CLK', VCC: 'VIN', }, }; function normalizePinName(metadataId: string, pinName: string): string { return COMPONENT_PIN_ALIASES[metadataId]?.[pinName] ?? pinName; } // ── Color helpers ───────────────────────────────────────────────────────────── const COLOR_NAME_TO_HEX: Record = { red: '#ff0000', black: '#000000', green: '#00c800', blue: '#0000ff', yellow: '#ffff00', orange: '#ff8800', white: '#ffffff', gray: '#808080', grey: '#808080', purple: '#800080', pink: '#ff69b4', cyan: '#00ffff', gold: '#ffd700', brown: '#8b4513', magenta: '#ff00ff', lime: '#00ff00', violet: '#ee82ee', maroon: '#800000', navy: '#000080', teal: '#008080', }; const HEX_TO_COLOR_NAME: Record = { '#ff0000': 'red', '#000000': 'black', '#00ff00': 'green', '#00c800': 'green', '#0000ff': 'blue', '#ffff00': 'yellow', '#ff8800': 'orange', '#ffffff': 'white', '#808080': 'gray', '#800080': 'purple', '#00ffff': 'cyan', '#ffd700': 'gold', }; function colorToHex(color: string): string { if (!color) return '#888888'; if (color.startsWith('#')) return color.toLowerCase(); return COLOR_NAME_TO_HEX[color.toLowerCase()] ?? '#888888'; } function hexToColorName(hex: string): string { return HEX_TO_COLOR_NAME[hex.toLowerCase()] ?? hex; } // ── Type conversion ─────────────────────────────────────────────────────────── function wokwiTypeToMetadataId(type: string): string { if (type.startsWith('wokwi-')) return type.slice(6); if (type.startsWith('board-')) return type.slice(6); return type; } function metadataIdToWokwiType(metadataId: string): string { return `wokwi-${metadataId}`; } // ── Library list parser ─────────────────────────────────────────────────────── /** * Parse the contents of a Wokwi libraries.txt file. * - Strips blank lines and # comments * - Includes Wokwi-hosted entries in the form name@wokwi:hash * so the backend can download and install them from wokwi.com */ export function parseLibrariesTxt(content: string): string[] { const libs: string[] = []; for (const raw of content.split('\n')) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; libs.push(line); } return libs; } // ── Export ──────────────────────────────────────────────────────────────────── export async function exportToWokwiZip( files: Array<{ name: string; content: string }>, components: VelxioComponent[], wires: Wire[], boardType: string, projectName: string, boardPosition: { x: number; y: number } = { x: 50, y: 50 }, ): Promise { const zip = new JSZip(); const boardWokwiType = BOARD_TO_WOKWI_TYPE[boardType] ?? 'wokwi-arduino-uno'; const boardId = BOARD_TO_WOKWI_ID[boardType] ?? 'uno'; // Build parts — board first, then user components // Subtract boardPosition so coords are relative to the board const parts: WokwiPart[] = [ { type: boardWokwiType, id: boardId, top: 0, left: 0, attrs: {} }, ...components.map((c) => ({ type: metadataIdToWokwiType(c.metadataId), id: c.id, top: Math.round(c.y - boardPosition.y), left: Math.round(c.x - boardPosition.x), attrs: c.properties as Record, })), ]; // Build connections const connections: [string, string, string, string[]][] = wires.map((w) => { const isBoardStart = w.start.componentId === 'arduino-uno' || w.start.componentId === 'arduino-nano' || w.start.componentId === 'nano-rp2040'; const isBoardEnd = w.end.componentId === 'arduino-uno' || w.end.componentId === 'arduino-nano' || w.end.componentId === 'nano-rp2040'; const startId = isBoardStart ? boardId : w.start.componentId; const endId = isBoardEnd ? boardId : w.end.componentId; return [ `${startId}:${w.start.pinName}`, `${endId}:${w.end.pinName}`, hexToColorName(w.color ?? '#888888'), [], ]; }); const diagram: WokwiDiagram = { version: 1, author: 'Velxio', editor: 'wokwi', parts, connections, }; zip.file('diagram.json', JSON.stringify(diagram, null, 2)); zip.file( 'wokwi-project.txt', `Exported from Velxio\n\nSimulate this project on https://velxio.dev\n`, ); for (const f of files) { zip.file(f.name, f.content); } const blob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${(projectName || 'velxio-project').replace(/[^a-z0-9_-]/gi, '-')}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); } // ── Import ──────────────────────────────────────────────────────────────────── export async function importFromWokwiZip(file: File): Promise { const zip = await JSZip.loadAsync(file); // diagram.json is required const diagramEntry = zip.file('diagram.json'); if (!diagramEntry) throw new Error('No diagram.json found in the zip file.'); const diagramText = await diagramEntry.async('string'); const diagram: WokwiDiagram = JSON.parse(diagramText); // Detect board const boardPart = diagram.parts.find((p) => WOKWI_TYPE_TO_BOARD[p.type]); const boardType = boardPart ? WOKWI_TYPE_TO_BOARD[boardPart.type] : 'arduino-uno'; const boardId = boardPart?.id ?? 'uno'; // Velxio internal component ID for the board element (must match DOM element id) const VELXIO_BOARD_ID: Record = { 'arduino-uno': 'arduino-uno', 'arduino-nano': 'arduino-nano', 'arduino-mega': 'arduino-mega', 'raspberry-pi-pico': 'nano-rp2040', }; const velxioBoardId = VELXIO_BOARD_ID[boardType] ?? 'arduino-uno'; // Board position from diagram. Apply a minimum offset so the board is never // crammed against the canvas top-left corner (Wokwi diagrams often use 0,0). const MIN_OFFSET = 50; const rawBoardX = boardPart?.left ?? MIN_OFFSET; const rawBoardY = boardPart?.top ?? MIN_OFFSET; const offsetX = rawBoardX < MIN_OFFSET ? MIN_OFFSET - rawBoardX : 0; const offsetY = rawBoardY < MIN_OFFSET ? MIN_OFFSET - rawBoardY : 0; const boardPosition = { x: rawBoardX + offsetX, y: rawBoardY + offsetY, }; // Convert non-board parts to Velxio components. // Apply the same offset so components keep their relative position to the board. const components: VelxioComponent[] = diagram.parts .filter((p) => !WOKWI_TYPE_TO_BOARD[p.type]) .map((p) => ({ id: p.id, metadataId: wokwiTypeToMetadataId(p.type), x: p.left + offsetX, y: p.top + offsetY, properties: { ...p.attrs }, })); // Convert connections to Velxio wires const wires: Wire[] = diagram.connections.map((conn, i) => { const [startStr, endStr, color] = conn; const colonA = startStr.indexOf(':'); const colonB = endStr.indexOf(':'); const startCompRaw = colonA >= 0 ? startStr.slice(0, colonA) : startStr; const startPin = colonA >= 0 ? startStr.slice(colonA + 1) : ''; const endCompRaw = colonB >= 0 ? endStr.slice(0, colonB) : endStr; const endPin = colonB >= 0 ? endStr.slice(colonB + 1) : ''; // Remap board part id → Velxio internal board id const startId = startCompRaw === boardId ? velxioBoardId : startCompRaw; const endId = endCompRaw === boardId ? velxioBoardId : endCompRaw; // Normalize pin names: Wokwi uses signal names (SDA, SCL, VCC) while // wokwi-elements use physical/board pin names (DATA, CLK, VIN). const startMetadataId = components.find((c) => c.id === startId)?.metadataId ?? ''; const endMetadataId = components.find((c) => c.id === endId)?.metadataId ?? ''; const normalizedStartPin = normalizePinName(startMetadataId, startPin); const normalizedEndPin = normalizePinName(endMetadataId, endPin); return { id: `wire-${i}-${Date.now()}`, start: { componentId: startId, pinName: normalizedStartPin, x: 0, y: 0 }, end: { componentId: endId, pinName: normalizedEndPin, x: 0, y: 0 }, waypoints: [], color: colorToHex(color), }; }); // Read code files (.ino, .h, .cpp, .c) const CODE_EXTS = new Set(['.ino', '.h', '.cpp', '.c']); const files: Array<{ name: string; content: string }> = []; for (const [filename, entry] of Object.entries(zip.files)) { if (entry.dir) continue; const basename = filename.split('/').pop() ?? filename; const ext = '.' + basename.split('.').pop()!.toLowerCase(); if (CODE_EXTS.has(ext)) { const content = await entry.async('string'); files.push({ name: basename, content }); } } // Sort: .ino first, then alphabetically files.sort((a, b) => { const aIno = a.name.endsWith('.ino'); const bIno = b.name.endsWith('.ino'); if (aIno && !bIno) return -1; if (!aIno && bIno) return 1; return a.name.localeCompare(b.name); }); // Parse libraries.txt const libraries: string[] = []; const libEntry = zip.file('libraries.txt'); if (libEntry) { libraries.push(...parseLibrariesTxt(await libEntry.async('string'))); } return { boardType, boardPosition, components, wires, files, libraries }; }