fix: run linter
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions

This commit is contained in:
Jan-Henrik Bruhn 2025-12-18 11:39:22 +01:00
parent 20b8d47b6a
commit a253901fb4
44 changed files with 3246 additions and 2523 deletions

View file

@ -1,19 +1,19 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from "electron";
// Expose protected methods that allow the renderer process to use // Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object // ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld("electronAPI", {
invoke: (channel: string, ...args: unknown[]) => { invoke: (channel: string, ...args: unknown[]) => {
const validChannels = [ const validChannels = [
'storage:savePattern', "storage:savePattern",
'storage:getPattern', "storage:getPattern",
'storage:getLatest', "storage:getLatest",
'storage:deletePattern', "storage:deletePattern",
'storage:clear', "storage:clear",
'dialog:openFile', "dialog:openFile",
'dialog:saveFile', "dialog:saveFile",
'fs:readFile', "fs:readFile",
'fs:writeFile', "fs:writeFile",
]; ];
if (validChannels.includes(channel)) { if (validChannels.includes(channel)) {
@ -23,15 +23,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
throw new Error(`Invalid IPC channel: ${channel}`); throw new Error(`Invalid IPC channel: ${channel}`);
}, },
// Bluetooth device selection // Bluetooth device selection
onBluetoothDeviceList: (callback: (devices: Array<{ deviceId: string; deviceName: string }>) => void) => { onBluetoothDeviceList: (
ipcRenderer.on('bluetooth:device-list', (_event, devices) => callback(devices)); callback: (
devices: Array<{ deviceId: string; deviceName: string }>,
) => void,
) => {
ipcRenderer.on("bluetooth:device-list", (_event, devices) =>
callback(devices),
);
}, },
selectBluetoothDevice: (deviceId: string) => { selectBluetoothDevice: (deviceId: string) => {
ipcRenderer.send('bluetooth:select-device', deviceId); ipcRenderer.send("bluetooth:select-device", deviceId);
}, },
}); });
// Also expose process type for platform detection // Also expose process type for platform detection
contextBridge.exposeInMainWorld('process', { contextBridge.exposeInMainWorld("process", {
type: 'renderer', type: "renderer",
}); });

View file

@ -1,14 +1,14 @@
import { useEffect } from 'react'; import { useEffect } from "react";
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from './stores/useMachineStore'; import { useMachineStore } from "./stores/useMachineStore";
import { usePatternStore } from './stores/usePatternStore'; import { usePatternStore } from "./stores/usePatternStore";
import { useUIStore } from './stores/useUIStore'; import { useUIStore } from "./stores/useUIStore";
import { AppHeader } from './components/AppHeader'; import { AppHeader } from "./components/AppHeader";
import { LeftSidebar } from './components/LeftSidebar'; import { LeftSidebar } from "./components/LeftSidebar";
import { PatternCanvas } from './components/PatternCanvas'; import { PatternCanvas } from "./components/PatternCanvas";
import { PatternPreviewPlaceholder } from './components/PatternPreviewPlaceholder'; import { PatternPreviewPlaceholder } from "./components/PatternPreviewPlaceholder";
import { BluetoothDevicePicker } from './components/BluetoothDevicePicker'; import { BluetoothDevicePicker } from "./components/BluetoothDevicePicker";
import './App.css'; import "./App.css";
function App() { function App() {
// Set page title with version // Set page title with version
@ -17,36 +17,27 @@ function App() {
}, []); }, []);
// Machine store - for auto-loading cached pattern // Machine store - for auto-loading cached pattern
const { const { resumedPattern, resumeFileName } = useMachineStore(
resumedPattern,
resumeFileName,
} = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
resumedPattern: state.resumedPattern, resumedPattern: state.resumedPattern,
resumeFileName: state.resumeFileName, resumeFileName: state.resumeFileName,
})) })),
); );
// Pattern store - for auto-loading cached pattern // Pattern store - for auto-loading cached pattern
const { const { pesData, setPattern, setPatternOffset } = usePatternStore(
pesData,
setPattern,
setPatternOffset,
} = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
setPattern: state.setPattern, setPattern: state.setPattern,
setPatternOffset: state.setPatternOffset, setPatternOffset: state.setPatternOffset,
})) })),
); );
// UI store - for Pyodide initialization // UI store - for Pyodide initialization
const { const { initializePyodide } = useUIStore(
initializePyodide,
} = useUIStore(
useShallow((state) => ({ useShallow((state) => ({
initializePyodide: state.initializePyodide, initializePyodide: state.initializePyodide,
})) })),
); );
// Initialize Pyodide in background on mount (non-blocking thanks to worker) // Initialize Pyodide in background on mount (non-blocking thanks to worker)
@ -57,11 +48,19 @@ function App() {
// Auto-load cached pattern when available // Auto-load cached pattern when available
useEffect(() => { useEffect(() => {
if (resumedPattern && !pesData) { if (resumedPattern && !pesData) {
console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset); console.log(
setPattern(resumedPattern.pesData, resumeFileName || ''); "[App] Loading resumed pattern:",
resumeFileName,
"Offset:",
resumedPattern.patternOffset,
);
setPattern(resumedPattern.pesData, resumeFileName || "");
// Restore the cached pattern offset // Restore the cached pattern offset
if (resumedPattern.patternOffset) { if (resumedPattern.patternOffset) {
setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y); setPatternOffset(
resumedPattern.patternOffset.x,
resumedPattern.patternOffset.y,
);
} }
} }
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]); }, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);

View file

@ -1,10 +1,10 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect } from "react";
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from '../stores/useMachineStore'; import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from '../stores/useUIStore'; import { useUIStore } from "../stores/useUIStore";
import { WorkflowStepper } from './WorkflowStepper'; import { WorkflowStepper } from "./WorkflowStepper";
import { ErrorPopover } from './ErrorPopover'; import { ErrorPopover } from "./ErrorPopover";
import { getStateVisualInfo } from '../utils/machineStateHelpers'; import { getStateVisualInfo } from "../utils/machineStateHelpers";
import { import {
CheckCircleIcon, CheckCircleIcon,
BoltIcon, BoltIcon,
@ -12,7 +12,7 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
ArrowPathIcon, ArrowPathIcon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/solid'; } from "@heroicons/react/24/solid";
export function AppHeader() { export function AppHeader() {
const { const {
@ -36,19 +36,15 @@ export function AppHeader() {
isPairingError: state.isPairingError, isPairingError: state.isPairingError,
isCommunicating: state.isCommunicating, isCommunicating: state.isCommunicating,
disconnect: state.disconnect, disconnect: state.disconnect,
})) })),
); );
const { const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore(
pyodideError,
showErrorPopover,
setErrorPopover,
} = useUIStore(
useShallow((state) => ({ useShallow((state) => ({
pyodideError: state.pyodideError, pyodideError: state.pyodideError,
showErrorPopover: state.showErrorPopover, showErrorPopover: state.showErrorPopover,
setErrorPopover: state.setErrorPopover, setErrorPopover: state.setErrorPopover,
})) })),
); );
const errorPopoverRef = useRef<HTMLDivElement>(null); const errorPopoverRef = useRef<HTMLDivElement>(null);
@ -80,8 +76,9 @@ export function AppHeader() {
}; };
if (showErrorPopover) { if (showErrorPopover) {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () =>
document.removeEventListener("mousedown", handleClickOutside);
} }
}, [showErrorPopover, setErrorPopover]); }, [showErrorPopover, setErrorPopover]);
@ -90,33 +87,44 @@ export function AppHeader() {
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center"> <div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
{/* Machine Connection Status - Responsive width column */} {/* Machine Connection Status - Responsive width column */}
<div className="flex items-center gap-3 w-full lg:w-[280px]"> <div className="flex items-center gap-3 w-full lg:w-[280px]">
<div className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50" style={{ visibility: isConnected ? 'visible' : 'hidden' }}></div> <div
<div className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" style={{ visibility: !isConnected ? 'visible' : 'hidden' }}></div> className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50"
style={{ visibility: isConnected ? "visible" : "hidden" }}
></div>
<div
className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5"
style={{ visibility: !isConnected ? "visible" : "hidden" }}
></div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">Respira</h1> <h1 className="text-lg lg:text-xl font-bold text-white leading-tight">
Respira
</h1>
{isConnected && machineInfo?.serialNumber && ( {isConnected && machineInfo?.serialNumber && (
<span <span
className="text-xs text-primary-200 cursor-help" className="text-xs text-primary-200 cursor-help"
title={`Serial: ${machineInfo.serialNumber}${ title={`Serial: ${machineInfo.serialNumber}${
machineInfo.macAddress machineInfo.macAddress
? `\nMAC: ${machineInfo.macAddress}` ? `\nMAC: ${machineInfo.macAddress}`
: '' : ""
}${ }${
machineInfo.totalCount !== undefined machineInfo.totalCount !== undefined
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}` ? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
: '' : ""
}${ }${
machineInfo.serviceCount !== undefined machineInfo.serviceCount !== undefined
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}` ? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
: '' : ""
}`} }`}
> >
{machineInfo.serialNumber} {machineInfo.serialNumber}
</span> </span>
)} )}
{isPolling && ( {isPolling && (
<ArrowPathIcon className="w-3.5 h-3.5 text-primary-200 animate-spin" title="Auto-refreshing status" /> <ArrowPathIcon
className="w-3.5 h-3.5 text-primary-200 animate-spin"
title="Auto-refreshing status"
/>
)} )}
</div> </div>
<div className="flex items-center gap-2 mt-1 min-h-[32px]"> <div className="flex items-center gap-2 mt-1 min-h-[32px]">
@ -146,9 +154,9 @@ export function AppHeader() {
ref={errorButtonRef} ref={errorButtonRef}
onClick={() => setErrorPopover(!showErrorPopover)} onClick={() => setErrorPopover(!showErrorPopover)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-danger-500/90 hover:bg-danger-600 text-white border border-danger-400 transition-all flex-shrink-0 ${ className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-danger-500/90 hover:bg-danger-600 text-white border border-danger-400 transition-all flex-shrink-0 ${
(machineErrorMessage || pyodideError) machineErrorMessage || pyodideError
? 'cursor-pointer animate-pulse hover:animate-none' ? "cursor-pointer animate-pulse hover:animate-none"
: 'invisible pointer-events-none' : "invisible pointer-events-none"
}`} }`}
title="Click to view error details" title="Click to view error details"
aria-label="View error details" aria-label="View error details"
@ -157,27 +165,30 @@ export function AppHeader() {
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" /> <ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span> <span>
{(() => { {(() => {
if (pyodideError) return 'Python Error'; if (pyodideError) return "Python Error";
if (isPairingError) return 'Pairing Required'; if (isPairingError) return "Pairing Required";
const errorMsg = machineErrorMessage || ''; const errorMsg = machineErrorMessage || "";
// Categorize by error message content // Categorize by error message content
if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) { if (
return 'Connection Error'; errorMsg.toLowerCase().includes("bluetooth") ||
errorMsg.toLowerCase().includes("connection")
) {
return "Connection Error";
} }
if (errorMsg.toLowerCase().includes('upload')) { if (errorMsg.toLowerCase().includes("upload")) {
return 'Upload Error'; return "Upload Error";
} }
if (errorMsg.toLowerCase().includes('pattern')) { if (errorMsg.toLowerCase().includes("pattern")) {
return 'Pattern Error'; return "Pattern Error";
} }
if (machineError !== undefined) { if (machineError !== undefined) {
return `Machine Error`; return `Machine Error`;
} }
// Default fallback // Default fallback
return 'Error'; return "Error";
})()} })()}
</span> </span>
</button> </button>

View file

@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from "react";
import type { BluetoothDevice } from '../types/electron'; import type { BluetoothDevice } from "../types/electron";
export function BluetoothDevicePicker() { export function BluetoothDevicePicker() {
const [devices, setDevices] = useState<BluetoothDevice[]>([]); const [devices, setDevices] = useState<BluetoothDevice[]>([]);
@ -10,7 +10,7 @@ export function BluetoothDevicePicker() {
// Only set up listener in Electron // Only set up listener in Electron
if (window.electronAPI?.onBluetoothDeviceList) { if (window.electronAPI?.onBluetoothDeviceList) {
window.electronAPI.onBluetoothDeviceList((deviceList) => { window.electronAPI.onBluetoothDeviceList((deviceList) => {
console.log('[BluetoothPicker] Received device list:', deviceList); console.log("[BluetoothPicker] Received device list:", deviceList);
setDevices(deviceList); setDevices(deviceList);
// Open the picker when scan starts (even if empty at first) // Open the picker when scan starts (even if empty at first)
if (!isOpen) { if (!isOpen) {
@ -26,38 +26,44 @@ export function BluetoothDevicePicker() {
}, [isOpen]); }, [isOpen]);
const handleSelectDevice = useCallback((deviceId: string) => { const handleSelectDevice = useCallback((deviceId: string) => {
console.log('[BluetoothPicker] User selected device:', deviceId); console.log("[BluetoothPicker] User selected device:", deviceId);
window.electronAPI?.selectBluetoothDevice(deviceId); window.electronAPI?.selectBluetoothDevice(deviceId);
setIsOpen(false); setIsOpen(false);
setDevices([]); setDevices([]);
}, []); }, []);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
console.log('[BluetoothPicker] User cancelled device selection'); console.log("[BluetoothPicker] User cancelled device selection");
window.electronAPI?.selectBluetoothDevice(''); window.electronAPI?.selectBluetoothDevice("");
setIsOpen(false); setIsOpen(false);
setDevices([]); setDevices([]);
setIsScanning(false); setIsScanning(false);
}, []); }, []);
// Handle escape key // Handle escape key
const handleEscape = useCallback((e: KeyboardEvent) => { const handleEscape = useCallback(
if (e.key === 'Escape') { (e: KeyboardEvent) => {
handleCancel(); if (e.key === "Escape") {
} handleCancel();
}, [handleCancel]); }
},
[handleCancel],
);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.addEventListener('keydown', handleEscape); document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener('keydown', handleEscape); return () => document.removeEventListener("keydown", handleEscape);
} }
}, [isOpen, handleEscape]); }, [isOpen, handleEscape]);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" onClick={handleCancel}> <div
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
onClick={handleCancel}
>
<div <div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 border-t-4 border-primary-600 dark:border-primary-500" className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 border-t-4 border-primary-600 dark:border-primary-500"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -66,23 +72,48 @@ export function BluetoothDevicePicker() {
aria-describedby="bluetooth-picker-message" aria-describedby="bluetooth-picker-message"
> >
<div className="p-6 border-b border-gray-300 dark:border-gray-600"> <div className="p-6 border-b border-gray-300 dark:border-gray-600">
<h3 id="bluetooth-picker-title" className="m-0 text-base lg:text-lg font-semibold dark:text-white"> <h3
id="bluetooth-picker-title"
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
>
Select Bluetooth Device Select Bluetooth Device
</h3> </h3>
</div> </div>
<div className="p-6"> <div className="p-6">
{isScanning && devices.length === 0 ? ( {isScanning && devices.length === 0 ? (
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300"> <div className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
<svg className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400"
<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> xmlns="http://www.w3.org/2000/svg"
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> </svg>
<span id="bluetooth-picker-message">Scanning for Bluetooth devices...</span> <span id="bluetooth-picker-message">
Scanning for Bluetooth devices...
</span>
</div> </div>
) : ( ) : (
<> <>
<p id="bluetooth-picker-message" className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100"> <p
{devices.length} device{devices.length !== 1 ? 's' : ''} found. Select a device to connect: id="bluetooth-picker-message"
className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100"
>
{devices.length} device{devices.length !== 1 ? "s" : ""} found.
Select a device to connect:
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{devices.map((device) => ( {devices.map((device) => (
@ -92,8 +123,12 @@ export function BluetoothDevicePicker() {
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-900 dark:hover:text-primary-100 active:bg-primary-200 dark:active:bg-primary-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-900 dark:hover:text-primary-100 active:bg-primary-200 dark:active:bg-primary-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={`Connect to ${device.deviceName}`} aria-label={`Connect to ${device.deviceName}`}
> >
<div className="font-semibold text-gray-900 dark:text-white">{device.deviceName}</div> <div className="font-semibold text-gray-900 dark:text-white">
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">{device.deviceId}</div> {device.deviceName}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{device.deviceId}
</div>
</button> </button>
))} ))}
</div> </div>

View file

@ -1,4 +1,4 @@
import { useEffect, useCallback } from 'react'; import { useEffect, useCallback } from "react";
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
@ -8,49 +8,65 @@ interface ConfirmDialogProps {
cancelText?: string; cancelText?: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
variant?: 'danger' | 'warning'; variant?: "danger" | "warning";
} }
export function ConfirmDialog({ export function ConfirmDialog({
isOpen, isOpen,
title, title,
message, message,
confirmText = 'Confirm', confirmText = "Confirm",
cancelText = 'Cancel', cancelText = "Cancel",
onConfirm, onConfirm,
onCancel, onCancel,
variant = 'warning', variant = "warning",
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
// Handle escape key // Handle escape key
const handleEscape = useCallback((e: KeyboardEvent) => { const handleEscape = useCallback(
if (e.key === 'Escape') { (e: KeyboardEvent) => {
onCancel(); if (e.key === "Escape") {
} onCancel();
}, [onCancel]); }
},
[onCancel],
);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.addEventListener('keydown', handleEscape); document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener('keydown', handleEscape); return () => document.removeEventListener("keydown", handleEscape);
} }
}, [isOpen, handleEscape]); }, [isOpen, handleEscape]);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" onClick={onCancel}> <div
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
onClick={onCancel}
>
<div <div
className={`bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === 'danger' ? 'border-t-4 border-danger-600 dark:border-danger-500' : 'border-t-4 border-warning-500 dark:border-warning-600'}`} className={`bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === "danger" ? "border-t-4 border-danger-600 dark:border-danger-500" : "border-t-4 border-warning-500 dark:border-warning-600"}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-labelledby="dialog-title" aria-labelledby="dialog-title"
aria-describedby="dialog-message" aria-describedby="dialog-message"
> >
<div className="p-6 border-b border-gray-300 dark:border-gray-600"> <div className="p-6 border-b border-gray-300 dark:border-gray-600">
<h3 id="dialog-title" className="m-0 text-base lg:text-lg font-semibold dark:text-white">{title}</h3> <h3
id="dialog-title"
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
>
{title}
</h3>
</div> </div>
<div className="p-6"> <div className="p-6">
<p id="dialog-message" className="m-0 leading-relaxed text-gray-900 dark:text-gray-100">{message}</p> <p
id="dialog-message"
className="m-0 leading-relaxed text-gray-900 dark:text-gray-100"
>
{message}
</p>
</div> </div>
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600"> <div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
<button <button
@ -64,9 +80,9 @@ export function ConfirmDialog({
<button <button
onClick={onConfirm} onClick={onConfirm}
className={ className={
variant === 'danger' variant === "danger"
? 'px-6 py-2.5 bg-danger-600 dark:bg-danger-700 text-white rounded-lg font-semibold text-sm hover:bg-danger-700 dark:hover:bg-danger-600 active:bg-danger-800 dark:active:bg-danger-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-danger-300 dark:focus:ring-danger-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900' ? "px-6 py-2.5 bg-danger-600 dark:bg-danger-700 text-white rounded-lg font-semibold text-sm hover:bg-danger-700 dark:hover:bg-danger-600 active:bg-danger-800 dark:active:bg-danger-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-danger-300 dark:focus:ring-danger-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
: 'px-6 py-2.5 bg-primary-600 dark:bg-primary-700 text-white rounded-lg font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900' : "px-6 py-2.5 bg-primary-600 dark:bg-primary-700 text-white rounded-lg font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
} }
aria-label={`Confirm: ${confirmText}`} aria-label={`Confirm: ${confirmText}`}
> >

View file

@ -1,13 +1,13 @@
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from '../stores/useMachineStore'; import { useMachineStore } from "../stores/useMachineStore";
import { isBluetoothSupported } from '../utils/bluetoothSupport'; import { isBluetoothSupported } from "../utils/bluetoothSupport";
import { ExclamationTriangleIcon } from '@heroicons/react/24/solid'; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
export function ConnectionPrompt() { export function ConnectionPrompt() {
const { connect } = useMachineStore( const { connect } = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
connect: state.connect, connect: state.connect,
})) })),
); );
if (isBluetoothSupported()) { if (isBluetoothSupported()) {
@ -15,13 +15,27 @@ export function ConnectionPrompt() {
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600"> <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5"> <div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" /> className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
/>
</svg> </svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Get Started</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
<p className="text-xs text-gray-600 dark:text-gray-400">Connect to your embroidery machine</p> Get Started
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400">
Connect to your embroidery machine
</p>
</div> </div>
</div> </div>
<button <button
@ -39,16 +53,21 @@ export function ConnectionPrompt() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-6 h-6 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5" /> <ExclamationTriangleIcon className="w-6 h-6 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">Browser Not Supported</h3> <h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">
Browser Not Supported
</h3>
<p className="text-sm text-warning-800 dark:text-warning-200 mb-3"> <p className="text-sm text-warning-800 dark:text-warning-200 mb-3">
Your browser doesn't support Web Bluetooth, which is required to connect to your embroidery machine. Your browser doesn't support Web Bluetooth, which is required to
connect to your embroidery machine.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-semibold text-warning-900 dark:text-warning-100">Please try one of these options:</p> <p className="text-sm font-semibold text-warning-900 dark:text-warning-100">
Please try one of these options:
</p>
<ul className="text-sm text-warning-800 dark:text-warning-200 space-y-1.5 ml-4 list-disc"> <ul className="text-sm text-warning-800 dark:text-warning-200 space-y-1.5 ml-4 list-disc">
<li>Use a supported browser (Chrome, Edge, or Opera)</li> <li>Use a supported browser (Chrome, Edge, or Opera)</li>
<li> <li>
Download the Desktop app from{' '} Download the Desktop app from{" "}
<a <a
href="https://github.com/jhbruhn/respira/releases/latest" href="https://github.com/jhbruhn/respira/releases/latest"
target="_blank" target="_blank"

View file

@ -1,6 +1,9 @@
import { forwardRef } from 'react'; import { forwardRef } from "react";
import { ExclamationTriangleIcon, InformationCircleIcon } from '@heroicons/react/24/solid'; import {
import { getErrorDetails } from '../utils/errorCodeHelpers'; ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/solid";
import { getErrorDetails } from "../utils/errorCodeHelpers";
interface ErrorPopoverProps { interface ErrorPopoverProps {
machineError?: number; machineError?: number;
@ -13,31 +16,32 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
({ machineError, isPairingError, errorMessage, pyodideError }, ref) => { ({ machineError, isPairingError, errorMessage, pyodideError }, ref) => {
const errorDetails = getErrorDetails(machineError); const errorDetails = getErrorDetails(machineError);
const isPairingErr = isPairingError; const isPairingErr = isPairingError;
const errorMsg = pyodideError || errorMessage || ''; const errorMsg = pyodideError || errorMessage || "";
const isInfo = isPairingErr || errorDetails?.isInformational; const isInfo = isPairingErr || errorDetails?.isInformational;
const bgColor = isInfo const bgColor = isInfo
? 'bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500' ? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
: 'bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500'; : "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
const iconColor = isInfo const iconColor = isInfo
? 'text-info-600 dark:text-info-400' ? "text-info-600 dark:text-info-400"
: 'text-danger-600 dark:text-danger-400'; : "text-danger-600 dark:text-danger-400";
const textColor = isInfo const textColor = isInfo
? 'text-info-900 dark:text-info-200' ? "text-info-900 dark:text-info-200"
: 'text-danger-900 dark:text-danger-200'; : "text-danger-900 dark:text-danger-200";
const descColor = isInfo const descColor = isInfo
? 'text-info-800 dark:text-info-300' ? "text-info-800 dark:text-info-300"
: 'text-danger-800 dark:text-danger-300'; : "text-danger-800 dark:text-danger-300";
const listColor = isInfo const listColor = isInfo
? 'text-info-700 dark:text-info-300' ? "text-info-700 dark:text-info-300"
: 'text-danger-700 dark:text-danger-300'; : "text-danger-700 dark:text-danger-300";
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon; const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error'); const title =
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
return ( return (
<div <div
@ -46,7 +50,9 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
role="dialog" role="dialog"
aria-label="Error details" aria-label="Error details"
> >
<div className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}> <div
className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} /> <Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1"> <div className="flex-1">
@ -59,18 +65,23 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
{errorDetails?.solutions && errorDetails.solutions.length > 0 && ( {errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<> <>
<h4 className={`text-sm font-semibold ${textColor} mb-2`}> <h4 className={`text-sm font-semibold ${textColor} mb-2`}>
{isInfo ? 'Steps:' : 'How to Fix:'} {isInfo ? "Steps:" : "How to Fix:"}
</h4> </h4>
<ol className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}> <ol
className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}
>
{errorDetails.solutions.map((solution, index) => ( {errorDetails.solutions.map((solution, index) => (
<li key={index} className="pl-2">{solution}</li> <li key={index} className="pl-2">
{solution}
</li>
))} ))}
</ol> </ol>
</> </>
)} )}
{machineError !== undefined && !errorDetails?.isInformational && ( {machineError !== undefined && !errorDetails?.isInformational && (
<p className={`text-xs ${descColor} mt-3 font-mono`}> <p className={`text-xs ${descColor} mt-3 font-mono`}>
Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')} Error Code: 0x
{machineError.toString(16).toUpperCase().padStart(2, "0")}
</p> </p>
)} )}
</div> </div>
@ -78,7 +89,7 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
</div> </div>
</div> </div>
); );
} },
); );
ErrorPopover.displayName = 'ErrorPopover'; ErrorPopover.displayName = "ErrorPopover";

View file

@ -1,15 +1,26 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from "react";
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from "zustand/react/shallow";
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore'; import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
import { usePatternStore } from '../stores/usePatternStore'; import { usePatternStore } from "../stores/usePatternStore";
import { useUIStore } from '../stores/useUIStore'; import { useUIStore } from "../stores/useUIStore";
import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter'; import {
import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers'; convertPesToPen,
import { PatternInfoSkeleton } from './SkeletonLoader'; type PesPatternData,
import { PatternInfo } from './PatternInfo'; } from "../formats/import/pesImporter";
import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid'; import {
import { createFileService } from '../platform'; canUploadPattern,
import type { IFileService } from '../platform/interfaces/IFileService'; getMachineStateCategory,
} from "../utils/machineStateHelpers";
import { PatternInfoSkeleton } from "./SkeletonLoader";
import { PatternInfo } from "./PatternInfo";
import {
ArrowUpTrayIcon,
CheckCircleIcon,
DocumentTextIcon,
FolderOpenIcon,
} from "@heroicons/react/24/solid";
import { createFileService } from "../platform";
import type { IFileService } from "../platform/interfaces/IFileService";
export function FileUpload() { export function FileUpload() {
// Machine store // Machine store
@ -32,7 +43,7 @@ export function FileUpload() {
resumeAvailable: state.resumeAvailable, resumeAvailable: state.resumeAvailable,
resumeFileName: state.resumeFileName, resumeFileName: state.resumeFileName,
uploadPattern: state.uploadPattern, uploadPattern: state.uploadPattern,
})) })),
); );
// Pattern store // Pattern store
@ -47,7 +58,7 @@ export function FileUpload() {
currentFileName: state.currentFileName, currentFileName: state.currentFileName,
patternOffset: state.patternOffset, patternOffset: state.patternOffset,
setPattern: state.setPattern, setPattern: state.setPattern,
})) })),
); );
// Derived state: pattern is uploaded if machine has pattern info // Derived state: pattern is uploaded if machine has pattern info
@ -65,16 +76,16 @@ export function FileUpload() {
pyodideProgress: state.pyodideProgress, pyodideProgress: state.pyodideProgress,
pyodideLoadingStep: state.pyodideLoadingStep, pyodideLoadingStep: state.pyodideLoadingStep,
initializePyodide: state.initializePyodide, 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());
// Use prop pesData if available (from cached pattern), otherwise use local state // Use prop pesData if available (from cached pattern), otherwise use local state
const pesData = pesDataProp || localPesData; const pesData = pesDataProp || localPesData;
// Use currentFileName from App state, or local fileName, or resumeFileName for display // Use currentFileName from App state, or local fileName, or resumeFileName for display
const displayFileName = currentFileName || fileName || resumeFileName || ''; const displayFileName = currentFileName || fileName || resumeFileName || "";
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleFileChange = useCallback( const handleFileChange = useCallback(
@ -83,16 +94,16 @@ export function FileUpload() {
try { try {
// Wait for Pyodide if it's still loading // Wait for Pyodide if it's still loading
if (!pyodideReady) { if (!pyodideReady) {
console.log('[FileUpload] Waiting for Pyodide to finish loading...'); console.log("[FileUpload] Waiting for Pyodide to finish loading...");
await initializePyodide(); await initializePyodide();
console.log('[FileUpload] Pyodide ready'); 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
if (fileService.hasNativeDialogs()) { if (fileService.hasNativeDialogs()) {
file = await fileService.openFileDialog({ accept: '.pes' }); file = await fileService.openFileDialog({ accept: ".pes" });
} else { } else {
// In browser, use the input element // In browser, use the input element
file = event?.target.files?.[0] || null; file = event?.target.files?.[0] || null;
@ -110,14 +121,14 @@ export function FileUpload() {
} catch (err) { } catch (err) {
alert( alert(
`Failed to load PES file: ${ `Failed to load PES file: ${
err instanceof Error ? err.message : 'Unknown error' err instanceof Error ? err.message : "Unknown error"
}` }`,
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, },
[fileService, setPattern, pyodideReady, initializePyodide] [fileService, setPattern, pyodideReady, initializePyodide],
); );
const handleUpload = useCallback(() => { const handleUpload = useCallback(() => {
@ -155,14 +166,26 @@ export function FileUpload() {
if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) { if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) {
const directions = []; const directions = [];
if (exceedsLeft) directions.push(`left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`); if (exceedsLeft)
if (exceedsRight) directions.push(`right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`); directions.push(
if (exceedsTop) directions.push(`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`); `left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`,
if (exceedsBottom) directions.push(`bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`); );
if (exceedsRight)
directions.push(
`right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`,
);
if (exceedsTop)
directions.push(
`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`,
);
if (exceedsBottom)
directions.push(
`bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`,
);
return { return {
fits: false, fits: false,
error: `Pattern exceeds hoop bounds: ${directions.join(', ')}. Adjust pattern position in preview.` error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`,
}; };
} }
@ -171,21 +194,36 @@ export function FileUpload() {
const boundsCheck = checkPatternFitsInHoop(); const boundsCheck = checkPatternFitsInHoop();
const borderColor = pesData ? 'border-secondary-600 dark:border-secondary-500' : 'border-gray-400 dark:border-gray-600'; const borderColor = pesData
const iconColor = pesData ? 'text-secondary-600 dark:text-secondary-400' : 'text-gray-600 dark:text-gray-400'; ? "border-secondary-600 dark:border-secondary-500"
: "border-gray-400 dark:border-gray-600";
const iconColor = pesData
? "text-secondary-600 dark:text-secondary-400"
: "text-gray-600 dark:text-gray-400";
return ( return (
<div className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}> <div
className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}
>
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<DocumentTextIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} /> <DocumentTextIcon
className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`}
/>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Pattern File</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Pattern File
</h3>
{pesData && displayFileName ? ( {pesData && displayFileName ? (
<p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={displayFileName}> <p
className="text-xs text-gray-600 dark:text-gray-400 truncate"
title={displayFileName}
>
{displayFileName} {displayFileName}
</p> </p>
) : ( ) : (
<p className="text-xs text-gray-600 dark:text-gray-400">No pattern loaded</p> <p className="text-xs text-gray-600 dark:text-gray-400">
No pattern loaded
</p>
)} )}
</div> </div>
</div> </div>
@ -217,18 +255,37 @@ export function FileUpload() {
/> />
<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 ${
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"
}`} }`}
> >
{isLoading ? ( {isLoading ? (
<> <>
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="w-3.5 h-3.5 animate-spin"
<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> 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> </svg>
<span>Loading...</span> <span>Loading...</span>
</> </>
@ -245,29 +302,53 @@ export function FileUpload() {
)} )}
</label> </label>
{pesData && canUploadPattern(machineStatus) && !patternUploaded && uploadProgress < 100 && ( {pesData &&
<button canUploadPattern(machineStatus) &&
onClick={handleUpload} !patternUploaded &&
disabled={!isConnected || isUploading || !boundsCheck.fits} uploadProgress < 100 && (
className="flex-1 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" <button
aria-label={isUploading ? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete` : boundsCheck.error || 'Upload pattern to machine'} onClick={handleUpload}
> disabled={!isConnected || isUploading || !boundsCheck.fits}
{isUploading ? ( className="flex-1 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
<> aria-label={
<svg className="w-3.5 h-3.5 animate-spin inline mr-1" fill="none" viewBox="0 0 24 24"> isUploading
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> ? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
<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> : boundsCheck.error || "Upload pattern to machine"
</svg> }
{uploadProgress > 0 ? uploadProgress.toFixed(0) + '%' : 'Uploading'} >
</> {isUploading ? (
) : ( <>
<> <svg
<ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" /> className="w-3.5 h-3.5 animate-spin inline mr-1"
Upload fill="none"
</> viewBox="0 0 24 24"
)} >
</button> <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>
{uploadProgress > 0
? uploadProgress.toFixed(0) + "%"
: "Uploading"}
</>
) : (
<>
<ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" />
Upload
</>
)}
</button>
)}
</div> </div>
{/* Pyodide initialization progress indicator - shown when initializing or waiting */} {/* Pyodide initialization progress indicator - shown when initializing or waiting */}
@ -276,8 +357,8 @@ export function FileUpload() {
<div className="flex justify-between items-center mb-1.5"> <div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400"> <span className="text-xs font-medium text-gray-600 dark:text-gray-400">
{isLoading && !pyodideReady {isLoading && !pyodideReady
? 'Please wait - initializing Python environment...' ? "Please wait - initializing Python environment..."
: pyodideLoadingStep || 'Initializing Python environment...'} : pyodideLoadingStep || "Initializing Python environment..."}
</span> </span>
<span className="text-xs font-bold text-primary-600 dark:text-primary-400"> <span className="text-xs font-bold text-primary-600 dark:text-primary-400">
{pyodideProgress.toFixed(0)}% {pyodideProgress.toFixed(0)}%
@ -291,17 +372,26 @@ export function FileUpload() {
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
{isLoading && !pyodideReady {isLoading && !pyodideReady
? 'File dialog will open automatically when ready' ? "File dialog will open automatically when ready"
: 'This only happens once on first use'} : "This only happens once on first use"}
</p> </p>
</div> </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
maxHeight: (pesData && (boundsCheck.error || !canUploadPattern(machineStatus))) ? '200px' : '0px', className="transition-all duration-200 ease-in-out overflow-hidden"
marginTop: (pesData && (boundsCheck.error || !canUploadPattern(machineStatus))) ? '12px' : '0px' style={{
}}> maxHeight:
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
? "200px"
: "0px",
marginTop:
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
? "12px"
: "0px",
}}
>
{pesData && !canUploadPattern(machineStatus) && ( {pesData && !canUploadPattern(machineStatus) && (
<div className="bg-warning-100 dark:bg-warning-900/20 text-warning-800 dark:text-warning-200 px-3 py-2 rounded border border-warning-200 dark:border-warning-800 text-sm"> <div className="bg-warning-100 dark:bg-warning-900/20 text-warning-800 dark:text-warning-200 px-3 py-2 rounded border border-warning-200 dark:border-warning-800 text-sm">
Cannot upload while {getMachineStateCategory(machineStatus)} Cannot upload while {getMachineStateCategory(machineStatus)}
@ -318,9 +408,13 @@ export function FileUpload() {
{isUploading && uploadProgress < 100 && ( {isUploading && uploadProgress < 100 && (
<div className="mt-3"> <div className="mt-3">
<div className="flex justify-between items-center mb-1.5"> <div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Uploading</span> <span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Uploading
</span>
<span className="text-xs font-bold text-secondary-600 dark:text-secondary-400"> <span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
{uploadProgress > 0 ? uploadProgress.toFixed(1) + '%' : 'Starting...'} {uploadProgress > 0
? uploadProgress.toFixed(1) + "%"
: "Starting..."}
</span> </span>
</div> </div>
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative"> <div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">

View file

@ -1,10 +1,10 @@
import { memo, useMemo } from 'react'; import { memo, useMemo } from "react";
import { Group, Line, Rect, Text, Circle } from 'react-konva'; import { Group, Line, Rect, Text, Circle } from "react-konva";
import type { PesPatternData } from '../formats/import/pesImporter'; import type { PesPatternData } from "../formats/import/pesImporter";
import { getThreadColor } from '../formats/import/pesImporter'; import { getThreadColor } from "../formats/import/pesImporter";
import type { MachineInfo } from '../types/machine'; import type { MachineInfo } from "../types/machine";
import { MOVE } from '../formats/import/constants'; import { MOVE } from "../formats/import/constants";
import { canvasColors } from '../utils/cssVariables'; import { canvasColors } from "../utils/cssVariables";
interface GridProps { interface GridProps {
gridSize: number; gridSize: number;
@ -23,12 +23,20 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
const horizontalLines: number[][] = []; const horizontalLines: number[][] = [];
// Vertical lines // Vertical lines
for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) { for (
let x = Math.floor(gridMinX / gridSize) * gridSize;
x <= gridMaxX;
x += gridSize
) {
verticalLines.push([x, gridMinY, x, gridMaxY]); verticalLines.push([x, gridMinY, x, gridMaxY]);
} }
// Horizontal lines // Horizontal lines
for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) { for (
let y = Math.floor(gridMinY / gridSize) * gridSize;
y <= gridMaxY;
y += gridSize
) {
horizontalLines.push([gridMinX, y, gridMaxX, y]); horizontalLines.push([gridMinX, y, gridMaxX, y]);
} }
@ -59,7 +67,7 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
); );
}); });
Grid.displayName = 'Grid'; Grid.displayName = "Grid";
export const Origin = memo(() => { export const Origin = memo(() => {
const originColor = canvasColors.origin(); const originColor = canvasColors.origin();
@ -72,7 +80,7 @@ export const Origin = memo(() => {
); );
}); });
Origin.displayName = 'Origin'; Origin.displayName = "Origin";
interface HoopProps { interface HoopProps {
machineInfo: MachineInfo; machineInfo: MachineInfo;
@ -108,7 +116,7 @@ export const Hoop = memo(({ machineInfo }: HoopProps) => {
); );
}); });
Hoop.displayName = 'Hoop'; Hoop.displayName = "Hoop";
interface PatternBoundsProps { interface PatternBoundsProps {
bounds: { minX: number; maxX: number; minY: number; maxY: number }; bounds: { minX: number; maxX: number; minY: number; maxY: number };
@ -133,7 +141,7 @@ export const PatternBounds = memo(({ bounds }: PatternBoundsProps) => {
); );
}); });
PatternBounds.displayName = 'PatternBounds'; PatternBounds.displayName = "PatternBounds";
interface StitchesProps { interface StitchesProps {
stitches: number[][]; stitches: number[][];
@ -142,113 +150,146 @@ interface StitchesProps {
showProgress?: boolean; showProgress?: boolean;
} }
export const Stitches = memo(({ stitches, pesData, currentStitchIndex, showProgress = false }: StitchesProps) => { export const Stitches = memo(
const stitchGroups = useMemo(() => { ({
interface StitchGroup { stitches,
color: string; pesData,
points: number[]; currentStitchIndex,
completed: boolean; showProgress = false,
isJump: boolean; }: StitchesProps) => {
} const stitchGroups = useMemo(() => {
interface StitchGroup {
const groups: StitchGroup[] = []; color: string;
let currentGroup: StitchGroup | null = null; points: number[];
completed: boolean;
let prevX = 0; isJump: boolean;
let prevY = 0;
for (let i = 0; i < stitches.length; i++) {
const stitch = stitches[i];
const [x, y, cmd, colorIndex] = stitch;
const isCompleted = i < currentStitchIndex;
const isJump = (cmd & MOVE) !== 0;
const color = getThreadColor(pesData, colorIndex);
// Start new group if color/status/type changes
if (
!currentGroup ||
currentGroup.color !== color ||
currentGroup.completed !== isCompleted ||
currentGroup.isJump !== isJump
) {
// For jump stitches, we need to create a line from previous position to current position
// So we include both the previous point and current point
if (isJump && i > 0) {
currentGroup = {
color,
points: [prevX, prevY, x, y],
completed: isCompleted,
isJump,
};
} else {
currentGroup = {
color,
points: [x, y],
completed: isCompleted,
isJump,
};
}
groups.push(currentGroup);
} else {
currentGroup.points.push(x, y);
} }
prevX = x; const groups: StitchGroup[] = [];
prevY = y; let currentGroup: StitchGroup | null = null;
}
return groups; let prevX = 0;
}, [stitches, pesData, currentStitchIndex]); let prevY = 0;
return ( for (let i = 0; i < stitches.length; i++) {
<Group name="stitches"> const stitch = stitches[i];
{stitchGroups.map((group, i) => ( const [x, y, cmd, colorIndex] = stitch;
<Line const isCompleted = i < currentStitchIndex;
key={i} const isJump = (cmd & MOVE) !== 0;
points={group.points} const color = getThreadColor(pesData, colorIndex);
stroke={group.color}
strokeWidth={group.isJump ? 1.5 : 1.5}
lineCap="round"
lineJoin="round"
dash={group.isJump ? [8, 4] : undefined}
opacity={group.isJump ? (group.completed ? 0.8 : 0.5) : (showProgress && !group.completed ? 0.3 : 1.0)}
/>
))}
</Group>
);
});
Stitches.displayName = 'Stitches'; // Start new group if color/status/type changes
if (
!currentGroup ||
currentGroup.color !== color ||
currentGroup.completed !== isCompleted ||
currentGroup.isJump !== isJump
) {
// For jump stitches, we need to create a line from previous position to current position
// So we include both the previous point and current point
if (isJump && i > 0) {
currentGroup = {
color,
points: [prevX, prevY, x, y],
completed: isCompleted,
isJump,
};
} else {
currentGroup = {
color,
points: [x, y],
completed: isCompleted,
isJump,
};
}
groups.push(currentGroup);
} else {
currentGroup.points.push(x, y);
}
prevX = x;
prevY = y;
}
return groups;
}, [stitches, pesData, currentStitchIndex]);
return (
<Group name="stitches">
{stitchGroups.map((group, i) => (
<Line
key={i}
points={group.points}
stroke={group.color}
strokeWidth={group.isJump ? 1.5 : 1.5}
lineCap="round"
lineJoin="round"
dash={group.isJump ? [8, 4] : undefined}
opacity={
group.isJump
? group.completed
? 0.8
: 0.5
: showProgress && !group.completed
? 0.3
: 1.0
}
/>
))}
</Group>
);
},
);
Stitches.displayName = "Stitches";
interface CurrentPositionProps { interface CurrentPositionProps {
currentStitchIndex: number; currentStitchIndex: number;
stitches: number[][]; stitches: number[][];
} }
export const CurrentPosition = memo(({ currentStitchIndex, stitches }: CurrentPositionProps) => { export const CurrentPosition = memo(
if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) { ({ currentStitchIndex, stitches }: CurrentPositionProps) => {
return null; if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) {
} return null;
}
const [x, y] = stitches[currentStitchIndex]; const [x, y] = stitches[currentStitchIndex];
const positionColor = canvasColors.position(); const positionColor = canvasColors.position();
return ( return (
<Group name="currentPosition"> <Group name="currentPosition">
<Circle <Circle
x={x} x={x}
y={y} y={y}
radius={8} radius={8}
fill={`${positionColor}4d`} fill={`${positionColor}4d`}
stroke={positionColor} stroke={positionColor}
strokeWidth={3} strokeWidth={3}
/> />
<Line points={[x - 12, y, x - 3, y]} stroke={positionColor} strokeWidth={2} /> <Line
<Line points={[x + 12, y, x + 3, y]} stroke={positionColor} strokeWidth={2} /> points={[x - 12, y, x - 3, y]}
<Line points={[x, y - 12, x, y - 3]} stroke={positionColor} strokeWidth={2} /> stroke={positionColor}
<Line points={[x, y + 12, x, y + 3]} stroke={positionColor} strokeWidth={2} /> strokeWidth={2}
</Group> />
); <Line
}); points={[x + 12, y, x + 3, y]}
stroke={positionColor}
strokeWidth={2}
/>
<Line
points={[x, y - 12, x, y - 3]}
stroke={positionColor}
strokeWidth={2}
/>
<Line
points={[x, y + 12, x, y + 3]}
stroke={positionColor}
strokeWidth={2}
/>
</Group>
);
},
);
CurrentPosition.displayName = 'CurrentPosition'; CurrentPosition.displayName = "CurrentPosition";

