mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
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
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:
parent
20b8d47b6a
commit
a253901fb4
44 changed files with 3246 additions and 2523 deletions
|
|
@ -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",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
57
src/App.tsx
57
src/App.tsx
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
handleCancel();
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
onCancel();
|
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
|
<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="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<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"}`}
|
||||||
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}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,20 +302,44 @@ export function FileUpload() {
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{pesData && canUploadPattern(machineStatus) && !patternUploaded && uploadProgress < 100 && (
|
{pesData &&
|
||||||
|
canUploadPattern(machineStatus) &&
|
||||||
|
!patternUploaded &&
|
||||||
|
uploadProgress < 100 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={!isConnected || isUploading || !boundsCheck.fits}
|
disabled={!isConnected || isUploading || !boundsCheck.fits}
|
||||||
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"
|
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={isUploading ? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete` : boundsCheck.error || 'Upload pattern to machine'}
|
aria-label={
|
||||||
|
isUploading
|
||||||
|
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
|
||||||
|
: boundsCheck.error || "Upload pattern to machine"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<>
|
<>
|
||||||
<svg className="w-3.5 h-3.5 animate-spin inline mr-1" 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 inline mr-1"
|
||||||
<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>
|
||||||
{uploadProgress > 0 ? uploadProgress.toFixed(0) + '%' : 'Uploading'}
|
{uploadProgress > 0
|
||||||
|
? uploadProgress.toFixed(0) + "%"
|
||||||
|
: "Uploading"}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,7 +150,13 @@ interface StitchesProps {
|
||||||
showProgress?: boolean;
|
showProgress?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Stitches = memo(({ stitches, pesData, currentStitchIndex, showProgress = false }: StitchesProps) => {
|
export const Stitches = memo(
|
||||||
|
({
|
||||||
|
stitches,
|
||||||
|
pesData,
|
||||||
|
currentStitchIndex,
|
||||||
|
showProgress = false,
|
||||||
|
}: StitchesProps) => {
|
||||||
const stitchGroups = useMemo(() => {
|
const stitchGroups = useMemo(() => {
|
||||||
interface StitchGroup {
|
interface StitchGroup {
|
||||||
color: string;
|
color: string;
|
||||||
|
|
@ -211,21 +225,31 @@ export const Stitches = memo(({ stitches, pesData, currentStitchIndex, showProgr
|
||||||
lineCap="round"
|
lineCap="round"
|
||||||
lineJoin="round"
|
lineJoin="round"
|
||||||
dash={group.isJump ? [8, 4] : undefined}
|
dash={group.isJump ? [8, 4] : undefined}
|
||||||
opacity={group.isJump ? (group.completed ? 0.8 : 0.5) : (showProgress && !group.completed ? 0.3 : 1.0)}
|
opacity={
|
||||||
|
group.isJump
|
||||||
|
? group.completed
|
||||||
|
? 0.8
|
||||||
|
: 0.5
|
||||||
|
: showProgress && !group.completed
|
||||||
|
? 0.3
|
||||||
|
: 1.0
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Stitches.displayName = 'Stitches';
|
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(
|
||||||
|
({ currentStitchIndex, stitches }: CurrentPositionProps) => {
|
||||||
if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) {
|
if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -243,12 +267,29 @@ export const CurrentPosition = memo(({ currentStitchIndex, stitches }: CurrentPo
|
||||||
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}
|
||||||
|
/>
|
||||||
|
<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>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
CurrentPosition.displayName = 'CurrentPosition';
|
CurrentPosition.displayName = "CurrentPosition";
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
(e: Konva.KonvaEventObject<DragEvent>) => {
|
||||||
const newOffset = {
|
const newOffset = {
|
||||||
x: e.target.x(),
|
x: e.target.x(),
|
||||||
y: e.target.y(),
|
y: e.target.y(),
|
||||||
};
|
};
|
||||||
setLocalPatternOffset(newOffset);
|
setLocalPatternOffset(newOffset);
|
||||||
setPatternOffset(newOffset.x, newOffset.y);
|
setPatternOffset(newOffset.x, newOffset.y);
|
||||||
}, [setPatternOffset]);
|
},
|
||||||
|
[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}
|
||||||
|
|
@ -244,18 +289,18 @@ export function PatternCanvas() {
|
||||||
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";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -285,22 +330,27 @@ export function PatternCanvas() {
|
||||||
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) => {
|
onMouseLeave={(e) => {
|
||||||
const stage = e.target.getStage();
|
const stage = e.target.getStage();
|
||||||
if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'grab';
|
if (stage && !patternUploaded && !isUploading)
|
||||||
|
stage.container().style.cursor = "grab";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stitches
|
<Stitches
|
||||||
stitches={pesData.penStitches.stitches.map((s, i): [number, number, number, number] => {
|
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]
|
// Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
|
||||||
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
|
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
|
||||||
const colorIndex = pesData.penStitches.colorBlocks.find(
|
const colorIndex =
|
||||||
(b) => i >= b.startStitch && i <= b.endStitch
|
pesData.penStitches.colorBlocks.find(
|
||||||
|
(b) => i >= b.startStitch && i <= b.endStitch,
|
||||||
)?.colorIndex ?? 0;
|
)?.colorIndex ?? 0;
|
||||||
return [s.x, s.y, cmd, colorIndex];
|
return [s.x, s.y, cmd, colorIndex];
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
pesData={pesData}
|
pesData={pesData}
|
||||||
currentStitchIndex={sewingProgress?.currentStitch || 0}
|
currentStitchIndex={sewingProgress?.currentStitch || 0}
|
||||||
showProgress={patternUploaded || isUploading}
|
showProgress={patternUploaded || isUploading}
|
||||||
|
|
@ -312,17 +362,23 @@ export function PatternCanvas() {
|
||||||
|
|
||||||
{/* Current position layer */}
|
{/* Current position layer */}
|
||||||
<Layer>
|
<Layer>
|
||||||
{pesData && pesData.penStitches && sewingProgress && sewingProgress.currentStitch > 0 && (
|
{pesData &&
|
||||||
|
pesData.penStitches &&
|
||||||
|
sewingProgress &&
|
||||||
|
sewingProgress.currentStitch > 0 && (
|
||||||
<Group x={localPatternOffset.x} y={localPatternOffset.y}>
|
<Group x={localPatternOffset.x} y={localPatternOffset.y}>
|
||||||
<CurrentPosition
|
<CurrentPosition
|
||||||
currentStitchIndex={sewingProgress.currentStitch}
|
currentStitchIndex={sewingProgress.currentStitch}
|
||||||
stitches={pesData.penStitches.stitches.map((s, i): [number, number, number, number] => {
|
stitches={pesData.penStitches.stitches.map(
|
||||||
|
(s, i): [number, number, number, number] => {
|
||||||
const cmd = s.isJump ? 0x10 : 0;
|
const cmd = s.isJump ? 0x10 : 0;
|
||||||
const colorIndex = pesData.penStitches.colorBlocks.find(
|
const colorIndex =
|
||||||
(b) => i >= b.startStitch && i <= b.endStitch
|
pesData.penStitches.colorBlocks.find(
|
||||||
|
(b) => i >= b.startStitch && i <= b.endStitch,
|
||||||
)?.colorIndex ?? 0;
|
)?.colorIndex ?? 0;
|
||||||
return [s.x, s.y, cmd, colorIndex];
|
return [s.x, s.y, cmd, colorIndex];
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,33 @@
|
||||||
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()}
|
||||||
|
{pesData.penStitches &&
|
||||||
|
pesData.penStitches.stitches.length !== pesData.stitchCount && (
|
||||||
<span
|
<span
|
||||||
className="text-gray-500 dark:text-gray-500 font-normal ml-1"
|
className="text-gray-500 dark:text-gray-500 font-normal ml-1"
|
||||||
title="Input stitch count from PES file (lock stitches were added for machine compatibility)"
|
title="Input stitch count from PES file (lock stitches were added for machine compatibility)"
|
||||||
|
|
@ -32,44 +39,50 @@ export function PatternInfo({ pesData, showThreadBlocks = false }: PatternInfoPr
|
||||||
</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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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...
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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,12 +59,13 @@ 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 =
|
||||||
|
totalStitches > 0
|
||||||
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
|
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
@ -343,7 +348,10 @@ export function ProgressMonitor() {
|
||||||
<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 ||
|
||||||
|
block.threadChart ||
|
||||||
|
block.threadDescription ||
|
||||||
|
block.threadCatalogNumber) && (
|
||||||
<span className="font-normal text-gray-600 dark:text-gray-400">
|
<span className="font-normal text-gray-600 dark:text-gray-400">
|
||||||
{" "}
|
{" "}
|
||||||
(
|
(
|
||||||
|
|
@ -351,16 +359,24 @@ export function ProgressMonitor() {
|
||||||
// Primary metadata: brand and catalog number
|
// Primary metadata: brand and catalog number
|
||||||
const primaryMetadata = [
|
const primaryMetadata = [
|
||||||
block.threadBrand,
|
block.threadBrand,
|
||||||
block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null
|
block.threadCatalogNumber
|
||||||
].filter(Boolean).join(" ");
|
? `#${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>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.PAUSE ||
|
||||||
machineStatus === MachineStatus.STOP ||
|
machineStatus === MachineStatus.STOP ||
|
||||||
machineStatus === MachineStatus.SEWING_INTERRUPTION) {
|
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,72 +443,111 @@ 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
|
||||||
|
className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`}
|
||||||
|
>
|
||||||
|
Error Code: 0x
|
||||||
|
{content.errorCode
|
||||||
|
.toString(16)
|
||||||
|
.toUpperCase()
|
||||||
|
.padStart(2, "0")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
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)
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* 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)
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +79,7 @@ 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
|
||||||
|
|
@ -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,7 +220,7 @@ 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
|
||||||
|
|
@ -246,7 +244,7 @@ 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
|
||||||
|
|
@ -325,7 +323,7 @@ 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
|
||||||
|
|
@ -372,7 +370,7 @@ 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 = [
|
||||||
|
|
@ -443,7 +441,7 @@ 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],
|
||||||
|
|
@ -508,7 +506,7 @@ 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)
|
||||||
|
|
@ -525,7 +523,7 @@ 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
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* 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)
|
||||||
|
|
@ -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,7 +212,9 @@ 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;
|
||||||
|
|
@ -221,14 +226,16 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
|
||||||
12
src/main.tsx
12
src/main.tsx
|
|
@ -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>,
|
||||||
)
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
|
"dialog:saveFile",
|
||||||
|
{
|
||||||
defaultPath: defaultName,
|
defaultPath: defaultName,
|
||||||
filters: [
|
filters: [
|
||||||
{ name: 'PEN Files', extensions: ['pen'] },
|
{ name: "PEN Files", extensions: ["pen"] },
|
||||||
{ name: 'All Files', extensions: ['*'] },
|
{ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,13 +397,19 @@ 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.IDLE ||
|
||||||
category === MachineStateCategory.WAITING ||
|
category === MachineStateCategory.WAITING ||
|
||||||
category === MachineStateCategory.COMPLETE;
|
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.SEWING_WAIT ||
|
||||||
status === MachineStatus.MASK_TRACE_COMPLETE ||
|
status === MachineStatus.MASK_TRACE_COMPLETE ||
|
||||||
status === MachineStatus.PAUSE ||
|
status === MachineStatus.PAUSE ||
|
||||||
status === MachineStatus.STOP ||
|
status === MachineStatus.STOP ||
|
||||||
status === MachineStatus.SEWING_INTERRUPTION;
|
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.IDLE ||
|
||||||
status === MachineStatus.SEWING_WAIT ||
|
status === MachineStatus.SEWING_WAIT ||
|
||||||
status === MachineStatus.MASK_TRACE_COMPLETE;
|
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];
|
||||||
|
|
|
||||||
|
|
@ -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")}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue