From eadbecc401d44bb6053aa02f848f3f314798dd56 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Wed, 10 Dec 2025 14:10:27 +0100 Subject: [PATCH] Add thread metadata display and unique color handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix PyStitch threadlist interpretation: threads = color blocks, not unique colors - Add uniqueColors array to PesPatternData with proper deduplication at data layer - Display thread metadata (brand, catalog number, chart, description) across all components - Show unique colors vs thread blocks (e.g., "5 / 12" colors/blocks) - Improve null value handling for missing thread metadata - Reorder metadata display: brand + catalog # • chart + description - Add metadata to pattern preview legend, tooltips, and color swatches 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/components/FileUpload.tsx | 48 +- src/components/PatternCanvas.tsx | 47 +- src/components/PatternSummaryCard.tsx | 50 +- src/components/ProgressMonitor.tsx | 728 +++++++++++++++----------- src/utils/pystitchConverter.ts | 607 +++++++++++---------- 5 files changed, 865 insertions(+), 615 deletions(-) diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index d438da1..e7b9635 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -126,7 +126,7 @@ export function FileUpload({ {!isLoading && pesData && (
-
+
Size @@ -140,22 +140,48 @@ export function FileUpload({ {pesData.stitchCount.toLocaleString()}
+
+ Colors / Blocks + + {pesData.uniqueColors.length} / {pesData.threads.length} + +
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(" • "); + + const tooltipText = metadata + ? `Color ${idx + 1}: ${color.hex} - ${metadata}` + : `Color ${idx + 1}: ${color.hex}`; + + return ( +
+ ); + })} + {pesData.uniqueColors.length > 8 && (
- +{pesData.colorCount - 8} + +{pesData.uniqueColors.length - 8}
)}
diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx index 91a559b..8782652 100644 --- a/src/components/PatternCanvas.tsx +++ b/src/components/PatternCanvas.tsx @@ -296,17 +296,42 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat {pesData && ( <> {/* Thread Legend Overlay */} -
-

Threads

- {pesData.threads.map((thread, index) => ( -
-
- Thread {index + 1} -
- ))} +
+

Colors

+ {pesData.uniqueColors.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(" "); + + return ( +
+
+
+
+ Color {idx + 1} +
+ {(primaryMetadata || secondaryMetadata) && ( +
+ {primaryMetadata} + {primaryMetadata && secondaryMetadata && } + {secondaryMetadata} +
+ )} +
+
+ ); + })}
{/* Pattern Offset Indicator */} diff --git a/src/components/PatternSummaryCard.tsx b/src/components/PatternSummaryCard.tsx index 40eca43..aa3084d 100644 --- a/src/components/PatternSummaryCard.tsx +++ b/src/components/PatternSummaryCard.tsx @@ -28,7 +28,7 @@ export function PatternSummaryCard({
-
+
Size @@ -42,22 +42,50 @@ export function PatternSummaryCard({ {pesData.stitchCount.toLocaleString()}
+
+ Colors + + {pesData.uniqueColors.length} + +
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"; +}