View file

@ -1,22 +1,22 @@
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from "zustand/react/shallow";
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore'; import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
import { usePatternStore } from '../stores/usePatternStore'; import { usePatternStore } from "../stores/usePatternStore";
import { ConnectionPrompt } from './ConnectionPrompt'; import { ConnectionPrompt } from "./ConnectionPrompt";
import { FileUpload } from './FileUpload'; import { FileUpload } from "./FileUpload";
import { PatternSummaryCard } from './PatternSummaryCard'; import { PatternSummaryCard } from "./PatternSummaryCard";
import { ProgressMonitor } from './ProgressMonitor'; import { ProgressMonitor } from "./ProgressMonitor";
export function LeftSidebar() { export function LeftSidebar() {
const { isConnected } = useMachineStore( const { isConnected } = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
isConnected: state.isConnected, isConnected: state.isConnected,
})) })),
); );
const { pesData } = usePatternStore( const { pesData } = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
})) })),
); );
// Derived state: pattern is uploaded if machine has pattern info // Derived state: pattern is uploaded if machine has pattern info

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState } from "react";
import { import {
InformationCircleIcon, InformationCircleIcon,
CheckCircleIcon, CheckCircleIcon,
@ -6,12 +6,15 @@ import {
PauseCircleIcon, PauseCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
WifiIcon, WifiIcon,
} from '@heroicons/react/24/solid'; } from "@heroicons/react/24/solid";
import type { MachineInfo } from '../types/machine'; import type { MachineInfo } from "../types/machine";
import { MachineStatus } from '../types/machine'; import { MachineStatus } from "../types/machine";
import { ConfirmDialog } from './ConfirmDialog'; import { ConfirmDialog } from "./ConfirmDialog";
import { shouldConfirmDisconnect, getStateVisualInfo } from '../utils/machineStateHelpers'; import {
import { hasError, getErrorDetails } from '../utils/errorCodeHelpers'; shouldConfirmDisconnect,
getStateVisualInfo,
} from "../utils/machineStateHelpers";
import { hasError, getErrorDetails } from "../utils/errorCodeHelpers";
interface MachineConnectionProps { interface MachineConnectionProps {
isConnected: boolean; isConnected: boolean;
@ -61,20 +64,31 @@ export function MachineConnection({
}; };
const statusBadgeColors = { const statusBadgeColors = {
idle: 'bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300', idle: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
info: 'bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300', info: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
active: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300', active:
waiting: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300', "bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
warning: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300', waiting:
complete: 'bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300', "bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
success: 'bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300', warning:
interrupted: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300', "bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
error: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300', complete:
danger: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300', "bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
success:
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
interrupted:
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
error:
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
danger:
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
}; };
// Only show error info when connected AND there's an actual error // Only show error info when connected AND there's an actual error
const errorInfo = (isConnected && hasError(machineError)) ? getErrorDetails(machineError) : null; const errorInfo =
isConnected && hasError(machineError)
? getErrorDetails(machineError)
: null;
return ( return (
<> <>
@ -83,8 +97,12 @@ export function MachineConnection({
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<WifiIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" /> <WifiIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Machine</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
<p className="text-xs text-gray-600 dark:text-gray-400">Ready to connect</p> Machine
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400">
Ready to connect
</p>
</div> </div>
</div> </div>
@ -100,43 +118,55 @@ export function MachineConnection({
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<WifiIcon className="w-6 h-6 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" /> <WifiIcon className="w-6 h-6 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Machine Info</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Machine Info
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400"> <p className="text-xs text-gray-600 dark:text-gray-400">
{machineInfo?.modelNumber || 'Brother Embroidery Machine'} {machineInfo?.modelNumber || "Brother Embroidery Machine"}
</p> </p>
</div> </div>
</div> </div>
{/* Error/Info Display */} {/* Error/Info Display */}
{errorInfo && ( {errorInfo &&
errorInfo.isInformational ? ( (errorInfo.isInformational ? (
<div className="mb-3 p-3 bg-info-50 dark:bg-info-900/20 border border-info-200 dark:border-info-800 rounded-lg"> <div className="mb-3 p-3 bg-info-50 dark:bg-info-900/20 border border-info-200 dark:border-info-800 rounded-lg">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<InformationCircleIcon className="w-4 h-4 text-info-600 dark:text-info-400 flex-shrink-0" /> <InformationCircleIcon className="w-4 h-4 text-info-600 dark:text-info-400 flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold text-info-900 dark:text-info-200 text-xs">{errorInfo.title}</div> <div className="font-semibold text-info-900 dark:text-info-200 text-xs">
{errorInfo.title}
</div>
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
<div className="mb-3 p-3 bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-lg"> <div className="mb-3 p-3 bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-lg">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="text-danger-600 dark:text-danger-400 flex-shrink-0"></span> <span className="text-danger-600 dark:text-danger-400 flex-shrink-0">
</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1">{errorInfo.title}</div> <div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1">
{errorInfo.title}
</div>
<div className="text-xs text-danger-700 dark:text-danger-300 font-mono"> <div className="text-xs text-danger-700 dark:text-danger-300 font-mono">
Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')} Error Code: 0x
{machineError.toString(16).toUpperCase().padStart(2, "0")}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
) ))}
)}
{/* Status Badge */} {/* Status Badge */}
<div className="mb-3"> <div className="mb-3">
<span className="text-xs text-gray-600 dark:text-gray-400 block mb-1">Status:</span> <span className="text-xs text-gray-600 dark:text-gray-400 block mb-1">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-semibold text-xs ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}> Status:
</span>
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-semibold text-xs ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}
>
{(() => { {(() => {
const Icon = stateIcons[stateVisual.iconName]; const Icon = stateIcons[stateVisual.iconName];
return <Icon className="w-3.5 h-3.5" />; return <Icon className="w-3.5 h-3.5" />;
@ -149,14 +179,19 @@ export function MachineConnection({
{machineInfo && ( {machineInfo && (
<div className="grid grid-cols-2 gap-2 text-xs mb-3"> <div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Max Area</span> <span className="text-gray-600 dark:text-gray-400 block">
Max Area
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{(machineInfo.maxWidth / 10).toFixed(1)} × {(machineInfo.maxHeight / 10).toFixed(1)} mm {(machineInfo.maxWidth / 10).toFixed(1)} ×{" "}
{(machineInfo.maxHeight / 10).toFixed(1)} mm
</span> </span>
</div> </div>
{machineInfo.totalCount !== undefined && ( {machineInfo.totalCount !== undefined && (
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Total Stitches</span> <span className="text-gray-600 dark:text-gray-400 block">
Total Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{machineInfo.totalCount.toLocaleString()} {machineInfo.totalCount.toLocaleString()}
</span> </span>

View file

@ -1,26 +1,36 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from "react";
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from "zustand/react/shallow";
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore'; import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
import { usePatternStore } from '../stores/usePatternStore'; import { usePatternStore } from "../stores/usePatternStore";
import { Stage, Layer, Group } from 'react-konva'; import { Stage, Layer, Group } from "react-konva";
import Konva from 'konva'; import Konva from "konva";
import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon, PhotoIcon, ArrowsPointingInIcon } from '@heroicons/react/24/solid'; import {
import type { PesPatternData } from '../formats/import/pesImporter'; PlusIcon,
import { calculateInitialScale } from '../utils/konvaRenderers'; MinusIcon,
import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents'; ArrowPathIcon,
LockClosedIcon,
PhotoIcon,
ArrowsPointingInIcon,
} from "@heroicons/react/24/solid";
import type { PesPatternData } from "../formats/import/pesImporter";
import { calculateInitialScale } from "../utils/konvaRenderers";
import {
Grid,
Origin,
Hoop,
Stitches,
PatternBounds,
CurrentPosition,
} from "./KonvaComponents";
export function PatternCanvas() { export function PatternCanvas() {
// Machine store // Machine store
const { const { sewingProgress, machineInfo, isUploading } = useMachineStore(
sewingProgress,
machineInfo,
isUploading,
} = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
sewingProgress: state.sewingProgress, sewingProgress: state.sewingProgress,
machineInfo: state.machineInfo, machineInfo: state.machineInfo,
isUploading: state.isUploading, isUploading: state.isUploading,
})) })),
); );
// Pattern store // Pattern store
@ -33,7 +43,7 @@ export function PatternCanvas() {
pesData: state.pesData, pesData: state.pesData,
patternOffset: state.patternOffset, patternOffset: state.patternOffset,
setPatternOffset: state.setPatternOffset, setPatternOffset: state.setPatternOffset,
})) })),
); );
// Derived state: pattern is uploaded if machine has pattern info // Derived state: pattern is uploaded if machine has pattern info
@ -43,18 +53,24 @@ export function PatternCanvas() {
const [stagePos, setStagePos] = useState({ x: 0, y: 0 }); const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
const [stageScale, setStageScale] = useState(1); const [stageScale, setStageScale] = useState(1);
const [localPatternOffset, setLocalPatternOffset] = useState(initialPatternOffset || { x: 0, y: 0 }); const [localPatternOffset, setLocalPatternOffset] = useState(
initialPatternOffset || { x: 0, y: 0 },
);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const initialScaleRef = useRef<number>(1); const initialScaleRef = useRef<number>(1);
const prevPesDataRef = useRef<PesPatternData | null>(null); const prevPesDataRef = useRef<PesPatternData | null>(null);
// Update pattern offset when initialPatternOffset changes // Update pattern offset when initialPatternOffset changes
if (initialPatternOffset && ( if (
localPatternOffset.x !== initialPatternOffset.x || initialPatternOffset &&
localPatternOffset.y !== initialPatternOffset.y (localPatternOffset.x !== initialPatternOffset.x ||
)) { localPatternOffset.y !== initialPatternOffset.y)
) {
setLocalPatternOffset(initialPatternOffset); setLocalPatternOffset(initialPatternOffset);
console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset); console.log(
"[PatternCanvas] Restored pattern offset:",
initialPatternOffset,
);
} }
// Track container size // Track container size
@ -91,10 +107,19 @@ export function PatternCanvas() {
prevPesDataRef.current = pesData; prevPesDataRef.current = pesData;
const { bounds } = pesData; const { bounds } = pesData;
const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX; const viewWidth = machineInfo
const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY; ? machineInfo.maxWidth
: bounds.maxX - bounds.minX;
const viewHeight = machineInfo
? machineInfo.maxHeight
: bounds.maxY - bounds.minY;
const initialScale = calculateInitialScale(containerSize.width, containerSize.height, viewWidth, viewHeight); const initialScale = calculateInitialScale(
containerSize.width,
containerSize.height,
viewWidth,
viewHeight,
);
initialScaleRef.current = initialScale; initialScaleRef.current = initialScale;
// Reset view when pattern changes // Reset view when pattern changes
@ -118,7 +143,10 @@ export function PatternCanvas() {
const direction = e.evt.deltaY > 0 ? -1 : 1; const direction = e.evt.deltaY > 0 ? -1 : 1;
setStageScale((oldScale) => { setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2)); const newScale = Math.max(
0.1,
Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2),
);
// Zoom towards pointer // Zoom towards pointer
setStagePos((prevPos) => { setStagePos((prevPos) => {
@ -204,34 +232,51 @@ export function PatternCanvas() {
}, [pesData, setPatternOffset]); }, [pesData, setPatternOffset]);
// Pattern drag handlers // Pattern drag handlers
const handlePatternDragEnd = useCallback((e: Konva.KonvaEventObject<DragEvent>) => { const handlePatternDragEnd = useCallback(
const newOffset = { (e: Konva.KonvaEventObject<DragEvent>) => {
x: e.target.x(), const newOffset = {
y: e.target.y(), x: e.target.x(),
}; y: e.target.y(),
setLocalPatternOffset(newOffset); };
setPatternOffset(newOffset.x, newOffset.y); setLocalPatternOffset(newOffset);
}, [setPatternOffset]); setPatternOffset(newOffset.x, newOffset.y);
},
[setPatternOffset],
);
const borderColor = pesData ? 'border-tertiary-600 dark:border-tertiary-500' : 'border-gray-400 dark:border-gray-600'; const borderColor = pesData
const iconColor = pesData ? 'text-tertiary-600 dark:text-tertiary-400' : 'text-gray-600 dark:text-gray-400'; ? "border-tertiary-600 dark:border-tertiary-500"
: "border-gray-400 dark:border-gray-600";
const iconColor = pesData
? "text-tertiary-600 dark:text-tertiary-400"
: "text-gray-600 dark:text-gray-400";
return ( return (
<div className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`}> <div
className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`}
>
<div className="flex items-start gap-3 mb-3 flex-shrink-0"> <div className="flex items-start gap-3 mb-3 flex-shrink-0">
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} /> <PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Pattern Preview</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Pattern Preview
</h3>
{pesData ? ( {pesData ? (
<p className="text-xs text-gray-600 dark:text-gray-400"> <p className="text-xs text-gray-600 dark:text-gray-400">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} × {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} ×{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</p> </p>
) : ( ) : (
<p className="text-xs text-gray-600 dark:text-gray-400">No pattern loaded</p> <p className="text-xs text-gray-600 dark:text-gray-400">
No pattern loaded
</p>
)} )}
</div> </div>
</div> </div>
<div className="relative w-full h-[400px] sm:h-[500px] lg:flex-1 lg:min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden" ref={containerRef}> <div
className="relative w-full h-[400px] sm:h-[500px] lg:flex-1 lg:min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
ref={containerRef}
>
{containerSize.width > 0 && ( {containerSize.width > 0 && (
<Stage <Stage
width={containerSize.width} width={containerSize.width}
@ -242,92 +287,103 @@ export function PatternCanvas() {
scaleY={stageScale} scaleY={stageScale}
draggable draggable
onWheel={handleWheel} onWheel={handleWheel}
onDragStart={() => { onDragStart={() => {
if (stageRef.current) { if (stageRef.current) {
stageRef.current.container().style.cursor = 'grabbing'; stageRef.current.container().style.cursor = "grabbing";
} }
}} }}
onDragEnd={() => { onDragEnd={() => {
if (stageRef.current) { if (stageRef.current) {
stageRef.current.container().style.cursor = 'grab'; stageRef.current.container().style.cursor = "grab";
} }
}} }}
ref={(node) => { ref={(node) => {
stageRef.current = node; stageRef.current = node;
if (node) { if (node) {
node.container().style.cursor = 'grab'; node.container().style.cursor = "grab";
} }
}} }}
> >
{/* Background layer: grid, origin, hoop */} {/* Background layer: grid, origin, hoop */}
<Layer> <Layer>
{pesData && ( {pesData && (
<> <>
<Grid <Grid
gridSize={100} gridSize={100}
bounds={pesData.bounds} bounds={pesData.bounds}
machineInfo={machineInfo} machineInfo={machineInfo}
/> />
<Origin /> <Origin />
{machineInfo && <Hoop machineInfo={machineInfo} />} {machineInfo && <Hoop machineInfo={machineInfo} />}
</> </>
)} )}
</Layer> </Layer>
{/* Pattern layer: draggable stitches and bounds */} {/* Pattern layer: draggable stitches and bounds */}
<Layer> <Layer>
{pesData && ( {pesData && (
<Group <Group
name="pattern-group" name="pattern-group"
draggable={!patternUploaded && !isUploading} draggable={!patternUploaded && !isUploading}
x={localPatternOffset.x} x={localPatternOffset.x}
y={localPatternOffset.y} y={localPatternOffset.y}
onDragEnd={handlePatternDragEnd} onDragEnd={handlePatternDragEnd}
onMouseEnter={(e) => { onMouseEnter={(e) => {
const stage = e.target.getStage(); const stage = e.target.getStage();
if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'move'; if (stage && !patternUploaded && !isUploading)
}} stage.container().style.cursor = "move";
onMouseLeave={(e) => { }}
const stage = e.target.getStage(); onMouseLeave={(e) => {
if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'grab'; const stage = e.target.getStage();
}} if (stage && !patternUploaded && !isUploading)
> stage.container().style.cursor = "grab";
<Stitches }}
stitches={pesData.penStitches.stitches.map((s, i): [number, number, number, number] => { >
// Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex] <Stitches
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump stitches={pesData.penStitches.stitches.map(
const colorIndex = pesData.penStitches.colorBlocks.find( (s, i): [number, number, number, number] => {
(b) => i >= b.startStitch && i <= b.endStitch // Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
)?.colorIndex ?? 0; const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
return [s.x, s.y, cmd, colorIndex]; const colorIndex =
})} pesData.penStitches.colorBlocks.find(
pesData={pesData} (b) => i >= b.startStitch && i <= b.endStitch,
currentStitchIndex={sewingProgress?.currentStitch || 0} )?.colorIndex ?? 0;
showProgress={patternUploaded || isUploading} return [s.x, s.y, cmd, colorIndex];
/> },
<PatternBounds bounds={pesData.bounds} /> )}
</Group> pesData={pesData}
)} currentStitchIndex={sewingProgress?.currentStitch || 0}
</Layer> showProgress={patternUploaded || isUploading}
/>
<PatternBounds bounds={pesData.bounds} />
</Group>
)}
</Layer>
{/* Current position layer */} {/* Current position layer */}
<Layer> <Layer>
{pesData && pesData.penStitches && sewingProgress && sewingProgress.currentStitch > 0 && ( {pesData &&
<Group x={localPatternOffset.x} y={localPatternOffset.y}> pesData.penStitches &&
<CurrentPosition sewingProgress &&
currentStitchIndex={sewingProgress.currentStitch} sewingProgress.currentStitch > 0 && (
stitches={pesData.penStitches.stitches.map((s, i): [number, number, number, number] => { <Group x={localPatternOffset.x} y={localPatternOffset.y}>
const cmd = s.isJump ? 0x10 : 0; <CurrentPosition
const colorIndex = pesData.penStitches.colorBlocks.find( currentStitchIndex={sewingProgress.currentStitch}
(b) => i >= b.startStitch && i <= b.endStitch stitches={pesData.penStitches.stitches.map(
)?.colorIndex ?? 0; (s, i): [number, number, number, number] => {
return [s.x, s.y, cmd, colorIndex]; const cmd = s.isJump ? 0x10 : 0;
})} const colorIndex =
/> pesData.penStitches.colorBlocks.find(
</Group> (b) => i >= b.startStitch && i <= b.endStitch,
)} )?.colorIndex ?? 0;
</Layer> return [s.x, s.y, cmd, colorIndex];
</Stage> },
)}
/>
</Group>
)}
</Layer>
</Stage>
)} )}
{/* Placeholder overlay when no pattern is loaded */} {/* Placeholder overlay when no pattern is loaded */}
@ -342,22 +398,28 @@ export function PatternCanvas() {
<> <>
{/* Thread Legend Overlay */} {/* Thread Legend Overlay */}
<div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]"> <div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
<h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">Colors</h4> <h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
Colors
</h4>
{pesData.uniqueColors.map((color, idx) => { {pesData.uniqueColors.map((color, idx) => {
// Primary metadata: brand and catalog number // Primary metadata: brand and catalog number
const primaryMetadata = [ const primaryMetadata = [
color.brand, color.brand,
color.catalogNumber ? `#${color.catalogNumber}` : null color.catalogNumber ? `#${color.catalogNumber}` : null,
].filter(Boolean).join(" "); ]
.filter(Boolean)
.join(" ");
// Secondary metadata: chart and description // Secondary metadata: chart and description
const secondaryMetadata = [ const secondaryMetadata = [color.chart, color.description]
color.chart, .filter(Boolean)
color.description .join(" ");
].filter(Boolean).join(" ");
return ( return (
<div key={idx} className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"> <div
key={idx}
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
>
<div <div
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5" className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
style={{ backgroundColor: color.hex }} style={{ backgroundColor: color.hex }}
@ -369,7 +431,9 @@ export function PatternCanvas() {
{(primaryMetadata || secondaryMetadata) && ( {(primaryMetadata || secondaryMetadata) && (
<div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words"> <div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
{primaryMetadata} {primaryMetadata}
{primaryMetadata && secondaryMetadata && <span className="mx-1"></span>} {primaryMetadata && secondaryMetadata && (
<span className="mx-1"></span>
)}
{secondaryMetadata} {secondaryMetadata}
</div> </div>
)} )}
@ -380,11 +444,17 @@ export function PatternCanvas() {
</div> </div>
{/* Pattern Offset Indicator */} {/* Pattern Offset Indicator */}
<div className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${ <div
patternUploaded ? 'bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600' : 'bg-white/95 dark:bg-gray-800/95' className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
}`}> patternUploaded
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
: "bg-white/95 dark:bg-gray-800/95"
}`}
>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Pattern Position:</div> <div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Pattern Position:
</div>
{patternUploaded && ( {patternUploaded && (
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400"> <div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" /> <LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
@ -393,26 +463,48 @@ export function PatternCanvas() {
)} )}
</div> </div>
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1"> <div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y: {(localPatternOffset.y / 10).toFixed(1)}mm X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
{(localPatternOffset.y / 10).toFixed(1)}mm
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400 italic"> <div className="text-xs text-gray-600 dark:text-gray-400 italic">
{patternUploaded ? 'Pattern locked • Drag background to pan' : 'Drag pattern to move • Drag background to pan'} {patternUploaded
? "Pattern locked • Drag background to pan"
: "Drag pattern to move • Drag background to pan"}
</div> </div>
</div> </div>
{/* Zoom Controls Overlay */} {/* Zoom Controls Overlay */}
<div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10"> <div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
<button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleCenterPattern} disabled={!pesData || patternUploaded || isUploading} title="Center Pattern in Hoop"> <button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleCenterPattern}
disabled={!pesData || patternUploaded || isUploading}
title="Center Pattern in Hoop"
>
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" /> <ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button> </button>
<button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomIn} title="Zoom In"> <button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleZoomIn}
title="Zoom In"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" /> <PlusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button> </button>
<span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">{Math.round(stageScale * 100)}%</span> <span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
<button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomOut} title="Zoom Out"> {Math.round(stageScale * 100)}%
</span>
<button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleZoomOut}
title="Zoom Out"
>
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" /> <MinusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button> </button>
<button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed ml-1" onClick={handleZoomReset} title="Reset Zoom"> <button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed ml-1"
onClick={handleZoomReset}
title="Reset Zoom"
>
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" /> <ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button> </button>
</div> </div>

View file

@ -1,75 +1,88 @@
import type { PesPatternData } from '../formats/import/pesImporter'; import type { PesPatternData } from "../formats/import/pesImporter";
interface PatternInfoProps { interface PatternInfoProps {
pesData: PesPatternData; pesData: PesPatternData;
showThreadBlocks?: boolean; showThreadBlocks?: boolean;
} }
export function PatternInfo({ pesData, showThreadBlocks = false }: PatternInfoProps) { export function PatternInfo({
pesData,
showThreadBlocks = false,
}: PatternInfoProps) {
return ( return (
<> <>
<div className="grid grid-cols-3 gap-2 text-xs mb-2"> <div className="grid grid-cols-3 gap-2 text-xs mb-2">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span> <span className="text-gray-600 dark:text-gray-400 block">Size</span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '} {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span> </span>
</div> </div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Stitches</span> <span className="text-gray-600 dark:text-gray-400 block">
Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.penStitches?.stitches.length.toLocaleString() || pesData.stitchCount.toLocaleString()} {pesData.penStitches?.stitches.length.toLocaleString() ||
{pesData.penStitches && pesData.penStitches.stitches.length !== pesData.stitchCount && ( pesData.stitchCount.toLocaleString()}
<span {pesData.penStitches &&
className="text-gray-500 dark:text-gray-500 font-normal ml-1" pesData.penStitches.stitches.length !== pesData.stitchCount && (
title="Input stitch count from PES file (lock stitches were added for machine compatibility)" <span
> className="text-gray-500 dark:text-gray-500 font-normal ml-1"
({pesData.stitchCount.toLocaleString()}) title="Input stitch count from PES file (lock stitches were added for machine compatibility)"
</span> >
)} ({pesData.stitchCount.toLocaleString()})
</span>
)}
</span> </span>
</div> </div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block"> <span className="text-gray-600 dark:text-gray-400 block">
{showThreadBlocks ? 'Colors / Blocks' : 'Colors'} {showThreadBlocks ? "Colors / Blocks" : "Colors"}
</span> </span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{showThreadBlocks {showThreadBlocks
? `${pesData.uniqueColors.length} / ${pesData.threads.length}` ? `${pesData.uniqueColors.length} / ${pesData.threads.length}`
: pesData.uniqueColors.length : pesData.uniqueColors.length}
}
</span> </span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span> <span className="text-xs text-gray-600 dark:text-gray-400">
Colors:
</span>
<div className="flex gap-1"> <div className="flex gap-1">
{pesData.uniqueColors.slice(0, 8).map((color, idx) => { {pesData.uniqueColors.slice(0, 8).map((color, idx) => {
// Primary metadata: brand and catalog number // Primary metadata: brand and catalog number
const primaryMetadata = [ const primaryMetadata = [
color.brand, color.brand,
color.catalogNumber ? `#${color.catalogNumber}` : null color.catalogNumber ? `#${color.catalogNumber}` : null,
].filter(Boolean).join(" "); ]
.filter(Boolean)
.join(" ");
// Secondary metadata: chart and description // Secondary metadata: chart and description
const secondaryMetadata = [ const secondaryMetadata = [color.chart, color.description]
color.chart, .filter(Boolean)
color.description .join(" ");
].filter(Boolean).join(" ");
const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • "); const metadata = [primaryMetadata, secondaryMetadata]
.filter(Boolean)
.join(" • ");
// Show which thread blocks use this color in PatternSummaryCard // Show which thread blocks use this color in PatternSummaryCard
const threadNumbers = color.threadIndices.map(i => i + 1).join(", "); const threadNumbers = color.threadIndices
.map((i) => i + 1)
.join(", ");
const tooltipText = showThreadBlocks const tooltipText = showThreadBlocks
? (metadata ? metadata
? `Color ${idx + 1}: ${color.hex} - ${metadata}` ? `Color ${idx + 1}: ${color.hex} - ${metadata}`
: `Color ${idx + 1}: ${color.hex}`) : `Color ${idx + 1}: ${color.hex}`
: (metadata : metadata
? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}` ? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`); : `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
return ( return (
<div <div

View file

@ -1,7 +1,9 @@
export function PatternPreviewPlaceholder() { export function PatternPreviewPlaceholder() {
return ( return (
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col"> <div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">Pattern Preview</h2> <h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">
Pattern Preview
</h2>
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden"> <div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
{/* Decorative background pattern */} {/* Decorative background pattern */}
<div className="absolute inset-0 opacity-5 dark:opacity-10"> <div className="absolute inset-0 opacity-5 dark:opacity-10">
@ -12,18 +14,41 @@ export function PatternPreviewPlaceholder() {
<div className="text-center relative z-10"> <div className="text-center relative z-10">
<div className="relative inline-block mb-6"> <div className="relative inline-block mb-6">
<svg className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /> className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg> </svg>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center"> <div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> className="w-5 h-5 text-primary-600 dark:text-primary-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg> </svg>
</div> </div>
</div> </div>
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">No Pattern Loaded</h3> <h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">
No Pattern Loaded
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto"> <p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
Connect to your machine and choose a PES embroidery file to see your design preview Connect to your machine and choose a PES embroidery file to see your
design preview
</p> </p>
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500"> <div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View file

@ -1,33 +1,26 @@
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from '../stores/useMachineStore'; import { useMachineStore } from "../stores/useMachineStore";
import { usePatternStore } from '../stores/usePatternStore'; import { usePatternStore } from "../stores/usePatternStore";
import { canDeletePattern } from '../utils/machineStateHelpers'; import { canDeletePattern } from "../utils/machineStateHelpers";
import { PatternInfo } from './PatternInfo'; import { PatternInfo } from "./PatternInfo";
import { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid'; import { DocumentTextIcon, TrashIcon } from "@heroicons/react/24/solid";
export function PatternSummaryCard() { export function PatternSummaryCard() {
// Machine store // Machine store
const { const { machineStatus, isDeleting, deletePattern } = useMachineStore(
machineStatus,
isDeleting,
deletePattern,
} = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
machineStatus: state.machineStatus, machineStatus: state.machineStatus,
isDeleting: state.isDeleting, isDeleting: state.isDeleting,
deletePattern: state.deletePattern, deletePattern: state.deletePattern,
})) })),
); );
// Pattern store // Pattern store
const { const { pesData, currentFileName } = usePatternStore(
pesData,
currentFileName,
} = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
currentFileName: state.currentFileName, currentFileName: state.currentFileName,
})) })),
); );
if (!pesData) return null; if (!pesData) return null;
@ -38,8 +31,13 @@ export function PatternSummaryCard() {
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<DocumentTextIcon className="w-6 h-6 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" /> <DocumentTextIcon className="w-6 h-6 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Active Pattern</h3> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
<p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={currentFileName}> Active Pattern
</h3>
<p
className="text-xs text-gray-600 dark:text-gray-400 truncate"
title={currentFileName}
>
{currentFileName} {currentFileName}
</p> </p>
</div> </div>
@ -55,9 +53,24 @@ export function PatternSummaryCard() {
> >
{isDeleting ? ( {isDeleting ? (
<> <>
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="w-3 h-3 animate-spin"
<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> 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> </svg>
Deleting... Deleting...
</> </>

View file

@ -1,7 +1,7 @@
import { useRef, useEffect, useState, useMemo } from "react"; import { useRef, useEffect, useState, useMemo } from "react";
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from '../stores/useMachineStore'; import { useMachineStore } from "../stores/useMachineStore";
import { usePatternStore } from '../stores/usePatternStore'; import { usePatternStore } from "../stores/usePatternStore";
import { import {
CheckCircleIcon, CheckCircleIcon,
ArrowRightIcon, ArrowRightIcon,
@ -42,7 +42,7 @@ export function ProgressMonitor() {
startMaskTrace: state.startMaskTrace, startMaskTrace: state.startMaskTrace,
startSewing: state.startSewing, startSewing: state.startSewing,
resumeSewing: state.resumeSewing, resumeSewing: state.resumeSewing,
})) })),
); );
// Pattern store // Pattern store
@ -59,14 +59,15 @@ export function ProgressMonitor() {
// Use PEN stitch count as fallback when machine reports 0 total stitches // Use PEN stitch count as fallback when machine reports 0 total stitches
const totalStitches = patternInfo const totalStitches = patternInfo
? (patternInfo.totalStitches === 0 && pesData?.penStitches ? patternInfo.totalStitches === 0 && pesData?.penStitches
? pesData.penStitches.stitches.length ? pesData.penStitches.stitches.length
: patternInfo.totalStitches) : patternInfo.totalStitches
: 0; : 0;
const progressPercent = totalStitches > 0 const progressPercent =
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100 totalStitches > 0
: 0; ? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
: 0;
// Calculate color block information from decoded penStitches // Calculate color block information from decoded penStitches
const colorBlocks = useMemo(() => { const colorBlocks = useMemo(() => {
@ -116,7 +117,10 @@ export function ProgressMonitor() {
return { totalMinutes: 0, elapsedMinutes: 0 }; return { totalMinutes: 0, elapsedMinutes: 0 };
} }
const result = calculatePatternTime(colorBlocks, currentStitch); const result = calculatePatternTime(colorBlocks, currentStitch);
return { totalMinutes: result.totalMinutes, elapsedMinutes: result.elapsedMinutes }; return {
totalMinutes: result.totalMinutes,
elapsedMinutes: result.elapsedMinutes,
};
}, [colorBlocks, currentStitch]); }, [colorBlocks, currentStitch]);
// Auto-scroll to current block // Auto-scroll to current block
@ -132,7 +136,8 @@ export function ProgressMonitor() {
// Handle scroll to detect if at bottom // Handle scroll to detect if at bottom
const handleColorBlocksScroll = () => { const handleColorBlocksScroll = () => {
if (colorBlocksScrollRef.current) { if (colorBlocksScrollRef.current) {
const { scrollTop, scrollHeight, clientHeight } = colorBlocksScrollRef.current; const { scrollTop, scrollHeight, clientHeight } =
colorBlocksScrollRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px threshold const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px threshold
setShowGradient(!isAtBottom); setShowGradient(!isAtBottom);
} }
@ -149,8 +154,8 @@ export function ProgressMonitor() {
}; };
checkScrollable(); checkScrollable();
window.addEventListener('resize', checkScrollable); window.addEventListener("resize", checkScrollable);
return () => window.removeEventListener('resize', checkScrollable); return () => window.removeEventListener("resize", checkScrollable);
}, [colorBlocks]); }, [colorBlocks]);
const stateIndicatorColors = { const stateIndicatorColors = {
@ -300,113 +305,124 @@ export function ProgressMonitor() {
className="lg:absolute lg:inset-0 flex flex-col gap-2 lg:overflow-y-auto scroll-smooth pr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-gray-100 dark:[&::-webkit-scrollbar-track]:bg-gray-700 [&::-webkit-scrollbar-thumb]:bg-primary-600 dark:[&::-webkit-scrollbar-thumb]:bg-primary-500 [&::-webkit-scrollbar-thumb]:rounded-full" className="lg:absolute lg:inset-0 flex flex-col gap-2 lg:overflow-y-auto scroll-smooth pr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-gray-100 dark:[&::-webkit-scrollbar-track]:bg-gray-700 [&::-webkit-scrollbar-thumb]:bg-primary-600 dark:[&::-webkit-scrollbar-thumb]:bg-primary-500 [&::-webkit-scrollbar-thumb]:rounded-full"
> >
{colorBlocks.map((block, index) => { {colorBlocks.map((block, index) => {
const isCompleted = currentStitch >= block.endStitch; const isCompleted = currentStitch >= block.endStitch;
const isCurrent = index === currentBlockIndex; const isCurrent = index === currentBlockIndex;
// Calculate progress within current block // Calculate progress within current block
let blockProgress = 0; let blockProgress = 0;
if (isCurrent) { if (isCurrent) {
blockProgress = blockProgress =
((currentStitch - block.startStitch) / block.stitchCount) * ((currentStitch - block.startStitch) / block.stitchCount) *
100; 100;
} else if (isCompleted) { } else if (isCompleted) {
blockProgress = 100; blockProgress = 100;
} }
return ( return (
<div <div
key={index} key={index}
ref={isCurrent ? currentBlockRef : null} ref={isCurrent ? currentBlockRef : null}
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${ className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
isCompleted isCompleted
? "border-success-600 bg-success-50 dark:bg-success-900/20" ? "border-success-600 bg-success-50 dark:bg-success-900/20"
: isCurrent : isCurrent
? "border-accent-600 bg-accent-50 dark:bg-accent-900/20 shadow-lg shadow-accent-600/20 animate-pulseGlow" ? "border-accent-600 bg-accent-50 dark:bg-accent-900/20 shadow-lg shadow-accent-600/20 animate-pulseGlow"
: "border-gray-200 dark:border-gray-600 bg-gray-300 dark:bg-gray-800/50 opacity-70" : "border-gray-200 dark:border-gray-600 bg-gray-300 dark:bg-gray-800/50 opacity-70"
}`} }`}
role="listitem" role="listitem"
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`} aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
{/* Color swatch */} {/* Color swatch */}
<div <div
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0" className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
style={{ style={{
backgroundColor: block.threadHex, backgroundColor: block.threadHex,
...(isCurrent && { borderColor: "#9333ea" }), ...(isCurrent && { borderColor: "#9333ea" }),
}} }}
title={`Thread color: ${block.threadHex}`} title={`Thread color: ${block.threadHex}`}
aria-label={`Thread color ${block.threadHex}`} aria-label={`Thread color ${block.threadHex}`}
/> />
{/* Thread info */} {/* Thread info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100"> <div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
Thread {block.colorIndex + 1} Thread {block.colorIndex + 1}
{(block.threadBrand || block.threadChart || block.threadDescription || block.threadCatalogNumber) && ( {(block.threadBrand ||
<span className="font-normal text-gray-600 dark:text-gray-400"> block.threadChart ||
{" "} block.threadDescription ||
( block.threadCatalogNumber) && (
{(() => { <span className="font-normal text-gray-600 dark:text-gray-400">
// Primary metadata: brand and catalog number {" "}
const primaryMetadata = [ (
block.threadBrand, {(() => {
block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null // Primary metadata: brand and catalog number
].filter(Boolean).join(" "); const primaryMetadata = [
block.threadBrand,
block.threadCatalogNumber
? `#${block.threadCatalogNumber}`
: null,
]
.filter(Boolean)
.join(" ");
// Secondary metadata: chart and description // Secondary metadata: chart and description
const secondaryMetadata = [ const secondaryMetadata = [
block.threadChart, block.threadChart,
block.threadDescription block.threadDescription,
].filter(Boolean).join(" "); ]
.filter(Boolean)
.join(" ");
return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • "); return [primaryMetadata, secondaryMetadata]
})()} .filter(Boolean)
) .join(" • ");
</span> })()}
)} )
</div> </span>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5"> )}
{block.stitchCount.toLocaleString()} stitches </div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{block.stitchCount.toLocaleString()} stitches
</div>
</div> </div>
{/* Status icon */}
{isCompleted ? (
<CheckCircleIcon
className="w-5 h-5 text-success-600 flex-shrink-0"
aria-label="Completed"
/>
) : isCurrent ? (
<ArrowRightIcon
className="w-5 h-5 text-accent-600 flex-shrink-0 animate-pulse"
aria-label="In progress"
/>
) : (
<CircleStackIcon
className="w-5 h-5 text-gray-400 flex-shrink-0"
aria-label="Pending"
/>
)}
</div> </div>
{/* Status icon */} {/* Progress bar for current block */}
{isCompleted ? ( {isCurrent && (
<CheckCircleIcon <div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
className="w-5 h-5 text-success-600 flex-shrink-0" <div
aria-label="Completed" className="h-full bg-accent-600 dark:bg-accent-500 transition-all duration-300 rounded-full"
/> style={{ width: `${blockProgress}%` }}
) : isCurrent ? ( role="progressbar"
<ArrowRightIcon aria-valuenow={Math.round(blockProgress)}
className="w-5 h-5 text-accent-600 flex-shrink-0 animate-pulse" aria-valuemin={0}
aria-label="In progress" aria-valuemax={100}
/> aria-label={`${Math.round(blockProgress)}% complete`}
) : ( />
<CircleStackIcon </div>
className="w-5 h-5 text-gray-400 flex-shrink-0"
aria-label="Pending"
/>
)} )}
</div> </div>
);
{/* Progress bar for current block */} })}
{isCurrent && (
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-accent-600 dark:bg-accent-500 transition-all duration-300 rounded-full"
style={{ width: `${blockProgress}%` }}
role="progressbar"
aria-valuenow={Math.round(blockProgress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${Math.round(blockProgress)}% complete`}
/>
</div>
)}
</div>
);
})}
</div> </div>
{/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */} {/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */}
{showGradient && ( {showGradient && (

View file

@ -1,15 +1,19 @@
interface SkeletonLoaderProps { interface SkeletonLoaderProps {
className?: string; className?: string;
variant?: 'text' | 'rect' | 'circle'; variant?: "text" | "rect" | "circle";
} }
export function SkeletonLoader({ className = '', variant = 'rect' }: SkeletonLoaderProps) { export function SkeletonLoader({
const baseClasses = 'animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]'; className = "",
variant = "rect",
}: SkeletonLoaderProps) {
const baseClasses =
"animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]";
const variantClasses = { const variantClasses = {
text: 'h-4 rounded', text: "h-4 rounded",
rect: 'rounded-lg', rect: "rounded-lg",
circle: 'rounded-full' circle: "rounded-full",
}; };
return ( return (
@ -29,9 +33,24 @@ export function PatternCanvasSkeleton() {
<div className="relative w-24 h-24 mx-auto"> <div className="relative w-24 h-24 mx-auto">
<SkeletonLoader className="w-24 h-24" variant="circle" /> <SkeletonLoader className="w-24 h-24" variant="circle" />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500 animate-spin" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="w-12 h-12 text-gray-400 dark:text-gray-500 animate-spin"
<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> 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> </svg>
</div> </div>
</div> </div>

View file

@ -1,10 +1,14 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from "react";
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from "zustand/react/shallow";
import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore'; import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
import { usePatternStore } from '../stores/usePatternStore'; import { usePatternStore } from "../stores/usePatternStore";
import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid'; import {
import { MachineStatus } from '../types/machine'; CheckCircleIcon,
import { getErrorDetails, hasError } from '../utils/errorCodeHelpers'; InformationCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../types/machine";
import { getErrorDetails, hasError } from "../utils/errorCodeHelpers";
interface Step { interface Step {
id: number; id: number;
@ -13,14 +17,14 @@ interface Step {
} }
const steps: Step[] = [ const steps: Step[] = [
{ id: 1, label: 'Connect', description: 'Connect to machine' }, { id: 1, label: "Connect", description: "Connect to machine" },
{ id: 2, label: 'Home Machine', description: 'Initialize hoop position' }, { id: 2, label: "Home Machine", description: "Initialize hoop position" },
{ id: 3, label: 'Load Pattern', description: 'Choose PES file' }, { id: 3, label: "Load Pattern", description: "Choose PES file" },
{ id: 4, label: 'Upload', description: 'Upload to machine' }, { id: 4, label: "Upload", description: "Upload to machine" },
{ id: 5, label: 'Mask Trace', description: 'Trace pattern area' }, { id: 5, label: "Mask Trace", description: "Trace pattern area" },
{ id: 6, label: 'Start Sewing', description: 'Begin embroidery' }, { id: 6, label: "Start Sewing", description: "Begin embroidery" },
{ id: 7, label: 'Monitor', description: 'Watch progress' }, { id: 7, label: "Monitor", description: "Watch progress" },
{ id: 8, label: 'Complete', description: 'Finish and remove' }, { id: 8, label: "Complete", description: "Finish and remove" },
]; ];
// Helper function to get guide content for a step // Helper function to get guide content for a step
@ -29,7 +33,7 @@ function getGuideContent(
machineStatus: MachineStatus, machineStatus: MachineStatus,
hasError: boolean, hasError: boolean,
errorCode?: number, errorCode?: number,
errorMessage?: string errorMessage?: string,
) { ) {
// Check for errors first // Check for errors first
if (hasError) { if (hasError) {
@ -37,19 +41,22 @@ function getGuideContent(
if (errorDetails?.isInformational) { if (errorDetails?.isInformational) {
return { return {
type: 'info' as const, type: "info" as const,
title: errorDetails.title, title: errorDetails.title,
description: errorDetails.description, description: errorDetails.description,
items: errorDetails.solutions || [] items: errorDetails.solutions || [],
}; };
} }
return { return {
type: 'error' as const, type: "error" as const,
title: errorDetails?.title || 'Error Occurred', title: errorDetails?.title || "Error Occurred",
description: errorDetails?.description || errorMessage || 'An error occurred. Please check the machine and try again.', description:
errorDetails?.description ||
errorMessage ||
"An error occurred. Please check the machine and try again.",
items: errorDetails?.solutions || [], items: errorDetails?.solutions || [],
errorCode errorCode,
}; };
} }
@ -57,156 +64,166 @@ function getGuideContent(
switch (stepId) { switch (stepId) {
case 1: case 1:
return { return {
type: 'info' as const, type: "info" as const,
title: 'Step 1: Connect to Machine', title: "Step 1: Connect to Machine",
description: 'To get started, connect to your Brother embroidery machine via Bluetooth.', description:
"To get started, connect to your Brother embroidery machine via Bluetooth.",
items: [ items: [
'Make sure your machine is powered on', "Make sure your machine is powered on",
'Enable Bluetooth on your machine', "Enable Bluetooth on your machine",
'Click the "Connect to Machine" button below' 'Click the "Connect to Machine" button below',
] ],
}; };
case 2: case 2:
return { return {
type: 'info' as const, type: "info" as const,
title: 'Step 2: Home Machine', title: "Step 2: Home Machine",
description: 'The hoop needs to be removed and an initial homing procedure must be performed.', description:
"The hoop needs to be removed and an initial homing procedure must be performed.",
items: [ items: [
'Remove the embroidery hoop from the machine completely', "Remove the embroidery hoop from the machine completely",
'Press the Accept button on the machine', "Press the Accept button on the machine",
'Wait for the machine to complete its initialization (homing)', "Wait for the machine to complete its initialization (homing)",
'Once initialization is complete, reattach the hoop', "Once initialization is complete, reattach the hoop",
'The machine should now recognize the hoop correctly' "The machine should now recognize the hoop correctly",
] ],
}; };
case 3: case 3:
return { return {
type: 'info' as const, type: "info" as const,
title: 'Step 3: Load Your Pattern', title: "Step 3: Load Your Pattern",
description: 'Choose a PES embroidery file from your computer to preview and upload.', description:
"Choose a PES embroidery file from your computer to preview and upload.",
items: [ items: [
'Click "Choose PES File" in the Pattern File section', 'Click "Choose PES File" in the Pattern File section',
'Select your embroidery design (.pes file)', "Select your embroidery design (.pes file)",
'Review the pattern preview on the right', "Review the pattern preview on the right",
'You can drag the pattern to adjust its position' "You can drag the pattern to adjust its position",
] ],
}; };
case 4: case 4:
return { return {
type: 'info' as const, type: "info" as const,
title: 'Step 4: Upload Pattern to Machine', title: "Step 4: Upload Pattern to Machine",
description: 'Send your pattern to the embroidery machine to prepare for sewing.', description:
"Send your pattern to the embroidery machine to prepare for sewing.",
items: [ items: [
'Review the pattern preview to ensure it\'s positioned correctly', "Review the pattern preview to ensure it's positioned correctly",
'Check the pattern size matches your hoop', "Check the pattern size matches your hoop",
'Click "Upload to Machine" when ready', 'Click "Upload to Machine" when ready',
'Wait for the upload to complete (this may take a minute)' "Wait for the upload to complete (this may take a minute)",
] ],
}; };
case 5: case 5:
// Check machine status for substates // Check machine status for substates
if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) { if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) {
return { return {
type: 'warning' as const, type: "warning" as const,
title: 'Machine Action Required', title: "Machine Action Required",
description: 'The machine is ready to trace the pattern outline.', description: "The machine is ready to trace the pattern outline.",
items: [ items: [
'Press the button on your machine to confirm and start the mask trace', "Press the button on your machine to confirm and start the mask trace",
'Ensure the hoop is properly attached', "Ensure the hoop is properly attached",
'Make sure the needle area is clear' "Make sure the needle area is clear",
] ],
}; };
} }
if (machineStatus === MachineStatus.MASK_TRACING) { if (machineStatus === MachineStatus.MASK_TRACING) {
return { return {
type: 'progress' as const, type: "progress" as const,
title: 'Mask Trace In Progress', title: "Mask Trace In Progress",
description: 'The machine is tracing the pattern boundary. Please wait...', description:
"The machine is tracing the pattern boundary. Please wait...",
items: [ items: [
'Watch the machine trace the outline', "Watch the machine trace the outline",
'Verify the pattern fits within your hoop', "Verify the pattern fits within your hoop",
'Do not interrupt the machine' "Do not interrupt the machine",
] ],
}; };
} }
return { return {
type: 'info' as const, type: "info" as const,
title: 'Step 5: Start Mask Trace', title: "Step 5: Start Mask Trace",
description: 'The mask trace helps the machine understand the pattern boundaries.', description:
"The mask trace helps the machine understand the pattern boundaries.",
items: [ items: [
'Click "Start Mask Trace" button in the Sewing Progress section', 'Click "Start Mask Trace" button in the Sewing Progress section',
'The machine will trace the pattern outline', "The machine will trace the pattern outline",
'This ensures the hoop is positioned correctly' "This ensures the hoop is positioned correctly",
] ],
}; };
case 6: case 6:
return { return {
type: 'success' as const, type: "success" as const,
title: 'Step 6: Ready to Sew!', title: "Step 6: Ready to Sew!",
description: 'The machine is ready to begin embroidering your pattern.', description: "The machine is ready to begin embroidering your pattern.",
items: [ items: [
'Verify your thread colors are correct', "Verify your thread colors are correct",
'Ensure the fabric is properly hooped', "Ensure the fabric is properly hooped",
'Click "Start Sewing" when ready' 'Click "Start Sewing" when ready',
] ],
}; };
case 7: case 7:
// Check for substates // Check for substates
if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) { if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) {
return { return {
type: 'warning' as const, type: "warning" as const,
title: 'Thread Change Required', title: "Thread Change Required",
description: 'The machine needs a different thread color to continue.', description:
"The machine needs a different thread color to continue.",
items: [ items: [
'Check the color blocks section to see which thread is needed', "Check the color blocks section to see which thread is needed",
'Change to the correct thread color', "Change to the correct thread color",
'Press the button on your machine to resume sewing' "Press the button on your machine to resume sewing",
] ],
}; };
} }
if (machineStatus === MachineStatus.PAUSE || if (
machineStatus === MachineStatus.STOP || machineStatus === MachineStatus.PAUSE ||
machineStatus === MachineStatus.SEWING_INTERRUPTION) { machineStatus === MachineStatus.STOP ||
machineStatus === MachineStatus.SEWING_INTERRUPTION
) {
return { return {
type: 'warning' as const, type: "warning" as const,
title: 'Sewing Paused', title: "Sewing Paused",
description: 'The embroidery has been paused or interrupted.', description: "The embroidery has been paused or interrupted.",
items: [ items: [
'Check if everything is okay with the machine', "Check if everything is okay with the machine",
'Click "Resume Sewing" when ready to continue', 'Click "Resume Sewing" when ready to continue',
'The machine will pick up where it left off' "The machine will pick up where it left off",
] ],
}; };
} }
return { return {
type: 'progress' as const, type: "progress" as const,
title: 'Step 7: Sewing In Progress', title: "Step 7: Sewing In Progress",
description: 'Your embroidery is being stitched. Monitor the progress below.', description:
"Your embroidery is being stitched. Monitor the progress below.",
items: [ items: [
'Watch the progress bar and current stitch count', "Watch the progress bar and current stitch count",
'The machine will pause when a color change is needed', "The machine will pause when a color change is needed",
'Do not leave the machine unattended' "Do not leave the machine unattended",
] ],
}; };
case 8: case 8:
return { return {
type: 'success' as const, type: "success" as const,
title: 'Step 8: Embroidery Complete!', title: "Step 8: Embroidery Complete!",
description: 'Your embroidery is finished. Great work!', description: "Your embroidery is finished. Great work!",
items: [ items: [
'Remove the hoop from the machine', "Remove the hoop from the machine",
'Press the Accept button on the machine', "Press the Accept button on the machine",
'Carefully remove your finished embroidery', "Carefully remove your finished embroidery",
'Trim any jump stitches or loose threads', "Trim any jump stitches or loose threads",
'Click "Delete Pattern" to start a new project' 'Click "Delete Pattern" to start a new project',
] ],
}; };
default: default:
@ -214,7 +231,12 @@ function getGuideContent(
} }
} }
function getCurrentStep(machineStatus: MachineStatus, isConnected: boolean, hasPattern: boolean, patternUploaded: boolean): number { function getCurrentStep(
machineStatus: MachineStatus,
isConnected: boolean,
hasPattern: boolean,
patternUploaded: boolean,
): number {
if (!isConnected) return 1; if (!isConnected) return 1;
// Check if machine needs homing (Initial state) // Check if machine needs homing (Initial state)
@ -262,23 +284,26 @@ export function WorkflowStepper() {
isConnected: state.isConnected, isConnected: state.isConnected,
machineError: state.machineError, machineError: state.machineError,
error: state.error, error: state.error,
})) })),
); );
// Pattern store // Pattern store
const { const { pesData } = usePatternStore(
pesData,
} = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
})) })),
); );
// Derived state: pattern is uploaded if machine has pattern info // Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded(); const patternUploaded = usePatternUploaded();
const hasPattern = pesData !== null; const hasPattern = pesData !== null;
const hasErrorFlag = hasError(machineError); const hasErrorFlag = hasError(machineError);
const currentStep = getCurrentStep(machineStatus, isConnected, hasPattern, patternUploaded); const currentStep = getCurrentStep(
machineStatus,
isConnected,
hasPattern,
patternUploaded,
);
const [showPopover, setShowPopover] = useState(false); const [showPopover, setShowPopover] = useState(false);
const [popoverStep, setPopoverStep] = useState<number | null>(null); const [popoverStep, setPopoverStep] = useState<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@ -287,10 +312,13 @@ export function WorkflowStepper() {
// Close popover when clicking outside // Close popover when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node)
) {
// Check if click was on a step circle // Check if click was on a step circle
const clickedStep = Object.values(stepRefs.current).find(ref => const clickedStep = Object.values(stepRefs.current).find((ref) =>
ref?.contains(event.target as Node) ref?.contains(event.target as Node),
); );
if (!clickedStep) { if (!clickedStep) {
setShowPopover(false); setShowPopover(false);
@ -299,8 +327,9 @@ export function WorkflowStepper() {
}; };
if (showPopover) { if (showPopover) {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () =>
document.removeEventListener("mousedown", handleClickOutside);
} }
}, [showPopover]); }, [showPopover]);
@ -318,16 +347,23 @@ export function WorkflowStepper() {
}; };
return ( return (
<div className="relative max-w-5xl mx-auto mt-2 lg:mt-4" role="navigation" aria-label="Workflow progress"> <div
className="relative max-w-5xl mx-auto mt-2 lg:mt-4"
role="navigation"
aria-label="Workflow progress"
>
{/* Progress bar background */} {/* Progress bar background */}
<div className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full" style={{ left: '16px', right: '16px' }} /> <div
className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full"
style={{ left: "16px", right: "16px" }}
/>
{/* Progress bar fill */} {/* Progress bar fill */}
<div <div
className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full" className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full"
style={{ style={{
left: '16px', left: "16px",
width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)` width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)`,
}} }}
role="progressbar" role="progressbar"
aria-valuenow={currentStep} aria-valuenow={currentStep}
@ -349,26 +385,31 @@ export function WorkflowStepper() {
className="flex flex-col items-center" className="flex flex-col items-center"
style={{ flex: 1 }} style={{ flex: 1 }}
role="listitem" role="listitem"
aria-current={isCurrent ? 'step' : undefined} aria-current={isCurrent ? "step" : undefined}
> >
{/* Step circle */} {/* Step circle */}
<div <div
ref={(el) => { stepRefs.current[step.id] = el; }} ref={(el) => {
stepRefs.current[step.id] = el;
}}
onClick={() => handleStepClick(step.id)} onClick={() => handleStepClick(step.id)}
className={` className={`
w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md
${step.id <= currentStep ? 'cursor-pointer hover:scale-110' : 'cursor-not-allowed'} ${step.id <= currentStep ? "cursor-pointer hover:scale-110" : "cursor-not-allowed"}
${isComplete ? 'bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30' : ''} ${isComplete ? "bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30" : ""}
${isCurrent ? 'bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900' : ''} ${isCurrent ? "bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900" : ""}
${isUpcoming ? 'bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70' : ''} ${isUpcoming ? "bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70" : ""}
${showPopover && popoverStep === step.id ? 'ring-4 ring-white dark:ring-gray-800' : ''} ${showPopover && popoverStep === step.id ? "ring-4 ring-white dark:ring-gray-800" : ""}
`} `}
aria-label={`${step.label}: ${isComplete ? 'completed' : isCurrent ? 'current' : 'upcoming'}. Click for details.`} aria-label={`${step.label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`}
role="button" role="button"
tabIndex={step.id <= currentStep ? 0 : -1} tabIndex={step.id <= currentStep ? 0 : -1}
> >
{isComplete ? ( {isComplete ? (
<CheckCircleIcon className="w-5 h-5 lg:w-6 lg:h-6" aria-hidden="true" /> <CheckCircleIcon
className="w-5 h-5 lg:w-6 lg:h-6"
aria-hidden="true"
/>
) : ( ) : (
step.id step.id
)} )}
@ -376,9 +417,15 @@ export function WorkflowStepper() {
{/* Step label */} {/* Step label */}
<div className="mt-1 lg:mt-2 text-center"> <div className="mt-1 lg:mt-2 text-center">
<div className={`text-xs font-semibold leading-tight ${ <div
isCurrent ? 'text-white' : isComplete ? 'text-success-200 dark:text-success-300' : 'text-primary-300/70 dark:text-primary-400/70' className={`text-xs font-semibold leading-tight ${
}`}> isCurrent
? "text-white"
: isComplete
? "text-success-200 dark:text-success-300"
: "text-primary-300/70 dark:text-primary-400/70"
}`}
>
{step.label} {step.label}
</div> </div>
</div> </div>
@ -396,74 +443,113 @@ export function WorkflowStepper() {
aria-label="Step guidance" aria-label="Step guidance"
> >
{(() => { {(() => {
const content = getGuideContent(popoverStep, machineStatus, hasErrorFlag, machineError, errorMessage || undefined); const content = getGuideContent(
popoverStep,
machineStatus,
hasErrorFlag,
machineError,
errorMessage || undefined,
);
if (!content) return null; if (!content) return null;
const colorClasses = { const colorClasses = {
info: 'bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500', info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
success: 'bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500', success:
warning: 'bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500', "bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
error: 'bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500', warning:
progress: 'bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500' "bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
error:
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
progress:
"bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
}; };
const iconColorClasses = { const iconColorClasses = {
info: 'text-info-600 dark:text-info-400', info: "text-info-600 dark:text-info-400",
success: 'text-success-600 dark:text-success-400', success: "text-success-600 dark:text-success-400",
warning: 'text-warning-600 dark:text-warning-400', warning: "text-warning-600 dark:text-warning-400",
error: 'text-danger-600 dark:text-danger-400', error: "text-danger-600 dark:text-danger-400",
progress: 'text-info-600 dark:text-info-400' progress: "text-info-600 dark:text-info-400",
}; };
const textColorClasses = { const textColorClasses = {
info: 'text-info-900 dark:text-info-200', info: "text-info-900 dark:text-info-200",
success: 'text-success-900 dark:text-success-200', success: "text-success-900 dark:text-success-200",
warning: 'text-warning-900 dark:text-warning-200', warning: "text-warning-900 dark:text-warning-200",
error: 'text-danger-900 dark:text-danger-200', error: "text-danger-900 dark:text-danger-200",
progress: 'text-info-900 dark:text-info-200' progress: "text-info-900 dark:text-info-200",
}; };
const descColorClasses = { const descColorClasses = {
info: 'text-info-800 dark:text-info-300', info: "text-info-800 dark:text-info-300",
success: 'text-success-800 dark:text-success-300', success: "text-success-800 dark:text-success-300",
warning: 'text-warning-800 dark:text-warning-300', warning: "text-warning-800 dark:text-warning-300",
error: 'text-danger-800 dark:text-danger-300', error: "text-danger-800 dark:text-danger-300",
progress: 'text-info-800 dark:text-info-300' progress: "text-info-800 dark:text-info-300",
}; };
const listColorClasses = { const listColorClasses = {
info: 'text-blue-700 dark:text-blue-300', info: "text-blue-700 dark:text-blue-300",
success: 'text-green-700 dark:text-green-300', success: "text-green-700 dark:text-green-300",
warning: 'text-yellow-700 dark:text-yellow-300', warning: "text-yellow-700 dark:text-yellow-300",
error: 'text-red-700 dark:text-red-300', error: "text-red-700 dark:text-red-300",
progress: 'text-cyan-700 dark:text-cyan-300' progress: "text-cyan-700 dark:text-cyan-300",
}; };
const Icon = content.type === 'error' ? ExclamationTriangleIcon : InformationCircleIcon; const Icon =
content.type === "error"
? ExclamationTriangleIcon
: InformationCircleIcon;
return ( return (
<div className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}> <div
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Icon className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`} /> <Icon
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
/>
<div className="flex-1"> <div className="flex-1">
<h3 className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}> <h3
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
>
{content.title} {content.title}
</h3> </h3>
<p className={`text-sm ${descColorClasses[content.type]} mb-3`}> <p
className={`text-sm ${descColorClasses[content.type]} mb-3`}
>
{content.description} {content.description}
</p> </p>
{content.items && content.items.length > 0 && ( {content.items && content.items.length > 0 && (
<ul className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}> <ul
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
>
{content.items.map((item, index) => ( {content.items.map((item, index) => (
<li key={index} className="pl-2" dangerouslySetInnerHTML={{ __html: item.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') }} /> <li
key={index}
className="pl-2"
dangerouslySetInnerHTML={{
__html: item.replace(
/\*\*(.*?)\*\*/g,
"<strong>$1</strong>",
),
}}
/>
))} ))}
</ul> </ul>
)} )}
{content.type === 'error' && content.errorCode !== undefined && ( {content.type === "error" &&
<p className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`}> content.errorCode !== undefined && (
Error Code: 0x{content.errorCode.toString(16).toUpperCase().padStart(2, '0')} <p
</p> className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`}
)} >
Error Code: 0x
{content.errorCode
.toString(16)
.toUpperCase()
.padStart(2, "0")}
</p>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,12 +1,12 @@
import type { WorkerMessage, WorkerResponse } from './worker'; import type { WorkerMessage, WorkerResponse } from "./worker";
import PatternConverterWorker from './worker?worker'; import PatternConverterWorker from "./worker?worker";
import { decodePenData } from '../pen/decoder'; import { decodePenData } from "../pen/decoder";
import type { DecodedPenData } from '../pen/types'; import type { DecodedPenData } from "../pen/types";
export type PyodideState = 'not_loaded' | 'loading' | 'ready' | 'error'; export type PyodideState = "not_loaded" | "loading" | "ready" | "error";
export interface PesPatternData { export interface PesPatternData {
stitches: number[][]; // Original PES stitches (for reference) stitches: number[][]; // Original PES stitches (for reference)
threads: Array<{ threads: Array<{
color: number; color: number;
hex: string; hex: string;
@ -24,7 +24,7 @@ export interface PesPatternData {
chart: string | null; chart: string | null;
threadIndices: number[]; threadIndices: number[];
}>; }>;
penData: Uint8Array; // Raw PEN bytes sent to machine penData: Uint8Array; // Raw PEN bytes sent to machine
penStitches: DecodedPenData; // Decoded PEN stitches (for rendering) penStitches: DecodedPenData; // Decoded PEN stitches (for rendering)
colorCount: number; colorCount: number;
stitchCount: number; stitchCount: number;
@ -40,7 +40,7 @@ export type ProgressCallback = (progress: number, step: string) => void;
class PatternConverterClient { class PatternConverterClient {
private worker: Worker | null = null; private worker: Worker | null = null;
private state: PyodideState = 'not_loaded'; private state: PyodideState = "not_loaded";
private error: string | null = null; private error: string | null = null;
private initPromise: Promise<void> | null = null; private initPromise: Promise<void> | null = null;
private progressCallbacks: Set<ProgressCallback> = new Set(); private progressCallbacks: Set<ProgressCallback> = new Set();
@ -64,7 +64,7 @@ class PatternConverterClient {
*/ */
async initialize(onProgress?: ProgressCallback): Promise<void> { async initialize(onProgress?: ProgressCallback): Promise<void> {
// If already ready, return immediately // If already ready, return immediately
if (this.state === 'ready') { if (this.state === "ready") {
return; return;
} }
@ -78,13 +78,13 @@ class PatternConverterClient {
// Create worker if it doesn't exist // Create worker if it doesn't exist
if (!this.worker) { if (!this.worker) {
console.log('[PatternConverterClient] Creating worker...'); console.log("[PatternConverterClient] Creating worker...");
try { try {
this.worker = new PatternConverterWorker(); this.worker = new PatternConverterWorker();
console.log('[PatternConverterClient] Worker created successfully'); console.log("[PatternConverterClient] Worker created successfully");
this.setupWorkerListeners(); this.setupWorkerListeners();
} catch (err) { } catch (err) {
console.error('[PatternConverterClient] Failed to create worker:', err); console.error("[PatternConverterClient] Failed to create worker:", err);
throw err; throw err;
} }
} }
@ -95,7 +95,7 @@ class PatternConverterClient {
} }
// Start initialization // Start initialization
this.state = 'loading'; this.state = "loading";
this.error = null; this.error = null;
this.initPromise = new Promise<void>((resolve, reject) => { this.initPromise = new Promise<void>((resolve, reject) => {
@ -103,44 +103,55 @@ class PatternConverterClient {
const message = event.data; const message = event.data;
switch (message.type) { switch (message.type) {
case 'INIT_PROGRESS': case "INIT_PROGRESS":
// Notify all progress callbacks // Notify all progress callbacks
this.progressCallbacks.forEach((callback) => { this.progressCallbacks.forEach((callback) => {
callback(message.progress, message.step); callback(message.progress, message.step);
}); });
break; break;
case 'INIT_COMPLETE': case "INIT_COMPLETE":
this.state = 'ready'; this.state = "ready";
this.progressCallbacks.clear(); this.progressCallbacks.clear();
this.worker?.removeEventListener('message', handleMessage); this.worker?.removeEventListener("message", handleMessage);
resolve(); resolve();
break; break;
case 'INIT_ERROR': case "INIT_ERROR":
this.state = 'error'; this.state = "error";
this.error = message.error; this.error = message.error;
this.progressCallbacks.clear(); this.progressCallbacks.clear();
this.worker?.removeEventListener('message', handleMessage); this.worker?.removeEventListener("message", handleMessage);
reject(new Error(message.error)); reject(new Error(message.error));
break; break;
} }
}; };
this.worker?.addEventListener('message', handleMessage); this.worker?.addEventListener("message", handleMessage);
// Send initialization message with asset URLs // Send initialization message with asset URLs
// Resolve URLs relative to the current page location // Resolve URLs relative to the current page location
const baseURL = window.location.origin + window.location.pathname.replace(/\/[^/]*$/, '/'); const baseURL =
const pyodideIndexURL = new URL('assets/', baseURL).href; window.location.origin +
const pystitchWheelURL = new URL('pystitch-1.0.0-py3-none-any.whl', baseURL).href; 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] Base URL:", baseURL);
console.log('[PatternConverterClient] Pyodide index URL:', pyodideIndexURL); console.log(
console.log('[PatternConverterClient] Pystitch wheel URL:', pystitchWheelURL); "[PatternConverterClient] Pyodide index URL:",
pyodideIndexURL,
);
console.log(
"[PatternConverterClient] Pystitch wheel URL:",
pystitchWheelURL,
);
const initMessage: WorkerMessage = { const initMessage: WorkerMessage = {
type: 'INITIALIZE', type: "INITIALIZE",
pyodideIndexURL, pyodideIndexURL,
pystitchWheelURL, pystitchWheelURL,
}; };
@ -155,19 +166,21 @@ class PatternConverterClient {
*/ */
async convertPesToPen(file: File): Promise<PesPatternData> { async convertPesToPen(file: File): Promise<PesPatternData> {
// Ensure worker is initialized // Ensure worker is initialized
if (this.state !== 'ready') { if (this.state !== "ready") {
throw new Error('Pyodide worker not initialized. Call initialize() first.'); throw new Error(
"Pyodide worker not initialized. Call initialize() first.",
);
} }
if (!this.worker) { if (!this.worker) {
throw new Error('Worker not available'); throw new Error("Worker not available");
} }
return new Promise<PesPatternData>((resolve, reject) => { return new Promise<PesPatternData>((resolve, reject) => {
// Store reference to worker for TypeScript null checking // Store reference to worker for TypeScript null checking
const worker = this.worker; const worker = this.worker;
if (!worker) { if (!worker) {
reject(new Error('Worker not available')); reject(new Error("Worker not available"));
return; return;
} }
@ -175,14 +188,20 @@ class PatternConverterClient {
const message = event.data; const message = event.data;
switch (message.type) { switch (message.type) {
case 'CONVERT_COMPLETE': { case "CONVERT_COMPLETE": {
worker.removeEventListener('message', handleMessage); worker.removeEventListener("message", handleMessage);
// Convert penData array back to Uint8Array // Convert penData array back to Uint8Array
const penData = new Uint8Array(message.data.penData); const penData = new Uint8Array(message.data.penData);
// Decode the PEN data to get stitches for rendering // Decode the PEN data to get stitches for rendering
const penStitches = decodePenData(penData); const penStitches = decodePenData(penData);
console.log('[PatternConverter] Decoded PEN data:', penStitches.stitches.length, 'stitches,', penStitches.colorBlocks.length, 'colors'); console.log(
"[PatternConverter] Decoded PEN data:",
penStitches.stitches.length,
"stitches,",
penStitches.colorBlocks.length,
"colors",
);
const result: PesPatternData = { const result: PesPatternData = {
...message.data, ...message.data,
@ -193,28 +212,28 @@ class PatternConverterClient {
break; break;
} }
case 'CONVERT_ERROR': case "CONVERT_ERROR":
worker.removeEventListener('message', handleMessage); worker.removeEventListener("message", handleMessage);
reject(new Error(message.error)); reject(new Error(message.error));
break; break;
} }
}; };
worker.addEventListener('message', handleMessage); worker.addEventListener("message", handleMessage);
// Read file as ArrayBuffer and send to worker // Read file as ArrayBuffer and send to worker
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const convertMessage: WorkerMessage = { const convertMessage: WorkerMessage = {
type: 'CONVERT_PES', type: "CONVERT_PES",
fileData: reader.result as ArrayBuffer, fileData: reader.result as ArrayBuffer,
fileName: file.name, fileName: file.name,
}; };
worker.postMessage(convertMessage); worker.postMessage(convertMessage);
}; };
reader.onerror = () => { reader.onerror = () => {
worker.removeEventListener('message', handleMessage); worker.removeEventListener("message", handleMessage);
reject(new Error('Failed to read file')); reject(new Error("Failed to read file"));
}; };
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
}); });
@ -226,16 +245,16 @@ class PatternConverterClient {
private setupWorkerListeners() { private setupWorkerListeners() {
if (!this.worker) return; if (!this.worker) return;
this.worker.addEventListener('error', (event) => { this.worker.addEventListener("error", (event) => {
console.error('[PyodideWorkerClient] Worker error:', event); console.error("[PyodideWorkerClient] Worker error:", event);
this.state = 'error'; this.state = "error";
this.error = event.message || 'Worker error'; this.error = event.message || "Worker error";
}); });
this.worker.addEventListener('messageerror', (event) => { this.worker.addEventListener("messageerror", (event) => {
console.error('[PyodideWorkerClient] Worker message error:', event); console.error("[PyodideWorkerClient] Worker message error:", event);
this.state = 'error'; this.state = "error";
this.error = 'Failed to deserialize worker message'; this.error = "Failed to deserialize worker message";
}); });
} }
@ -247,7 +266,7 @@ class PatternConverterClient {
this.worker.terminate(); this.worker.terminate();
this.worker = null; this.worker = null;
} }
this.state = 'not_loaded'; this.state = "not_loaded";
this.error = null; this.error = null;
this.initPromise = null; this.initPromise = null;
this.progressCallbacks.clear(); this.progressCallbacks.clear();

View file

@ -8,10 +8,10 @@
*/ */
// Stitch type flags (bitmasks - can be combined) // Stitch type flags (bitmasks - can be combined)
export const STITCH = 0x00; // Regular stitch (no flags) export const STITCH = 0x00; // Regular stitch (no flags)
export const MOVE = 0x10; // Jump/move stitch (move without stitching) export const MOVE = 0x10; // Jump/move stitch (move without stitching)
export const JUMP = MOVE; // Alias: JUMP is the same as MOVE export const JUMP = MOVE; // Alias: JUMP is the same as MOVE
export const TRIM = 0x20; // Trim thread command export const TRIM = 0x20; // Trim thread command
export const COLOR_CHANGE = 0x40; // Color change command export const COLOR_CHANGE = 0x40; // Color change command
export const STOP = 0x80; // Stop command export const STOP = 0x80; // Stop command
export const END = 0x100; // End of pattern export const END = 0x100; // End of pattern

View file

@ -11,7 +11,6 @@ export async function convertPesToPen(file: File): Promise<PesPatternData> {
return await patternConverterClient.convertPesToPen(file); return await patternConverterClient.convertPesToPen(file);
} }
/** /**
* Get thread color from pattern data * Get thread color from pattern data
*/ */

View file

@ -1,24 +1,19 @@
import { loadPyodide, type PyodideInterface } from 'pyodide'; import { loadPyodide, type PyodideInterface } from "pyodide";
import { import { STITCH, MOVE, TRIM, END } from "./constants";
STITCH, import { encodeStitchesToPen } from "../pen/encoder";
MOVE,
TRIM,
END,
} from './constants';
import { encodeStitchesToPen } from '../pen/encoder';
// Message types from main thread // Message types from main thread
export type WorkerMessage = export type WorkerMessage =
| { type: 'INITIALIZE'; pyodideIndexURL?: string; pystitchWheelURL?: string } | { type: "INITIALIZE"; pyodideIndexURL?: string; pystitchWheelURL?: string }
| { type: 'CONVERT_PES'; fileData: ArrayBuffer; fileName: string }; | { type: "CONVERT_PES"; fileData: ArrayBuffer; fileName: string };
// Response types to main thread // Response types to main thread
export type WorkerResponse = export type WorkerResponse =
| { type: 'INIT_PROGRESS'; progress: number; step: string } | { type: "INIT_PROGRESS"; progress: number; step: string }
| { type: 'INIT_COMPLETE' } | { type: "INIT_COMPLETE" }
| { type: 'INIT_ERROR'; error: string } | { type: "INIT_ERROR"; error: string }
| { | {
type: 'CONVERT_COMPLETE'; type: "CONVERT_COMPLETE";
data: { data: {
stitches: number[][]; stitches: number[][];
threads: Array<{ threads: Array<{
@ -49,9 +44,9 @@ export type WorkerResponse =
}; };
}; };
} }
| { type: 'CONVERT_ERROR'; error: string }; | { type: "CONVERT_ERROR"; error: string };
console.log('[PatternConverterWorker] Worker script loaded'); console.log("[PatternConverterWorker] Worker script loaded");
let pyodide: PyodideInterface | null = null; let pyodide: PyodideInterface | null = null;
let isInitializing = false; let isInitializing = false;
@ -67,79 +62,82 @@ const jsEmbConstants = {
/** /**
* Initialize Pyodide with progress tracking * Initialize Pyodide with progress tracking
*/ */
async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: string) { async function initializePyodide(
pyodideIndexURL?: string,
pystitchWheelURL?: string,
) {
if (pyodide) { if (pyodide) {
return; // Already initialized return; // Already initialized
} }
if (isInitializing) { if (isInitializing) {
throw new Error('Initialization already in progress'); throw new Error("Initialization already in progress");
} }
isInitializing = true; isInitializing = true;
try { try {
self.postMessage({ self.postMessage({
type: 'INIT_PROGRESS', type: "INIT_PROGRESS",
progress: 0, progress: 0,
step: 'Starting initialization...', step: "Starting initialization...",
} as WorkerResponse); } as WorkerResponse);
console.log('[PyodideWorker] Loading Pyodide runtime...'); console.log("[PyodideWorker] Loading Pyodide runtime...");
self.postMessage({ self.postMessage({
type: 'INIT_PROGRESS', type: "INIT_PROGRESS",
progress: 10, progress: 10,
step: 'Loading Python runtime...', step: "Loading Python runtime...",
} as WorkerResponse); } as WorkerResponse);
// Load Pyodide runtime // Load Pyodide runtime
// Use provided URL or default to /assets/ // Use provided URL or default to /assets/
const indexURL = pyodideIndexURL || '/assets/'; const indexURL = pyodideIndexURL || "/assets/";
console.log('[PyodideWorker] Pyodide index URL:', indexURL); console.log("[PyodideWorker] Pyodide index URL:", indexURL);
pyodide = await loadPyodide({ pyodide = await loadPyodide({
indexURL: indexURL, indexURL: indexURL,
}); });
console.log('[PyodideWorker] Pyodide runtime loaded'); console.log("[PyodideWorker] Pyodide runtime loaded");
self.postMessage({ self.postMessage({
type: 'INIT_PROGRESS', type: "INIT_PROGRESS",
progress: 70, progress: 70,
step: 'Python runtime loaded', step: "Python runtime loaded",
} as WorkerResponse); } as WorkerResponse);
self.postMessage({ self.postMessage({
type: 'INIT_PROGRESS', type: "INIT_PROGRESS",
progress: 75, progress: 75,
step: 'Loading pystitch library...', step: "Loading pystitch library...",
} as WorkerResponse); } as WorkerResponse);
// Load pystitch wheel // Load pystitch wheel
// Use provided URL or default // Use provided URL or default
const wheelURL = pystitchWheelURL || '/pystitch-1.0.0-py3-none-any.whl'; const wheelURL = pystitchWheelURL || "/pystitch-1.0.0-py3-none-any.whl";
console.log('[PyodideWorker] Pystitch wheel URL:', wheelURL); console.log("[PyodideWorker] Pystitch wheel URL:", wheelURL);
await pyodide.loadPackage(wheelURL); await pyodide.loadPackage(wheelURL);
console.log('[PyodideWorker] pystitch library loaded'); console.log("[PyodideWorker] pystitch library loaded");
self.postMessage({ self.postMessage({
type: 'INIT_PROGRESS', type: "INIT_PROGRESS",
progress: 100, progress: 100,
step: 'Ready!', step: "Ready!",
} as WorkerResponse); } as WorkerResponse);
self.postMessage({ self.postMessage({
type: 'INIT_COMPLETE', type: "INIT_COMPLETE",
} as WorkerResponse); } as WorkerResponse);
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error'; const errorMsg = err instanceof Error ? err.message : "Unknown error";
console.error('[PyodideWorker] Initialization error:', err); console.error("[PyodideWorker] Initialization error:", err);
self.postMessage({ self.postMessage({
type: 'INIT_ERROR', type: "INIT_ERROR",
error: errorMsg, error: errorMsg,
} as WorkerResponse); } as WorkerResponse);
@ -154,18 +152,18 @@ async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: st
*/ */
async function convertPesToPen(fileData: ArrayBuffer) { async function convertPesToPen(fileData: ArrayBuffer) {
if (!pyodide) { if (!pyodide) {
throw new Error('Pyodide not initialized'); throw new Error("Pyodide not initialized");
} }
try { try {
// Register our JavaScript constants module for Python to import // Register our JavaScript constants module for Python to import
pyodide.registerJsModule('js_emb_constants', jsEmbConstants); pyodide.registerJsModule("js_emb_constants", jsEmbConstants);
// Convert to Uint8Array // Convert to Uint8Array
const uint8Array = new Uint8Array(fileData); const uint8Array = new Uint8Array(fileData);
// Write file to Pyodide virtual filesystem // Write file to Pyodide virtual filesystem
const tempFileName = '/tmp/pattern.pes'; const tempFileName = "/tmp/pattern.pes";
pyodide.FS.writeFile(tempFileName, uint8Array); pyodide.FS.writeFile(tempFileName, uint8Array);
// Read the pattern using PyStitch (same logic as original converter) // Read the pattern using PyStitch (same logic as original converter)
@ -277,11 +275,11 @@ for i, stitch in enumerate(pattern.stitches):
// Extract stitches and validate // Extract stitches and validate
const stitches: number[][] = Array.from( const stitches: number[][] = Array.from(
data.stitches as ArrayLike<ArrayLike<number>> data.stitches as ArrayLike<ArrayLike<number>>,
).map((stitch) => Array.from(stitch)); ).map((stitch) => Array.from(stitch));
if (!stitches || stitches.length === 0) { if (!stitches || stitches.length === 0) {
throw new Error('Invalid PES file or no stitches found'); throw new Error("Invalid PES file or no stitches found");
} }
// Extract thread data - preserve null values for unavailable metadata // Extract thread data - preserve null values for unavailable metadata
@ -301,27 +299,27 @@ for i, stitch in enumerate(pattern.stitches):
catalogNum !== undefined && catalogNum !== undefined &&
catalogNum !== null && catalogNum !== null &&
catalogNum !== -1 && catalogNum !== -1 &&
catalogNum !== '-1' && catalogNum !== "-1" &&
catalogNum !== '' catalogNum !== ""
? String(catalogNum) ? String(catalogNum)
: null; : null;
return { return {
color: thread.color ?? 0, color: thread.color ?? 0,
hex: thread.hex || '#000000', hex: thread.hex || "#000000",
catalogNumber: normalizedCatalog, catalogNumber: normalizedCatalog,
brand: thread.brand && thread.brand !== '' ? thread.brand : null, brand: thread.brand && thread.brand !== "" ? thread.brand : null,
description: description:
thread.description && thread.description !== '' thread.description && thread.description !== ""
? thread.description ? thread.description
: null, : null,
chart: thread.chart && thread.chart !== '' ? thread.chart : null, chart: thread.chart && thread.chart !== "" ? thread.chart : null,
}; };
}); });
// Encode stitches to PEN format using the extracted encoder // Encode stitches to PEN format using the extracted encoder
console.log('[patternConverter] Encoding stitches to PEN format...'); console.log("[patternConverter] Encoding stitches to PEN format...");
console.log(' - Input stitches:', stitches); console.log(" - Input stitches:", stitches);
const { penBytes: penStitches, bounds } = encodeStitchesToPen(stitches); const { penBytes: penStitches, bounds } = encodeStitchesToPen(stitches);
const { minX, maxX, minY, maxY } = bounds; const { minX, maxX, minY, maxY } = bounds;
@ -352,13 +350,13 @@ for i, stitch in enumerate(pattern.stitches):
description: string | null; description: string | null;
chart: string | null; chart: string | null;
threadIndices: number[]; threadIndices: number[];
}> }>,
); );
// Calculate PEN stitch count (should match what machine will count) // Calculate PEN stitch count (should match what machine will count)
const penStitchCount = penStitches.length / 4; const penStitchCount = penStitches.length / 4;
console.log('[patternConverter] PEN encoding complete:'); console.log("[patternConverter] PEN encoding complete:");
console.log(` - PyStitch stitches: ${stitches.length}`); console.log(` - PyStitch stitches: ${stitches.length}`);
console.log(` - PEN bytes: ${penStitches.length}`); console.log(` - PEN bytes: ${penStitches.length}`);
console.log(` - PEN stitches (bytes/4): ${penStitchCount}`); console.log(` - PEN stitches (bytes/4): ${penStitchCount}`);
@ -366,7 +364,7 @@ for i, stitch in enumerate(pattern.stitches):
// Post result back to main thread // Post result back to main thread
self.postMessage({ self.postMessage({
type: 'CONVERT_COMPLETE', type: "CONVERT_COMPLETE",
data: { data: {
stitches, stitches,
threads, threads,
@ -383,11 +381,11 @@ for i, stitch in enumerate(pattern.stitches):
}, },
} as WorkerResponse); } as WorkerResponse);
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error'; const errorMsg = err instanceof Error ? err.message : "Unknown error";
console.error('[PyodideWorker] Conversion error:', err); console.error("[PyodideWorker] Conversion error:", err);
self.postMessage({ self.postMessage({
type: 'CONVERT_ERROR', type: "CONVERT_ERROR",
error: errorMsg, error: errorMsg,
} as WorkerResponse); } as WorkerResponse);
@ -398,26 +396,32 @@ for i, stitch in enumerate(pattern.stitches):
// Handle messages from main thread // Handle messages from main thread
self.onmessage = async (event: MessageEvent<WorkerMessage>) => { self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
const message = event.data; const message = event.data;
console.log('[PatternConverterWorker] Received message:', message.type); console.log("[PatternConverterWorker] Received message:", message.type);
try { try {
switch (message.type) { switch (message.type) {
case 'INITIALIZE': case "INITIALIZE":
console.log('[PatternConverterWorker] Starting initialization...'); console.log("[PatternConverterWorker] Starting initialization...");
await initializePyodide(message.pyodideIndexURL, message.pystitchWheelURL); await initializePyodide(
message.pyodideIndexURL,
message.pystitchWheelURL,
);
break; break;
case 'CONVERT_PES': case "CONVERT_PES":
console.log('[PatternConverterWorker] Starting PES conversion...'); console.log("[PatternConverterWorker] Starting PES conversion...");
await convertPesToPen(message.fileData); await convertPesToPen(message.fileData);
break; break;
default: default:
console.error('[PatternConverterWorker] Unknown message type:', message); console.error(
"[PatternConverterWorker] Unknown message type:",
message,
);
} }
} catch (err) { } catch (err) {
console.error('[PatternConverterWorker] Error handling message:', err); console.error("[PatternConverterWorker] Error handling message:", err);
} }
}; };
console.log('[PatternConverterWorker] Message handler registered'); console.log("[PatternConverterWorker] Message handler registered");

View file

@ -5,13 +5,13 @@
* The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits. * The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits.
*/ */
import type { DecodedPenStitch, DecodedPenData, PenColorBlock } from './types'; import type { DecodedPenStitch, DecodedPenData, PenColorBlock } from "./types";
// PEN format flags // PEN format flags
const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching) const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching)
const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command
const PEN_COLOR_END = 0x03; // Last stitch before color change const PEN_COLOR_END = 0x03; // Last stitch before color change
const PEN_DATA_END = 0x05; // Last stitch of entire pattern const PEN_DATA_END = 0x05; // Last stitch of entire pattern
/** /**
* Decode a single PEN stitch (4 bytes) into coordinates and flags * Decode a single PEN stitch (4 bytes) into coordinates and flags
@ -22,7 +22,7 @@ const PEN_DATA_END = 0x05; // Last stitch of entire pattern
*/ */
export function decodePenStitch( export function decodePenStitch(
bytes: Uint8Array | number[], bytes: Uint8Array | number[],
offset: number offset: number,
): DecodedPenStitch { ): DecodedPenStitch {
const xLow = bytes[offset]; const xLow = bytes[offset];
const xHigh = bytes[offset + 1]; const xHigh = bytes[offset + 1];
@ -37,14 +37,14 @@ export function decodePenStitch(
const yFlags = yRaw & 0x07; const yFlags = yRaw & 0x07;
// Clear flags and shift right to get actual coordinates // Clear flags and shift right to get actual coordinates
const xClean = xRaw & 0xFFF8; const xClean = xRaw & 0xfff8;
const yClean = yRaw & 0xFFF8; const yClean = yRaw & 0xfff8;
// Convert to signed 16-bit // Convert to signed 16-bit
let xSigned = xClean; let xSigned = xClean;
let ySigned = yClean; let ySigned = yClean;
if (xSigned > 0x7FFF) xSigned = xSigned - 0x10000; if (xSigned > 0x7fff) xSigned = xSigned - 0x10000;
if (ySigned > 0x7FFF) ySigned = ySigned - 0x10000; if (ySigned > 0x7fff) ySigned = ySigned - 0x10000;
// Shift right by 3 to get actual coordinates // Shift right by 3 to get actual coordinates
const x = xSigned >> 3; const x = xSigned >> 3;
@ -76,9 +76,13 @@ export function decodePenStitch(
* @param bytes PEN format byte array * @param bytes PEN format byte array
* @returns Array of decoded stitches * @returns Array of decoded stitches
*/ */
export function decodeAllPenStitches(bytes: Uint8Array | number[]): DecodedPenStitch[] { export function decodeAllPenStitches(
bytes: Uint8Array | number[],
): DecodedPenStitch[] {
if (bytes.length < 4 || bytes.length % 4 !== 0) { if (bytes.length < 4 || bytes.length % 4 !== 0) {
throw new Error(`Invalid PEN data size: ${bytes.length} bytes (must be multiple of 4)`); throw new Error(
`Invalid PEN data size: ${bytes.length} bytes (must be multiple of 4)`,
);
} }
const stitches: DecodedPenStitch[] = []; const stitches: DecodedPenStitch[] = [];
@ -169,9 +173,15 @@ export function decodePenData(data: Uint8Array): DecodedPenData {
* @param stitchIndex Index of the stitch * @param stitchIndex Index of the stitch
* @returns Color index, or -1 if not found * @returns Color index, or -1 if not found
*/ */
export function getStitchColor(penData: DecodedPenData, stitchIndex: number): number { export function getStitchColor(
penData: DecodedPenData,
stitchIndex: number,
): number {
for (const block of penData.colorBlocks) { for (const block of penData.colorBlocks) {
if (stitchIndex >= block.startStitchIndex && stitchIndex <= block.endStitchIndex) { if (
stitchIndex >= block.startStitchIndex &&
stitchIndex <= block.endStitchIndex
) {
return block.colorIndex; return block.colorIndex;
} }
} }

View file

@ -1,51 +1,51 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { import {
encodeStitchPosition, encodeStitchPosition,
calculateLockDirection, calculateLockDirection,
generateLockStitches, generateLockStitches,
encodeStitchesToPen, encodeStitchesToPen,
LOCK_STITCH_JUMP_SIZE LOCK_STITCH_JUMP_SIZE,
} from './encoder'; } from "./encoder";
import { decodeAllPenStitches } from './decoder'; import { decodeAllPenStitches } from "./decoder";
import { STITCH, MOVE, TRIM, END } from '../import/constants'; import { STITCH, MOVE, TRIM, END } from "../import/constants";
// PEN format flag constants for testing // PEN format flag constants for testing
const PEN_FEED_DATA = 0x01; const PEN_FEED_DATA = 0x01;
const PEN_CUT_DATA = 0x02; const PEN_CUT_DATA = 0x02;
describe('encodeStitchPosition', () => { describe("encodeStitchPosition", () => {
it('should encode position (0, 0) correctly', () => { it("should encode position (0, 0) correctly", () => {
const result = encodeStitchPosition(0, 0); const result = encodeStitchPosition(0, 0);
expect(result).toEqual([0x00, 0x00, 0x00, 0x00]); expect(result).toEqual([0x00, 0x00, 0x00, 0x00]);
}); });
it('should shift coordinates left by 3 bits', () => { it("should shift coordinates left by 3 bits", () => {
// Position (1, 1) should become (8, 8) after shifting // Position (1, 1) should become (8, 8) after shifting
const result = encodeStitchPosition(1, 1); const result = encodeStitchPosition(1, 1);
expect(result).toEqual([0x08, 0x00, 0x08, 0x00]); expect(result).toEqual([0x08, 0x00, 0x08, 0x00]);
}); });
it('should handle negative coordinates', () => { it("should handle negative coordinates", () => {
// -1 in 16-bit signed = 0xFFFF, shifted left 3 = 0xFFF8 // -1 in 16-bit signed = 0xFFFF, shifted left 3 = 0xFFF8
const result = encodeStitchPosition(-1, -1); const result = encodeStitchPosition(-1, -1);
expect(result).toEqual([0xF8, 0xFF, 0xF8, 0xFF]); expect(result).toEqual([0xf8, 0xff, 0xf8, 0xff]);
}); });
it('should encode multi-byte coordinates correctly', () => { it("should encode multi-byte coordinates correctly", () => {
// Position (128, 0) -> shifted = 1024 = 0x0400 // Position (128, 0) -> shifted = 1024 = 0x0400
const result = encodeStitchPosition(128, 0); const result = encodeStitchPosition(128, 0);
expect(result).toEqual([0x00, 0x04, 0x00, 0x00]); expect(result).toEqual([0x00, 0x04, 0x00, 0x00]);
}); });
it('should round fractional coordinates', () => { it("should round fractional coordinates", () => {
const result = encodeStitchPosition(1.5, 2.4); const result = encodeStitchPosition(1.5, 2.4);
// 2 << 3 = 16, 2 << 3 = 16 // 2 << 3 = 16, 2 << 3 = 16
expect(result).toEqual([0x10, 0x00, 0x10, 0x00]); expect(result).toEqual([0x10, 0x00, 0x10, 0x00]);
}); });
}); });
describe('calculateLockDirection', () => { describe("calculateLockDirection", () => {
it('should look ahead for forward direction', () => { it("should look ahead for forward direction", () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, STITCH, 0], [10, 0, STITCH, 0],
@ -62,7 +62,7 @@ describe('calculateLockDirection', () => {
expect(magnitude).toBeCloseTo(8.0, 1); expect(magnitude).toBeCloseTo(8.0, 1);
}); });
it('should look backward for backward direction', () => { it("should look backward for backward direction", () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, STITCH, 0], [10, 0, STITCH, 0],
@ -79,10 +79,10 @@ describe('calculateLockDirection', () => {
expect(magnitude).toBeCloseTo(8.0, 1); expect(magnitude).toBeCloseTo(8.0, 1);
}); });
it('should skip MOVE stitches when accumulating', () => { it("should skip MOVE stitches when accumulating", () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[5, 0, MOVE, 0], // Should be skipped [5, 0, MOVE, 0], // Should be skipped
[10, 0, STITCH, 0], [10, 0, STITCH, 0],
[15, 0, STITCH, 0], [15, 0, STITCH, 0],
]; ];
@ -93,10 +93,8 @@ describe('calculateLockDirection', () => {
expect(result.dirX).toBeGreaterThan(0); expect(result.dirX).toBeGreaterThan(0);
}); });
it('should return fallback diagonal for empty or short stitch sequences', () => { it("should return fallback diagonal for empty or short stitch sequences", () => {
const stitches = [ const stitches = [[0, 0, STITCH, 0]];
[0, 0, STITCH, 0],
];
const result = calculateLockDirection(stitches, 0, true); const result = calculateLockDirection(stitches, 0, true);
@ -106,7 +104,7 @@ describe('calculateLockDirection', () => {
expect(result.dirY).toBeCloseTo(expectedMag, 1); expect(result.dirY).toBeCloseTo(expectedMag, 1);
}); });
it('should normalize accumulated vector to magnitude 8.0', () => { it("should normalize accumulated vector to magnitude 8.0", () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[3, 4, STITCH, 0], // Distance = 5 [3, 4, STITCH, 0], // Distance = 5
@ -124,7 +122,7 @@ describe('calculateLockDirection', () => {
expect(magnitude).toBeCloseTo(8.0, 1); expect(magnitude).toBeCloseTo(8.0, 1);
}); });
it('should stop accumulating after reaching target length', () => { it("should stop accumulating after reaching target length", () => {
// Create a long chain of stitches // Create a long chain of stitches
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
@ -144,13 +142,13 @@ describe('calculateLockDirection', () => {
}); });
}); });
describe('generateLockStitches', () => { describe("generateLockStitches", () => {
it('should generate 8 lock stitches (32 bytes)', () => { it("should generate 8 lock stitches (32 bytes)", () => {
const result = generateLockStitches(0, 0, 8.0, 0); const result = generateLockStitches(0, 0, 8.0, 0);
expect(result.length).toBe(32); // 8 stitches * 4 bytes each expect(result.length).toBe(32); // 8 stitches * 4 bytes each
}); });
it('should alternate between +dir and -dir', () => { it("should alternate between +dir and -dir", () => {
const result = generateLockStitches(0, 0, 8.0, 0); const result = generateLockStitches(0, 0, 8.0, 0);
expect(result.length).toBe(32); // 8 stitches * 4 bytes expect(result.length).toBe(32); // 8 stitches * 4 bytes
@ -159,7 +157,7 @@ describe('generateLockStitches', () => {
expect(result2.length).toBe(32); expect(result2.length).toBe(32);
}); });
it('should rotate stitches in the given direction', () => { it("should rotate stitches in the given direction", () => {
// Direction pointing right (8, 0) // Direction pointing right (8, 0)
const result = generateLockStitches(0, 0, 8.0, 0); const result = generateLockStitches(0, 0, 8.0, 0);
@ -176,8 +174,8 @@ describe('generateLockStitches', () => {
}); });
}); });
describe('encodeStitchesToPen', () => { describe("encodeStitchesToPen", () => {
it('should encode a simple stitch sequence', () => { it("should encode a simple stitch sequence", () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, STITCH, 0], [10, 0, STITCH, 0],
@ -192,7 +190,7 @@ describe('encodeStitchesToPen', () => {
expect(result.bounds.maxX).toBe(20); expect(result.bounds.maxX).toBe(20);
}); });
it('should track bounds correctly', () => { it("should track bounds correctly", () => {
const stitches = [ const stitches = [
[10, 20, STITCH, 0], [10, 20, STITCH, 0],
[-5, 30, STITCH, 0], [-5, 30, STITCH, 0],
@ -208,7 +206,7 @@ describe('encodeStitchesToPen', () => {
expect(result.bounds.maxY).toBe(30); expect(result.bounds.maxY).toBe(30);
}); });
it('should mark the last stitch with DATA_END flag', () => { it("should mark the last stitch with DATA_END flag", () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, END, 0], [10, 0, END, 0],
@ -222,14 +220,14 @@ describe('encodeStitchesToPen', () => {
expect(xLow & 0x07).toBe(0x05); // DATA_END flag expect(xLow & 0x07).toBe(0x05); // DATA_END flag
}); });
it('should handle color changes with lock stitches', () => { it("should handle color changes with lock stitches", () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], // Color 0 [0, 0, STITCH, 0], // Color 0
[10, 0, STITCH, 0], // Color 0 [10, 0, STITCH, 0], // Color 0
[20, 0, STITCH, 0], // Color 0 - last stitch before color change [20, 0, STITCH, 0], // Color 0 - last stitch before color change
[20, 0, STITCH, 1], // Color 1 - first stitch of new color [20, 0, STITCH, 1], // Color 1 - first stitch of new color
[30, 0, STITCH, 1], // Color 1 [30, 0, STITCH, 1], // Color 1
[40, 0, END, 1], // Color 1 - last stitch [40, 0, END, 1], // Color 1 - last stitch
]; ];
const result = encodeStitchesToPen(stitches); const result = encodeStitchesToPen(stitches);
@ -246,13 +244,13 @@ describe('encodeStitchesToPen', () => {
expect(result.penBytes.length).toBeGreaterThan(90); // Should have many bytes from lock stitches expect(result.penBytes.length).toBeGreaterThan(90); // Should have many bytes from lock stitches
}); });
it('should encode color change sequence in correct order', () => { it("should encode color change sequence in correct order", () => {
// Test the exact sequence of operations for a color change // Test the exact sequence of operations for a color change
const stitches = [ const stitches = [
[0, 0, STITCH, 0], // Color 0 [0, 0, STITCH, 0], // Color 0
[10, 0, STITCH, 0], // Color 0 - last stitch before color change [10, 0, STITCH, 0], // Color 0 - last stitch before color change
[10, 0, STITCH, 1], // Color 1 - first stitch (same position) [10, 0, STITCH, 1], // Color 1 - first stitch (same position)
[20, 0, STITCH | END, 1], // Color 1 - last stitch [20, 0, STITCH | END, 1], // Color 1 - last stitch
]; ];
const result = encodeStitchesToPen(stitches); const result = encodeStitchesToPen(stitches);
@ -325,11 +323,11 @@ describe('encodeStitchesToPen', () => {
expect(decoded[idx].isDataEnd).toBe(true); expect(decoded[idx].isDataEnd).toBe(true);
}); });
it('should encode color change with jump in correct order', () => { it("should encode color change with jump in correct order", () => {
// Test color change when next color is at a different position // Test color change when next color is at a different position
const stitches = [ const stitches = [
[0, 0, STITCH, 0], // Color 0 [0, 0, STITCH, 0], // Color 0
[10, 0, STITCH, 0], // Color 0 - last before change [10, 0, STITCH, 0], // Color 0 - last before change
[30, 10, STITCH, 1], // Color 1 - different position, requires jump [30, 10, STITCH, 1], // Color 1 - different position, requires jump
[40, 10, STITCH | END, 1], [40, 10, STITCH | END, 1],
]; ];
@ -372,13 +370,13 @@ describe('encodeStitchesToPen', () => {
expect(decoded[idx].y).toBe(10); expect(decoded[idx].y).toBe(10);
}); });
it('should encode color change followed by explicit JUMP in correct order', () => { it("should encode color change followed by explicit JUMP in correct order", () => {
// Test when PES data has a JUMP stitch immediately after color change // Test when PES data has a JUMP stitch immediately after color change
// This is a common pattern: color change, then jump to new location // This is a common pattern: color change, then jump to new location
const stitches = [ const stitches = [
[0, 0, STITCH, 0], // Color 0 [0, 0, STITCH, 0], // Color 0
[10, 0, STITCH, 0], // Color 0 - last before change [10, 0, STITCH, 0], // Color 0 - last before change
[50, 20, MOVE, 1], // Color 1 - JUMP to new location (50, 20) [50, 20, MOVE, 1], // Color 1 - JUMP to new location (50, 20)
[50, 20, STITCH, 1], // Color 1 - first actual stitch at new location [50, 20, STITCH, 1], // Color 1 - first actual stitch at new location
[60, 20, STITCH | END, 1], [60, 20, STITCH | END, 1],
]; ];
@ -443,12 +441,12 @@ describe('encodeStitchesToPen', () => {
expect(decoded[idx].isDataEnd).toBe(true); expect(decoded[idx].isDataEnd).toBe(true);
}); });
it('should handle long jumps with lock stitches and cut in correct order', () => { it("should handle long jumps with lock stitches and cut in correct order", () => {
// Test the exact sequence for a long jump (distance > 50) // Test the exact sequence for a long jump (distance > 50)
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, STITCH, 0], [10, 0, STITCH, 0],
[100, 0, MOVE, 0], // Long jump (distance = 90 > 50) [100, 0, MOVE, 0], // Long jump (distance = 90 > 50)
[110, 0, STITCH, 0], [110, 0, STITCH, 0],
[120, 0, STITCH | END, 0], [120, 0, STITCH | END, 0],
]; ];
@ -508,10 +506,10 @@ describe('encodeStitchesToPen', () => {
expect(decoded[idx].isDataEnd).toBe(true); expect(decoded[idx].isDataEnd).toBe(true);
}); });
it('should encode MOVE flag for jump stitches', () => { it("should encode MOVE flag for jump stitches", () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, MOVE, 0], // Short jump (no lock stitches) [10, 0, MOVE, 0], // Short jump (no lock stitches)
[20, 0, END, 0], [20, 0, END, 0],
]; ];
@ -525,10 +523,10 @@ describe('encodeStitchesToPen', () => {
expect(yLow & 0x01).toBe(0x01); // FEED_DATA flag expect(yLow & 0x01).toBe(0x01); // FEED_DATA flag
}); });
it('should not include MOVE stitches in bounds calculation', () => { it("should not include MOVE stitches in bounds calculation", () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[100, 100, MOVE, 0], // Jump - should not affect bounds [100, 100, MOVE, 0], // Jump - should not affect bounds
[10, 10, STITCH, 0], [10, 10, STITCH, 0],
[20, 20, STITCH | END, 0], // Last stitch with both STITCH and END flags [20, 20, STITCH | END, 0], // Last stitch with both STITCH and END flags
]; ];
@ -542,7 +540,7 @@ describe('encodeStitchesToPen', () => {
expect(result.bounds.maxY).toBe(20); expect(result.bounds.maxY).toBe(20);
}); });
it('should handle TRIM flag correctly', () => { it("should handle TRIM flag correctly", () => {
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
[10, 0, TRIM, 0], [10, 0, TRIM, 0],
@ -574,7 +572,7 @@ describe('encodeStitchesToPen', () => {
expect(decoded[idx].isDataEnd).toBe(true); expect(decoded[idx].isDataEnd).toBe(true);
}); });
it('should handle empty stitch array', () => { it("should handle empty stitch array", () => {
const stitches: number[][] = []; const stitches: number[][] = [];
const result = encodeStitchesToPen(stitches); const result = encodeStitchesToPen(stitches);
@ -586,10 +584,8 @@ describe('encodeStitchesToPen', () => {
expect(result.bounds.maxY).toBe(0); expect(result.bounds.maxY).toBe(0);
}); });
it('should handle single stitch', () => { it("should handle single stitch", () => {
const stitches = [ const stitches = [[5, 10, END, 0]];
[5, 10, END, 0],
];
const result = encodeStitchesToPen(stitches); const result = encodeStitchesToPen(stitches);
@ -601,7 +597,7 @@ describe('encodeStitchesToPen', () => {
// END stitches update bounds (they're not MOVE stitches) // END stitches update bounds (they're not MOVE stitches)
}); });
it('should add DATA_END flag to last stitch even without END flag in input', () => { it("should add DATA_END flag to last stitch even without END flag in input", () => {
// Test that the encoder automatically marks the last stitch with DATA_END // Test that the encoder automatically marks the last stitch with DATA_END
// even if the input stitches don't have an END flag // even if the input stitches don't have an END flag
const stitches = [ const stitches = [
@ -623,7 +619,7 @@ describe('encodeStitchesToPen', () => {
expect(decoded[10].y).toBe(0); expect(decoded[10].y).toBe(0);
}); });
it('should add DATA_END flag when input has explicit END flag', () => { it("should add DATA_END flag when input has explicit END flag", () => {
// Verify that END flag in input also results in DATA_END flag in output // Verify that END flag in input also results in DATA_END flag in output
const stitches = [ const stitches = [
[0, 0, STITCH, 0], [0, 0, STITCH, 0],
@ -640,7 +636,7 @@ describe('encodeStitchesToPen', () => {
expect(decoded[10].y).toBe(0); expect(decoded[10].y).toBe(0);
}); });
it('should add lock stitches at the very start of the pattern', () => { it("should add lock stitches at the very start of the pattern", () => {
// Matching C# behavior: Nuihajime_TomeDataPlus is called when counter <= 2 // Matching C# behavior: Nuihajime_TomeDataPlus is called when counter <= 2
// This adds starting lock stitches to secure the thread at pattern start // This adds starting lock stitches to secure the thread at pattern start
const stitches = [ const stitches = [

View file

@ -5,18 +5,18 @@
* The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits. * The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits.
*/ */
import { MOVE, TRIM, END } from '../import/constants'; import { MOVE, TRIM, END } from "../import/constants";
// PEN format flags for Brother machines // PEN format flags for Brother machines
const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching) const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching)
const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command
const PEN_COLOR_END = 0x03; // Last stitch before color change const PEN_COLOR_END = 0x03; // Last stitch before color change
const PEN_DATA_END = 0x05; // Last stitch of entire pattern const PEN_DATA_END = 0x05; // Last stitch of entire pattern
// Constants from PesxToPen.cs // Constants from PesxToPen.cs
const FEED_LENGTH = 50; // Long jump threshold requiring lock stitches and cut const FEED_LENGTH = 50; // Long jump threshold requiring lock stitches and cut
const TARGET_LENGTH = 8.0; // Target accumulated length for lock stitch direction const TARGET_LENGTH = 8.0; // Target accumulated length for lock stitch direction
const MAX_POINTS = 5; // Maximum points to accumulate for lock stitch direction const MAX_POINTS = 5; // Maximum points to accumulate for lock stitch direction
export const LOCK_STITCH_JUMP_SIZE = 2.0; export const LOCK_STITCH_JUMP_SIZE = 2.0;
const LOCK_STITCH_SCALE = LOCK_STITCH_JUMP_SIZE / 8.0; // Scale the magnitude-8 vector down to 4 const LOCK_STITCH_SCALE = LOCK_STITCH_JUMP_SIZE / 8.0; // Scale the magnitude-8 vector down to 4
@ -45,12 +45,7 @@ export function encodeStitchPosition(x: number, y: number): number[] {
const xEnc = (Math.round(x) << 3) & 0xffff; const xEnc = (Math.round(x) << 3) & 0xffff;
const yEnc = (Math.round(y) << 3) & 0xffff; const yEnc = (Math.round(y) << 3) & 0xffff;
return [ return [xEnc & 0xff, (xEnc >> 8) & 0xff, yEnc & 0xff, (yEnc >> 8) & 0xff];
xEnc & 0xff,
(xEnc >> 8) & 0xff,
yEnc & 0xff,
(yEnc >> 8) & 0xff
];
} }
/** /**
@ -70,7 +65,7 @@ export function encodeStitchPosition(x: number, y: number): number[] {
export function calculateLockDirection( export function calculateLockDirection(
stitches: number[][], stitches: number[][],
currentIndex: number, currentIndex: number,
lookAhead: boolean lookAhead: boolean,
): { dirX: number; dirY: number } { ): { dirX: number; dirY: number } {
let accumulatedX = 0; let accumulatedX = 0;
let accumulatedY = 0; let accumulatedY = 0;
@ -84,7 +79,7 @@ export function calculateLockDirection(
: Math.min(MAX_POINTS, currentIndex); : Math.min(MAX_POINTS, currentIndex);
for (let i = 0; i < maxIterations; i++) { for (let i = 0; i < maxIterations; i++) {
const idx = currentIndex + (step * (i + 1)); const idx = currentIndex + step * (i + 1);
if (idx < 0 || idx >= stitches.length) break; if (idx < 0 || idx >= stitches.length) break;
const stitch = stitches[idx]; const stitch = stitches[idx];
@ -94,13 +89,17 @@ export function calculateLockDirection(
if ((cmd & MOVE) !== 0) continue; if ((cmd & MOVE) !== 0) continue;
// Accumulate relative coordinates // Accumulate relative coordinates
const deltaX = Math.round(stitch[0]) - Math.round(stitches[currentIndex][0]); const deltaX =
const deltaY = Math.round(stitch[1]) - Math.round(stitches[currentIndex][1]); Math.round(stitch[0]) - Math.round(stitches[currentIndex][0]);
const deltaY =
Math.round(stitch[1]) - Math.round(stitches[currentIndex][1]);
accumulatedX += deltaX; accumulatedX += deltaX;
accumulatedY += deltaY; accumulatedY += deltaY;
const length = Math.sqrt(accumulatedX * accumulatedX + accumulatedY * accumulatedY); const length = Math.sqrt(
accumulatedX * accumulatedX + accumulatedY * accumulatedY,
);
// Track the maximum length vector seen so far // Track the maximum length vector seen so far
if (length > maxLength) { if (length > maxLength) {
@ -113,7 +112,7 @@ export function calculateLockDirection(
if (length >= TARGET_LENGTH) { if (length >= TARGET_LENGTH) {
return { return {
dirX: (accumulatedX * 8.0) / length, dirX: (accumulatedX * 8.0) / length,
dirY: (accumulatedY * 8.0) / length dirY: (accumulatedY * 8.0) / length,
}; };
} }
} }
@ -122,7 +121,7 @@ export function calculateLockDirection(
if (maxLength > 0.1) { if (maxLength > 0.1) {
return { return {
dirX: (bestX * 8.0) / maxLength, dirX: (bestX * 8.0) / maxLength,
dirY: (bestY * 8.0) / maxLength dirY: (bestY * 8.0) / maxLength,
}; };
} }
@ -140,7 +139,12 @@ export function calculateLockDirection(
* @param dirY Direction Y component (magnitude ~8.0) * @param dirY Direction Y component (magnitude ~8.0)
* @returns Array of PEN bytes for lock stitches (32 bytes = 8 stitches * 4 bytes) * @returns Array of PEN bytes for lock stitches (32 bytes = 8 stitches * 4 bytes)
*/ */
export function generateLockStitches(x: number, y: number, dirX: number, dirY: number): number[] { export function generateLockStitches(
x: number,
y: number,
dirX: number,
dirY: number,
): number[] {
const lockBytes: number[] = []; const lockBytes: number[] = [];
// Generate 8 lock stitches in alternating pattern // Generate 8 lock stitches in alternating pattern
@ -153,7 +157,7 @@ export function generateLockStitches(x: number, y: number, dirX: number, dirY: n
// Generate 8 stitches alternating between forward and backward // Generate 8 stitches alternating between forward and backward
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
// Alternate between forward (+) and backward (-) direction // Alternate between forward (+) and backward (-) direction
const sign = (i % 2 === 0) ? 1 : -1; const sign = i % 2 === 0 ? 1 : -1;
const xAdd = scaledDirX * sign; const xAdd = scaledDirX * sign;
const yAdd = scaledDirY * sign; const yAdd = scaledDirY * sign;
lockBytes.push(...encodeStitchPosition(x + xAdd, y + yAdd)); lockBytes.push(...encodeStitchPosition(x + xAdd, y + yAdd));
@ -181,7 +185,6 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
let prevX = 0; let prevX = 0;
let prevY = 0; let prevY = 0;
for (let i = 0; i < stitches.length; i++) { for (let i = 0; i < stitches.length; i++) {
const stitch = stitches[i]; const stitch = stitches[i];
const absX = Math.round(stitch[0]); const absX = Math.round(stitch[0]);
@ -209,26 +212,30 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Loop B: End/Cut Vector - Look BACKWARD at previous stitches // Loop B: End/Cut Vector - Look BACKWARD at previous stitches
// This hides the knot inside the embroidery we just finished // This hides the knot inside the embroidery we just finished
const finishDir = calculateLockDirection(stitches, i - 1, false); const finishDir = calculateLockDirection(stitches, i - 1, false);
penStitches.push(...generateLockStitches(prevX, prevY, finishDir.dirX, finishDir.dirY)); penStitches.push(
...generateLockStitches(prevX, prevY, finishDir.dirX, finishDir.dirY),
);
// Encode jump with both FEED and CUT flags // Encode jump with both FEED and CUT flags
const xEncoded = (absX << 3) & 0xffff; const xEncoded = (absX << 3) & 0xffff;
let yEncoded = (absY << 3) & 0xffff; let yEncoded = (absY << 3) & 0xffff;
yEncoded |= PEN_FEED_DATA; // Jump flag yEncoded |= PEN_FEED_DATA; // Jump flag
yEncoded |= PEN_CUT_DATA; // Cut flag for long jumps yEncoded |= PEN_CUT_DATA; // Cut flag for long jumps
penStitches.push( penStitches.push(
xEncoded & 0xff, xEncoded & 0xff,
(xEncoded >> 8) & 0xff, (xEncoded >> 8) & 0xff,
yEncoded & 0xff, yEncoded & 0xff,
(yEncoded >> 8) & 0xff (yEncoded >> 8) & 0xff,
); );
// Add starting lock stitches at new position // Add starting lock stitches at new position
// Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches // Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches
// This hides the knot under the stitches we're about to make // This hides the knot under the stitches we're about to make
const startDir = calculateLockDirection(stitches, i, true); const startDir = calculateLockDirection(stitches, i, true);
penStitches.push(...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY)); penStitches.push(
...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY),
);
// Update position and continue // Update position and continue
prevX = absX; prevX = absX;
@ -258,7 +265,10 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Check for color change by comparing stitch color index // Check for color change by comparing stitch color index
const nextStitch = stitches[i + 1]; const nextStitch = stitches[i + 1];
const nextStitchColor = nextStitch?.[3]; const nextStitchColor = nextStitch?.[3];
const isColorChange = !isLastStitch && nextStitchColor !== undefined && nextStitchColor !== stitchColor; const isColorChange =
!isLastStitch &&
nextStitchColor !== undefined &&
nextStitchColor !== stitchColor;
// Mark the very last stitch of the pattern with DATA_END // Mark the very last stitch of the pattern with DATA_END
if (isLastStitch) { if (isLastStitch) {
@ -270,7 +280,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
xEncoded & 0xff, xEncoded & 0xff,
(xEncoded >> 8) & 0xff, (xEncoded >> 8) & 0xff,
yEncoded & 0xff, yEncoded & 0xff,
(yEncoded >> 8) & 0xff (yEncoded >> 8) & 0xff,
); );
// Update position for next iteration // Update position for next iteration
@ -283,7 +293,9 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Calculate direction for starting locks (look forward into the pattern) // Calculate direction for starting locks (look forward into the pattern)
const startDir = calculateLockDirection(stitches, i, true); const startDir = calculateLockDirection(stitches, i, true);
penStitches.push(...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY)); penStitches.push(
...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY),
);
} }
// Handle color change: finishing lock, COLOR_END+CUT, jump, starting lock // Handle color change: finishing lock, COLOR_END+CUT, jump, starting lock
@ -297,7 +309,9 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Loop C: Color Change Vector - Look FORWARD at the stop event data // Loop C: Color Change Vector - Look FORWARD at the stop event data
// This aligns the knot with the stop command's data block for correct tension // This aligns the knot with the stop command's data block for correct tension
const finishDir = calculateLockDirection(stitches, i, true); const finishDir = calculateLockDirection(stitches, i, true);
penStitches.push(...generateLockStitches(absX, absY, finishDir.dirX, finishDir.dirY)); penStitches.push(
...generateLockStitches(absX, absY, finishDir.dirX, finishDir.dirY),
);
// Step 2: Add COLOR_END + CUT command at CURRENT position (same stitch!) // Step 2: Add COLOR_END + CUT command at CURRENT position (same stitch!)
// This is where the machine pauses and waits for the user to change thread color // This is where the machine pauses and waits for the user to change thread color
@ -313,7 +327,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
colorEndCutXEncoded & 0xff, colorEndCutXEncoded & 0xff,
(colorEndCutXEncoded >> 8) & 0xff, (colorEndCutXEncoded >> 8) & 0xff,
colorEndCutYEncoded & 0xff, colorEndCutYEncoded & 0xff,
(colorEndCutYEncoded >> 8) & 0xff (colorEndCutYEncoded >> 8) & 0xff,
); );
// Machine pauses here for color change // Machine pauses here for color change
@ -339,7 +353,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
jumpXEncoded & 0xff, jumpXEncoded & 0xff,
(jumpXEncoded >> 8) & 0xff, (jumpXEncoded >> 8) & 0xff,
jumpYEncoded & 0xff, jumpYEncoded & 0xff,
(jumpYEncoded >> 8) & 0xff (jumpYEncoded >> 8) & 0xff,
); );
} }
@ -347,8 +361,14 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches in new color // Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches in new color
// This hides the knot under the stitches we're about to make // This hides the knot under the stitches we're about to make
const nextStitchIdx = nextIsJump ? i + 2 : i + 1; const nextStitchIdx = nextIsJump ? i + 2 : i + 1;
const startDir = calculateLockDirection(stitches, nextStitchIdx < stitches.length ? nextStitchIdx : i, true); const startDir = calculateLockDirection(
penStitches.push(...generateLockStitches(jumpToX, jumpToY, startDir.dirX, startDir.dirY)); stitches,
nextStitchIdx < stitches.length ? nextStitchIdx : i,
true,
);
penStitches.push(
...generateLockStitches(jumpToX, jumpToY, startDir.dirX, startDir.dirY),
);
// Update position // Update position
prevX = jumpToX; prevX = jumpToX;

View file

@ -9,18 +9,18 @@
* A single decoded PEN stitch with coordinates and flags * A single decoded PEN stitch with coordinates and flags
*/ */
export interface DecodedPenStitch { export interface DecodedPenStitch {
x: number; // X coordinate (already shifted right by 3) x: number; // X coordinate (already shifted right by 3)
y: number; // Y coordinate (already shifted right by 3) y: number; // Y coordinate (already shifted right by 3)
xFlags: number; // Flags from X coordinate low 3 bits xFlags: number; // Flags from X coordinate low 3 bits
yFlags: number; // Flags from Y coordinate low 3 bits yFlags: number; // Flags from Y coordinate low 3 bits
isFeed: boolean; // Jump/move without stitching (Y-bit 0) isFeed: boolean; // Jump/move without stitching (Y-bit 0)
isCut: boolean; // Trim/cut thread (Y-bit 1) isCut: boolean; // Trim/cut thread (Y-bit 1)
isColorEnd: boolean; // Color change marker (X-bits 0-2 = 0x03) isColorEnd: boolean; // Color change marker (X-bits 0-2 = 0x03)
isDataEnd: boolean; // Pattern end marker (X-bits 0-2 = 0x05) isDataEnd: boolean; // Pattern end marker (X-bits 0-2 = 0x05)
// Compatibility aliases // Compatibility aliases
isJump: boolean; // Alias for isFeed (backward compatibility) isJump: boolean; // Alias for isFeed (backward compatibility)
flags: number; // Combined flags (backward compatibility) flags: number; // Combined flags (backward compatibility)
} }
/** /**
@ -28,12 +28,12 @@ export interface DecodedPenStitch {
*/ */
export interface PenColorBlock { export interface PenColorBlock {
startStitchIndex: number; // Index of first stitch in this color startStitchIndex: number; // Index of first stitch in this color
endStitchIndex: number; // Index of last stitch in this color endStitchIndex: number; // Index of last stitch in this color
colorIndex: number; // Color number (0-based) colorIndex: number; // Color number (0-based)
// Compatibility aliases // Compatibility aliases
startStitch: number; // Alias for startStitchIndex (backward compatibility) startStitch: number; // Alias for startStitchIndex (backward compatibility)
endStitch: number; // Alias for endStitchIndex (backward compatibility) endStitch: number; // Alias for endStitchIndex (backward compatibility)
} }
/** /**

View file

@ -1,10 +1,10 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import './index.css' import "./index.css";
import App from './App.tsx' import App from "./App.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
) );

View file

@ -1,4 +1,4 @@
import type { IFileService } from '../interfaces/IFileService'; import type { IFileService } from "../interfaces/IFileService";
/** /**
* Browser implementation of file service using HTML input elements * Browser implementation of file service using HTML input elements
@ -6,8 +6,8 @@ import type { IFileService } from '../interfaces/IFileService';
export class BrowserFileService implements IFileService { export class BrowserFileService implements IFileService {
async openFileDialog(options: { accept: string }): Promise<File | null> { async openFileDialog(options: { accept: string }): Promise<File | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const input = document.createElement('input'); const input = document.createElement("input");
input.type = 'file'; input.type = "file";
input.accept = options.accept; input.accept = options.accept;
input.onchange = (e) => { input.onchange = (e) => {
@ -25,7 +25,7 @@ export class BrowserFileService implements IFileService {
async saveFileDialog(): Promise<void> { async saveFileDialog(): Promise<void> {
// No-op in browser - could implement download if needed in the future // No-op in browser - could implement download if needed in the future
console.warn('saveFileDialog not implemented in browser'); console.warn("saveFileDialog not implemented in browser");
} }
hasNativeDialogs(): boolean { hasNativeDialogs(): boolean {

View file

@ -1,6 +1,9 @@
import { PatternCacheService } from '../../services/PatternCacheService'; import { PatternCacheService } from "../../services/PatternCacheService";
import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService'; import type {
import type { PesPatternData } from '../../formats/import/pesImporter'; IStorageService,
ICachedPattern,
} from "../interfaces/IStorageService";
import type { PesPatternData } from "../../formats/import/pesImporter";
/** /**
* Browser implementation of storage service using localStorage * Browser implementation of storage service using localStorage
@ -11,7 +14,7 @@ export class BrowserStorageService implements IStorageService {
uuid: string, uuid: string,
pesData: PesPatternData, pesData: PesPatternData,
fileName: string, fileName: string,
patternOffset?: { x: number; y: number } patternOffset?: { x: number; y: number },
): Promise<void> { ): Promise<void> {
PatternCacheService.savePattern(uuid, pesData, fileName, patternOffset); PatternCacheService.savePattern(uuid, pesData, fileName, patternOffset);
} }
@ -36,7 +39,12 @@ export class BrowserStorageService implements IStorageService {
PatternCacheService.clearCache(); PatternCacheService.clearCache();
} }
async getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }> { async getCacheInfo(): Promise<{
hasCache: boolean;
fileName?: string;
uuid?: string;
age?: number;
}> {
return PatternCacheService.getCacheInfo(); return PatternCacheService.getCacheInfo();
} }
} }

View file

@ -1,4 +1,4 @@
import type { IFileService } from '../interfaces/IFileService'; import type { IFileService } from "../interfaces/IFileService";
/** /**
* Electron implementation of file service using native dialogs via IPC * Electron implementation of file service using native dialogs via IPC
@ -6,14 +6,17 @@ import type { IFileService } from '../interfaces/IFileService';
export class ElectronFileService implements IFileService { export class ElectronFileService implements IFileService {
async openFileDialog(): Promise<File | null> { async openFileDialog(): Promise<File | null> {
if (!window.electronAPI) { if (!window.electronAPI) {
throw new Error('Electron API not available'); throw new Error("Electron API not available");
} }
try { try {
const result = await window.electronAPI.invoke<{ filePath: string; fileName: string } | null>('dialog:openFile', { const result = await window.electronAPI.invoke<{
filePath: string;
fileName: string;
} | null>("dialog:openFile", {
filters: [ filters: [
{ name: 'PES Files', extensions: ['pes'] }, { name: "PES Files", extensions: ["pes"] },
{ name: 'All Files', extensions: ['*'] }, { name: "All Files", extensions: ["*"] },
], ],
}); });
@ -22,34 +25,46 @@ export class ElectronFileService implements IFileService {
} }
// Read the file content // Read the file content
const buffer = await window.electronAPI.invoke<ArrayBuffer>('fs:readFile', result.filePath); const buffer = await window.electronAPI.invoke<ArrayBuffer>(
"fs:readFile",
result.filePath,
);
const blob = new Blob([buffer]); const blob = new Blob([buffer]);
return new File([blob], result.fileName, { type: 'application/octet-stream' }); return new File([blob], result.fileName, {
type: "application/octet-stream",
});
} catch (err) { } catch (err) {
console.error('[ElectronFileService] Failed to open file:', err); console.error("[ElectronFileService] Failed to open file:", err);
return null; return null;
} }
} }
async saveFileDialog(data: Uint8Array, defaultName: string): Promise<void> { async saveFileDialog(data: Uint8Array, defaultName: string): Promise<void> {
if (!window.electronAPI) { if (!window.electronAPI) {
throw new Error('Electron API not available'); throw new Error("Electron API not available");
} }
try { try {
const filePath = await window.electronAPI.invoke<string | null>('dialog:saveFile', { const filePath = await window.electronAPI.invoke<string | null>(
defaultPath: defaultName, "dialog:saveFile",
filters: [ {
{ name: 'PEN Files', extensions: ['pen'] }, defaultPath: defaultName,
{ name: 'All Files', extensions: ['*'] }, filters: [
], { name: "PEN Files", extensions: ["pen"] },
}); { name: "All Files", extensions: ["*"] },
],
},
);
if (filePath) { if (filePath) {
await window.electronAPI.invoke('fs:writeFile', filePath, Array.from(data)); await window.electronAPI.invoke(
"fs:writeFile",
filePath,
Array.from(data),
);
} }
} catch (err) { } catch (err) {
console.error('[ElectronFileService] Failed to save file:', err); console.error("[ElectronFileService] Failed to save file:", err);
throw err; throw err;
} }
} }

View file

@ -1,5 +1,8 @@
import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService'; import type {
import type { PesPatternData } from '../../formats/import/pesImporter'; IStorageService,
ICachedPattern,
} from "../interfaces/IStorageService";
import type { PesPatternData } from "../../formats/import/pesImporter";
/** /**
* Electron implementation of storage service using electron-store via IPC * Electron implementation of storage service using electron-store via IPC
@ -7,7 +10,7 @@ import type { PesPatternData } from '../../formats/import/pesImporter';
export class ElectronStorageService implements IStorageService { export class ElectronStorageService implements IStorageService {
private async invoke<T>(channel: string, ...args: unknown[]): Promise<T> { private async invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
if (!window.electronAPI) { if (!window.electronAPI) {
throw new Error('Electron API not available'); throw new Error("Electron API not available");
} }
return window.electronAPI.invoke(channel, ...args); return window.electronAPI.invoke(channel, ...args);
} }
@ -16,7 +19,7 @@ export class ElectronStorageService implements IStorageService {
uuid: string, uuid: string,
pesData: PesPatternData, pesData: PesPatternData,
fileName: string, fileName: string,
patternOffset?: { x: number; y: number } patternOffset?: { x: number; y: number },
): Promise<void> { ): Promise<void> {
// Convert Uint8Array to array for JSON serialization over IPC // Convert Uint8Array to array for JSON serialization over IPC
const serializable = { const serializable = {
@ -31,14 +34,17 @@ export class ElectronStorageService implements IStorageService {
}; };
// Fire and forget (sync-like behavior to match interface) // Fire and forget (sync-like behavior to match interface)
this.invoke('storage:savePattern', serializable).catch(err => { this.invoke("storage:savePattern", serializable).catch((err) => {
console.error('[ElectronStorage] Failed to save pattern:', err); console.error("[ElectronStorage] Failed to save pattern:", err);
}); });
} }
async getPatternByUUID(uuid: string): Promise<ICachedPattern | null> { async getPatternByUUID(uuid: string): Promise<ICachedPattern | null> {
try { try {
const pattern = await this.invoke<ICachedPattern | null>('storage:getPattern', uuid); const pattern = await this.invoke<ICachedPattern | null>(
"storage:getPattern",
uuid,
);
if (pattern && Array.isArray(pattern.pesData.penData)) { if (pattern && Array.isArray(pattern.pesData.penData)) {
// Restore Uint8Array from array // Restore Uint8Array from array
@ -47,14 +53,16 @@ export class ElectronStorageService implements IStorageService {
return pattern; return pattern;
} catch (err) { } catch (err) {
console.error('[ElectronStorage] Failed to get pattern:', err); console.error("[ElectronStorage] Failed to get pattern:", err);
return null; return null;
} }
} }
async getMostRecentPattern(): Promise<ICachedPattern | null> { async getMostRecentPattern(): Promise<ICachedPattern | null> {
try { try {
const pattern = await this.invoke<ICachedPattern | null>('storage:getLatest'); const pattern = await this.invoke<ICachedPattern | null>(
"storage:getLatest",
);
if (pattern && Array.isArray(pattern.pesData.penData)) { if (pattern && Array.isArray(pattern.pesData.penData)) {
// Restore Uint8Array from array // Restore Uint8Array from array
@ -63,7 +71,7 @@ export class ElectronStorageService implements IStorageService {
return pattern; return pattern;
} catch (err) { } catch (err) {
console.error('[ElectronStorage] Failed to get latest pattern:', err); console.error("[ElectronStorage] Failed to get latest pattern:", err);
return null; return null;
} }
} }
@ -71,29 +79,38 @@ export class ElectronStorageService implements IStorageService {
async hasPattern(): Promise<boolean> { async hasPattern(): Promise<boolean> {
// Since this is async in Electron, we can't truly implement this synchronously // Since this is async in Electron, we can't truly implement this synchronously
// Returning false as a safe default // Returning false as a safe default
console.warn('[ElectronStorage] hasPattern called synchronously, returning false'); console.warn(
"[ElectronStorage] hasPattern called synchronously, returning false",
);
return false; return false;
} }
async deletePattern(uuid: string): Promise<void> { async deletePattern(uuid: string): Promise<void> {
try { try {
await this.invoke('storage:deletePattern', uuid); await this.invoke("storage:deletePattern", uuid);
} catch (err) { } catch (err) {
console.error('[ElectronStorage] Failed to delete pattern:', err); console.error("[ElectronStorage] Failed to delete pattern:", err);
} }
} }
async clearCache(): Promise<void> { async clearCache(): Promise<void> {
try { try {
await this.invoke('storage:clear'); await this.invoke("storage:clear");
} catch (err) { } catch (err) {
console.error('[ElectronStorage] Failed to clear cache:', err); console.error("[ElectronStorage] Failed to clear cache:", err);
} }
} }
async getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }> { async getCacheInfo(): Promise<{
hasCache: boolean;
fileName?: string;
uuid?: string;
age?: number;
}> {
// This needs to be async in Electron, return empty info synchronously // This needs to be async in Electron, return empty info synchronously
console.warn('[ElectronStorage] getCacheInfo called synchronously, returning empty'); console.warn(
"[ElectronStorage] getCacheInfo called synchronously, returning empty",
);
return { hasCache: false }; return { hasCache: false };
} }
} }

View file

@ -1,15 +1,19 @@
import type { IStorageService } from './interfaces/IStorageService'; import type { IStorageService } from "./interfaces/IStorageService";
import type { IFileService } from './interfaces/IFileService'; import type { IFileService } from "./interfaces/IFileService";
import { BrowserStorageService } from './browser/BrowserStorageService'; import { BrowserStorageService } from "./browser/BrowserStorageService";
import { BrowserFileService } from './browser/BrowserFileService'; import { BrowserFileService } from "./browser/BrowserFileService";
import { ElectronStorageService } from './electron/ElectronStorageService'; import { ElectronStorageService } from "./electron/ElectronStorageService";
import { ElectronFileService } from './electron/ElectronFileService'; import { ElectronFileService } from "./electron/ElectronFileService";
/** /**
* Detect if running in Electron * Detect if running in Electron
*/ */
export function isElectron(): boolean { export function isElectron(): boolean {
return !!(typeof window !== 'undefined' && window.process && window.process.type === 'renderer'); return !!(
typeof window !== "undefined" &&
window.process &&
window.process.type === "renderer"
);
} }
/** /**

View file

@ -1,4 +1,4 @@
import type { PesPatternData } from '../../formats/import/pesImporter'; import type { PesPatternData } from "../../formats/import/pesImporter";
export interface ICachedPattern { export interface ICachedPattern {
uuid: string; uuid: string;
@ -13,7 +13,7 @@ export interface IStorageService {
uuid: string, uuid: string,
pesData: PesPatternData, pesData: PesPatternData,
fileName: string, fileName: string,
patternOffset?: { x: number; y: number } patternOffset?: { x: number; y: number },
): Promise<void>; ): Promise<void>;
getPatternByUUID(uuid: string): Promise<ICachedPattern | null>; getPatternByUUID(uuid: string): Promise<ICachedPattern | null>;
@ -21,5 +21,10 @@ export interface IStorageService {
hasPattern(uuid: string): Promise<boolean>; hasPattern(uuid: string): Promise<boolean>;
deletePattern(uuid: string): Promise<void>; deletePattern(uuid: string): Promise<void>;
clearCache(): Promise<void>; clearCache(): Promise<void>;
getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }>; getCacheInfo(): Promise<{
hasCache: boolean;
fileName?: string;
uuid?: string;
age?: number;
}>;
} }

View file

@ -9,7 +9,7 @@ import { MachineStatus } from "../types/machine";
export class BluetoothPairingError extends Error { export class BluetoothPairingError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = 'BluetoothPairingError'; this.name = "BluetoothPairingError";
} }
} }
@ -57,7 +57,8 @@ export class BrotherPP1Service {
private isProcessingQueue = false; private isProcessingQueue = false;
private isCommunicating = false; private isCommunicating = false;
private isInitialConnection = false; private isInitialConnection = false;
private communicationCallbacks: Set<(isCommunicating: boolean) => void> = new Set(); private communicationCallbacks: Set<(isCommunicating: boolean) => void> =
new Set();
private disconnectCallbacks: Set<() => void> = new Set(); private disconnectCallbacks: Set<() => void> = new Set();
/** /**
@ -65,7 +66,9 @@ export class BrotherPP1Service {
* @param callback Function called when communication state changes * @param callback Function called when communication state changes
* @returns Unsubscribe function * @returns Unsubscribe function
*/ */
onCommunicationChange(callback: (isCommunicating: boolean) => void): () => void { onCommunicationChange(
callback: (isCommunicating: boolean) => void,
): () => void {
this.communicationCallbacks.add(callback); this.communicationCallbacks.add(callback);
// Immediately call with current state // Immediately call with current state
callback(this.isCommunicating); callback(this.isCommunicating);
@ -89,19 +92,19 @@ export class BrotherPP1Service {
private setCommunicating(value: boolean) { private setCommunicating(value: boolean) {
if (this.isCommunicating !== value) { if (this.isCommunicating !== value) {
this.isCommunicating = value; this.isCommunicating = value;
this.communicationCallbacks.forEach(callback => callback(value)); this.communicationCallbacks.forEach((callback) => callback(value));
} }
} }
private handleDisconnect() { private handleDisconnect() {
console.log('[BrotherPP1Service] Device disconnected'); console.log("[BrotherPP1Service] Device disconnected");
this.server = null; this.server = null;
this.writeCharacteristic = null; this.writeCharacteristic = null;
this.readCharacteristic = null; this.readCharacteristic = null;
this.commandQueue = []; this.commandQueue = [];
this.isProcessingQueue = false; this.isProcessingQueue = false;
this.setCommunicating(false); this.setCommunicating(false);
this.disconnectCallbacks.forEach(callback => callback()); this.disconnectCallbacks.forEach((callback) => callback());
} }
async connect(): Promise<void> { async connect(): Promise<void> {
@ -116,7 +119,7 @@ export class BrotherPP1Service {
} }
// Listen for disconnection events // Listen for disconnection events
this.device.addEventListener('gattserverdisconnected', () => { this.device.addEventListener("gattserverdisconnected", () => {
this.handleDisconnect(); this.handleDisconnect();
}); });
@ -126,7 +129,8 @@ export class BrotherPP1Service {
const service = await this.server.getPrimaryService(SERVICE_UUID); const service = await this.server.getPrimaryService(SERVICE_UUID);
console.log("Got primary service"); console.log("Got primary service");
this.writeCharacteristic = await service.getCharacteristic(WRITE_CHAR_UUID); this.writeCharacteristic =
await service.getCharacteristic(WRITE_CHAR_UUID);
this.readCharacteristic = await service.getCharacteristic(READ_CHAR_UUID); this.readCharacteristic = await service.getCharacteristic(READ_CHAR_UUID);
console.log("Connected to Brother PP1 machine"); console.log("Connected to Brother PP1 machine");
@ -136,7 +140,9 @@ export class BrotherPP1Service {
console.log("Validating connection with test command..."); console.log("Validating connection with test command...");
try { try {
await this.getMachineState(); await this.getMachineState();
console.log("Connection validation successful - device is properly paired"); console.log(
"Connection validation successful - device is properly paired",
);
} catch (e) { } catch (e) {
console.log("Connection validation failed:", e); console.log("Connection validation failed:", e);
// Disconnect to clean up // Disconnect to clean up
@ -289,16 +295,21 @@ export class BrotherPP1Service {
// Detect pairing issues during initial connection - empty or invalid response // Detect pairing issues during initial connection - empty or invalid response
if (this.isInitialConnection) { if (this.isInitialConnection) {
if (response.length === 0) { if (response.length === 0) {
console.log('[BrotherPP1] Empty response received - device likely not paired'); console.log(
"[BrotherPP1] Empty response received - device likely not paired",
);
throw new BluetoothPairingError( throw new BluetoothPairingError(
'Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system\'s Bluetooth settings. After pairing, try connecting again.' "Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.",
); );
} }
// Check for invalid response (less than 3 bytes means no proper command response) // Check for invalid response (less than 3 bytes means no proper command response)
if (response.length < 3) { if (response.length < 3) {
console.log('[BrotherPP1] Invalid response length:', response.length); console.log(
"[BrotherPP1] Invalid response length:",
response.length,
);
throw new BluetoothPairingError( throw new BluetoothPairingError(
'Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system\'s Bluetooth settings. After pairing, try connecting again.' "Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.",
); );
} }
} }
@ -322,11 +333,12 @@ export class BrotherPP1Service {
if (this.isInitialConnection && error instanceof Error) { if (this.isInitialConnection && error instanceof Error) {
const errorMsg = error.message.toLowerCase(); const errorMsg = error.message.toLowerCase();
if ( if (
errorMsg.includes('gatt server is disconnected') || errorMsg.includes("gatt server is disconnected") ||
(errorMsg.includes('writevaluewithresponse') && errorMsg.includes('gatt server is disconnected')) (errorMsg.includes("writevaluewithresponse") &&
errorMsg.includes("gatt server is disconnected"))
) { ) {
throw new BluetoothPairingError( throw new BluetoothPairingError(
'Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system\'s Bluetooth settings. After pairing, try connecting again.' "Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.",
); );
} }
} }
@ -369,7 +381,7 @@ export class BrotherPP1Service {
serviceCount = serviceData.serviceCount; serviceCount = serviceData.serviceCount;
totalCount = serviceData.totalCount; totalCount = serviceData.totalCount;
} catch (err) { } catch (err) {
console.warn('[BrotherPP1] Failed to fetch service count:', err); console.warn("[BrotherPP1] Failed to fetch service count:", err);
} }
return { return {
@ -385,17 +397,23 @@ export class BrotherPP1Service {
}; };
} }
async getServiceCount(): Promise<{ serviceCount: number; totalCount: number }> { async getServiceCount(): Promise<{
serviceCount: number;
totalCount: number;
}> {
const response = await this.sendCommand(Commands.SERVICE_COUNT); const response = await this.sendCommand(Commands.SERVICE_COUNT);
const data = response.slice(2); const data = response.slice(2);
// Read uint32 values in little-endian format // Read uint32 values in little-endian format
const readUInt32LE = (offset: number) => const readUInt32LE = (offset: number) =>
data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24); data[offset] |
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
(data[offset + 3] << 24);
return { return {
serviceCount: readUInt32LE(0), // Bytes 0-3 serviceCount: readUInt32LE(0), // Bytes 0-3
totalCount: readUInt32LE(4), // Bytes 4-7 totalCount: readUInt32LE(4), // Bytes 4-7
}; };
} }
@ -427,8 +445,10 @@ export class BrotherPP1Service {
speed: readUInt16LE(12), speed: readUInt16LE(12),
}; };
console.log('[BrotherPP1] Pattern Info Response:', { console.log("[BrotherPP1] Pattern Info Response:", {
rawData: Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' '), rawData: Array.from(data)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" "),
parsed: patternInfo, parsed: patternInfo,
}); });
@ -580,7 +600,7 @@ export class BrotherPP1Service {
payload[24] = flip; payload[24] = flip;
payload[25] = frame; payload[25] = frame;
console.log('[DEBUG] Layout bounds:', { console.log("[DEBUG] Layout bounds:", {
boundLeft, boundLeft,
boundTop, boundTop,
boundRight, boundRight,
@ -675,7 +695,7 @@ export class BrotherPP1Service {
moveX = patternOffset.x - patternCenterX; moveX = patternOffset.x - patternCenterX;
moveY = patternOffset.y - patternCenterY; moveY = patternOffset.y - patternCenterY;
console.log('[LAYOUT] Using user-defined offset:', { console.log("[LAYOUT] Using user-defined offset:", {
patternOffset, patternOffset,
patternCenter: { x: patternCenterX, y: patternCenterY }, patternCenter: { x: patternCenterX, y: patternCenterY },
moveX, moveX,
@ -688,7 +708,7 @@ export class BrotherPP1Service {
moveX = -patternCenterX; moveX = -patternCenterX;
moveY = -patternCenterY; moveY = -patternCenterY;
console.log('[LAYOUT] Auto-centering pattern:', { moveX, moveY }); console.log("[LAYOUT] Auto-centering pattern:", { moveX, moveY });
} }
// Send layout with actual pattern bounds // Send layout with actual pattern bounds

View file

@ -1,4 +1,4 @@
import type { PesPatternData } from '../formats/import/pesImporter'; import type { PesPatternData } from "../formats/import/pesImporter";
interface CachedPattern { interface CachedPattern {
uuid: string; uuid: string;
@ -8,13 +8,15 @@ interface CachedPattern {
patternOffset?: { x: number; y: number }; patternOffset?: { x: number; y: number };
} }
const CACHE_KEY = 'brother_pattern_cache'; const CACHE_KEY = "brother_pattern_cache";
/** /**
* Convert UUID Uint8Array to hex string * Convert UUID Uint8Array to hex string
*/ */
export function uuidToString(uuid: Uint8Array): string { export function uuidToString(uuid: Uint8Array): string {
return Array.from(uuid).map(b => b.toString(16).padStart(2, '0')).join(''); return Array.from(uuid)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
} }
/** /**
@ -36,7 +38,7 @@ export class PatternCacheService {
uuid: string, uuid: string,
pesData: PesPatternData, pesData: PesPatternData,
fileName: string, fileName: string,
patternOffset?: { x: number; y: number } patternOffset?: { x: number; y: number },
): void { ): void {
try { try {
// Convert penData Uint8Array to array for JSON serialization // Convert penData Uint8Array to array for JSON serialization
@ -54,11 +56,18 @@ export class PatternCacheService {
}; };
localStorage.setItem(CACHE_KEY, JSON.stringify(cached)); localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
console.log('[PatternCache] Saved pattern:', fileName, 'UUID:', uuid, 'Offset:', patternOffset); console.log(
"[PatternCache] Saved pattern:",
fileName,
"UUID:",
uuid,
"Offset:",
patternOffset,
);
} catch (err) { } catch (err) {
console.error('[PatternCache] Failed to save pattern:', err); console.error("[PatternCache] Failed to save pattern:", err);
// If quota exceeded, clear and try again // If quota exceeded, clear and try again
if (err instanceof Error && err.name === 'QuotaExceededError') { if (err instanceof Error && err.name === "QuotaExceededError") {
this.clearCache(); this.clearCache();
} }
} }
@ -78,7 +87,12 @@ export class PatternCacheService {
// Check if UUID matches // Check if UUID matches
if (pattern.uuid !== uuid) { if (pattern.uuid !== uuid) {
console.log('[PatternCache] UUID mismatch. Cached:', pattern.uuid, 'Requested:', uuid); console.log(
"[PatternCache] UUID mismatch. Cached:",
pattern.uuid,
"Requested:",
uuid,
);
return null; return null;
} }
@ -87,10 +101,15 @@ export class PatternCacheService {
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData); pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
} }
console.log('[PatternCache] Found cached pattern:', pattern.fileName, 'UUID:', uuid); console.log(
"[PatternCache] Found cached pattern:",
pattern.fileName,
"UUID:",
uuid,
);
return pattern; return pattern;
} catch (err) { } catch (err) {
console.error('[PatternCache] Failed to retrieve pattern:', err); console.error("[PatternCache] Failed to retrieve pattern:", err);
return null; return null;
} }
} }
@ -114,7 +133,7 @@ export class PatternCacheService {
return pattern; return pattern;
} catch (err) { } catch (err) {
console.error('[PatternCache] Failed to retrieve pattern:', err); console.error("[PatternCache] Failed to retrieve pattern:", err);
return null; return null;
} }
} }
@ -135,10 +154,10 @@ export class PatternCacheService {
const cached = this.getPatternByUUID(uuid); const cached = this.getPatternByUUID(uuid);
if (cached) { if (cached) {
localStorage.removeItem(CACHE_KEY); localStorage.removeItem(CACHE_KEY);
console.log('[PatternCache] Deleted pattern with UUID:', uuid); console.log("[PatternCache] Deleted pattern with UUID:", uuid);
} }
} catch (err) { } catch (err) {
console.error('[PatternCache] Failed to delete pattern:', err); console.error("[PatternCache] Failed to delete pattern:", err);
} }
} }
@ -148,16 +167,21 @@ export class PatternCacheService {
static clearCache(): void { static clearCache(): void {
try { try {
localStorage.removeItem(CACHE_KEY); localStorage.removeItem(CACHE_KEY);
console.log('[PatternCache] Cache cleared'); console.log("[PatternCache] Cache cleared");
} catch (err) { } catch (err) {
console.error('[PatternCache] Failed to clear cache:', err); console.error("[PatternCache] Failed to clear cache:", err);
} }
} }
/** /**
* Get cache info for debugging * Get cache info for debugging
*/ */
static getCacheInfo(): { hasCache: boolean; fileName?: string; uuid?: string; age?: number } { static getCacheInfo(): {
hasCache: boolean;
fileName?: string;
uuid?: string;
age?: number;
} {
const pattern = this.getMostRecentPattern(); const pattern = this.getMostRecentPattern();
if (!pattern) { if (!pattern) {
return { hasCache: false }; return { hasCache: false };

View file

@ -1,5 +1,5 @@
import { create } from 'zustand'; import { create } from "zustand";
import type { PesPatternData } from '../formats/import/pesImporter'; import type { PesPatternData } from "../formats/import/pesImporter";
interface PatternState { interface PatternState {
// Pattern data // Pattern data
@ -19,7 +19,7 @@ interface PatternState {
export const usePatternStore = create<PatternState>((set) => ({ export const usePatternStore = create<PatternState>((set) => ({
// Initial state // Initial state
pesData: null, pesData: null,
currentFileName: '', currentFileName: "",
patternOffset: { x: 0, y: 0 }, patternOffset: { x: 0, y: 0 },
patternUploaded: false, patternUploaded: false,
@ -36,7 +36,7 @@ export const usePatternStore = create<PatternState>((set) => ({
// Update pattern offset // Update pattern offset
setPatternOffset: (x: number, y: number) => { setPatternOffset: (x: number, y: number) => {
set({ patternOffset: { x, y } }); set({ patternOffset: { x, y } });
console.log('[PatternStore] Pattern offset changed:', { x, y }); console.log("[PatternStore] Pattern offset changed:", { x, y });
}, },
// Mark pattern as uploaded/not uploaded // Mark pattern as uploaded/not uploaded
@ -61,6 +61,9 @@ export const usePatternStore = create<PatternState>((set) => ({
// Selector hooks for common use cases // Selector hooks for common use cases
export const usePesData = () => usePatternStore((state) => state.pesData); export const usePesData = () => usePatternStore((state) => state.pesData);
export const usePatternFileName = () => usePatternStore((state) => state.currentFileName); export const usePatternFileName = () =>
export const usePatternOffset = () => usePatternStore((state) => state.patternOffset); usePatternStore((state) => state.currentFileName);
export const usePatternUploaded = () => usePatternStore((state) => state.patternUploaded); export const usePatternOffset = () =>
usePatternStore((state) => state.patternOffset);
export const usePatternUploaded = () =>
usePatternStore((state) => state.patternUploaded);

View file

@ -1,5 +1,5 @@
import { create } from 'zustand'; import { create } from "zustand";
import { patternConverterClient } from '../formats/import/client'; import { patternConverterClient } from "../formats/import/client";
interface UIState { interface UIState {
// Pyodide state // Pyodide state
@ -23,26 +23,41 @@ export const useUIStore = create<UIState>((set) => ({
pyodideReady: false, pyodideReady: false,
pyodideError: null, pyodideError: null,
pyodideProgress: 0, pyodideProgress: 0,
pyodideLoadingStep: '', pyodideLoadingStep: "",
showErrorPopover: false, showErrorPopover: false,
// Initialize Pyodide with progress tracking // Initialize Pyodide with progress tracking
initializePyodide: async () => { initializePyodide: async () => {
try { try {
// Reset progress // Reset progress
set({ pyodideProgress: 0, pyodideLoadingStep: 'Starting...', pyodideError: null }); set({
pyodideProgress: 0,
pyodideLoadingStep: "Starting...",
pyodideError: null,
});
// Initialize with progress callback // Initialize with progress callback
await patternConverterClient.initialize((progress, step) => { await patternConverterClient.initialize((progress, step) => {
set({ pyodideProgress: progress, pyodideLoadingStep: step }); set({ pyodideProgress: progress, pyodideLoadingStep: step });
}); });
set({ pyodideReady: true, pyodideProgress: 100, pyodideLoadingStep: 'Ready!' }); set({
console.log('[UIStore] Pyodide initialized successfully'); pyodideReady: true,
pyodideProgress: 100,
pyodideLoadingStep: "Ready!",
});
console.log("[UIStore] Pyodide initialized successfully");
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Python environment'; const errorMessage =
set({ pyodideError: errorMessage, pyodideProgress: 0, pyodideLoadingStep: '' }); err instanceof Error
console.error('[UIStore] Failed to initialize Pyodide:', err); ? err.message
: "Failed to initialize Python environment";
set({
pyodideError: errorMessage,
pyodideProgress: 0,
pyodideLoadingStep: "",
});
console.error("[UIStore] Failed to initialize Pyodide:", err);
} }
}, },
@ -65,6 +80,9 @@ 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 usePyodideProgress = () =>
export const usePyodideLoadingStep = () => useUIStore((state) => state.pyodideLoadingStep); useUIStore((state) => state.pyodideProgress);
export const useErrorPopover = () => useUIStore((state) => state.showErrorPopover); export const usePyodideLoadingStep = () =>
useUIStore((state) => state.pyodideLoadingStep);
export const useErrorPopover = () =>
useUIStore((state) => state.showErrorPopover);

View file

@ -5,7 +5,9 @@ export interface BluetoothDevice {
export interface ElectronAPI { export interface ElectronAPI {
invoke<T = unknown>(channel: string, ...args: unknown[]): Promise<T>; invoke<T = unknown>(channel: string, ...args: unknown[]): Promise<T>;
onBluetoothDeviceList: (callback: (devices: BluetoothDevice[]) => void) => void; onBluetoothDeviceList: (
callback: (devices: BluetoothDevice[]) => void,
) => void;
selectBluetoothDevice: (deviceId: string) => void; selectBluetoothDevice: (deviceId: string) => void;
} }

View file

@ -19,33 +19,33 @@ export const MachineStatus = {
HOOP_AVOIDANCEING: 0x51, HOOP_AVOIDANCEING: 0x51,
RL_RECEIVING: 0x60, RL_RECEIVING: 0x60,
RL_RECEIVED: 0x61, RL_RECEIVED: 0x61,
None: 0xDD, None: 0xdd,
TryConnecting: 0xFF, TryConnecting: 0xff,
} as const; } as const;
export type MachineStatus = typeof MachineStatus[keyof typeof MachineStatus]; export type MachineStatus = (typeof MachineStatus)[keyof typeof MachineStatus];
export const MachineStatusNames: Record<MachineStatus, string> = { export const MachineStatusNames: Record<MachineStatus, string> = {
[MachineStatus.Initial]: 'Initial', [MachineStatus.Initial]: "Initial",
[MachineStatus.LowerThread]: 'Lower Thread', [MachineStatus.LowerThread]: "Lower Thread",
[MachineStatus.IDLE]: 'Idle', [MachineStatus.IDLE]: "Idle",
[MachineStatus.SEWING_WAIT]: 'Ready to Sew', [MachineStatus.SEWING_WAIT]: "Ready to Sew",
[MachineStatus.SEWING_DATA_RECEIVE]: 'Receiving Data', [MachineStatus.SEWING_DATA_RECEIVE]: "Receiving Data",
[MachineStatus.MASK_TRACE_LOCK_WAIT]: 'Waiting for Mask Trace', [MachineStatus.MASK_TRACE_LOCK_WAIT]: "Waiting for Mask Trace",
[MachineStatus.MASK_TRACING]: 'Mask Tracing', [MachineStatus.MASK_TRACING]: "Mask Tracing",
[MachineStatus.MASK_TRACE_COMPLETE]: 'Mask Trace Complete', [MachineStatus.MASK_TRACE_COMPLETE]: "Mask Trace Complete",
[MachineStatus.SEWING]: 'Sewing', [MachineStatus.SEWING]: "Sewing",
[MachineStatus.SEWING_COMPLETE]: 'Complete', [MachineStatus.SEWING_COMPLETE]: "Complete",
[MachineStatus.SEWING_INTERRUPTION]: 'Interrupted', [MachineStatus.SEWING_INTERRUPTION]: "Interrupted",
[MachineStatus.COLOR_CHANGE_WAIT]: 'Waiting for Color Change', [MachineStatus.COLOR_CHANGE_WAIT]: "Waiting for Color Change",
[MachineStatus.PAUSE]: 'Paused', [MachineStatus.PAUSE]: "Paused",
[MachineStatus.STOP]: 'Stopped', [MachineStatus.STOP]: "Stopped",
[MachineStatus.HOOP_AVOIDANCE]: 'Hoop Avoidance', [MachineStatus.HOOP_AVOIDANCE]: "Hoop Avoidance",
[MachineStatus.HOOP_AVOIDANCEING]: 'Hoop Avoidance In Progress', [MachineStatus.HOOP_AVOIDANCEING]: "Hoop Avoidance In Progress",
[MachineStatus.RL_RECEIVING]: 'RL Receiving', [MachineStatus.RL_RECEIVING]: "RL Receiving",
[MachineStatus.RL_RECEIVED]: 'RL Received', [MachineStatus.RL_RECEIVED]: "RL Received",
[MachineStatus.None]: 'None', [MachineStatus.None]: "None",
[MachineStatus.TryConnecting]: 'Connecting', [MachineStatus.TryConnecting]: "Connecting",
}; };
export interface MachineInfo { export interface MachineInfo {

View file

@ -11,9 +11,9 @@ export function getCSSVariable(name: string): string {
* Canvas color helpers * Canvas color helpers
*/ */
export const canvasColors = { export const canvasColors = {
grid: () => getCSSVariable('--color-canvas-grid'), grid: () => getCSSVariable("--color-canvas-grid"),
origin: () => getCSSVariable('--color-canvas-origin'), origin: () => getCSSVariable("--color-canvas-origin"),
hoop: () => getCSSVariable('--color-canvas-hoop'), hoop: () => getCSSVariable("--color-canvas-hoop"),
bounds: () => getCSSVariable('--color-canvas-bounds'), bounds: () => getCSSVariable("--color-canvas-bounds"),
position: () => getCSSVariable('--color-canvas-position'), position: () => getCSSVariable("--color-canvas-position"),
}; };

View file

@ -1,8 +1,8 @@
import Konva from 'konva'; import Konva from "konva";
import type { PesPatternData } from '../formats/import/pesImporter'; import type { PesPatternData } from "../formats/import/pesImporter";
import { getThreadColor } from '../formats/import/pesImporter'; import { getThreadColor } from "../formats/import/pesImporter";
import type { MachineInfo } from '../types/machine'; import type { MachineInfo } from "../types/machine";
import { MOVE } from '../formats/import/constants'; import { MOVE } from "../formats/import/constants";
/** /**
* Renders a grid with specified spacing * Renders a grid with specified spacing
@ -11,9 +11,9 @@ export function renderGrid(
layer: Konva.Layer, layer: Konva.Layer,
gridSize: number, gridSize: number,
bounds: { minX: number; maxX: number; minY: number; maxY: number }, bounds: { minX: number; maxX: number; minY: number; maxY: number },
machineInfo: MachineInfo | null machineInfo: MachineInfo | null,
): void { ): void {
const gridGroup = new Konva.Group({ name: 'grid' }); const gridGroup = new Konva.Group({ name: "grid" });
// Determine grid bounds based on hoop or pattern // Determine grid bounds based on hoop or pattern
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX; const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX;
@ -22,20 +22,28 @@ export function renderGrid(
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY; const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY;
// Vertical lines // Vertical lines
for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) { for (
let x = Math.floor(gridMinX / gridSize) * gridSize;
x <= gridMaxX;
x += gridSize
) {
const line = new Konva.Line({ const line = new Konva.Line({
points: [x, gridMinY, x, gridMaxY], points: [x, gridMinY, x, gridMaxY],
stroke: '#e0e0e0', stroke: "#e0e0e0",
strokeWidth: 1, strokeWidth: 1,
}); });
gridGroup.add(line); gridGroup.add(line);
} }
// Horizontal lines // Horizontal lines
for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) { for (
let y = Math.floor(gridMinY / gridSize) * gridSize;
y <= gridMaxY;
y += gridSize
) {
const line = new Konva.Line({ const line = new Konva.Line({
points: [gridMinX, y, gridMaxX, y], points: [gridMinX, y, gridMaxX, y],
stroke: '#e0e0e0', stroke: "#e0e0e0",
strokeWidth: 1, strokeWidth: 1,
}); });
gridGroup.add(line); gridGroup.add(line);
@ -48,19 +56,19 @@ export function renderGrid(
* Renders the origin crosshair at (0,0) * Renders the origin crosshair at (0,0)
*/ */
export function renderOrigin(layer: Konva.Layer): void { export function renderOrigin(layer: Konva.Layer): void {
const originGroup = new Konva.Group({ name: 'origin' }); const originGroup = new Konva.Group({ name: "origin" });
// Horizontal line // Horizontal line
const hLine = new Konva.Line({ const hLine = new Konva.Line({
points: [-10, 0, 10, 0], points: [-10, 0, 10, 0],
stroke: '#888', stroke: "#888",
strokeWidth: 2, strokeWidth: 2,
}); });
// Vertical line // Vertical line
const vLine = new Konva.Line({ const vLine = new Konva.Line({
points: [0, -10, 0, 10], points: [0, -10, 0, 10],
stroke: '#888', stroke: "#888",
strokeWidth: 2, strokeWidth: 2,
}); });
@ -72,7 +80,7 @@ export function renderOrigin(layer: Konva.Layer): void {
* Renders the hoop boundary and label * Renders the hoop boundary and label
*/ */
export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void { export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
const hoopGroup = new Konva.Group({ name: 'hoop' }); const hoopGroup = new Konva.Group({ name: "hoop" });
const hoopWidth = machineInfo.maxWidth; const hoopWidth = machineInfo.maxWidth;
const hoopHeight = machineInfo.maxHeight; const hoopHeight = machineInfo.maxHeight;
@ -87,7 +95,7 @@ export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
y: hoopTop, y: hoopTop,
width: hoopWidth, width: hoopWidth,
height: hoopHeight, height: hoopHeight,
stroke: '#2196F3', stroke: "#2196F3",
strokeWidth: 3, strokeWidth: 3,
dash: [10, 5], dash: [10, 5],
}); });
@ -98,9 +106,9 @@ export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
y: hoopTop + 10, y: hoopTop + 10,
text: `Hoop: ${(hoopWidth / 10).toFixed(0)} x ${(hoopHeight / 10).toFixed(0)} mm`, text: `Hoop: ${(hoopWidth / 10).toFixed(0)} x ${(hoopHeight / 10).toFixed(0)} mm`,
fontSize: 14, fontSize: 14,
fontFamily: 'sans-serif', fontFamily: "sans-serif",
fontStyle: 'bold', fontStyle: "bold",
fill: '#2196F3', fill: "#2196F3",
}); });
hoopGroup.add(rect, label); hoopGroup.add(rect, label);
@ -114,9 +122,9 @@ export function renderStitches(
container: Konva.Layer | Konva.Group, container: Konva.Layer | Konva.Group,
stitches: number[][], stitches: number[][],
pesData: PesPatternData, pesData: PesPatternData,
currentStitchIndex: number currentStitchIndex: number,
): void { ): void {
const stitchesGroup = new Konva.Group({ name: 'stitches' }); const stitchesGroup = new Konva.Group({ name: "stitches" });
// Group stitches by color, completion status, and type (stitch vs jump) // Group stitches by color, completion status, and type (stitch vs jump)
interface StitchGroup { interface StitchGroup {
@ -164,8 +172,8 @@ export function renderStitches(
points: group.points, points: group.points,
stroke: group.color, stroke: group.color,
strokeWidth: 1.0, strokeWidth: 1.0,
lineCap: 'round', lineCap: "round",
lineJoin: 'round', lineJoin: "round",
dash: [5, 5], dash: [5, 5],
opacity: group.completed ? 0.6 : 0.25, opacity: group.completed ? 0.6 : 0.25,
}); });
@ -176,8 +184,8 @@ export function renderStitches(
points: group.points, points: group.points,
stroke: group.color, stroke: group.color,
strokeWidth: 1.5, strokeWidth: 1.5,
lineCap: 'round', lineCap: "round",
lineJoin: 'round', lineJoin: "round",
opacity: group.completed ? 1.0 : 0.3, opacity: group.completed ? 1.0 : 0.3,
}); });
stitchesGroup.add(line); stitchesGroup.add(line);
@ -192,7 +200,7 @@ export function renderStitches(
*/ */
export function renderPatternBounds( export function renderPatternBounds(
container: Konva.Layer | Konva.Group, container: Konva.Layer | Konva.Group,
bounds: { minX: number; maxX: number; minY: number; maxY: number } bounds: { minX: number; maxX: number; minY: number; maxY: number },
): void { ): void {
const { minX, maxX, minY, maxY } = bounds; const { minX, maxX, minY, maxY } = bounds;
const patternWidth = maxX - minX; const patternWidth = maxX - minX;
@ -203,7 +211,7 @@ export function renderPatternBounds(
y: minY, y: minY,
width: patternWidth, width: patternWidth,
height: patternHeight, height: patternHeight,
stroke: '#ff0000', stroke: "#ff0000",
strokeWidth: 2, strokeWidth: 2,
dash: [5, 5], dash: [5, 5],
}); });
@ -217,47 +225,47 @@ export function renderPatternBounds(
export function renderCurrentPosition( export function renderCurrentPosition(
container: Konva.Layer | Konva.Group, container: Konva.Layer | Konva.Group,
currentStitchIndex: number, currentStitchIndex: number,
stitches: number[][] stitches: number[][],
): void { ): void {
if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) return; if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) return;
const stitch = stitches[currentStitchIndex]; const stitch = stitches[currentStitchIndex];
const [x, y] = stitch; const [x, y] = stitch;
const posGroup = new Konva.Group({ name: 'currentPosition' }); const posGroup = new Konva.Group({ name: "currentPosition" });
// Circle with fill // Circle with fill
const circle = new Konva.Circle({ const circle = new Konva.Circle({
x, x,
y, y,
radius: 8, radius: 8,
fill: 'rgba(255, 0, 0, 0.3)', fill: "rgba(255, 0, 0, 0.3)",
stroke: '#ff0000', stroke: "#ff0000",
strokeWidth: 3, strokeWidth: 3,
}); });
// Crosshair lines // Crosshair lines
const hLine1 = new Konva.Line({ const hLine1 = new Konva.Line({
points: [x - 12, y, x - 3, y], points: [x - 12, y, x - 3, y],
stroke: '#ff0000', stroke: "#ff0000",
strokeWidth: 2, strokeWidth: 2,
}); });
const hLine2 = new Konva.Line({ const hLine2 = new Konva.Line({
points: [x + 12, y, x + 3, y], points: [x + 12, y, x + 3, y],
stroke: '#ff0000', stroke: "#ff0000",
strokeWidth: 2, strokeWidth: 2,
}); });
const vLine1 = new Konva.Line({ const vLine1 = new Konva.Line({
points: [x, y - 12, x, y - 3], points: [x, y - 12, x, y - 3],
stroke: '#ff0000', stroke: "#ff0000",
strokeWidth: 2, strokeWidth: 2,
}); });
const vLine2 = new Konva.Line({ const vLine2 = new Konva.Line({
points: [x, y + 12, x, y + 3], points: [x, y + 12, x, y + 3],
stroke: '#ff0000', stroke: "#ff0000",
strokeWidth: 2, strokeWidth: 2,
}); });
@ -270,9 +278,9 @@ export function renderCurrentPosition(
*/ */
export function renderLegend( export function renderLegend(
layer: Konva.Layer, layer: Konva.Layer,
pesData: PesPatternData pesData: PesPatternData,
): void { ): void {
const legendGroup = new Konva.Group({ name: 'legend' }); const legendGroup = new Konva.Group({ name: "legend" });
// Semi-transparent background for better readability // Semi-transparent background for better readability
const bgPadding = 8; const bgPadding = 8;
@ -284,9 +292,9 @@ export function renderLegend(
y: 10, y: 10,
width: 100, width: 100,
height: legendHeight, height: legendHeight,
fill: 'rgba(255, 255, 255, 0.9)', fill: "rgba(255, 255, 255, 0.9)",
cornerRadius: 4, cornerRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.2)', shadowColor: "rgba(0, 0, 0, 0.2)",
shadowBlur: 4, shadowBlur: 4,
shadowOffset: { x: 0, y: 2 }, shadowOffset: { x: 0, y: 2 },
}); });
@ -305,7 +313,7 @@ export function renderLegend(
width: 20, width: 20,
height: 20, height: 20,
fill: color, fill: color,
stroke: '#000', stroke: "#000",
strokeWidth: 1, strokeWidth: 1,
}); });
@ -315,8 +323,8 @@ export function renderLegend(
y: legendY + 5, y: legendY + 5,
text: `Thread ${i + 1}`, text: `Thread ${i + 1}`,
fontSize: 12, fontSize: 12,
fontFamily: 'sans-serif', fontFamily: "sans-serif",
fill: '#000', fill: "#000",
}); });
legendGroup.add(swatch, label); legendGroup.add(swatch, label);
@ -334,7 +342,7 @@ export function renderDimensions(
patternWidth: number, patternWidth: number,
patternHeight: number, patternHeight: number,
stageWidth: number, stageWidth: number,
stageHeight: number stageHeight: number,
): void { ): void {
const dimensionText = `${(patternWidth / 10).toFixed(1)} x ${(patternHeight / 10).toFixed(1)} mm`; const dimensionText = `${(patternWidth / 10).toFixed(1)} x ${(patternHeight / 10).toFixed(1)} mm`;
@ -348,9 +356,9 @@ export function renderDimensions(
y: stageHeight - textHeight - padding - 80, // Above zoom controls y: stageHeight - textHeight - padding - 80, // Above zoom controls
width: textWidth, width: textWidth,
height: textHeight, height: textHeight,
fill: 'rgba(255, 255, 255, 0.9)', fill: "rgba(255, 255, 255, 0.9)",
cornerRadius: 4, cornerRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.2)', shadowColor: "rgba(0, 0, 0, 0.2)",
shadowBlur: 4, shadowBlur: 4,
shadowOffset: { x: 0, y: 2 }, shadowOffset: { x: 0, y: 2 },
}); });
@ -362,10 +370,10 @@ export function renderDimensions(
height: textHeight, height: textHeight,
text: dimensionText, text: dimensionText,
fontSize: 14, fontSize: 14,
fontFamily: 'sans-serif', fontFamily: "sans-serif",
fill: '#000', fill: "#000",
align: 'center', align: "center",
verticalAlign: 'middle', verticalAlign: "middle",
}); });
layer.add(background, text); layer.add(background, text);
@ -379,7 +387,7 @@ export function calculateInitialScale(
stageHeight: number, stageHeight: number,
viewWidth: number, viewWidth: number,
viewHeight: number, viewHeight: number,
padding: number = 40 padding: number = 40,
): number { ): number {
const scaleX = (stageWidth - 2 * padding) / viewWidth; const scaleX = (stageWidth - 2 * padding) / viewWidth;
const scaleY = (stageHeight - 2 * padding) / viewHeight; const scaleY = (stageHeight - 2 * padding) / viewHeight;

View file

@ -1,23 +1,26 @@
import { MachineStatus } from '../types/machine'; import { MachineStatus } from "../types/machine";
/** /**
* Machine state categories for safety logic * Machine state categories for safety logic
*/ */
export const MachineStateCategory = { export const MachineStateCategory = {
IDLE: 'idle', IDLE: "idle",
ACTIVE: 'active', ACTIVE: "active",
WAITING: 'waiting', WAITING: "waiting",
COMPLETE: 'complete', COMPLETE: "complete",
INTERRUPTED: 'interrupted', INTERRUPTED: "interrupted",
ERROR: 'error', ERROR: "error",
} as const; } as const;
export type MachineStateCategoryType = typeof MachineStateCategory[keyof typeof MachineStateCategory]; export type MachineStateCategoryType =
(typeof MachineStateCategory)[keyof typeof MachineStateCategory];
/** /**
* Categorize a machine status into a semantic safety category * Categorize a machine status into a semantic safety category
*/ */
export function getMachineStateCategory(status: MachineStatus): MachineStateCategoryType { export function getMachineStateCategory(
status: MachineStatus,
): MachineStateCategoryType {
switch (status) { switch (status) {
// IDLE states - safe to perform any action // IDLE states - safe to perform any action
case MachineStatus.IDLE: case MachineStatus.IDLE:
@ -67,9 +70,11 @@ export function getMachineStateCategory(status: MachineStatus): MachineStateCate
export function canDeletePattern(status: MachineStatus): boolean { export function canDeletePattern(status: MachineStatus): boolean {
const category = getMachineStateCategory(status); const category = getMachineStateCategory(status);
// Can delete in IDLE, WAITING, or COMPLETE states, never during ACTIVE operations // Can delete in IDLE, WAITING, or COMPLETE states, never during ACTIVE operations
return category === MachineStateCategory.IDLE || return (
category === MachineStateCategory.WAITING || category === MachineStateCategory.IDLE ||
category === MachineStateCategory.COMPLETE; category === MachineStateCategory.WAITING ||
category === MachineStateCategory.COMPLETE
);
} }
/** /**
@ -79,8 +84,10 @@ export function canDeletePattern(status: MachineStatus): boolean {
export function canUploadPattern(status: MachineStatus): boolean { export function canUploadPattern(status: MachineStatus): boolean {
const category = getMachineStateCategory(status); const category = getMachineStateCategory(status);
// Can upload in IDLE or COMPLETE states (includes MASK_TRACE_COMPLETE) // Can upload in IDLE or COMPLETE states (includes MASK_TRACE_COMPLETE)
return category === MachineStateCategory.IDLE || return (
category === MachineStateCategory.COMPLETE; category === MachineStateCategory.IDLE ||
category === MachineStateCategory.COMPLETE
);
} }
/** /**
@ -89,11 +96,13 @@ export function canUploadPattern(status: MachineStatus): boolean {
*/ */
export function canStartSewing(status: MachineStatus): boolean { export function canStartSewing(status: MachineStatus): boolean {
// Only in specific ready states // Only in specific ready states
return status === MachineStatus.SEWING_WAIT || return (
status === MachineStatus.MASK_TRACE_COMPLETE || status === MachineStatus.SEWING_WAIT ||
status === MachineStatus.PAUSE || status === MachineStatus.MASK_TRACE_COMPLETE ||
status === MachineStatus.STOP || status === MachineStatus.PAUSE ||
status === MachineStatus.SEWING_INTERRUPTION; status === MachineStatus.STOP ||
status === MachineStatus.SEWING_INTERRUPTION
);
} }
/** /**
@ -101,9 +110,11 @@ export function canStartSewing(status: MachineStatus): boolean {
*/ */
export function canStartMaskTrace(status: MachineStatus): boolean { export function canStartMaskTrace(status: MachineStatus): boolean {
// Can start mask trace when IDLE (after upload), SEWING_WAIT, or after previous trace // Can start mask trace when IDLE (after upload), SEWING_WAIT, or after previous trace
return status === MachineStatus.IDLE || return (
status === MachineStatus.SEWING_WAIT || status === MachineStatus.IDLE ||
status === MachineStatus.MASK_TRACE_COMPLETE; status === MachineStatus.SEWING_WAIT ||
status === MachineStatus.MASK_TRACE_COMPLETE
);
} }
/** /**
@ -123,8 +134,10 @@ export function canResumeSewing(status: MachineStatus): boolean {
export function shouldConfirmDisconnect(status: MachineStatus): boolean { export function shouldConfirmDisconnect(status: MachineStatus): boolean {
const category = getMachineStateCategory(status); const category = getMachineStateCategory(status);
// Confirm if disconnecting during active operation or waiting for action // Confirm if disconnecting during active operation or waiting for action
return category === MachineStateCategory.ACTIVE || return (
category === MachineStateCategory.WAITING; category === MachineStateCategory.ACTIVE ||
category === MachineStateCategory.WAITING
);
} }
/** /**
@ -132,7 +145,13 @@ export function shouldConfirmDisconnect(status: MachineStatus): boolean {
*/ */
export interface StateVisualInfo { export interface StateVisualInfo {
color: string; color: string;
iconName: 'ready' | 'active' | 'waiting' | 'complete' | 'interrupted' | 'error'; iconName:
| "ready"
| "active"
| "waiting"
| "complete"
| "interrupted"
| "error";
label: string; label: string;
description: string; description: string;
} }
@ -147,41 +166,41 @@ export function getStateVisualInfo(status: MachineStatus): StateVisualInfo {
// Map state category to visual properties // Map state category to visual properties
const visualMap: Record<MachineStateCategoryType, StateVisualInfo> = { const visualMap: Record<MachineStateCategoryType, StateVisualInfo> = {
[MachineStateCategory.IDLE]: { [MachineStateCategory.IDLE]: {
color: 'info', color: "info",
iconName: 'ready', iconName: "ready",
label: 'Ready', label: "Ready",
description: 'Machine is idle and ready for operations' description: "Machine is idle and ready for operations",
}, },
[MachineStateCategory.ACTIVE]: { [MachineStateCategory.ACTIVE]: {
color: 'warning', color: "warning",
iconName: 'active', iconName: "active",
label: 'Active', label: "Active",
description: 'Operation in progress - do not interrupt' description: "Operation in progress - do not interrupt",
}, },
[MachineStateCategory.WAITING]: { [MachineStateCategory.WAITING]: {
color: 'warning', color: "warning",
iconName: 'waiting', iconName: "waiting",
label: 'Waiting', label: "Waiting",
description: 'Waiting for user or machine action' description: "Waiting for user or machine action",
}, },
[MachineStateCategory.COMPLETE]: { [MachineStateCategory.COMPLETE]: {
color: 'success', color: "success",
iconName: 'complete', iconName: "complete",
label: 'Complete', label: "Complete",
description: 'Operation completed successfully' description: "Operation completed successfully",
}, },
[MachineStateCategory.INTERRUPTED]: { [MachineStateCategory.INTERRUPTED]: {
color: 'danger', color: "danger",
iconName: 'interrupted', iconName: "interrupted",
label: 'Interrupted', label: "Interrupted",
description: 'Operation paused or stopped' description: "Operation paused or stopped",
}, },
[MachineStateCategory.ERROR]: { [MachineStateCategory.ERROR]: {
color: 'danger', color: "danger",
iconName: 'error', iconName: "error",
label: 'Error', label: "Error",
description: 'Machine in error or unknown state' description: "Machine in error or unknown state",
} },
}; };
return visualMap[category]; return visualMap[category];

View file

@ -20,7 +20,7 @@ export function convertStitchesToMinutes(stitchCount: number): number {
*/ */
export function calculatePatternTime( export function calculatePatternTime(
colorBlocks: Array<{ stitchCount: number }>, colorBlocks: Array<{ stitchCount: number }>,
currentStitch: number currentStitch: number,
): { ): {
totalMinutes: number; totalMinutes: number;
elapsedMinutes: number; elapsedMinutes: number;
@ -44,7 +44,8 @@ export function calculatePatternTime(
break; break;
} else { } else {
// We're partway through this block // We're partway through this block
const stitchesInBlock = currentStitch - (cumulativeStitches - block.stitchCount); const stitchesInBlock =
currentStitch - (cumulativeStitches - block.stitchCount);
elapsedMinutes += convertStitchesToMinutes(stitchesInBlock); elapsedMinutes += convertStitchesToMinutes(stitchesInBlock);
break; break;
} }
@ -63,5 +64,5 @@ export function calculatePatternTime(
export function formatMinutes(minutes: number): string { export function formatMinutes(minutes: number): string {
const mins = Math.floor(minutes); const mins = Math.floor(minutes);
const secs = Math.round((minutes - mins) * 60); const secs = Math.round((minutes - mins) * 60);
return `${mins}:${String(secs).padStart(2, '0')}`; return `${mins}:${String(secs).padStart(2, "0")}`;
} }

View file

@ -1,9 +1,9 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: "node",
include: ['src/**/*.{test,spec}.{js,ts}'], include: ["src/**/*.{test,spec}.{js,ts}"],
}, },
}); });