Colors:
- {pesData.threads.slice(0, 8).map((thread, idx) => (
-
- ))}
- {pesData.colorCount > 8 && (
+ {pesData.uniqueColors.slice(0, 8).map((color, idx) => {
+ // Primary metadata: brand and catalog number
+ const primaryMetadata = [
+ color.brand,
+ color.catalogNumber ? `#${color.catalogNumber}` : null
+ ].filter(Boolean).join(" ");
+
+ // Secondary metadata: chart and description
+ const secondaryMetadata = [
+ color.chart,
+ color.description
+ ].filter(Boolean).join(" ");
+
+ const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
+
+ // Show which thread blocks use this color
+ const threadNumbers = color.threadIndices.map(i => i + 1).join(", ");
+ const tooltipText = metadata
+ ? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
+ : `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
+
+ return (
+
+ );
+ })}
+ {pesData.uniqueColors.length > 8 && (
- +{pesData.colorCount - 8}
+ +{pesData.uniqueColors.length - 8}
)}
diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx
index 215fa82..29ec2e8 100644
--- a/src/components/ProgressMonitor.tsx
+++ b/src/components/ProgressMonitor.tsx
@@ -1,316 +1,412 @@
-import {
- CheckCircleIcon,
- ArrowRightIcon,
- CircleStackIcon,
- PlayIcon,
- CheckBadgeIcon,
- ClockIcon,
- PauseCircleIcon,
- ExclamationCircleIcon,
- ChartBarIcon,
- ArrowPathIcon
-} from '@heroicons/react/24/solid';
-import type { PatternInfo, SewingProgress } from '../types/machine';
-import { MachineStatus } from '../types/machine';
-import type { PesPatternData } from '../utils/pystitchConverter';
-import {
- canStartSewing,
- canStartMaskTrace,
- canResumeSewing,
- getStateVisualInfo
-} from '../utils/machineStateHelpers';
-
-interface ProgressMonitorProps {
- machineStatus: MachineStatus;
- patternInfo: PatternInfo | null;
- sewingProgress: SewingProgress | null;
- pesData: PesPatternData | null;
- onStartMaskTrace: () => void;
- onStartSewing: () => void;
- onResumeSewing: () => void;
- onDeletePattern: () => void;
- isDeleting?: boolean;
-}
-
-export function ProgressMonitor({
- machineStatus,
- patternInfo,
- sewingProgress,
- pesData,
- onStartMaskTrace,
- onStartSewing,
- onResumeSewing,
- isDeleting = false,
-}: ProgressMonitorProps) {
- // State indicators
- const isMaskTraceComplete = machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
-
- const stateVisual = getStateVisualInfo(machineStatus);
-
- const progressPercent = patternInfo
- ? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
- : 0;
-
- // Calculate color block information from pesData
- const colorBlocks = pesData ? (() => {
- const blocks: Array<{
- colorIndex: number;
- threadHex: string;
- startStitch: number;
- endStitch: number;
- stitchCount: number;
- }> = [];
-
- let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
- let blockStartStitch = 0;
-
- for (let i = 0; i < pesData.stitches.length; i++) {
- const stitchColorIndex = pesData.stitches[i][3];
-
- // When color changes, save the previous block
- if (stitchColorIndex !== currentColorIndex || i === pesData.stitches.length - 1) {
- const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
- blocks.push({
- colorIndex: currentColorIndex,
- threadHex: pesData.threads[currentColorIndex]?.hex || '#000000',
- startStitch: blockStartStitch,
- endStitch: endStitch,
- stitchCount: endStitch - blockStartStitch,
- });
-
- currentColorIndex = stitchColorIndex;
- blockStartStitch = i;
- }
- }
-
- return blocks;
- })() : [];
-
- // Determine current color block based on current stitch
- const currentStitch = sewingProgress?.currentStitch || 0;
- const currentBlockIndex = colorBlocks.findIndex(
- block => currentStitch >= block.startStitch && currentStitch < block.endStitch
- );
-
- const stateIndicatorColors = {
- idle: 'bg-blue-50 dark:bg-blue-900/20 border-blue-600',
- info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-600',
- active: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
- waiting: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
- warning: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
- complete: 'bg-green-50 dark:bg-green-900/20 border-green-600',
- success: 'bg-green-50 dark:bg-green-900/20 border-green-600',
- interrupted: 'bg-red-50 dark:bg-red-900/20 border-red-600',
- error: 'bg-red-50 dark:bg-red-900/20 border-red-600',
- danger: 'bg-red-50 dark:bg-red-900/20 border-red-600',
- };
-
- return (
-
-
-
-
-
Sewing Progress
- {sewingProgress && (
-
- {progressPercent.toFixed(1)}% complete
-
- )}
-
-
-
- {/* Pattern Info */}
- {patternInfo && (
-
-
- Total Stitches
- {patternInfo.totalStitches.toLocaleString()}
-
-
- Est. Time
-
- {Math.floor(patternInfo.totalTime / 60)}:{String(patternInfo.totalTime % 60).padStart(2, '0')}
-
-
-
- Speed
- {patternInfo.speed} spm
-
-
- )}
-
- {/* Progress Bar */}
- {sewingProgress && (
-
-
-
-
-
- Current Stitch
-
- {sewingProgress.currentStitch.toLocaleString()} / {patternInfo?.totalStitches.toLocaleString() || 0}
-
-
-
- Time Elapsed
-
- {Math.floor(sewingProgress.currentTime / 60)}:{String(sewingProgress.currentTime % 60).padStart(2, '0')}
-
-
-
-
- )}
-
- {/* State Visual Indicator */}
- {patternInfo && (() => {
- const iconMap = {
- ready:
,
- active:
,
- waiting:
,
- complete:
,
- interrupted:
,
- error:
- };
-
- return (
-
-
- {iconMap[stateVisual.iconName]}
-
-
-
{stateVisual.label}
-
{stateVisual.description}
-
-
- );
- })()}
-
- {/* Color Blocks */}
- {colorBlocks.length > 0 && (
-
-
Color Blocks
-
- {colorBlocks.map((block, index) => {
- const isCompleted = currentStitch >= block.endStitch;
- const isCurrent = index === currentBlockIndex;
-
- // Calculate progress within current block
- let blockProgress = 0;
- if (isCurrent) {
- blockProgress = ((currentStitch - block.startStitch) / block.stitchCount) * 100;
- } else if (isCompleted) {
- blockProgress = 100;
- }
-
- return (
-
-
- {/* Color swatch */}
-
-
- {/* Thread info */}
-
-
- Thread {block.colorIndex + 1}
-
-
- {block.stitchCount.toLocaleString()} stitches
-
-
-
- {/* Status icon */}
- {isCompleted ? (
-
- ) : isCurrent ? (
-
- ) : (
-
- )}
-
-
- {/* Progress bar for current block */}
- {isCurrent && (
-
- )}
-
- );
- })}
-
-
- )}
-
- {/* Action buttons */}
-
- {/* Resume has highest priority when available */}
- {canResumeSewing(machineStatus) && (
-
- )}
-
- {/* Start Sewing - primary action, takes more space */}
- {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
-
- )}
-
- {/* Start Mask Trace - secondary action */}
- {canStartMaskTrace(machineStatus) && (
-
- )}
-
-
- );
-}
+import {
+ CheckCircleIcon,
+ ArrowRightIcon,
+ CircleStackIcon,
+ PlayIcon,
+ CheckBadgeIcon,
+ ClockIcon,
+ PauseCircleIcon,
+ ExclamationCircleIcon,
+ ChartBarIcon,
+ ArrowPathIcon,
+} from "@heroicons/react/24/solid";
+import type { PatternInfo, SewingProgress } from "../types/machine";
+import { MachineStatus } from "../types/machine";
+import type { PesPatternData } from "../utils/pystitchConverter";
+import {
+ canStartSewing,
+ canStartMaskTrace,
+ canResumeSewing,
+ getStateVisualInfo,
+} from "../utils/machineStateHelpers";
+
+interface ProgressMonitorProps {
+ machineStatus: MachineStatus;
+ patternInfo: PatternInfo | null;
+ sewingProgress: SewingProgress | null;
+ pesData: PesPatternData | null;
+ onStartMaskTrace: () => void;
+ onStartSewing: () => void;
+ onResumeSewing: () => void;
+ onDeletePattern: () => void;
+ isDeleting?: boolean;
+}
+
+export function ProgressMonitor({
+ machineStatus,
+ patternInfo,
+ sewingProgress,
+ pesData,
+ onStartMaskTrace,
+ onStartSewing,
+ onResumeSewing,
+ isDeleting = false,
+}: ProgressMonitorProps) {
+ // State indicators
+ const isMaskTraceComplete =
+ machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
+
+ const stateVisual = getStateVisualInfo(machineStatus);
+
+ const progressPercent = patternInfo
+ ? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
+ : 0;
+
+ // Calculate color block information from pesData
+ const colorBlocks = pesData
+ ? (() => {
+ const blocks: Array<{
+ colorIndex: number;
+ threadHex: string;
+ startStitch: number;
+ endStitch: number;
+ stitchCount: number;
+ threadCatalogNumber: string | null;
+ threadBrand: string | null;
+ threadDescription: string | null;
+ threadChart: string | null;
+ }> = [];
+
+ let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
+ let blockStartStitch = 0;
+
+ for (let i = 0; i < pesData.stitches.length; i++) {
+ const stitchColorIndex = pesData.stitches[i][3];
+
+ // When color changes, save the previous block
+ if (
+ stitchColorIndex !== currentColorIndex ||
+ i === pesData.stitches.length - 1
+ ) {
+ const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
+ const thread = pesData.threads[currentColorIndex];
+ blocks.push({
+ colorIndex: currentColorIndex,
+ threadHex: thread?.hex || "#000000",
+ threadCatalogNumber: thread?.catalogNumber ?? null,
+ threadBrand: thread?.brand ?? null,
+ threadDescription: thread?.description ?? null,
+ threadChart: thread?.chart ?? null,
+ startStitch: blockStartStitch,
+ endStitch: endStitch,
+ stitchCount: endStitch - blockStartStitch,
+ });
+
+ currentColorIndex = stitchColorIndex;
+ blockStartStitch = i;
+ }
+ }
+
+ return blocks;
+ })()
+ : [];
+
+ // Determine current color block based on current stitch
+ const currentStitch = sewingProgress?.currentStitch || 0;
+ const currentBlockIndex = colorBlocks.findIndex(
+ (block) =>
+ currentStitch >= block.startStitch && currentStitch < block.endStitch,
+ );
+
+ const stateIndicatorColors = {
+ idle: "bg-blue-50 dark:bg-blue-900/20 border-blue-600",
+ info: "bg-blue-50 dark:bg-blue-900/20 border-blue-600",
+ active: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
+ waiting: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
+ warning: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
+ complete: "bg-green-50 dark:bg-green-900/20 border-green-600",
+ success: "bg-green-50 dark:bg-green-900/20 border-green-600",
+ interrupted: "bg-red-50 dark:bg-red-900/20 border-red-600",
+ error: "bg-red-50 dark:bg-red-900/20 border-red-600",
+ danger: "bg-red-50 dark:bg-red-900/20 border-red-600",
+ };
+
+ return (
+
+
+
+
+
+ Sewing Progress
+
+ {sewingProgress && (
+
+ {progressPercent.toFixed(1)}% complete
+
+ )}
+
+
+
+ {/* Pattern Info */}
+ {patternInfo && (
+
+
+
+ Total Stitches
+
+
+ {patternInfo.totalStitches.toLocaleString()}
+
+
+
+
+ Est. Time
+
+
+ {Math.floor(patternInfo.totalTime / 60)}:
+ {String(patternInfo.totalTime % 60).padStart(2, "0")}
+
+
+
+
+ Speed
+
+
+ {patternInfo.speed} spm
+
+
+
+ )}
+
+ {/* Progress Bar */}
+ {sewingProgress && (
+
+
+
+
+
+
+ Current Stitch
+
+
+ {sewingProgress.currentStitch.toLocaleString()} /{" "}
+ {patternInfo?.totalStitches.toLocaleString() || 0}
+
+
+
+
+ Time Elapsed
+
+
+ {Math.floor(sewingProgress.currentTime / 60)}:
+ {String(sewingProgress.currentTime % 60).padStart(2, "0")}
+
+
+
+
+ )}
+
+ {/* State Visual Indicator */}
+ {patternInfo &&
+ (() => {
+ const iconMap = {
+ ready: (
+
+ ),
+ active: (
+
+ ),
+ waiting: (
+
+ ),
+ complete: (
+
+ ),
+ interrupted: (
+
+ ),
+ error: (
+
+ ),
+ };
+
+ return (
+
+
+ {iconMap[stateVisual.iconName]}
+
+
+
+ {stateVisual.label}
+
+
+ {stateVisual.description}
+
+
+
+ );
+ })()}
+
+ {/* Color Blocks */}
+ {colorBlocks.length > 0 && (
+
+
+ Color Blocks
+
+
+ {colorBlocks.map((block, index) => {
+ const isCompleted = currentStitch >= block.endStitch;
+ const isCurrent = index === currentBlockIndex;
+
+ // Calculate progress within current block
+ let blockProgress = 0;
+ if (isCurrent) {
+ blockProgress =
+ ((currentStitch - block.startStitch) / block.stitchCount) *
+ 100;
+ } else if (isCompleted) {
+ blockProgress = 100;
+ }
+
+ return (
+
+
+ {/* Color swatch */}
+
+
+ {/* Thread info */}
+
+
+ Thread {block.colorIndex + 1}
+ {(block.threadBrand || block.threadChart || block.threadDescription || block.threadCatalogNumber) && (
+
+ {" "}
+ (
+ {(() => {
+ // Primary metadata: brand and catalog number
+ const primaryMetadata = [
+ block.threadBrand,
+ block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null
+ ].filter(Boolean).join(" ");
+
+ // Secondary metadata: chart and description
+ const secondaryMetadata = [
+ block.threadChart,
+ block.threadDescription
+ ].filter(Boolean).join(" ");
+
+ return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
+ })()}
+ )
+
+ )}
+
+
+ {block.stitchCount.toLocaleString()} stitches
+
+
+
+ {/* Status icon */}
+ {isCompleted ? (
+
+ ) : isCurrent ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Progress bar for current block */}
+ {isCurrent && (
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Action buttons */}
+
+ {/* Resume has highest priority when available */}
+ {canResumeSewing(machineStatus) && (
+
+ )}
+
+ {/* Start Sewing - primary action, takes more space */}
+ {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
+
+ )}
+
+ {/* Start Mask Trace - secondary action */}
+ {canStartMaskTrace(machineStatus) && (
+
+ )}
+
+
+ );
+}
diff --git a/src/utils/pystitchConverter.ts b/src/utils/pystitchConverter.ts
index a5d8996..3b28fd1 100644
--- a/src/utils/pystitchConverter.ts
+++ b/src/utils/pystitchConverter.ts
@@ -1,266 +1,341 @@
-import { pyodideLoader } from './pyodideLoader';
-import {
- STITCH,
- MOVE,
- TRIM,
- END,
- PEN_FEED_DATA,
- PEN_CUT_DATA,
- PEN_COLOR_END,
- PEN_DATA_END,
-} from './embroideryConstants';
-
-// JavaScript constants module to expose to Python
-const jsEmbConstants = {
- STITCH,
- MOVE,
- TRIM,
- END,
-};
-
-export interface PesPatternData {
- stitches: number[][];
- threads: Array<{
- color: number;
- hex: string;
- }>;
- penData: Uint8Array;
- colorCount: number;
- stitchCount: number;
- bounds: {
- minX: number;
- maxX: number;
- minY: number;
- maxY: number;
- };
-}
-
-/**
- * Reads a PES file using PyStitch and converts it to PEN format
- */
-export async function convertPesToPen(file: File): Promise
{
- // Ensure Pyodide is initialized
- const pyodide = await pyodideLoader.initialize();
-
- // Register our JavaScript constants module for Python to import
- pyodide.registerJsModule('js_emb_constants', jsEmbConstants);
-
- // Read the PES file
- const buffer = await file.arrayBuffer();
- const uint8Array = new Uint8Array(buffer);
-
- // Write file to Pyodide virtual filesystem
- const filename = '/tmp/pattern.pes';
- pyodide.FS.writeFile(filename, uint8Array);
-
- // Read the pattern using PyStitch
- const result = await pyodide.runPythonAsync(`
-import pystitch
-from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE
-from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END
-
-# Read the PES file
-pattern = pystitch.read('${filename}')
-
-def map_cmd(pystitch_cmd):
- """Map PyStitch command to our JavaScript constant values
-
- This ensures we have known, consistent values regardless of PyStitch's internal values.
- Our JS constants use pyembroidery-style bitmask values:
- STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100
- """
- if pystitch_cmd == STITCH:
- return JS_STITCH
- elif pystitch_cmd == JUMP:
- return JS_MOVE # PyStitch JUMP maps to our MOVE constant
- elif pystitch_cmd == TRIM:
- return JS_TRIM
- elif pystitch_cmd == END:
- return JS_END
- else:
- # For any other commands, preserve as bitmask
- result = JS_STITCH
- if pystitch_cmd & JUMP:
- result |= JS_MOVE
- if pystitch_cmd & TRIM:
- result |= JS_TRIM
- if pystitch_cmd & END:
- result |= JS_END
- return result
-
-# Use the raw stitches list which preserves command flags
-# Each stitch in pattern.stitches is [x, y, cmd]
-# We need to assign color indices based on COLOR_CHANGE commands
-# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches)
-
-stitches_with_colors = []
-current_color = 0
-
-for i, stitch in enumerate(pattern.stitches):
- x, y, cmd = stitch
-
- # Check for color change command - increment color but don't add stitch
- if cmd == COLOR_CHANGE:
- current_color += 1
- continue
-
- # Check for stop command - skip it
- if cmd == STOP:
- continue
-
- # Check for standalone END command (no stitch data)
- if cmd == END:
- continue
-
- # Add actual stitch with color index and mapped command
- # Map PyStitch cmd values to our known JavaScript constant values
- mapped_cmd = map_cmd(cmd)
- stitches_with_colors.append([x, y, mapped_cmd, current_color])
-
-# Convert to JSON-serializable format
-{
- 'stitches': stitches_with_colors,
- 'threads': [
- {
- 'color': thread.color if hasattr(thread, 'color') else 0,
- 'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000'
- }
- for thread in pattern.threadlist
- ],
- 'thread_count': len(pattern.threadlist),
- 'stitch_count': len(stitches_with_colors),
- 'color_changes': current_color
-}
- `);
-
- // Convert Python result to JavaScript
- const data = result.toJs({ dict_converter: Object.fromEntries });
-
-
- // Clean up virtual file
- try {
- pyodide.FS.unlink(filename);
- } catch {
- // Ignore errors
- }
-
- // Extract stitches and validate
- const stitches: number[][] = Array.from(data.stitches as ArrayLike>).map((stitch) =>
- Array.from(stitch)
- );
-
- if (!stitches || stitches.length === 0) {
- throw new Error('Invalid PES file or no stitches found');
- }
-
- // Extract thread data
- const threads = (data.threads as Array<{ color?: number; hex?: string }>).map((thread) => ({
- color: thread.color || 0,
- hex: thread.hex || '#000000',
- }));
-
- // Track bounds
- let minX = Infinity;
- let maxX = -Infinity;
- let minY = Infinity;
- let maxY = -Infinity;
-
- // PyStitch returns ABSOLUTE coordinates
- // PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780)
- const penStitches: number[] = [];
-
- for (let i = 0; i < stitches.length; i++) {
- const stitch = stitches[i];
- const absX = Math.round(stitch[0]);
- const absY = Math.round(stitch[1]);
- const cmd = stitch[2];
- const stitchColor = stitch[3]; // Color index from PyStitch
-
- // Track bounds for non-jump stitches
- if (cmd === STITCH) {
- minX = Math.min(minX, absX);
- maxX = Math.max(maxX, absX);
- minY = Math.min(minY, absY);
- maxY = Math.max(maxY, absY);
- }
-
- // Encode absolute coordinates with flags in low 3 bits
- // Shift coordinates left by 3 bits to make room for flags
- // As per official app line 780: buffer[index64] = (byte) ((int) numArray4[index64 / 4, 0] << 3 & (int) byte.MaxValue);
- let xEncoded = (absX << 3) & 0xFFFF;
- let yEncoded = (absY << 3) & 0xFFFF;
-
- // Add command flags to Y-coordinate based on stitch type
- if (cmd & MOVE) {
- // MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching
- yEncoded |= PEN_FEED_DATA;
- }
- if (cmd & TRIM) {
- // TRIM: Set bit 1 (CUT_DATA) - cut thread command
- yEncoded |= PEN_CUT_DATA;
- }
-
- // Check if this is the last stitch
- const isLastStitch = i === stitches.length - 1 || (cmd & END) !== 0;
-
- // Check for color change by comparing stitch color index
- // Mark the LAST stitch of the previous color with PEN_COLOR_END
- // BUT: if this is the last stitch of the entire pattern, use DATA_END instead
- const nextStitch = stitches[i + 1];
- const nextStitchColor = nextStitch?.[3];
-
- if (!isLastStitch && nextStitchColor !== undefined && nextStitchColor !== stitchColor) {
- // This is the last stitch before a color change (but not the last stitch overall)
- xEncoded = (xEncoded & 0xFFF8) | PEN_COLOR_END;
- } else if (isLastStitch) {
- // This is the very last stitch of the pattern
- xEncoded = (xEncoded & 0xFFF8) | PEN_DATA_END;
- }
-
- // Add stitch as 4 bytes: [X_low, X_high, Y_low, Y_high]
- penStitches.push(
- xEncoded & 0xFF,
- (xEncoded >> 8) & 0xFF,
- yEncoded & 0xFF,
- (yEncoded >> 8) & 0xFF
- );
-
- // Check for end command
- if ((cmd & END) !== 0) {
- break;
- }
- }
-
- const penData = new Uint8Array(penStitches);
-
- return {
- stitches,
- threads,
- penData,
- colorCount: data.thread_count,
- stitchCount: data.stitch_count,
- bounds: {
- minX: minX === Infinity ? 0 : minX,
- maxX: maxX === -Infinity ? 0 : maxX,
- minY: minY === Infinity ? 0 : minY,
- maxY: maxY === -Infinity ? 0 : maxY,
- },
- };
-}
-
-/**
- * Get thread color from pattern data
- */
-export function getThreadColor(data: PesPatternData, colorIndex: number): string {
- if (!data.threads || colorIndex < 0 || colorIndex >= data.threads.length) {
- // Default colors if not specified or index out of bounds
- const defaultColors = [
- '#FF0000', '#00FF00', '#0000FF', '#FFFF00',
- '#FF00FF', '#00FFFF', '#FFA500', '#800080',
- ];
- const safeIndex = Math.max(0, colorIndex) % defaultColors.length;
- return defaultColors[safeIndex];
- }
-
- return data.threads[colorIndex]?.hex || '#000000';
-}
+import { pyodideLoader } from "./pyodideLoader";
+import {
+ STITCH,
+ MOVE,
+ TRIM,
+ END,
+ PEN_FEED_DATA,
+ PEN_CUT_DATA,
+ PEN_COLOR_END,
+ PEN_DATA_END,
+} from "./embroideryConstants";
+
+// JavaScript constants module to expose to Python
+const jsEmbConstants = {
+ STITCH,
+ MOVE,
+ TRIM,
+ END,
+};
+
+export interface PesPatternData {
+ stitches: number[][];
+ threads: Array<{
+ color: number;
+ hex: string;
+ brand: string | null;
+ catalogNumber: string | null;
+ description: string | null;
+ chart: string | null;
+ }>;
+ uniqueColors: Array<{
+ color: number;
+ hex: string;
+ brand: string | null;
+ catalogNumber: string | null;
+ description: string | null;
+ chart: string | null;
+ threadIndices: number[]; // Which thread entries use this color
+ }>;
+ penData: Uint8Array;
+ colorCount: number;
+ stitchCount: number;
+ bounds: {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+ };
+}
+
+/**
+ * Reads a PES file using PyStitch and converts it to PEN format
+ */
+export async function convertPesToPen(file: File): Promise {
+ // Ensure Pyodide is initialized
+ const pyodide = await pyodideLoader.initialize();
+
+ // Register our JavaScript constants module for Python to import
+ pyodide.registerJsModule("js_emb_constants", jsEmbConstants);
+
+ // Read the PES file
+ const buffer = await file.arrayBuffer();
+ const uint8Array = new Uint8Array(buffer);
+
+ // Write file to Pyodide virtual filesystem
+ const filename = "/tmp/pattern.pes";
+ pyodide.FS.writeFile(filename, uint8Array);
+
+ // Read the pattern using PyStitch
+ const result = await pyodide.runPythonAsync(`
+import pystitch
+from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE
+from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END
+
+# Read the PES file
+pattern = pystitch.read('${filename}')
+
+def map_cmd(pystitch_cmd):
+ """Map PyStitch command to our JavaScript constant values
+
+ This ensures we have known, consistent values regardless of PyStitch's internal values.
+ Our JS constants use pyembroidery-style bitmask values:
+ STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100
+ """
+ if pystitch_cmd == STITCH:
+ return JS_STITCH
+ elif pystitch_cmd == JUMP:
+ return JS_MOVE # PyStitch JUMP maps to our MOVE constant
+ elif pystitch_cmd == TRIM:
+ return JS_TRIM
+ elif pystitch_cmd == END:
+ return JS_END
+ else:
+ # For any other commands, preserve as bitmask
+ result = JS_STITCH
+ if pystitch_cmd & JUMP:
+ result |= JS_MOVE
+ if pystitch_cmd & TRIM:
+ result |= JS_TRIM
+ if pystitch_cmd & END:
+ result |= JS_END
+ return result
+
+# Use the raw stitches list which preserves command flags
+# Each stitch in pattern.stitches is [x, y, cmd]
+# We need to assign color indices based on COLOR_CHANGE commands
+# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches)
+
+stitches_with_colors = []
+current_color = 0
+
+for i, stitch in enumerate(pattern.stitches):
+ x, y, cmd = stitch
+
+ # Check for color change command - increment color but don't add stitch
+ if cmd == COLOR_CHANGE:
+ current_color += 1
+ continue
+
+ # Check for stop command - skip it
+ if cmd == STOP:
+ continue
+
+ # Check for standalone END command (no stitch data)
+ if cmd == END:
+ continue
+
+ # Add actual stitch with color index and mapped command
+ # Map PyStitch cmd values to our known JavaScript constant values
+ mapped_cmd = map_cmd(cmd)
+ stitches_with_colors.append([x, y, mapped_cmd, current_color])
+
+# Convert to JSON-serializable format
+{
+ 'stitches': stitches_with_colors,
+ 'threads': [
+ {
+ 'color': thread.color if hasattr(thread, 'color') else 0,
+ 'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000',
+ 'catalog_number': thread.catalog_number if hasattr(thread, 'catalog_number') else -1,
+ 'brand': thread.brand if hasattr(thread, 'brand') else "",
+ 'description': thread.description if hasattr(thread, 'description') else "",
+ 'chart': thread.chart if hasattr(thread, 'chart') else ""
+ }
+ for thread in pattern.threadlist
+ ],
+ 'thread_count': len(pattern.threadlist),
+ 'stitch_count': len(stitches_with_colors),
+ 'color_changes': current_color
+}
+ `);
+
+ // Convert Python result to JavaScript
+ const data = result.toJs({ dict_converter: Object.fromEntries });
+
+ // Clean up virtual file
+ try {
+ pyodide.FS.unlink(filename);
+ } catch {
+ // Ignore errors
+ }
+
+ // Extract stitches and validate
+ const stitches: number[][] = Array.from(
+ data.stitches as ArrayLike>,
+ ).map((stitch) => Array.from(stitch));
+
+ if (!stitches || stitches.length === 0) {
+ throw new Error("Invalid PES file or no stitches found");
+ }
+
+ // Extract thread data - preserve null values for unavailable metadata
+ const threads = (
+ data.threads as Array<{
+ color?: number;
+ hex?: string;
+ catalog_number?: number | string;
+ brand?: string;
+ description?: string;
+ chart?: string;
+ }>
+ ).map((thread) => {
+ // Normalize catalog_number - can be string or number from PyStitch
+ const catalogNum = thread.catalog_number;
+ const normalizedCatalog =
+ catalogNum !== undefined &&
+ catalogNum !== null &&
+ catalogNum !== -1 &&
+ catalogNum !== "-1" &&
+ catalogNum !== ""
+ ? String(catalogNum)
+ : null;
+
+ return {
+ color: thread.color ?? 0,
+ hex: thread.hex || "#000000",
+ catalogNumber: normalizedCatalog,
+ brand: thread.brand && thread.brand !== "" ? thread.brand : null,
+ description: thread.description && thread.description !== "" ? thread.description : null,
+ chart: thread.chart && thread.chart !== "" ? thread.chart : null,
+ };
+ });
+
+ // Track bounds
+ let minX = Infinity;
+ let maxX = -Infinity;
+ let minY = Infinity;
+ let maxY = -Infinity;
+
+ // PyStitch returns ABSOLUTE coordinates
+ // PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780)
+ const penStitches: number[] = [];
+
+ for (let i = 0; i < stitches.length; i++) {
+ const stitch = stitches[i];
+ const absX = Math.round(stitch[0]);
+ const absY = Math.round(stitch[1]);
+ const cmd = stitch[2];
+ const stitchColor = stitch[3]; // Color index from PyStitch
+
+ // Track bounds for non-jump stitches
+ if (cmd === STITCH) {
+ minX = Math.min(minX, absX);
+ maxX = Math.max(maxX, absX);
+ minY = Math.min(minY, absY);
+ maxY = Math.max(maxY, absY);
+ }
+
+ // Encode absolute coordinates with flags in low 3 bits
+ // Shift coordinates left by 3 bits to make room for flags
+ // As per official app line 780: buffer[index64] = (byte) ((int) numArray4[index64 / 4, 0] << 3 & (int) byte.MaxValue);
+ let xEncoded = (absX << 3) & 0xffff;
+ let yEncoded = (absY << 3) & 0xffff;
+
+ // Add command flags to Y-coordinate based on stitch type
+ if (cmd & MOVE) {
+ // MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching
+ yEncoded |= PEN_FEED_DATA;
+ }
+ if (cmd & TRIM) {
+ // TRIM: Set bit 1 (CUT_DATA) - cut thread command
+ yEncoded |= PEN_CUT_DATA;
+ }
+
+ // Check if this is the last stitch
+ const isLastStitch = i === stitches.length - 1 || (cmd & END) !== 0;
+
+ // Check for color change by comparing stitch color index
+ // Mark the LAST stitch of the previous color with PEN_COLOR_END
+ // BUT: if this is the last stitch of the entire pattern, use DATA_END instead
+ const nextStitch = stitches[i + 1];
+ const nextStitchColor = nextStitch?.[3];
+
+ if (
+ !isLastStitch &&
+ nextStitchColor !== undefined &&
+ nextStitchColor !== stitchColor
+ ) {
+ // This is the last stitch before a color change (but not the last stitch overall)
+ xEncoded = (xEncoded & 0xfff8) | PEN_COLOR_END;
+ } else if (isLastStitch) {
+ // This is the very last stitch of the pattern
+ xEncoded = (xEncoded & 0xfff8) | PEN_DATA_END;
+ }
+
+ // Add stitch as 4 bytes: [X_low, X_high, Y_low, Y_high]
+ penStitches.push(
+ xEncoded & 0xff,
+ (xEncoded >> 8) & 0xff,
+ yEncoded & 0xff,
+ (yEncoded >> 8) & 0xff,
+ );
+
+ // Check for end command
+ if ((cmd & END) !== 0) {
+ break;
+ }
+ }
+
+ const penData = new Uint8Array(penStitches);
+
+ // Calculate unique colors from threads (threads represent color blocks, not unique colors)
+ const uniqueColors = threads.reduce((acc, thread, idx) => {
+ const existing = acc.find(c => c.hex === thread.hex);
+ if (existing) {
+ existing.threadIndices.push(idx);
+ } else {
+ acc.push({
+ color: thread.color,
+ hex: thread.hex,
+ brand: thread.brand,
+ catalogNumber: thread.catalogNumber,
+ description: thread.description,
+ chart: thread.chart,
+ threadIndices: [idx],
+ });
+ }
+ return acc;
+ }, [] as PesPatternData['uniqueColors']);
+
+ return {
+ stitches,
+ threads,
+ uniqueColors,
+ penData,
+ colorCount: data.thread_count,
+ stitchCount: data.stitch_count,
+ bounds: {
+ minX: minX === Infinity ? 0 : minX,
+ maxX: maxX === -Infinity ? 0 : maxX,
+ minY: minY === Infinity ? 0 : minY,
+ maxY: maxY === -Infinity ? 0 : maxY,
+ },
+ };
+}
+
+/**
+ * Get thread color from pattern data
+ */
+export function getThreadColor(
+ data: PesPatternData,
+ colorIndex: number,
+): string {
+ if (!data.threads || colorIndex < 0 || colorIndex >= data.threads.length) {
+ // Default colors if not specified or index out of bounds
+ const defaultColors = [
+ "#FF0000",
+ "#00FF00",
+ "#0000FF",
+ "#FFFF00",
+ "#FF00FF",
+ "#00FFFF",
+ "#FFA500",
+ "#800080",
+ ];
+ const safeIndex = Math.max(0, colorIndex) % defaultColors.length;
+ return defaultColors[safeIndex];
+ }
+
+ return data.threads[colorIndex]?.hex || "#000000";
+}