Add thread metadata display and unique color handling

- 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 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-12-10 14:10:27 +01:00
parent 501a7e8538
commit eadbecc401
5 changed files with 865 additions and 615 deletions

View file

@ -126,7 +126,7 @@ export function FileUpload({
{!isLoading && pesData && ( {!isLoading && pesData && (
<div className="mb-3"> <div className="mb-3">
<div className="grid grid-cols-2 gap-2 text-xs mb-2"> <div className="grid grid-cols-3 gap-2 text-xs mb-2">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-50 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">
@ -140,22 +140,48 @@ export function FileUpload({
{pesData.stitchCount.toLocaleString()} {pesData.stitchCount.toLocaleString()}
</span> </span>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Colors / Blocks</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.uniqueColors.length} / {pesData.threads.length}
</span>
</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.threads.slice(0, 8).map((thread, idx) => ( {pesData.uniqueColors.slice(0, 8).map((color, idx) => {
<div // Primary metadata: brand and catalog number
key={idx} const primaryMetadata = [
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600" color.brand,
style={{ backgroundColor: thread.hex }} color.catalogNumber ? `#${color.catalogNumber}` : null
title={`Thread ${idx + 1}: ${thread.hex}`} ].filter(Boolean).join(" ");
/>
))} // Secondary metadata: chart and description
{pesData.colorCount > 8 && ( 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 (
<div
key={idx}
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: color.hex }}
title={tooltipText}
/>
);
})}
{pesData.uniqueColors.length > 8 && (
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[7px] font-bold text-gray-600 dark:text-gray-300"> <div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[7px] font-bold text-gray-600 dark:text-gray-300">
+{pesData.colorCount - 8} +{pesData.uniqueColors.length - 8}
</div> </div>
)} )}
</div> </div>

View file

@ -296,17 +296,42 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
{pesData && ( {pesData && (
<> <>
{/* Thread Legend Overlay */} {/* Thread Legend Overlay */}
<div className="absolute top-2.5 left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2.5 rounded-lg shadow-lg z-10 max-w-[150px]"> <div className="absolute top-2.5 left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2.5 rounded-lg shadow-lg z-10 max-w-[200px]">
<h4 className="m-0 mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1.5">Threads</h4> <h4 className="m-0 mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1.5">Colors</h4>
{pesData.threads.map((thread, index) => ( {pesData.uniqueColors.map((color, idx) => {
<div key={index} className="flex items-center gap-2 mb-1.5 last:mb-0"> // Primary metadata: brand and catalog number
<div const primaryMetadata = [
className="w-4 h-4 rounded border border-black dark:border-gray-300 flex-shrink-0" color.brand,
style={{ backgroundColor: thread.hex }} color.catalogNumber ? `#${color.catalogNumber}` : null
/> ].filter(Boolean).join(" ");
<span className="text-[11px] text-gray-900 dark:text-gray-100">Thread {index + 1}</span>
</div> // Secondary metadata: chart and description
))} const secondaryMetadata = [
color.chart,
color.description
].filter(Boolean).join(" ");
return (
<div key={idx} className="flex items-start gap-2 mb-1.5 last:mb-0">
<div
className="w-4 h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
style={{ backgroundColor: color.hex }}
/>
<div className="flex-1 min-w-0">
<div className="text-[11px] font-semibold text-gray-900 dark:text-gray-100">
Color {idx + 1}
</div>
{(primaryMetadata || secondaryMetadata) && (
<div className="text-[9px] text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
{primaryMetadata}
{primaryMetadata && secondaryMetadata && <span className="mx-1"></span>}
{secondaryMetadata}
</div>
)}
</div>
</div>
);
})}
</div> </div>
{/* Pattern Offset Indicator */} {/* Pattern Offset Indicator */}

View file

@ -28,7 +28,7 @@ export function PatternSummaryCard({
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 text-xs mb-3"> <div className="grid grid-cols-3 gap-2 text-xs mb-3">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-50 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">
@ -42,22 +42,50 @@ export function PatternSummaryCard({
{pesData.stitchCount.toLocaleString()} {pesData.stitchCount.toLocaleString()}
</span> </span>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Colors</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.uniqueColors.length}
</span>
</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.threads.slice(0, 8).map((thread, idx) => ( {pesData.uniqueColors.slice(0, 8).map((color, idx) => {
<div // Primary metadata: brand and catalog number
key={idx} const primaryMetadata = [
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600" color.brand,
style={{ backgroundColor: thread.hex }} color.catalogNumber ? `#${color.catalogNumber}` : null
title={`Thread ${idx + 1}: ${thread.hex}`} ].filter(Boolean).join(" ");
/>
))} // Secondary metadata: chart and description
{pesData.colorCount > 8 && ( 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 (
<div
key={idx}
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: color.hex }}
title={tooltipText}
/>
);
})}
{pesData.uniqueColors.length > 8 && (
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[7px] font-bold text-gray-600 dark:text-gray-300"> <div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[7px] font-bold text-gray-600 dark:text-gray-300">
+{pesData.colorCount - 8} +{pesData.uniqueColors.length - 8}
</div> </div>
)} )}
</div> </div>

View file

@ -1,316 +1,412 @@
import { import {
CheckCircleIcon, CheckCircleIcon,
ArrowRightIcon, ArrowRightIcon,
CircleStackIcon, CircleStackIcon,
PlayIcon, PlayIcon,
CheckBadgeIcon, CheckBadgeIcon,
ClockIcon, ClockIcon,
PauseCircleIcon, PauseCircleIcon,
ExclamationCircleIcon, ExclamationCircleIcon,
ChartBarIcon, ChartBarIcon,
ArrowPathIcon ArrowPathIcon,
} from '@heroicons/react/24/solid'; } from "@heroicons/react/24/solid";
import type { PatternInfo, SewingProgress } from '../types/machine'; import type { PatternInfo, SewingProgress } from "../types/machine";
import { MachineStatus } from '../types/machine'; import { MachineStatus } from "../types/machine";
import type { PesPatternData } from '../utils/pystitchConverter'; import type { PesPatternData } from "../utils/pystitchConverter";
import { import {
canStartSewing, canStartSewing,
canStartMaskTrace, canStartMaskTrace,
canResumeSewing, canResumeSewing,
getStateVisualInfo getStateVisualInfo,
} from '../utils/machineStateHelpers'; } from "../utils/machineStateHelpers";
interface ProgressMonitorProps { interface ProgressMonitorProps {
machineStatus: MachineStatus; machineStatus: MachineStatus;
patternInfo: PatternInfo | null; patternInfo: PatternInfo | null;
sewingProgress: SewingProgress | null; sewingProgress: SewingProgress | null;
pesData: PesPatternData | null; pesData: PesPatternData | null;
onStartMaskTrace: () => void; onStartMaskTrace: () => void;
onStartSewing: () => void; onStartSewing: () => void;
onResumeSewing: () => void; onResumeSewing: () => void;
onDeletePattern: () => void; onDeletePattern: () => void;
isDeleting?: boolean; isDeleting?: boolean;
} }
export function ProgressMonitor({ export function ProgressMonitor({
machineStatus, machineStatus,
patternInfo, patternInfo,
sewingProgress, sewingProgress,
pesData, pesData,
onStartMaskTrace, onStartMaskTrace,
onStartSewing, onStartSewing,
onResumeSewing, onResumeSewing,
isDeleting = false, isDeleting = false,
}: ProgressMonitorProps) { }: ProgressMonitorProps) {
// State indicators // State indicators
const isMaskTraceComplete = machineStatus === MachineStatus.MASK_TRACE_COMPLETE; const isMaskTraceComplete =
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
const stateVisual = getStateVisualInfo(machineStatus);
const stateVisual = getStateVisualInfo(machineStatus);
const progressPercent = patternInfo
? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100 const progressPercent = patternInfo
: 0; ? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
: 0;
// Calculate color block information from pesData
const colorBlocks = pesData ? (() => { // Calculate color block information from pesData
const blocks: Array<{ const colorBlocks = pesData
colorIndex: number; ? (() => {
threadHex: string; const blocks: Array<{
startStitch: number; colorIndex: number;
endStitch: number; threadHex: string;
stitchCount: number; startStitch: number;
}> = []; endStitch: number;
stitchCount: number;
let currentColorIndex = pesData.stitches[0]?.[3] ?? 0; threadCatalogNumber: string | null;
let blockStartStitch = 0; threadBrand: string | null;
threadDescription: string | null;
for (let i = 0; i < pesData.stitches.length; i++) { threadChart: string | null;
const stitchColorIndex = pesData.stitches[i][3]; }> = [];
// When color changes, save the previous block let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
if (stitchColorIndex !== currentColorIndex || i === pesData.stitches.length - 1) { let blockStartStitch = 0;
const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
blocks.push({ for (let i = 0; i < pesData.stitches.length; i++) {
colorIndex: currentColorIndex, const stitchColorIndex = pesData.stitches[i][3];
threadHex: pesData.threads[currentColorIndex]?.hex || '#000000',
startStitch: blockStartStitch, // When color changes, save the previous block
endStitch: endStitch, if (
stitchCount: endStitch - blockStartStitch, stitchColorIndex !== currentColorIndex ||
}); i === pesData.stitches.length - 1
) {
currentColorIndex = stitchColorIndex; const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
blockStartStitch = i; const thread = pesData.threads[currentColorIndex];
} blocks.push({
} colorIndex: currentColorIndex,
threadHex: thread?.hex || "#000000",
return blocks; threadCatalogNumber: thread?.catalogNumber ?? null,
})() : []; threadBrand: thread?.brand ?? null,
threadDescription: thread?.description ?? null,
// Determine current color block based on current stitch threadChart: thread?.chart ?? null,
const currentStitch = sewingProgress?.currentStitch || 0; startStitch: blockStartStitch,
const currentBlockIndex = colorBlocks.findIndex( endStitch: endStitch,
block => currentStitch >= block.startStitch && currentStitch < block.endStitch stitchCount: endStitch - blockStartStitch,
); });
const stateIndicatorColors = { currentColorIndex = stitchColorIndex;
idle: 'bg-blue-50 dark:bg-blue-900/20 border-blue-600', blockStartStitch = i;
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', return blocks;
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', // Determine current color block based on current stitch
danger: 'bg-red-50 dark:bg-red-900/20 border-red-600', const currentStitch = sewingProgress?.currentStitch || 0;
}; const currentBlockIndex = colorBlocks.findIndex(
(block) =>
return ( currentStitch >= block.startStitch && currentStitch < block.endStitch,
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-purple-600 dark:border-purple-500"> );
<div className="flex items-start gap-3 mb-3">
<ChartBarIcon className="w-6 h-6 text-purple-600 dark:text-purple-400 flex-shrink-0 mt-0.5" /> const stateIndicatorColors = {
<div className="flex-1 min-w-0"> idle: "bg-blue-50 dark:bg-blue-900/20 border-blue-600",
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Sewing Progress</h3> info: "bg-blue-50 dark:bg-blue-900/20 border-blue-600",
{sewingProgress && ( active: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
<p className="text-xs text-gray-600 dark:text-gray-400"> waiting: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
{progressPercent.toFixed(1)}% complete warning: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
</p> complete: "bg-green-50 dark:bg-green-900/20 border-green-600",
)} success: "bg-green-50 dark:bg-green-900/20 border-green-600",
</div> interrupted: "bg-red-50 dark:bg-red-900/20 border-red-600",
</div> error: "bg-red-50 dark:bg-red-900/20 border-red-600",
danger: "bg-red-50 dark:bg-red-900/20 border-red-600",
{/* Pattern Info */} };
{patternInfo && (
<div className="grid grid-cols-3 gap-2 text-xs mb-3"> return (
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-purple-600 dark:border-purple-500">
<span className="text-gray-600 dark:text-gray-400 block">Total Stitches</span> <div className="flex items-start gap-3 mb-3">
<span className="font-semibold text-gray-900 dark:text-gray-100">{patternInfo.totalStitches.toLocaleString()}</span> <ChartBarIcon className="w-6 h-6 text-purple-600 dark:text-purple-400 flex-shrink-0 mt-0.5" />
</div> <div className="flex-1 min-w-0">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded"> <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
<span className="text-gray-600 dark:text-gray-400 block">Est. Time</span> Sewing Progress
<span className="font-semibold text-gray-900 dark:text-gray-100"> </h3>
{Math.floor(patternInfo.totalTime / 60)}:{String(patternInfo.totalTime % 60).padStart(2, '0')} {sewingProgress && (
</span> <p className="text-xs text-gray-600 dark:text-gray-400">
</div> {progressPercent.toFixed(1)}% complete
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded"> </p>
<span className="text-gray-600 dark:text-gray-400 block">Speed</span> )}
<span className="font-semibold text-gray-900 dark:text-gray-100">{patternInfo.speed} spm</span> </div>
</div> </div>
</div>
)} {/* Pattern Info */}
{patternInfo && (
{/* Progress Bar */} <div className="grid grid-cols-3 gap-2 text-xs mb-3">
{sewingProgress && ( <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<div className="mb-3"> <span className="text-gray-600 dark:text-gray-400 block">
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2"> Total Stitches
<div className="h-full bg-gradient-to-r from-purple-600 to-purple-700 dark:from-purple-600 dark:to-purple-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]" style={{ width: `${progressPercent}%` }} /> </span>
</div> <span className="font-semibold text-gray-900 dark:text-gray-100">
{patternInfo.totalStitches.toLocaleString()}
<div className="grid grid-cols-2 gap-2 text-xs mb-3"> </span>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded"> </div>
<span className="text-gray-600 dark:text-gray-400 block">Current Stitch</span> <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="text-gray-600 dark:text-gray-400 block">
{sewingProgress.currentStitch.toLocaleString()} / {patternInfo?.totalStitches.toLocaleString() || 0} Est. Time
</span> </span>
</div> <span className="font-semibold text-gray-900 dark:text-gray-100">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded"> {Math.floor(patternInfo.totalTime / 60)}:
<span className="text-gray-600 dark:text-gray-400 block">Time Elapsed</span> {String(patternInfo.totalTime % 60).padStart(2, "0")}
<span className="font-semibold text-gray-900 dark:text-gray-100"> </span>
{Math.floor(sewingProgress.currentTime / 60)}:{String(sewingProgress.currentTime % 60).padStart(2, '0')} </div>
</span> <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
</div> <span className="text-gray-600 dark:text-gray-400 block">
</div> Speed
</div> </span>
)} <span className="font-semibold text-gray-900 dark:text-gray-100">
{patternInfo.speed} spm
{/* State Visual Indicator */} </span>
{patternInfo && (() => { </div>
const iconMap = { </div>
ready: <ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />, )}
active: <PlayIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />,
waiting: <PauseCircleIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />, {/* Progress Bar */}
complete: <CheckBadgeIcon className="w-5 h-5 text-green-600 dark:text-green-400" />, {sewingProgress && (
interrupted: <PauseCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />, <div className="mb-3">
error: <ExclamationCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" /> <div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2">
}; <div
className="h-full bg-gradient-to-r from-purple-600 to-purple-700 dark:from-purple-600 dark:to-purple-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]"
return ( style={{ width: `${progressPercent}%` }}
<div className={`flex items-center gap-3 p-2.5 rounded-lg mb-3 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}> />
<div className="flex-shrink-0"> </div>
{iconMap[stateVisual.iconName]}
</div> <div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="flex-1"> <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<div className="font-semibold text-xs dark:text-gray-100">{stateVisual.label}</div> <span className="text-gray-600 dark:text-gray-400 block">
<div className="text-[10px] text-gray-600 dark:text-gray-400">{stateVisual.description}</div> Current Stitch
</div> </span>
</div> <span className="font-semibold text-gray-900 dark:text-gray-100">
); {sewingProgress.currentStitch.toLocaleString()} /{" "}
})()} {patternInfo?.totalStitches.toLocaleString() || 0}
</span>
{/* Color Blocks */} </div>
{colorBlocks.length > 0 && ( <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<div className="mb-3"> <span className="text-gray-600 dark:text-gray-400 block">
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300">Color Blocks</h4> Time Elapsed
<div className="flex flex-col gap-2"> </span>
{colorBlocks.map((block, index) => { <span className="font-semibold text-gray-900 dark:text-gray-100">
const isCompleted = currentStitch >= block.endStitch; {Math.floor(sewingProgress.currentTime / 60)}:
const isCurrent = index === currentBlockIndex; {String(sewingProgress.currentTime % 60).padStart(2, "0")}
</span>
// Calculate progress within current block </div>
let blockProgress = 0; </div>
if (isCurrent) { </div>
blockProgress = ((currentStitch - block.startStitch) / block.stitchCount) * 100; )}
} else if (isCompleted) {
blockProgress = 100; {/* State Visual Indicator */}
} {patternInfo &&
(() => {
return ( const iconMap = {
<div ready: (
key={index} <ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${ ),
isCompleted active: (
? 'border-green-600 bg-green-50 dark:bg-green-900/20' <PlayIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
: isCurrent ),
? 'border-purple-600 bg-purple-50 dark:bg-purple-900/20 shadow-lg shadow-purple-600/20 animate-pulseGlow' waiting: (
: 'border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 opacity-70' <PauseCircleIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
}`} ),
role="listitem" complete: (
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? 'completed' : isCurrent ? 'in progress' : 'pending'}`} <CheckBadgeIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
> ),
<div className="flex items-center gap-2.5"> interrupted: (
{/* Color swatch */} <PauseCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
<div ),
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0" error: (
style={{ <ExclamationCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
backgroundColor: block.threadHex, ),
...(isCurrent && { borderColor: '#9333ea' }) };
}}
title={`Thread color: ${block.threadHex}`} return (
aria-label={`Thread color ${block.threadHex}`} <div
/> className={`flex items-center gap-3 p-2.5 rounded-lg mb-3 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}
>
{/* Thread info */} <div className="flex-shrink-0">
<div className="flex-1 min-w-0"> {iconMap[stateVisual.iconName]}
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100"> </div>
Thread {block.colorIndex + 1} <div className="flex-1">
</div> <div className="font-semibold text-xs dark:text-gray-100">
<div className="text-[10px] text-gray-600 dark:text-gray-400 mt-0.5"> {stateVisual.label}
{block.stitchCount.toLocaleString()} stitches </div>
</div> <div className="text-[10px] text-gray-600 dark:text-gray-400">
</div> {stateVisual.description}
</div>
{/* Status icon */} </div>
{isCompleted ? ( </div>
<CheckCircleIcon className="w-5 h-5 text-green-600 flex-shrink-0" aria-label="Completed" /> );
) : isCurrent ? ( })()}
<ArrowRightIcon className="w-5 h-5 text-purple-600 flex-shrink-0 animate-pulse" aria-label="In progress" />
) : ( {/* Color Blocks */}
<CircleStackIcon className="w-5 h-5 text-gray-400 flex-shrink-0" aria-label="Pending" /> {colorBlocks.length > 0 && (
)} <div className="mb-3">
</div> <h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300">
Color Blocks
{/* Progress bar for current block */} </h4>
{isCurrent && ( <div className="flex flex-col gap-2">
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"> {colorBlocks.map((block, index) => {
<div const isCompleted = currentStitch >= block.endStitch;
className="h-full bg-purple-600 dark:bg-purple-500 transition-all duration-300 rounded-full" const isCurrent = index === currentBlockIndex;
style={{ width: `${blockProgress}%` }}
role="progressbar" // Calculate progress within current block
aria-valuenow={Math.round(blockProgress)} let blockProgress = 0;
aria-valuemin={0} if (isCurrent) {
aria-valuemax={100} blockProgress =
aria-label={`${Math.round(blockProgress)}% complete`} ((currentStitch - block.startStitch) / block.stitchCount) *
/> 100;
</div> } else if (isCompleted) {
)} blockProgress = 100;
</div> }
);
})} return (
</div> <div
</div> key={index}
)} className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
isCompleted
{/* Action buttons */} ? "border-green-600 bg-green-50 dark:bg-green-900/20"
<div className="flex gap-2"> : isCurrent
{/* Resume has highest priority when available */} ? "border-purple-600 bg-purple-50 dark:bg-purple-900/20 shadow-lg shadow-purple-600/20 animate-pulseGlow"
{canResumeSewing(machineStatus) && ( : "border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 opacity-70"
<button }`}
onClick={onResumeSewing} role="listitem"
disabled={isDeleting} aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" >
aria-label="Resume sewing the current pattern" <div className="flex items-center gap-2.5">
> {/* Color swatch */}
<PlayIcon className="w-3.5 h-3.5" /> <div
Resume Sewing className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
</button> style={{
)} backgroundColor: block.threadHex,
...(isCurrent && { borderColor: "#9333ea" }),
{/* Start Sewing - primary action, takes more space */} }}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && ( title={`Thread color: ${block.threadHex}`}
<button aria-label={`Thread color ${block.threadHex}`}
onClick={onStartSewing} />
disabled={isDeleting}
className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" {/* Thread info */}
aria-label="Start sewing the pattern" <div className="flex-1 min-w-0">
> <div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
<PlayIcon className="w-3.5 h-3.5" /> Thread {block.colorIndex + 1}
Start Sewing {(block.threadBrand || block.threadChart || block.threadDescription || block.threadCatalogNumber) && (
</button> <span className="font-normal text-gray-600 dark:text-gray-400">
)} {" "}
(
{/* Start Mask Trace - secondary action */} {(() => {
{canStartMaskTrace(machineStatus) && ( // Primary metadata: brand and catalog number
<button const primaryMetadata = [
onClick={onStartMaskTrace} block.threadBrand,
disabled={isDeleting} block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" ].filter(Boolean).join(" ");
aria-label={isMaskTraceComplete ? 'Start mask trace again' : 'Start mask trace'}
> // Secondary metadata: chart and description
<ArrowPathIcon className="w-3.5 h-3.5" /> const secondaryMetadata = [
{isMaskTraceComplete ? 'Trace Again' : 'Start Mask Trace'} block.threadChart,
</button> block.threadDescription
)} ].filter(Boolean).join(" ");
</div>
</div> return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
); })()}
} )
</span>
)}
</div>
<div className="text-[10px] text-gray-600 dark:text-gray-400 mt-0.5">
{block.stitchCount.toLocaleString()} stitches
</div>
</div>
{/* Status icon */}
{isCompleted ? (
<CheckCircleIcon
className="w-5 h-5 text-green-600 flex-shrink-0"
aria-label="Completed"
/>
) : isCurrent ? (
<ArrowRightIcon
className="w-5 h-5 text-purple-600 flex-shrink-0 animate-pulse"
aria-label="In progress"
/>
) : (
<CircleStackIcon
className="w-5 h-5 text-gray-400 flex-shrink-0"
aria-label="Pending"
/>
)}
</div>
{/* Progress bar for current block */}
{isCurrent && (
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 dark:bg-purple-500 transition-all duration-300 rounded-full"
style={{ width: `${blockProgress}%` }}
role="progressbar"
aria-valuenow={Math.round(blockProgress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${Math.round(blockProgress)}% complete`}
/>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Action buttons */}
<div className="flex gap-2">
{/* Resume has highest priority when available */}
{canResumeSewing(machineStatus) && (
<button
onClick={onResumeSewing}
disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Resume sewing the current pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Resume Sewing
</button>
)}
{/* Start Sewing - primary action, takes more space */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<button
onClick={onStartSewing}
disabled={isDeleting}
className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Start sewing the pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Start Sewing
</button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<button
onClick={onStartMaskTrace}
disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={
isMaskTraceComplete
? "Start mask trace again"
: "Start mask trace"
}
>
<ArrowPathIcon className="w-3.5 h-3.5" />
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
</button>
)}
</div>
</div>
);
}

View file

@ -1,266 +1,341 @@
import { pyodideLoader } from './pyodideLoader'; import { pyodideLoader } from "./pyodideLoader";
import { import {
STITCH, STITCH,
MOVE, MOVE,
TRIM, TRIM,
END, END,
PEN_FEED_DATA, PEN_FEED_DATA,
PEN_CUT_DATA, PEN_CUT_DATA,
PEN_COLOR_END, PEN_COLOR_END,
PEN_DATA_END, PEN_DATA_END,
} from './embroideryConstants'; } from "./embroideryConstants";
// JavaScript constants module to expose to Python // JavaScript constants module to expose to Python
const jsEmbConstants = { const jsEmbConstants = {
STITCH, STITCH,
MOVE, MOVE,
TRIM, TRIM,
END, END,
}; };
export interface PesPatternData { export interface PesPatternData {
stitches: number[][]; stitches: number[][];
threads: Array<{ threads: Array<{
color: number; color: number;
hex: string; hex: string;
}>; brand: string | null;
penData: Uint8Array; catalogNumber: string | null;
colorCount: number; description: string | null;
stitchCount: number; chart: string | null;
bounds: { }>;
minX: number; uniqueColors: Array<{
maxX: number; color: number;
minY: number; hex: string;
maxY: number; brand: string | null;
}; catalogNumber: string | null;
} description: string | null;
chart: string | null;
/** threadIndices: number[]; // Which thread entries use this color
* Reads a PES file using PyStitch and converts it to PEN format }>;
*/ penData: Uint8Array;
export async function convertPesToPen(file: File): Promise<PesPatternData> { colorCount: number;
// Ensure Pyodide is initialized stitchCount: number;
const pyodide = await pyodideLoader.initialize(); bounds: {
minX: number;
// Register our JavaScript constants module for Python to import maxX: number;
pyodide.registerJsModule('js_emb_constants', jsEmbConstants); minY: number;
maxY: number;
// Read the PES file };
const buffer = await file.arrayBuffer(); }
const uint8Array = new Uint8Array(buffer);
/**
// Write file to Pyodide virtual filesystem * Reads a PES file using PyStitch and converts it to PEN format
const filename = '/tmp/pattern.pes'; */
pyodide.FS.writeFile(filename, uint8Array); export async function convertPesToPen(file: File): Promise<PesPatternData> {
// Ensure Pyodide is initialized
// Read the pattern using PyStitch const pyodide = await pyodideLoader.initialize();
const result = await pyodide.runPythonAsync(`
import pystitch // Register our JavaScript constants module for Python to import
from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE pyodide.registerJsModule("js_emb_constants", jsEmbConstants);
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
# Read the PES file const buffer = await file.arrayBuffer();
pattern = pystitch.read('${filename}') const uint8Array = new Uint8Array(buffer);
def map_cmd(pystitch_cmd): // Write file to Pyodide virtual filesystem
"""Map PyStitch command to our JavaScript constant values const filename = "/tmp/pattern.pes";
pyodide.FS.writeFile(filename, uint8Array);
This ensures we have known, consistent values regardless of PyStitch's internal values.
Our JS constants use pyembroidery-style bitmask values: // Read the pattern using PyStitch
STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100 const result = await pyodide.runPythonAsync(`
""" import pystitch
if pystitch_cmd == STITCH: from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE
return JS_STITCH from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END
elif pystitch_cmd == JUMP:
return JS_MOVE # PyStitch JUMP maps to our MOVE constant # Read the PES file
elif pystitch_cmd == TRIM: pattern = pystitch.read('${filename}')
return JS_TRIM
elif pystitch_cmd == END: def map_cmd(pystitch_cmd):
return JS_END """Map PyStitch command to our JavaScript constant values
else:
# For any other commands, preserve as bitmask This ensures we have known, consistent values regardless of PyStitch's internal values.
result = JS_STITCH Our JS constants use pyembroidery-style bitmask values:
if pystitch_cmd & JUMP: STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100
result |= JS_MOVE """
if pystitch_cmd & TRIM: if pystitch_cmd == STITCH:
result |= JS_TRIM return JS_STITCH
if pystitch_cmd & END: elif pystitch_cmd == JUMP:
result |= JS_END return JS_MOVE # PyStitch JUMP maps to our MOVE constant
return result elif pystitch_cmd == TRIM:
return JS_TRIM
# Use the raw stitches list which preserves command flags elif pystitch_cmd == END:
# Each stitch in pattern.stitches is [x, y, cmd] return JS_END
# We need to assign color indices based on COLOR_CHANGE commands else:
# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches) # For any other commands, preserve as bitmask
result = JS_STITCH
stitches_with_colors = [] if pystitch_cmd & JUMP:
current_color = 0 result |= JS_MOVE
if pystitch_cmd & TRIM:
for i, stitch in enumerate(pattern.stitches): result |= JS_TRIM
x, y, cmd = stitch if pystitch_cmd & END:
result |= JS_END
# Check for color change command - increment color but don't add stitch return result
if cmd == COLOR_CHANGE:
current_color += 1 # Use the raw stitches list which preserves command flags
continue # Each stitch in pattern.stitches is [x, y, cmd]
# We need to assign color indices based on COLOR_CHANGE commands
# Check for stop command - skip it # and filter out COLOR_CHANGE and STOP commands (they're not actual stitches)
if cmd == STOP:
continue stitches_with_colors = []
current_color = 0
# Check for standalone END command (no stitch data)
if cmd == END: for i, stitch in enumerate(pattern.stitches):
continue x, y, cmd = stitch
# Add actual stitch with color index and mapped command # Check for color change command - increment color but don't add stitch
# Map PyStitch cmd values to our known JavaScript constant values if cmd == COLOR_CHANGE:
mapped_cmd = map_cmd(cmd) current_color += 1
stitches_with_colors.append([x, y, mapped_cmd, current_color]) continue
# Convert to JSON-serializable format # Check for stop command - skip it
{ if cmd == STOP:
'stitches': stitches_with_colors, continue
'threads': [
{ # Check for standalone END command (no stitch data)
'color': thread.color if hasattr(thread, 'color') else 0, if cmd == END:
'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000' continue
}
for thread in pattern.threadlist # Add actual stitch with color index and mapped command
], # Map PyStitch cmd values to our known JavaScript constant values
'thread_count': len(pattern.threadlist), mapped_cmd = map_cmd(cmd)
'stitch_count': len(stitches_with_colors), stitches_with_colors.append([x, y, mapped_cmd, current_color])
'color_changes': current_color
} # Convert to JSON-serializable format
`); {
'stitches': stitches_with_colors,
// Convert Python result to JavaScript 'threads': [
const data = result.toJs({ dict_converter: Object.fromEntries }); {
'color': thread.color if hasattr(thread, 'color') else 0,
'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000',
// Clean up virtual file 'catalog_number': thread.catalog_number if hasattr(thread, 'catalog_number') else -1,
try { 'brand': thread.brand if hasattr(thread, 'brand') else "",
pyodide.FS.unlink(filename); 'description': thread.description if hasattr(thread, 'description') else "",
} catch { 'chart': thread.chart if hasattr(thread, 'chart') else ""
// Ignore errors }
} for thread in pattern.threadlist
],
// Extract stitches and validate 'thread_count': len(pattern.threadlist),
const stitches: number[][] = Array.from(data.stitches as ArrayLike<ArrayLike<number>>).map((stitch) => 'stitch_count': len(stitches_with_colors),
Array.from(stitch) 'color_changes': current_color
); }
`);
if (!stitches || stitches.length === 0) {
throw new Error('Invalid PES file or no stitches found'); // Convert Python result to JavaScript
} const data = result.toJs({ dict_converter: Object.fromEntries });
// Extract thread data // Clean up virtual file
const threads = (data.threads as Array<{ color?: number; hex?: string }>).map((thread) => ({ try {
color: thread.color || 0, pyodide.FS.unlink(filename);
hex: thread.hex || '#000000', } catch {
})); // Ignore errors
}
// Track bounds
let minX = Infinity; // Extract stitches and validate
let maxX = -Infinity; const stitches: number[][] = Array.from(
let minY = Infinity; data.stitches as ArrayLike<ArrayLike<number>>,
let maxY = -Infinity; ).map((stitch) => Array.from(stitch));
// PyStitch returns ABSOLUTE coordinates if (!stitches || stitches.length === 0) {
// PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780) throw new Error("Invalid PES file or no stitches found");
const penStitches: number[] = []; }
for (let i = 0; i < stitches.length; i++) { // Extract thread data - preserve null values for unavailable metadata
const stitch = stitches[i]; const threads = (
const absX = Math.round(stitch[0]); data.threads as Array<{
const absY = Math.round(stitch[1]); color?: number;
const cmd = stitch[2]; hex?: string;
const stitchColor = stitch[3]; // Color index from PyStitch catalog_number?: number | string;
brand?: string;
// Track bounds for non-jump stitches description?: string;
if (cmd === STITCH) { chart?: string;
minX = Math.min(minX, absX); }>
maxX = Math.max(maxX, absX); ).map((thread) => {
minY = Math.min(minY, absY); // Normalize catalog_number - can be string or number from PyStitch
maxY = Math.max(maxY, absY); const catalogNum = thread.catalog_number;
} const normalizedCatalog =
catalogNum !== undefined &&
// Encode absolute coordinates with flags in low 3 bits catalogNum !== null &&
// Shift coordinates left by 3 bits to make room for flags catalogNum !== -1 &&
// As per official app line 780: buffer[index64] = (byte) ((int) numArray4[index64 / 4, 0] << 3 & (int) byte.MaxValue); catalogNum !== "-1" &&
let xEncoded = (absX << 3) & 0xFFFF; catalogNum !== ""
let yEncoded = (absY << 3) & 0xFFFF; ? String(catalogNum)
: null;
// Add command flags to Y-coordinate based on stitch type
if (cmd & MOVE) { return {
// MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching color: thread.color ?? 0,
yEncoded |= PEN_FEED_DATA; hex: thread.hex || "#000000",
} catalogNumber: normalizedCatalog,
if (cmd & TRIM) { brand: thread.brand && thread.brand !== "" ? thread.brand : null,
// TRIM: Set bit 1 (CUT_DATA) - cut thread command description: thread.description && thread.description !== "" ? thread.description : null,
yEncoded |= PEN_CUT_DATA; chart: thread.chart && thread.chart !== "" ? thread.chart : null,
} };
});
// Check if this is the last stitch
const isLastStitch = i === stitches.length - 1 || (cmd & END) !== 0; // Track bounds
let minX = Infinity;
// Check for color change by comparing stitch color index let maxX = -Infinity;
// Mark the LAST stitch of the previous color with PEN_COLOR_END let minY = Infinity;
// BUT: if this is the last stitch of the entire pattern, use DATA_END instead let maxY = -Infinity;
const nextStitch = stitches[i + 1];
const nextStitchColor = nextStitch?.[3]; // PyStitch returns ABSOLUTE coordinates
// PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780)
if (!isLastStitch && nextStitchColor !== undefined && nextStitchColor !== stitchColor) { const penStitches: number[] = [];
// This is the last stitch before a color change (but not the last stitch overall)
xEncoded = (xEncoded & 0xFFF8) | PEN_COLOR_END; for (let i = 0; i < stitches.length; i++) {
} else if (isLastStitch) { const stitch = stitches[i];
// This is the very last stitch of the pattern const absX = Math.round(stitch[0]);
xEncoded = (xEncoded & 0xFFF8) | PEN_DATA_END; const absY = Math.round(stitch[1]);
} const cmd = stitch[2];
const stitchColor = stitch[3]; // Color index from PyStitch
// Add stitch as 4 bytes: [X_low, X_high, Y_low, Y_high]
penStitches.push( // Track bounds for non-jump stitches
xEncoded & 0xFF, if (cmd === STITCH) {
(xEncoded >> 8) & 0xFF, minX = Math.min(minX, absX);
yEncoded & 0xFF, maxX = Math.max(maxX, absX);
(yEncoded >> 8) & 0xFF minY = Math.min(minY, absY);
); maxY = Math.max(maxY, absY);
}
// Check for end command
if ((cmd & END) !== 0) { // Encode absolute coordinates with flags in low 3 bits
break; // 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;
const penData = new Uint8Array(penStitches);
// Add command flags to Y-coordinate based on stitch type
return { if (cmd & MOVE) {
stitches, // MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching
threads, yEncoded |= PEN_FEED_DATA;
penData, }
colorCount: data.thread_count, if (cmd & TRIM) {
stitchCount: data.stitch_count, // TRIM: Set bit 1 (CUT_DATA) - cut thread command
bounds: { yEncoded |= PEN_CUT_DATA;
minX: minX === Infinity ? 0 : minX, }
maxX: maxX === -Infinity ? 0 : maxX,
minY: minY === Infinity ? 0 : minY, // Check if this is the last stitch
maxY: maxY === -Infinity ? 0 : maxY, 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];
* Get thread color from pattern data const nextStitchColor = nextStitch?.[3];
*/
export function getThreadColor(data: PesPatternData, colorIndex: number): string { if (
if (!data.threads || colorIndex < 0 || colorIndex >= data.threads.length) { !isLastStitch &&
// Default colors if not specified or index out of bounds nextStitchColor !== undefined &&
const defaultColors = [ nextStitchColor !== stitchColor
'#FF0000', '#00FF00', '#0000FF', '#FFFF00', ) {
'#FF00FF', '#00FFFF', '#FFA500', '#800080', // This is the last stitch before a color change (but not the last stitch overall)
]; xEncoded = (xEncoded & 0xfff8) | PEN_COLOR_END;
const safeIndex = Math.max(0, colorIndex) % defaultColors.length; } else if (isLastStitch) {
return defaultColors[safeIndex]; // This is the very last stitch of the pattern
} xEncoded = (xEncoded & 0xfff8) | PEN_DATA_END;
}
return data.threads[colorIndex]?.hex || '#000000';
} // 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";
}