mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
feature: Implement Web Worker-based pattern conversion with progress tracking
Move Pyodide/PyStitch pattern conversion to a dedicated Web Worker for non-blocking UI performance. Pyodide now loads in the background on app startup with real-time progress indicator showing download and initialization status. Key changes: - Create patternConverter.worker.ts with full PES to PEN conversion logic - Add patternConverterClient.ts singleton for worker management - Implement progress tracking (0-100%) with step descriptions - Add inline progress bar in FileUpload component with contextual messaging - Configure COEP headers for Electron to support Web Workers - Pass dynamic asset URLs to worker for file:// protocol support - Update UIStore with progress state management - Simplify pystitchConverter.ts to delegate to worker client Benefits: - Truly non-blocking UI (heavy Python/WASM runs off main thread) - Real progress feedback during 15MB Pyodide download - Works in both browser and Electron (dev and production) - Faster app startup perception with background loading - Better UX with "waiting" state if user clicks before ready 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
077c7d0bf5
commit
0dfc8b731b
8 changed files with 926 additions and 413 deletions
184
electron/main.ts
184
electron/main.ts
|
|
@ -1,10 +1,10 @@
|
||||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
import { app, BrowserWindow, ipcMain, dialog } from "electron";
|
||||||
import { join } from 'path';
|
import { join } from "path";
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from "fs";
|
||||||
import Store from 'electron-store';
|
import Store from "electron-store";
|
||||||
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||||
|
|
||||||
import started from 'electron-squirrel-startup';
|
import started from "electron-squirrel-startup";
|
||||||
|
|
||||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||||
if (started) {
|
if (started) {
|
||||||
|
|
@ -14,13 +14,13 @@ if (started) {
|
||||||
updateElectronApp({
|
updateElectronApp({
|
||||||
updateSource: {
|
updateSource: {
|
||||||
type: UpdateSourceType.StaticStorage,
|
type: UpdateSourceType.StaticStorage,
|
||||||
baseUrl: `https://jhbruhn.github.io/respira/update/${process.platform}/${process.arch}`
|
baseUrl: `https://jhbruhn.github.io/respira/update/${process.platform}/${process.arch}`,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Enable Web Bluetooth
|
// Enable Web Bluetooth
|
||||||
app.commandLine.appendSwitch('enable-web-bluetooth', 'true');
|
app.commandLine.appendSwitch("enable-web-bluetooth", "true");
|
||||||
app.commandLine.appendSwitch('enable-experimental-web-platform-features');
|
app.commandLine.appendSwitch("enable-experimental-web-platform-features");
|
||||||
|
|
||||||
const store = new Store();
|
const store = new Store();
|
||||||
|
|
||||||
|
|
@ -33,13 +33,13 @@ function createWindow() {
|
||||||
autoHideMenuBar: true, // Hide the menu bar (can be toggled with Alt key)
|
autoHideMenuBar: true, // Hide the menu bar (can be toggled with Alt key)
|
||||||
title: `Respira v${app.getVersion()}`,
|
title: `Respira v${app.getVersion()}`,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, 'preload.js'),
|
preload: join(__dirname, "preload.js"),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
// Enable SharedArrayBuffer for Pyodide
|
// Enable SharedArrayBuffer for Pyodide
|
||||||
additionalArguments: ['--enable-features=SharedArrayBuffer'],
|
additionalArguments: ["--enable-features=SharedArrayBuffer"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -48,55 +48,82 @@ function createWindow() {
|
||||||
// The handler may be called multiple times as new devices are discovered
|
// The handler may be called multiple times as new devices are discovered
|
||||||
let deviceSelectionCallback: ((deviceId: string) => void) | null = null;
|
let deviceSelectionCallback: ((deviceId: string) => void) | null = null;
|
||||||
|
|
||||||
mainWindow.webContents.on('select-bluetooth-device', (event, deviceList, callback) => {
|
mainWindow.webContents.on(
|
||||||
|
"select-bluetooth-device",
|
||||||
|
(event, deviceList, callback) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
console.log('[Bluetooth] Device list updated:', deviceList.map(d => ({
|
console.log(
|
||||||
|
"[Bluetooth] Device list updated:",
|
||||||
|
deviceList.map((d) => ({
|
||||||
name: d.deviceName,
|
name: d.deviceName,
|
||||||
id: d.deviceId
|
id: d.deviceId,
|
||||||
})));
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
// Store the callback for later use (may be called multiple times as devices are discovered)
|
// Store the callback for later use (may be called multiple times as devices are discovered)
|
||||||
console.log('[Bluetooth] Storing new callback, previous callback existed:', !!deviceSelectionCallback);
|
console.log(
|
||||||
|
"[Bluetooth] Storing new callback, previous callback existed:",
|
||||||
|
!!deviceSelectionCallback,
|
||||||
|
);
|
||||||
deviceSelectionCallback = callback;
|
deviceSelectionCallback = callback;
|
||||||
|
|
||||||
// Always send device list to renderer (even if empty) to show scanning UI
|
// Always send device list to renderer (even if empty) to show scanning UI
|
||||||
console.log('[Bluetooth] Sending device list to renderer for selection');
|
console.log("[Bluetooth] Sending device list to renderer for selection");
|
||||||
mainWindow.webContents.send('bluetooth:device-list', deviceList.map(d => ({
|
mainWindow.webContents.send(
|
||||||
|
"bluetooth:device-list",
|
||||||
|
deviceList.map((d) => ({
|
||||||
deviceId: d.deviceId,
|
deviceId: d.deviceId,
|
||||||
deviceName: d.deviceName || 'Unknown Device'
|
deviceName: d.deviceName || "Unknown Device",
|
||||||
})));
|
})),
|
||||||
});
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Handle device selection from renderer
|
// Handle device selection from renderer
|
||||||
ipcMain.on('bluetooth:select-device', (_event, deviceId: string) => {
|
ipcMain.on("bluetooth:select-device", (_event, deviceId: string) => {
|
||||||
console.log('[Bluetooth] Renderer selected device:', deviceId);
|
console.log("[Bluetooth] Renderer selected device:", deviceId);
|
||||||
if (deviceSelectionCallback) {
|
if (deviceSelectionCallback) {
|
||||||
console.log('[Bluetooth] Calling callback with deviceId:', deviceId);
|
console.log("[Bluetooth] Calling callback with deviceId:", deviceId);
|
||||||
deviceSelectionCallback(deviceId);
|
deviceSelectionCallback(deviceId);
|
||||||
deviceSelectionCallback = null;
|
deviceSelectionCallback = null;
|
||||||
} else {
|
} else {
|
||||||
console.error('[Bluetooth] No callback available! Device selection may have timed out.');
|
console.error(
|
||||||
|
"[Bluetooth] No callback available! Device selection may have timed out.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optional: Handle Bluetooth pairing for Windows/Linux PIN validation
|
// Optional: Handle Bluetooth pairing for Windows/Linux PIN validation
|
||||||
mainWindow.webContents.session.setBluetoothPairingHandler((details, callback) => {
|
mainWindow.webContents.session.setBluetoothPairingHandler(
|
||||||
console.log('[Bluetooth] Pairing request:', details);
|
(details, callback) => {
|
||||||
|
console.log("[Bluetooth] Pairing request:", details);
|
||||||
// Auto-confirm pairing
|
// Auto-confirm pairing
|
||||||
callback({ confirmed: true /*pairingKind: 'confirm' */});
|
callback({ confirmed: true /*pairingKind: 'confirm' */ });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Set COOP/COEP headers for Pyodide SharedArrayBuffer support
|
// Set COOP/COEP headers for Pyodide SharedArrayBuffer support
|
||||||
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||||
callback({
|
(details, callback) => {
|
||||||
responseHeaders: {
|
// Apply headers to ALL resources including workers
|
||||||
|
const headers: Record<string, string[]> = {
|
||||||
...details.responseHeaders,
|
...details.responseHeaders,
|
||||||
'Cross-Origin-Opener-Policy': ['same-origin'],
|
"Cross-Origin-Opener-Policy": ["unsafe-none"],
|
||||||
'Cross-Origin-Embedder-Policy': ['require-corp'],
|
"Cross-Origin-Embedder-Policy": ["unsafe-none"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// For same-origin resources (including workers), add CORP header
|
||||||
|
if (
|
||||||
|
details.url.startsWith("http://localhost") ||
|
||||||
|
details.url.startsWith("file://")
|
||||||
|
) {
|
||||||
|
headers["Cross-Origin-Resource-Policy"] = ["same-origin"];
|
||||||
|
}
|
||||||
|
|
||||||
|
callback({ responseHeaders: headers });
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// Load the app
|
// Load the app
|
||||||
// MAIN_WINDOW_VITE_DEV_SERVER_URL is provided by @electron-forge/plugin-vite
|
// MAIN_WINDOW_VITE_DEV_SERVER_URL is provided by @electron-forge/plugin-vite
|
||||||
|
|
@ -105,19 +132,21 @@ function createWindow() {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
} else {
|
} else {
|
||||||
// MAIN_WINDOW_VITE_NAME is the renderer name from forge.config.js
|
// MAIN_WINDOW_VITE_NAME is the renderer name from forge.config.js
|
||||||
mainWindow.loadFile(join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
|
mainWindow.loadFile(
|
||||||
|
join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(createWindow);
|
app.whenReady().then(createWindow);
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on("window-all-closed", () => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== "darwin") {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow();
|
||||||
}
|
}
|
||||||
|
|
@ -126,69 +155,76 @@ app.on('activate', () => {
|
||||||
// IPC Handlers
|
// IPC Handlers
|
||||||
|
|
||||||
// Storage handlers (using electron-store)
|
// Storage handlers (using electron-store)
|
||||||
ipcMain.handle('storage:savePattern', async (_event, pattern) => {
|
ipcMain.handle("storage:savePattern", async (_event, pattern) => {
|
||||||
try {
|
try {
|
||||||
store.set(`pattern:${pattern.uuid}`, pattern);
|
store.set(`pattern:${pattern.uuid}`, pattern);
|
||||||
store.set('pattern:latest', pattern);
|
store.set("pattern:latest", pattern);
|
||||||
console.log('[Storage] Saved pattern:', pattern.fileName, 'UUID:', pattern.uuid);
|
console.log(
|
||||||
|
"[Storage] Saved pattern:",
|
||||||
|
pattern.fileName,
|
||||||
|
"UUID:",
|
||||||
|
pattern.uuid,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Storage] Failed to save pattern:', err);
|
console.error("[Storage] Failed to save pattern:", err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:getPattern', async (_event, uuid) => {
|
ipcMain.handle("storage:getPattern", async (_event, uuid) => {
|
||||||
try {
|
try {
|
||||||
const pattern = store.get(`pattern:${uuid}`, null);
|
const pattern = store.get(`pattern:${uuid}`, null);
|
||||||
if (pattern) {
|
if (pattern) {
|
||||||
console.log('[Storage] Retrieved pattern for UUID:', uuid);
|
console.log("[Storage] Retrieved pattern for UUID:", uuid);
|
||||||
}
|
}
|
||||||
return pattern;
|
return pattern;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Storage] Failed to get pattern:', err);
|
console.error("[Storage] Failed to get pattern:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:getLatest', async () => {
|
ipcMain.handle("storage:getLatest", async () => {
|
||||||
try {
|
try {
|
||||||
const pattern = store.get('pattern:latest', null);
|
const pattern = store.get("pattern:latest", null);
|
||||||
return pattern;
|
return pattern;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Storage] Failed to get latest pattern:', err);
|
console.error("[Storage] Failed to get latest pattern:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:deletePattern', async (_event, uuid) => {
|
ipcMain.handle("storage:deletePattern", async (_event, uuid) => {
|
||||||
try {
|
try {
|
||||||
store.delete(`pattern:${uuid}`);
|
store.delete(`pattern:${uuid}`);
|
||||||
console.log('[Storage] Deleted pattern with UUID:', uuid);
|
console.log("[Storage] Deleted pattern with UUID:", uuid);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Storage] Failed to delete pattern:', err);
|
console.error("[Storage] Failed to delete pattern:", err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:clear', async () => {
|
ipcMain.handle("storage:clear", async () => {
|
||||||
try {
|
try {
|
||||||
const keys = Object.keys(store.store).filter(k => k.startsWith('pattern:'));
|
const keys = Object.keys(store.store).filter((k) =>
|
||||||
keys.forEach(k => store.delete(k));
|
k.startsWith("pattern:"),
|
||||||
console.log('[Storage] Cleared all patterns');
|
);
|
||||||
|
keys.forEach((k) => store.delete(k));
|
||||||
|
console.log("[Storage] Cleared all patterns");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Storage] Failed to clear cache:', err);
|
console.error("[Storage] Failed to clear cache:", err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// File dialog handlers
|
// File dialog handlers
|
||||||
ipcMain.handle('dialog:openFile', async (_event, options) => {
|
ipcMain.handle("dialog:openFile", async (_event, options) => {
|
||||||
try {
|
try {
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
properties: ['openFile'],
|
properties: ["openFile"],
|
||||||
filters: options.filters,
|
filters: options.filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -197,17 +233,17 @@ ipcMain.handle('dialog:openFile', async (_event, options) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = result.filePaths[0];
|
const filePath = result.filePaths[0];
|
||||||
const fileName = filePath.split(/[\\/]/).pop() || '';
|
const fileName = filePath.split(/[\\/]/).pop() || "";
|
||||||
|
|
||||||
console.log('[Dialog] File selected:', fileName);
|
console.log("[Dialog] File selected:", fileName);
|
||||||
return { filePath, fileName };
|
return { filePath, fileName };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Dialog] Failed to open file:', err);
|
console.error("[Dialog] Failed to open file:", err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('dialog:saveFile', async (_event, options) => {
|
ipcMain.handle("dialog:saveFile", async (_event, options) => {
|
||||||
try {
|
try {
|
||||||
const result = await dialog.showSaveDialog({
|
const result = await dialog.showSaveDialog({
|
||||||
defaultPath: options.defaultPath,
|
defaultPath: options.defaultPath,
|
||||||
|
|
@ -218,33 +254,33 @@ ipcMain.handle('dialog:saveFile', async (_event, options) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Dialog] Save file selected:', result.filePath);
|
console.log("[Dialog] Save file selected:", result.filePath);
|
||||||
return result.filePath;
|
return result.filePath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Dialog] Failed to save file:', err);
|
console.error("[Dialog] Failed to save file:", err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// File system handlers
|
// File system handlers
|
||||||
ipcMain.handle('fs:readFile', async (_event, filePath) => {
|
ipcMain.handle("fs:readFile", async (_event, filePath) => {
|
||||||
try {
|
try {
|
||||||
const buffer = await fs.readFile(filePath);
|
const buffer = await fs.readFile(filePath);
|
||||||
console.log('[FS] Read file:', filePath, 'Size:', buffer.length);
|
console.log("[FS] Read file:", filePath, "Size:", buffer.length);
|
||||||
return buffer;
|
return buffer;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[FS] Failed to read file:', err);
|
console.error("[FS] Failed to read file:", err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('fs:writeFile', async (_event, filePath, data) => {
|
ipcMain.handle("fs:writeFile", async (_event, filePath, data) => {
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(filePath, Buffer.from(data));
|
await fs.writeFile(filePath, Buffer.from(data));
|
||||||
console.log('[FS] Wrote file:', filePath);
|
console.log("[FS] Wrote file:", filePath);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[FS] Failed to write file:', err);
|
console.error("[FS] Failed to write file:", err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ function App() {
|
||||||
const errorPopoverRef = useRef<HTMLDivElement>(null);
|
const errorPopoverRef = useRef<HTMLDivElement>(null);
|
||||||
const errorButtonRef = useRef<HTMLButtonElement>(null);
|
const errorButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Initialize Pyodide on mount
|
// Initialize Pyodide in background on mount (non-blocking thanks to worker)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializePyodide();
|
initializePyodide();
|
||||||
}, [initializePyodide]);
|
}, [initializePyodide]);
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,19 @@ export function FileUpload() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// UI store
|
// UI store
|
||||||
const pyodideReady = useUIStore((state) => state.pyodideReady);
|
const {
|
||||||
|
pyodideReady,
|
||||||
|
pyodideProgress,
|
||||||
|
pyodideLoadingStep,
|
||||||
|
initializePyodide,
|
||||||
|
} = useUIStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
pyodideReady: state.pyodideReady,
|
||||||
|
pyodideProgress: state.pyodideProgress,
|
||||||
|
pyodideLoadingStep: state.pyodideLoadingStep,
|
||||||
|
initializePyodide: state.initializePyodide,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null);
|
const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null);
|
||||||
const [fileName, setFileName] = useState<string>('');
|
const [fileName, setFileName] = useState<string>('');
|
||||||
const [fileService] = useState<IFileService>(() => createFileService());
|
const [fileService] = useState<IFileService>(() => createFileService());
|
||||||
|
|
@ -65,13 +77,15 @@ export function FileUpload() {
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
async (event?: React.ChangeEvent<HTMLInputElement>) => {
|
async (event?: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!pyodideReady) {
|
|
||||||
alert('Python environment is still loading. Please wait...');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// Wait for Pyodide if it's still loading
|
||||||
|
if (!pyodideReady) {
|
||||||
|
console.log('[FileUpload] Waiting for Pyodide to finish loading...');
|
||||||
|
await initializePyodide();
|
||||||
|
console.log('[FileUpload] Pyodide ready');
|
||||||
|
}
|
||||||
|
|
||||||
let file: File | null = null;
|
let file: File | null = null;
|
||||||
|
|
||||||
// In Electron, use native file dialogs
|
// In Electron, use native file dialogs
|
||||||
|
|
@ -101,7 +115,7 @@ export function FileUpload() {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fileService, setPattern, pyodideReady]
|
[fileService, setPattern, pyodideReady, initializePyodide]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpload = useCallback(() => {
|
const handleUpload = useCallback(() => {
|
||||||
|
|
@ -256,13 +270,13 @@ export function FileUpload() {
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
id="file-input"
|
id="file-input"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={!pyodideReady || isLoading || patternUploaded || isUploading}
|
disabled={isLoading || patternUploaded || isUploading}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
|
htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
|
||||||
onClick={fileService.hasNativeDialogs() ? () => handleFileChange() : undefined}
|
onClick={fileService.hasNativeDialogs() ? () => handleFileChange() : undefined}
|
||||||
className={`flex-[2] flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 rounded font-semibold text-sm transition-all ${
|
className={`flex-[2] flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 rounded font-semibold text-sm transition-all ${
|
||||||
!pyodideReady || isLoading || patternUploaded || isUploading
|
isLoading || patternUploaded || isUploading
|
||||||
? 'opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white'
|
? 'opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white'
|
||||||
: 'cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600'
|
: 'cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -275,14 +289,6 @@ export function FileUpload() {
|
||||||
</svg>
|
</svg>
|
||||||
<span>Loading...</span>
|
<span>Loading...</span>
|
||||||
</>
|
</>
|
||||||
) : !pyodideReady ? (
|
|
||||||
<>
|
|
||||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Initializing...</span>
|
|
||||||
</>
|
|
||||||
) : patternUploaded ? (
|
) : patternUploaded ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircleIcon className="w-3.5 h-3.5" />
|
<CheckCircleIcon className="w-3.5 h-3.5" />
|
||||||
|
|
@ -321,6 +327,33 @@ export function FileUpload() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pyodide initialization progress indicator - shown when initializing or waiting */}
|
||||||
|
{!pyodideReady && pyodideProgress > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
{isLoading && !pyodideReady
|
||||||
|
? 'Please wait - initializing Python environment...'
|
||||||
|
: pyodideLoadingStep || 'Initializing Python environment...'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{pyodideProgress.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 via-blue-600 to-blue-700 dark:from-blue-600 dark:via-blue-700 dark:to-blue-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
|
||||||
|
style={{ width: `${pyodideProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
|
||||||
|
{isLoading && !pyodideReady
|
||||||
|
? 'File dialog will open automatically when ready'
|
||||||
|
: 'This only happens once on first use'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error/warning messages with smooth transition - placed after buttons */}
|
{/* Error/warning messages with smooth transition - placed after buttons */}
|
||||||
<div className="transition-all duration-200 ease-in-out overflow-hidden" style={{
|
<div className="transition-all duration-200 ease-in-out overflow-hidden" style={{
|
||||||
maxHeight: (pesData && (boundsCheck.error || !canUploadPattern(machineStatus))) ? '200px' : '0px',
|
maxHeight: (pesData && (boundsCheck.error || !canUploadPattern(machineStatus))) ? '200px' : '0px',
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { pyodideLoader } from '../utils/pyodideLoader';
|
import { patternConverterClient } from '../utils/patternConverterClient';
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
// Pyodide state
|
// Pyodide state
|
||||||
pyodideReady: boolean;
|
pyodideReady: boolean;
|
||||||
pyodideError: string | null;
|
pyodideError: string | null;
|
||||||
|
pyodideProgress: number;
|
||||||
|
pyodideLoadingStep: string;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
showErrorPopover: boolean;
|
showErrorPopover: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initializePyodide: () => Promise<void>;
|
initializePyodide: () => Promise<void>;
|
||||||
|
setPyodideProgress: (progress: number, step: string) => void;
|
||||||
toggleErrorPopover: () => void;
|
toggleErrorPopover: () => void;
|
||||||
setErrorPopover: (show: boolean) => void;
|
setErrorPopover: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -19,21 +22,35 @@ export const useUIStore = create<UIState>((set) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
pyodideReady: false,
|
pyodideReady: false,
|
||||||
pyodideError: null,
|
pyodideError: null,
|
||||||
|
pyodideProgress: 0,
|
||||||
|
pyodideLoadingStep: '',
|
||||||
showErrorPopover: false,
|
showErrorPopover: false,
|
||||||
|
|
||||||
// Initialize Pyodide
|
// Initialize Pyodide with progress tracking
|
||||||
initializePyodide: async () => {
|
initializePyodide: async () => {
|
||||||
try {
|
try {
|
||||||
await pyodideLoader.initialize();
|
// Reset progress
|
||||||
set({ pyodideReady: true });
|
set({ pyodideProgress: 0, pyodideLoadingStep: 'Starting...', pyodideError: null });
|
||||||
|
|
||||||
|
// Initialize with progress callback
|
||||||
|
await patternConverterClient.initialize((progress, step) => {
|
||||||
|
set({ pyodideProgress: progress, pyodideLoadingStep: step });
|
||||||
|
});
|
||||||
|
|
||||||
|
set({ pyodideReady: true, pyodideProgress: 100, pyodideLoadingStep: 'Ready!' });
|
||||||
console.log('[UIStore] Pyodide initialized successfully');
|
console.log('[UIStore] Pyodide initialized successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Python environment';
|
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Python environment';
|
||||||
set({ pyodideError: errorMessage });
|
set({ pyodideError: errorMessage, pyodideProgress: 0, pyodideLoadingStep: '' });
|
||||||
console.error('[UIStore] Failed to initialize Pyodide:', err);
|
console.error('[UIStore] Failed to initialize Pyodide:', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Set progress manually (for external updates)
|
||||||
|
setPyodideProgress: (progress: number, step: string) => {
|
||||||
|
set({ pyodideProgress: progress, pyodideLoadingStep: step });
|
||||||
|
},
|
||||||
|
|
||||||
// Toggle error popover visibility
|
// Toggle error popover visibility
|
||||||
toggleErrorPopover: () => {
|
toggleErrorPopover: () => {
|
||||||
set((state) => ({ showErrorPopover: !state.showErrorPopover }));
|
set((state) => ({ showErrorPopover: !state.showErrorPopover }));
|
||||||
|
|
@ -48,4 +65,6 @@ export const useUIStore = create<UIState>((set) => ({
|
||||||
// Selector hooks for common use cases
|
// Selector hooks for common use cases
|
||||||
export const usePyodideReady = () => useUIStore((state) => state.pyodideReady);
|
export const usePyodideReady = () => useUIStore((state) => state.pyodideReady);
|
||||||
export const usePyodideError = () => useUIStore((state) => state.pyodideError);
|
export const usePyodideError = () => useUIStore((state) => state.pyodideError);
|
||||||
|
export const usePyodideProgress = () => useUIStore((state) => state.pyodideProgress);
|
||||||
|
export const usePyodideLoadingStep = () => useUIStore((state) => state.pyodideLoadingStep);
|
||||||
export const useErrorPopover = () => useUIStore((state) => state.showErrorPopover);
|
export const useErrorPopover = () => useUIStore((state) => state.showErrorPopover);
|
||||||
|
|
|
||||||
248
src/utils/patternConverterClient.ts
Normal file
248
src/utils/patternConverterClient.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
import type { WorkerMessage, WorkerResponse } from '../workers/patternConverter.worker';
|
||||||
|
import PatternConverterWorker from '../workers/patternConverter.worker?worker';
|
||||||
|
|
||||||
|
export type PyodideState = 'not_loaded' | 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
export interface PesPatternData {
|
||||||
|
stitches: number[][];
|
||||||
|
threads: Array<{
|
||||||
|
color: number;
|
||||||
|
hex: string;
|
||||||
|
brand: string | null;
|
||||||
|
catalogNumber: string | null;
|
||||||
|
description: string | null;
|
||||||
|
chart: string | null;
|
||||||
|
}>;
|
||||||
|
uniqueColors: Array<{
|
||||||
|
color: number;
|
||||||
|
hex: string;
|
||||||
|
brand: string | null;
|
||||||
|
catalogNumber: string | null;
|
||||||
|
description: string | null;
|
||||||
|
chart: string | null;
|
||||||
|
threadIndices: number[];
|
||||||
|
}>;
|
||||||
|
penData: Uint8Array;
|
||||||
|
colorCount: number;
|
||||||
|
stitchCount: number;
|
||||||
|
bounds: {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minY: number;
|
||||||
|
maxY: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressCallback = (progress: number, step: string) => void;
|
||||||
|
|
||||||
|
class PatternConverterClient {
|
||||||
|
private worker: Worker | null = null;
|
||||||
|
private state: PyodideState = 'not_loaded';
|
||||||
|
private error: string | null = null;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private progressCallbacks: Set<ProgressCallback> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current Pyodide state
|
||||||
|
*/
|
||||||
|
getState(): PyodideState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the error message if state is 'error'
|
||||||
|
*/
|
||||||
|
getError(): string | null {
|
||||||
|
return this.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the worker and load Pyodide
|
||||||
|
*/
|
||||||
|
async initialize(onProgress?: ProgressCallback): Promise<void> {
|
||||||
|
// If already ready, return immediately
|
||||||
|
if (this.state === 'ready') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If currently loading, add progress callback and wait for the existing promise
|
||||||
|
if (this.initPromise) {
|
||||||
|
if (onProgress) {
|
||||||
|
this.progressCallbacks.add(onProgress);
|
||||||
|
}
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create worker if it doesn't exist
|
||||||
|
if (!this.worker) {
|
||||||
|
console.log('[PatternConverterClient] Creating worker...');
|
||||||
|
try {
|
||||||
|
this.worker = new PatternConverterWorker();
|
||||||
|
console.log('[PatternConverterClient] Worker created successfully');
|
||||||
|
this.setupWorkerListeners();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PatternConverterClient] Failed to create worker:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add progress callback if provided
|
||||||
|
if (onProgress) {
|
||||||
|
this.progressCallbacks.add(onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initialization
|
||||||
|
this.state = 'loading';
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
this.initPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
const handleMessage = (event: MessageEvent<WorkerResponse>) => {
|
||||||
|
const message = event.data;
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'INIT_PROGRESS':
|
||||||
|
// Notify all progress callbacks
|
||||||
|
this.progressCallbacks.forEach((callback) => {
|
||||||
|
callback(message.progress, message.step);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'INIT_COMPLETE':
|
||||||
|
this.state = 'ready';
|
||||||
|
this.progressCallbacks.clear();
|
||||||
|
this.worker?.removeEventListener('message', handleMessage);
|
||||||
|
resolve();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'INIT_ERROR':
|
||||||
|
this.state = 'error';
|
||||||
|
this.error = message.error;
|
||||||
|
this.progressCallbacks.clear();
|
||||||
|
this.worker?.removeEventListener('message', handleMessage);
|
||||||
|
reject(new Error(message.error));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.worker?.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
// Send initialization message with asset URLs
|
||||||
|
// Resolve URLs relative to the current page location
|
||||||
|
const baseURL = window.location.origin + window.location.pathname.replace(/\/[^/]*$/, '/');
|
||||||
|
const pyodideIndexURL = new URL('assets/', baseURL).href;
|
||||||
|
const pystitchWheelURL = new URL('pystitch-1.0.0-py3-none-any.whl', baseURL).href;
|
||||||
|
|
||||||
|
console.log('[PatternConverterClient] Base URL:', baseURL);
|
||||||
|
console.log('[PatternConverterClient] Pyodide index URL:', pyodideIndexURL);
|
||||||
|
console.log('[PatternConverterClient] Pystitch wheel URL:', pystitchWheelURL);
|
||||||
|
|
||||||
|
const initMessage: WorkerMessage = {
|
||||||
|
type: 'INITIALIZE',
|
||||||
|
pyodideIndexURL,
|
||||||
|
pystitchWheelURL,
|
||||||
|
};
|
||||||
|
this.worker?.postMessage(initMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PES file to PEN format using the worker
|
||||||
|
*/
|
||||||
|
async convertPesToPen(file: File): Promise<PesPatternData> {
|
||||||
|
// Ensure worker is initialized
|
||||||
|
if (this.state !== 'ready') {
|
||||||
|
throw new Error('Pyodide worker not initialized. Call initialize() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.worker) {
|
||||||
|
throw new Error('Worker not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<PesPatternData>((resolve, reject) => {
|
||||||
|
// Store reference to worker for TypeScript null checking
|
||||||
|
const worker = this.worker;
|
||||||
|
if (!worker) {
|
||||||
|
reject(new Error('Worker not available'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent<WorkerResponse>) => {
|
||||||
|
const message = event.data;
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'CONVERT_COMPLETE': {
|
||||||
|
worker.removeEventListener('message', handleMessage);
|
||||||
|
// Convert penData array back to Uint8Array
|
||||||
|
const result: PesPatternData = {
|
||||||
|
...message.data,
|
||||||
|
penData: new Uint8Array(message.data.penData),
|
||||||
|
};
|
||||||
|
resolve(result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CONVERT_ERROR':
|
||||||
|
worker.removeEventListener('message', handleMessage);
|
||||||
|
reject(new Error(message.error));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
// Read file as ArrayBuffer and send to worker
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const convertMessage: WorkerMessage = {
|
||||||
|
type: 'CONVERT_PES',
|
||||||
|
fileData: reader.result as ArrayBuffer,
|
||||||
|
fileName: file.name,
|
||||||
|
};
|
||||||
|
worker.postMessage(convertMessage);
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
worker.removeEventListener('message', handleMessage);
|
||||||
|
reject(new Error('Failed to read file'));
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup worker event listeners
|
||||||
|
*/
|
||||||
|
private setupWorkerListeners() {
|
||||||
|
if (!this.worker) return;
|
||||||
|
|
||||||
|
this.worker.addEventListener('error', (event) => {
|
||||||
|
console.error('[PyodideWorkerClient] Worker error:', event);
|
||||||
|
this.state = 'error';
|
||||||
|
this.error = event.message || 'Worker error';
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker.addEventListener('messageerror', (event) => {
|
||||||
|
console.error('[PyodideWorkerClient] Worker message error:', event);
|
||||||
|
this.state = 'error';
|
||||||
|
this.error = 'Failed to deserialize worker message';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminate the worker (cleanup)
|
||||||
|
*/
|
||||||
|
terminate() {
|
||||||
|
if (this.worker) {
|
||||||
|
this.worker.terminate();
|
||||||
|
this.worker = null;
|
||||||
|
}
|
||||||
|
this.state = 'not_loaded';
|
||||||
|
this.error = null;
|
||||||
|
this.initPromise = null;
|
||||||
|
this.progressCallbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const patternConverterClient = new PatternConverterClient();
|
||||||
|
|
@ -1,318 +1,16 @@
|
||||||
import { pyodideLoader } from "./pyodideLoader";
|
import { patternConverterClient, type PesPatternData } from "./patternConverterClient";
|
||||||
import {
|
|
||||||
STITCH,
|
|
||||||
MOVE,
|
|
||||||
TRIM,
|
|
||||||
END,
|
|
||||||
PEN_FEED_DATA,
|
|
||||||
PEN_CUT_DATA,
|
|
||||||
PEN_COLOR_END,
|
|
||||||
PEN_DATA_END,
|
|
||||||
} from "./embroideryConstants";
|
|
||||||
|
|
||||||
// JavaScript constants module to expose to Python
|
// Re-export the type for backwards compatibility
|
||||||
const jsEmbConstants = {
|
export type { PesPatternData };
|
||||||
STITCH,
|
|
||||||
MOVE,
|
|
||||||
TRIM,
|
|
||||||
END,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface PesPatternData {
|
|
||||||
stitches: number[][];
|
|
||||||
threads: Array<{
|
|
||||||
color: number;
|
|
||||||
hex: string;
|
|
||||||
brand: string | null;
|
|
||||||
catalogNumber: string | null;
|
|
||||||
description: string | null;
|
|
||||||
chart: string | null;
|
|
||||||
}>;
|
|
||||||
uniqueColors: Array<{
|
|
||||||
color: number;
|
|
||||||
hex: string;
|
|
||||||
brand: string | null;
|
|
||||||
catalogNumber: string | null;
|
|
||||||
description: string | null;
|
|
||||||
chart: string | null;
|
|
||||||
threadIndices: number[]; // Which thread entries use this color
|
|
||||||
}>;
|
|
||||||
penData: Uint8Array;
|
|
||||||
colorCount: number;
|
|
||||||
stitchCount: number;
|
|
||||||
bounds: {
|
|
||||||
minX: number;
|
|
||||||
maxX: number;
|
|
||||||
minY: number;
|
|
||||||
maxY: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a PES file using PyStitch and converts it to PEN format
|
* Reads a PES file using PyStitch (via Web Worker) and converts it to PEN format
|
||||||
*/
|
*/
|
||||||
export async function convertPesToPen(file: File): Promise<PesPatternData> {
|
export async function convertPesToPen(file: File): Promise<PesPatternData> {
|
||||||
// Ensure Pyodide is initialized
|
// Delegate to the worker client
|
||||||
const pyodide = await pyodideLoader.initialize();
|
return await patternConverterClient.convertPesToPen(file);
|
||||||
|
|
||||||
// Register our JavaScript constants module for Python to import
|
|
||||||
pyodide.registerJsModule("js_emb_constants", jsEmbConstants);
|
|
||||||
|
|
||||||
// Read the PES file
|
|
||||||
const buffer = await file.arrayBuffer();
|
|
||||||
const uint8Array = new Uint8Array(buffer);
|
|
||||||
|
|
||||||
// Write file to Pyodide virtual filesystem
|
|
||||||
const filename = "/tmp/pattern.pes";
|
|
||||||
pyodide.FS.writeFile(filename, uint8Array);
|
|
||||||
|
|
||||||
// Read the pattern using PyStitch
|
|
||||||
const result = await pyodide.runPythonAsync(`
|
|
||||||
import pystitch
|
|
||||||
from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE
|
|
||||||
from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END
|
|
||||||
|
|
||||||
# Read the PES file
|
|
||||||
pattern = pystitch.read('${filename}')
|
|
||||||
|
|
||||||
def map_cmd(pystitch_cmd):
|
|
||||||
"""Map PyStitch command to our JavaScript constant values
|
|
||||||
|
|
||||||
This ensures we have known, consistent values regardless of PyStitch's internal values.
|
|
||||||
Our JS constants use pyembroidery-style bitmask values:
|
|
||||||
STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100
|
|
||||||
"""
|
|
||||||
if pystitch_cmd == STITCH:
|
|
||||||
return JS_STITCH
|
|
||||||
elif pystitch_cmd == JUMP:
|
|
||||||
return JS_MOVE # PyStitch JUMP maps to our MOVE constant
|
|
||||||
elif pystitch_cmd == TRIM:
|
|
||||||
return JS_TRIM
|
|
||||||
elif pystitch_cmd == END:
|
|
||||||
return JS_END
|
|
||||||
else:
|
|
||||||
# For any other commands, preserve as bitmask
|
|
||||||
result = JS_STITCH
|
|
||||||
if pystitch_cmd & JUMP:
|
|
||||||
result |= JS_MOVE
|
|
||||||
if pystitch_cmd & TRIM:
|
|
||||||
result |= JS_TRIM
|
|
||||||
if pystitch_cmd & END:
|
|
||||||
result |= JS_END
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Use the raw stitches list which preserves command flags
|
|
||||||
# Each stitch in pattern.stitches is [x, y, cmd]
|
|
||||||
# We need to assign color indices based on COLOR_CHANGE commands
|
|
||||||
# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches)
|
|
||||||
|
|
||||||
stitches_with_colors = []
|
|
||||||
current_color = 0
|
|
||||||
|
|
||||||
for i, stitch in enumerate(pattern.stitches):
|
|
||||||
x, y, cmd = stitch
|
|
||||||
|
|
||||||
# Check for color change command - increment color but don't add stitch
|
|
||||||
if cmd == COLOR_CHANGE:
|
|
||||||
current_color += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check for stop command - skip it
|
|
||||||
if cmd == STOP:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check for standalone END command (no stitch data)
|
|
||||||
if cmd == END:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Add actual stitch with color index and mapped command
|
|
||||||
# Map PyStitch cmd values to our known JavaScript constant values
|
|
||||||
mapped_cmd = map_cmd(cmd)
|
|
||||||
stitches_with_colors.append([x, y, mapped_cmd, current_color])
|
|
||||||
|
|
||||||
# Convert to JSON-serializable format
|
|
||||||
{
|
|
||||||
'stitches': stitches_with_colors,
|
|
||||||
'threads': [
|
|
||||||
{
|
|
||||||
'color': thread.color if hasattr(thread, 'color') else 0,
|
|
||||||
'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000',
|
|
||||||
'catalog_number': thread.catalog_number if hasattr(thread, 'catalog_number') else -1,
|
|
||||||
'brand': thread.brand if hasattr(thread, 'brand') else "",
|
|
||||||
'description': thread.description if hasattr(thread, 'description') else "",
|
|
||||||
'chart': thread.chart if hasattr(thread, 'chart') else ""
|
|
||||||
}
|
|
||||||
for thread in pattern.threadlist
|
|
||||||
],
|
|
||||||
'thread_count': len(pattern.threadlist),
|
|
||||||
'stitch_count': len(stitches_with_colors),
|
|
||||||
'color_changes': current_color
|
|
||||||
}
|
}
|
||||||
`);
|
|
||||||
|
|
||||||
// Convert Python result to JavaScript
|
|
||||||
const data = result.toJs({ dict_converter: Object.fromEntries });
|
|
||||||
|
|
||||||
// Clean up virtual file
|
|
||||||
try {
|
|
||||||
pyodide.FS.unlink(filename);
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract stitches and validate
|
|
||||||
const stitches: number[][] = Array.from(
|
|
||||||
data.stitches as ArrayLike<ArrayLike<number>>,
|
|
||||||
).map((stitch) => Array.from(stitch));
|
|
||||||
|
|
||||||
if (!stitches || stitches.length === 0) {
|
|
||||||
throw new Error("Invalid PES file or no stitches found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract thread data - preserve null values for unavailable metadata
|
|
||||||
const threads = (
|
|
||||||
data.threads as Array<{
|
|
||||||
color?: number;
|
|
||||||
hex?: string;
|
|
||||||
catalog_number?: number | string;
|
|
||||||
brand?: string;
|
|
||||||
description?: string;
|
|
||||||
chart?: string;
|
|
||||||
}>
|
|
||||||
).map((thread) => {
|
|
||||||
// Normalize catalog_number - can be string or number from PyStitch
|
|
||||||
const catalogNum = thread.catalog_number;
|
|
||||||
const normalizedCatalog =
|
|
||||||
catalogNum !== undefined &&
|
|
||||||
catalogNum !== null &&
|
|
||||||
catalogNum !== -1 &&
|
|
||||||
catalogNum !== "-1" &&
|
|
||||||
catalogNum !== ""
|
|
||||||
? String(catalogNum)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
color: thread.color ?? 0,
|
|
||||||
hex: thread.hex || "#000000",
|
|
||||||
catalogNumber: normalizedCatalog,
|
|
||||||
brand: thread.brand && thread.brand !== "" ? thread.brand : null,
|
|
||||||
description: thread.description && thread.description !== "" ? thread.description : null,
|
|
||||||
chart: thread.chart && thread.chart !== "" ? thread.chart : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track bounds
|
|
||||||
let minX = Infinity;
|
|
||||||
let maxX = -Infinity;
|
|
||||||
let minY = Infinity;
|
|
||||||
let maxY = -Infinity;
|
|
||||||
|
|
||||||
// PyStitch returns ABSOLUTE coordinates
|
|
||||||
// PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780)
|
|
||||||
const penStitches: number[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < stitches.length; i++) {
|
|
||||||
const stitch = stitches[i];
|
|
||||||
const absX = Math.round(stitch[0]);
|
|
||||||
const absY = Math.round(stitch[1]);
|
|
||||||
const cmd = stitch[2];
|
|
||||||
const stitchColor = stitch[3]; // Color index from PyStitch
|
|
||||||
|
|
||||||
// Track bounds for non-jump stitches
|
|
||||||
if (cmd === STITCH) {
|
|
||||||
minX = Math.min(minX, absX);
|
|
||||||
maxX = Math.max(maxX, absX);
|
|
||||||
minY = Math.min(minY, absY);
|
|
||||||
maxY = Math.max(maxY, absY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode absolute coordinates with flags in low 3 bits
|
|
||||||
// Shift coordinates left by 3 bits to make room for flags
|
|
||||||
// As per official app line 780: buffer[index64] = (byte) ((int) numArray4[index64 / 4, 0] << 3 & (int) byte.MaxValue);
|
|
||||||
let xEncoded = (absX << 3) & 0xffff;
|
|
||||||
let yEncoded = (absY << 3) & 0xffff;
|
|
||||||
|
|
||||||
// Add command flags to Y-coordinate based on stitch type
|
|
||||||
if (cmd & MOVE) {
|
|
||||||
// MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching
|
|
||||||
yEncoded |= PEN_FEED_DATA;
|
|
||||||
}
|
|
||||||
if (cmd & TRIM) {
|
|
||||||
// TRIM: Set bit 1 (CUT_DATA) - cut thread command
|
|
||||||
yEncoded |= PEN_CUT_DATA;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is the last stitch
|
|
||||||
const isLastStitch = i === stitches.length - 1 || (cmd & END) !== 0;
|
|
||||||
|
|
||||||
// Check for color change by comparing stitch color index
|
|
||||||
// Mark the LAST stitch of the previous color with PEN_COLOR_END
|
|
||||||
// BUT: if this is the last stitch of the entire pattern, use DATA_END instead
|
|
||||||
const nextStitch = stitches[i + 1];
|
|
||||||
const nextStitchColor = nextStitch?.[3];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isLastStitch &&
|
|
||||||
nextStitchColor !== undefined &&
|
|
||||||
nextStitchColor !== stitchColor
|
|
||||||
) {
|
|
||||||
// This is the last stitch before a color change (but not the last stitch overall)
|
|
||||||
xEncoded = (xEncoded & 0xfff8) | PEN_COLOR_END;
|
|
||||||
} else if (isLastStitch) {
|
|
||||||
// This is the very last stitch of the pattern
|
|
||||||
xEncoded = (xEncoded & 0xfff8) | PEN_DATA_END;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add stitch as 4 bytes: [X_low, X_high, Y_low, Y_high]
|
|
||||||
penStitches.push(
|
|
||||||
xEncoded & 0xff,
|
|
||||||
(xEncoded >> 8) & 0xff,
|
|
||||||
yEncoded & 0xff,
|
|
||||||
(yEncoded >> 8) & 0xff,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check for end command
|
|
||||||
if ((cmd & END) !== 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const penData = new Uint8Array(penStitches);
|
|
||||||
|
|
||||||
// Calculate unique colors from threads (threads represent color blocks, not unique colors)
|
|
||||||
const uniqueColors = threads.reduce((acc, thread, idx) => {
|
|
||||||
const existing = acc.find(c => c.hex === thread.hex);
|
|
||||||
if (existing) {
|
|
||||||
existing.threadIndices.push(idx);
|
|
||||||
} else {
|
|
||||||
acc.push({
|
|
||||||
color: thread.color,
|
|
||||||
hex: thread.hex,
|
|
||||||
brand: thread.brand,
|
|
||||||
catalogNumber: thread.catalogNumber,
|
|
||||||
description: thread.description,
|
|
||||||
chart: thread.chart,
|
|
||||||
threadIndices: [idx],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, [] as PesPatternData['uniqueColors']);
|
|
||||||
|
|
||||||
return {
|
|
||||||
stitches,
|
|
||||||
threads,
|
|
||||||
uniqueColors,
|
|
||||||
penData,
|
|
||||||
colorCount: data.thread_count,
|
|
||||||
stitchCount: data.stitch_count,
|
|
||||||
bounds: {
|
|
||||||
minX: minX === Infinity ? 0 : minX,
|
|
||||||
maxX: maxX === -Infinity ? 0 : maxX,
|
|
||||||
minY: minY === Infinity ? 0 : minY,
|
|
||||||
maxY: maxY === -Infinity ? 0 : maxY,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get thread color from pattern data
|
* Get thread color from pattern data
|
||||||
|
|
|
||||||
474
src/workers/patternConverter.worker.ts
Normal file
474
src/workers/patternConverter.worker.ts
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
import { loadPyodide, type PyodideInterface } from 'pyodide';
|
||||||
|
import {
|
||||||
|
STITCH,
|
||||||
|
MOVE,
|
||||||
|
TRIM,
|
||||||
|
END,
|
||||||
|
PEN_FEED_DATA,
|
||||||
|
PEN_CUT_DATA,
|
||||||
|
PEN_COLOR_END,
|
||||||
|
PEN_DATA_END,
|
||||||
|
} from '../utils/embroideryConstants';
|
||||||
|
|
||||||
|
// Message types from main thread
|
||||||
|
export type WorkerMessage =
|
||||||
|
| { type: 'INITIALIZE'; pyodideIndexURL?: string; pystitchWheelURL?: string }
|
||||||
|
| { type: 'CONVERT_PES'; fileData: ArrayBuffer; fileName: string };
|
||||||
|
|
||||||
|
// Response types to main thread
|
||||||
|
export type WorkerResponse =
|
||||||
|
| { type: 'INIT_PROGRESS'; progress: number; step: string }
|
||||||
|
| { type: 'INIT_COMPLETE' }
|
||||||
|
| { type: 'INIT_ERROR'; error: string }
|
||||||
|
| {
|
||||||
|
type: 'CONVERT_COMPLETE';
|
||||||
|
data: {
|
||||||
|
stitches: number[][];
|
||||||
|
threads: Array<{
|
||||||
|
color: number;
|
||||||
|
hex: string;
|
||||||
|
brand: string | null;
|
||||||
|
catalogNumber: string | null;
|
||||||
|
description: string | null;
|
||||||
|
chart: string | null;
|
||||||
|
}>;
|
||||||
|
uniqueColors: Array<{
|
||||||
|
color: number;
|
||||||
|
hex: string;
|
||||||
|
brand: string | null;
|
||||||
|
catalogNumber: string | null;
|
||||||
|
description: string | null;
|
||||||
|
chart: string | null;
|
||||||
|
threadIndices: number[];
|
||||||
|
}>;
|
||||||
|
penData: number[]; // Serialized as array
|
||||||
|
colorCount: number;
|
||||||
|
stitchCount: number;
|
||||||
|
bounds: {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minY: number;
|
||||||
|
maxY: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { type: 'CONVERT_ERROR'; error: string };
|
||||||
|
|
||||||
|
console.log('[PatternConverterWorker] Worker script loaded');
|
||||||
|
|
||||||
|
let pyodide: PyodideInterface | null = null;
|
||||||
|
let isInitializing = false;
|
||||||
|
|
||||||
|
// JavaScript constants module to expose to Python
|
||||||
|
const jsEmbConstants = {
|
||||||
|
STITCH,
|
||||||
|
MOVE,
|
||||||
|
TRIM,
|
||||||
|
END,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Pyodide with progress tracking
|
||||||
|
*/
|
||||||
|
async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: string) {
|
||||||
|
if (pyodide) {
|
||||||
|
return; // Already initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitializing) {
|
||||||
|
throw new Error('Initialization already in progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitializing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
self.postMessage({
|
||||||
|
type: 'INIT_PROGRESS',
|
||||||
|
progress: 0,
|
||||||
|
step: 'Starting initialization...',
|
||||||
|
} as WorkerResponse);
|
||||||
|
|
||||||
|
console.log('[PyodideWorker] Loading Pyodide runtime...');
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'INIT_PROGRESS',
|
||||||
|
progress: 10,
|
||||||
|
step: 'Loading Python runtime...',
|
||||||
|
} as WorkerResponse);
|
||||||
|
|
||||||
|
// Load Pyodide runtime
|
||||||
|
// Use provided URL or default to /assets/
|
||||||
|
const indexURL = pyodideIndexURL || '/assets/';
|
||||||
|
console.log('[PyodideWorker] Pyodide index URL:', indexURL);
|
||||||
|
|
||||||
|
pyodide = await loadPyodide({
|
||||||
|
indexURL: indexURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[PyodideWorker] Pyodide runtime loaded');
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'INIT_PROGRESS',
|
||||||
|
progress: 70,
|
||||||
|
step: 'Python runtime loaded',
|
||||||
|
} as WorkerResponse);
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'INIT_PROGRESS',
|
||||||
|
progress: 75,
|
||||||
|
step: 'Loading pystitch library...',
|
||||||
|
} as WorkerResponse);
|
||||||
|
|
||||||
|
// Load pystitch wheel
|
||||||
|
// Use provided URL or default
|
||||||
|
const wheelURL = pystitchWheelURL || '/pystitch-1.0.0-py3-none-any.whl';
|
||||||
|
console.log('[PyodideWorker] Pystitch wheel URL:', wheelURL);
|
||||||
|
|
||||||
|
await pyodide.loadPackage(wheelURL);
|
||||||
|
|
||||||
|
console.log('[PyodideWorker] pystitch library loaded');
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'INIT_PROGRESS',
|
||||||
|
progress: 100,
|
||||||
|
step: 'Ready!',
|
||||||
|
} as WorkerResponse);
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'INIT_COMPLETE',
|
||||||
|
} as WorkerResponse);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
console.error('[PyodideWorker] Initialization error:', err);
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'INIT_ERROR',
|
||||||
|
error: errorMsg,
|
||||||
|
} as WorkerResponse);
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
isInitializing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PES file to PEN format
|
||||||
|
*/
|
||||||
|
async function convertPesToPen(fileData: ArrayBuffer) {
|
||||||
|
if (!pyodide) {
|
||||||
|
throw new Error('Pyodide not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Register our JavaScript constants module for Python to import
|
||||||
|
pyodide.registerJsModule('js_emb_constants', jsEmbConstants);
|
||||||
|
|
||||||
|
// Convert to Uint8Array
|
||||||
|
const uint8Array = new Uint8Array(fileData);
|
||||||
|
|
||||||
|
// Write file to Pyodide virtual filesystem
|
||||||
|
const tempFileName = '/tmp/pattern.pes';
|
||||||
|
pyodide.FS.writeFile(tempFileName, uint8Array);
|
||||||
|
|
||||||
|
// Read the pattern using PyStitch (same logic as original converter)
|
||||||
|
const result = await pyodide.runPythonAsync(`
|
||||||
|
import pystitch
|
||||||
|
from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE
|
||||||
|
from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END
|
||||||
|
|
||||||
|
# Read the PES file
|
||||||
|
pattern = pystitch.read('${tempFileName}')
|
||||||
|
|
||||||
|
def map_cmd(pystitch_cmd):
|
||||||
|
"""Map PyStitch command to our JavaScript constant values
|
||||||
|
|
||||||
|
This ensures we have known, consistent values regardless of PyStitch's internal values.
|
||||||
|
Our JS constants use pyembroidery-style bitmask values:
|
||||||
|
STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100
|
||||||
|
"""
|
||||||
|
if pystitch_cmd == STITCH:
|
||||||
|
return JS_STITCH
|
||||||
|
elif pystitch_cmd == JUMP:
|
||||||
|
return JS_MOVE # PyStitch JUMP maps to our MOVE constant
|
||||||
|
elif pystitch_cmd == TRIM:
|
||||||
|
return JS_TRIM
|
||||||
|
elif pystitch_cmd == END:
|
||||||
|
return JS_END
|
||||||
|
else:
|
||||||
|
# For any other commands, preserve as bitmask
|
||||||
|
result = JS_STITCH
|
||||||
|
if pystitch_cmd & JUMP:
|
||||||
|
result |= JS_MOVE
|
||||||
|
if pystitch_cmd & TRIM:
|
||||||
|
result |= JS_TRIM
|
||||||
|
if pystitch_cmd & END:
|
||||||
|
result |= JS_END
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Use the raw stitches list which preserves command flags
|
||||||
|
# Each stitch in pattern.stitches is [x, y, cmd]
|
||||||
|
# We need to assign color indices based on COLOR_CHANGE commands
|
||||||
|
# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches)
|
||||||
|
|
||||||
|
stitches_with_colors = []
|
||||||
|
current_color = 0
|
||||||
|
|
||||||
|
for i, stitch in enumerate(pattern.stitches):
|
||||||
|
x, y, cmd = stitch
|
||||||
|
|
||||||
|
# Check for color change command - increment color but don't add stitch
|
||||||
|
if cmd == COLOR_CHANGE:
|
||||||
|
current_color += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for stop command - skip it
|
||||||
|
if cmd == STOP:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for standalone END command (no stitch data)
|
||||||
|
if cmd == END:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add actual stitch with color index and mapped command
|
||||||
|
# Map PyStitch cmd values to our known JavaScript constant values
|
||||||
|
mapped_cmd = map_cmd(cmd)
|
||||||
|
stitches_with_colors.append([x, y, mapped_cmd, current_color])
|
||||||
|
|
||||||
|
# Convert to JSON-serializable format
|
||||||
|
{
|
||||||
|
'stitches': stitches_with_colors,
|
||||||
|
'threads': [
|
||||||
|
{
|
||||||
|
'color': thread.color if hasattr(thread, 'color') else 0,
|
||||||
|
'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000',
|
||||||
|
'catalog_number': thread.catalog_number if hasattr(thread, 'catalog_number') else -1,
|
||||||
|
'brand': thread.brand if hasattr(thread, 'brand') else "",
|
||||||
|
'description': thread.description if hasattr(thread, 'description') else "",
|
||||||
|
'chart': thread.chart if hasattr(thread, 'chart') else ""
|
||||||
|
}
|
||||||
|
for thread in pattern.threadlist
|
||||||
|
],
|
||||||
|
'thread_count': len(pattern.threadlist),
|
||||||
|
'stitch_count': len(stitches_with_colors),
|
||||||
|
'color_changes': current_color
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Convert Python result to JavaScript
|
||||||
|
const data = result.toJs({ dict_converter: Object.fromEntries });
|
||||||
|
|
||||||
|
// Clean up virtual file
|
||||||
|
try {
|
||||||
|
pyodide.FS.unlink(tempFileName);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract stitches and validate
|
||||||
|
const stitches: number[][] = Array.from(
|
||||||
|
data.stitches as ArrayLike<ArrayLike<number>>
|
||||||
|
).map((stitch) => Array.from(stitch));
|
||||||
|
|
||||||
|
if (!stitches || stitches.length === 0) {
|
||||||
|
throw new Error('Invalid PES file or no stitches found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract thread data - preserve null values for unavailable metadata
|
||||||
|
const threads = (
|
||||||
|
data.threads as Array<{
|
||||||
|
color?: number;
|
||||||
|
hex?: string;
|
||||||
|
catalog_number?: number | string;
|
||||||
|
brand?: string;
|
||||||
|
description?: string;
|
||||||
|
chart?: string;
|
||||||
|
}>
|
||||||
|
).map((thread) => {
|
||||||
|
// Normalize catalog_number - can be string or number from PyStitch
|
||||||
|
const catalogNum = thread.catalog_number;
|
||||||
|
const normalizedCatalog =
|
||||||
|
catalogNum !== undefined &&
|
||||||
|
catalogNum !== null &&
|
||||||
|
catalogNum !== -1 &&
|
||||||
|
catalogNum !== '-1' &&
|
||||||
|
catalogNum !== ''
|
||||||
|
? String(catalogNum)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: thread.color ?? 0,
|
||||||
|
hex: thread.hex || '#000000',
|
||||||
|
catalogNumber: normalizedCatalog,
|
||||||
|
brand: thread.brand && thread.brand !== '' ? thread.brand : null,
|
||||||
|
description:
|
||||||
|
thread.description && thread.description !== ''
|
||||||
|
? thread.description
|
||||||
|
: null,
|
||||||
|
chart: thread.chart && thread.chart !== '' ? thread.chart : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track bounds
|
||||||
|
let minX = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
// PyStitch returns ABSOLUTE coordinates
|
||||||
|
// PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780)
|
||||||
|
const penStitches: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < stitches.length; i++) {
|
||||||
|
const stitch = stitches[i];
|
||||||
|
const absX = Math.round(stitch[0]);
|
||||||
|
const absY = Math.round(stitch[1]);
|
||||||
|
const cmd = stitch[2];
|
||||||
|
const stitchColor = stitch[3]; // Color index from PyStitch
|
||||||
|
|
||||||
|
// Track bounds for non-jump stitches
|
||||||
|
if (cmd === STITCH) {
|
||||||
|
minX = Math.min(minX, absX);
|
||||||
|
maxX = Math.max(maxX, absX);
|
||||||
|
minY = Math.min(minY, absY);
|
||||||
|
maxY = Math.max(maxY, absY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode absolute coordinates with flags in low 3 bits
|
||||||
|
// Shift coordinates left by 3 bits to make room for flags
|
||||||
|
// As per official app line 780: buffer[index64] = (byte) ((int) numArray4[index64 / 4, 0] << 3 & (int) byte.MaxValue);
|
||||||
|
let xEncoded = (absX << 3) & 0xffff;
|
||||||
|
let yEncoded = (absY << 3) & 0xffff;
|
||||||
|
|
||||||
|
// Add command flags to Y-coordinate based on stitch type
|
||||||
|
if (cmd & MOVE) {
|
||||||
|
// MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching
|
||||||
|
yEncoded |= PEN_FEED_DATA;
|
||||||
|
}
|
||||||
|
if (cmd & TRIM) {
|
||||||
|
// TRIM: Set bit 1 (CUT_DATA) - cut thread command
|
||||||
|
yEncoded |= PEN_CUT_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the last stitch
|
||||||
|
const isLastStitch = i === stitches.length - 1 || (cmd & END) !== 0;
|
||||||
|
|
||||||
|
// Check for color change by comparing stitch color index
|
||||||
|
// Mark the LAST stitch of the previous color with PEN_COLOR_END
|
||||||
|
// BUT: if this is the last stitch of the entire pattern, use DATA_END instead
|
||||||
|
const nextStitch = stitches[i + 1];
|
||||||
|
const nextStitchColor = nextStitch?.[3];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isLastStitch &&
|
||||||
|
nextStitchColor !== undefined &&
|
||||||
|
nextStitchColor !== stitchColor
|
||||||
|
) {
|
||||||
|
// This is the last stitch before a color change (but not the last stitch overall)
|
||||||
|
xEncoded = (xEncoded & 0xfff8) | PEN_COLOR_END;
|
||||||
|
} else if (isLastStitch) {
|
||||||
|
// This is the very last stitch of the pattern
|
||||||
|
xEncoded = (xEncoded & 0xfff8) | PEN_DATA_END;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stitch as 4 bytes: [X_low, X_high, Y_low, Y_high]
|
||||||
|
penStitches.push(
|
||||||
|
xEncoded & 0xff,
|
||||||
|
(xEncoded >> 8) & 0xff,
|
||||||
|
yEncoded & 0xff,
|
||||||
|
(yEncoded >> 8) & 0xff
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for end command
|
||||||
|
if ((cmd & END) !== 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate unique colors from threads (threads represent color blocks, not unique colors)
|
||||||
|
const uniqueColors = threads.reduce(
|
||||||
|
(acc, thread, idx) => {
|
||||||
|
const existing = acc.find((c) => c.hex === thread.hex);
|
||||||
|
if (existing) {
|
||||||
|
existing.threadIndices.push(idx);
|
||||||
|
} else {
|
||||||
|
acc.push({
|
||||||
|
color: thread.color,
|
||||||
|
hex: thread.hex,
|
||||||
|
brand: thread.brand,
|
||||||
|
catalogNumber: thread.catalogNumber,
|
||||||
|
description: thread.description,
|
||||||
|
chart: thread.chart,
|
||||||
|
threadIndices: [idx],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[] as Array<{
|
||||||
|
color: number;
|
||||||
|
hex: string;
|
||||||
|
brand: string | null;
|
||||||
|
catalogNumber: string | null;
|
||||||
|
description: string | null;
|
||||||
|
chart: string | null;
|
||||||
|
threadIndices: number[];
|
||||||
|
}>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Post result back to main thread
|
||||||
|
self.postMessage({
|
||||||
|
type: 'CONVERT_COMPLETE',
|
||||||
|
data: {
|
||||||
|
stitches,
|
||||||
|
threads,
|
||||||
|
uniqueColors,
|
||||||
|
penData: penStitches, // Send as array (will be converted to Uint8Array in main thread)
|
||||||
|
colorCount: data.thread_count,
|
||||||
|
stitchCount: data.stitch_count,
|
||||||
|
bounds: {
|
||||||
|
minX: minX === Infinity ? 0 : minX,
|
||||||
|
maxX: maxX === -Infinity ? 0 : maxX,
|
||||||
|
minY: minY === Infinity ? 0 : minY,
|
||||||
|
maxY: maxY === -Infinity ? 0 : maxY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as WorkerResponse);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
console.error('[PyodideWorker] Conversion error:', err);
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'CONVERT_ERROR',
|
||||||
|
error: errorMsg,
|
||||||
|
} as WorkerResponse);
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle messages from main thread
|
||||||
|
self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||||
|
const message = event.data;
|
||||||
|
console.log('[PatternConverterWorker] Received message:', message.type);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'INITIALIZE':
|
||||||
|
console.log('[PatternConverterWorker] Starting initialization...');
|
||||||
|
await initializePyodide(message.pyodideIndexURL, message.pystitchWheelURL);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'CONVERT_PES':
|
||||||
|
console.log('[PatternConverterWorker] Starting PES conversion...');
|
||||||
|
await convertPesToPen(message.fileData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error('[PatternConverterWorker] Unknown message type:', message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PatternConverterWorker] Error handling message:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[PatternConverterWorker] Message handler registered');
|
||||||
|
|
@ -144,10 +144,15 @@ export default defineConfig({
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['pyodide'],
|
exclude: ['pyodide'],
|
||||||
},
|
},
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
headers: {
|
headers: {
|
||||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||||
|
// Mark all dev server resources as same-origin
|
||||||
|
'Cross-Origin-Resource-Policy': 'same-origin',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue