Compare commits

...

12 commits

Author SHA1 Message Date
Jan-Henrik Bruhn
7fd31d209c
Merge pull request #28 from jhbruhn/feature/pattern-rotation
Some checks failed
Build, Test, and Lint / Build, Test, and Lint (push) Has been cancelled
Draft Release / Draft Release (push) Has been cancelled
Draft Release / Build Web App (push) Has been cancelled
Draft Release / Build Release - macos-latest (push) Has been cancelled
Draft Release / Build Release - ubuntu-latest (push) Has been cancelled
Draft Release / Build Release - windows-latest (push) Has been cancelled
Draft Release / Upload to GitHub Release (push) Has been cancelled
Feature: pattern rotation
2025-12-25 21:55:07 +01:00
Jan-Henrik Bruhn
213be4670c
Merge pull request #27 from jhbruhn/fix/error-badge-auto-popup
feature: Add error badge with auto-opening popover for machine errors
2025-12-25 21:54:38 +01:00
786464c4c6 fix: TypeScript build errors in WorkflowStepper and errorCodeHelpers
- Remove unreachable error type check in WorkflowStepper icon selection
- Remove error code display block that accessed non-existent properties
- Add missing shortName property to fallback error info objects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 21:53:28 +01:00
e92c9f9616 fix: Remove debug logs and refactor with utility functions
- Removed all rotation/canvas debug console.log statements
- Added calculateBoundsFromDecodedStitches() utility to eliminate code duplication
- Used calculatePatternCenter() consistently across FileUpload and rotationUtils
- Cleaner code with single source of truth for calculations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 21:49:46 +01:00
271229c200 fix: Correct center pattern button calculation for Konva pivot points
The center pattern function was using the old calculation that didn't account for Konva's offsetX/offsetY pivot points. Since the pattern's center is now its pivot point, centering at the origin is simply {x: 0, y: 0}.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 21:49:33 +01:00
a97439da7b fix: Refactor KonvaComponents and remove unused rotation code
Moved KonvaComponents.tsx into PatternCanvas subfolder for better organization and removed the unused RotationHandle component (143 lines) that was replaced by Konva's native Transformer. Updated import paths accordingly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 21:37:34 +01:00
469c08d45b feature: Create unified PatternLayer component to eliminate duplication
Unified three separate layer implementations into single PatternLayer component:

**PatternLayer.tsx (156 lines)**
- Handles both interactive (original) and locked (uploaded) pattern rendering
- Props-driven configuration for dragging, rotation, transformer
- Includes CurrentPosition indicator for uploaded patterns
- Memoized stitches and center calculations for performance
- Single source of truth for pattern rendering logic

**Benefits:**
- Eliminated ~140 lines of duplicated layer code
- Reduced PatternCanvas from 388 to 271 lines (-117 lines, -30%)
- Consistent rendering behavior across pattern states
- Easier to maintain and test pattern rendering
- Cleaner component composition in PatternCanvas

**Removed from PatternCanvas:**
- Original pattern layer implementation (74 lines)
- Uploaded pattern layer implementation (33 lines)
- Current position layer implementation (26 lines)
- Duplicate Group, Transformer, Stitches setup
- Redundant center calculations and stitch conversions

**Complete refactoring impact (All Phases):**
- Original: 786 lines in single monolithic file
- After Phase 4: 271 lines main + hooks + components
- **Total reduction: -515 lines (-65%)**

File structure now:
- PatternCanvas.tsx: 271 lines (orchestration)
- PatternLayer.tsx: 156 lines (rendering)
- useCanvasViewport.ts: 166 lines (viewport)
- usePatternTransform.ts: 179 lines (transform)
- 3 display components + 3 utilities

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 21:29:24 +01:00
99d32f9029 feature: Extract canvas state management into custom React hooks
Create two specialized custom hooks for PatternCanvas:

**useCanvasViewport (166 lines)**
- Manages canvas zoom, pan, and container resize
- Handles wheel zoom and button zoom operations
- Tracks container size with ResizeObserver
- Calculates initial scale when pattern changes
- Returns: stagePos, stageScale, containerSize, zoom handlers

**usePatternTransform (179 lines)**
- Manages pattern position, rotation, and transform state
- Handles drag and transform end events
- Syncs local state with global pattern store
- Manages transformer attachment/detachment
- Returns: offsets, rotation, refs, event handlers

Benefits:
- Reduced PatternCanvas from 608 to 388 lines (-220 lines, -36%)
- Better separation of concerns (viewport vs pattern transform)
- Reusable hooks for other canvas components
- Easier to test state management logic in isolation
- Cleaner component with focused responsibility

Combined refactoring impact (Phase 1+2+3):
- Original: 786 lines in single file
- After Phase 3: 388 lines main + hooks + components
- Total reduction: -398 lines (-51%)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 21:24:46 +01:00
b008fd3aa8 feature: Extract PatternCanvas display components into reusable modules
Extract three complex inline components into separate files:
- ThreadLegend: Thread color display with metadata (52 lines extracted)
- PatternPositionIndicator: Position/rotation display with locked state (49 lines extracted)
- ZoomControls: Zoom and pan control buttons (41 lines extracted)

Benefits:
- Reduced PatternCanvas.tsx from 730 to 608 lines (-122 lines)
- Cleaner component separation and reusability
- Better testability for individual UI components
- Removed unused icon imports (PlusIcon, MinusIcon, etc.)
- Single responsibility per component

Total refactoring impact (Phase 1+2):
- Before: 786 lines in single file
- After: 608 lines main + 3 focused components
- Reduction: -178 lines of complex inline code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 21:19:20 +01:00
07275fa75a feature: Refactor PatternCanvas with utility functions and folder structure
Extract duplicate code into reusable utilities:
- calculatePatternCenter(): Eliminates 6x duplication of center calculations
- convertPenStitchesToPesFormat(): Eliminates 3x duplication of stitch conversion
- calculateZoomToPoint(): Eliminates 2x duplication of zoom math

Reorganize into subfolder:
- Created src/components/PatternCanvas/ directory
- Moved PatternCanvas.tsx into subfolder
- Added index.ts for clean imports
- All utilities in patternCanvasHelpers.ts

Benefits:
- Reduced ~50+ lines of duplicated code
- Single source of truth for common operations
- Easier to test and maintain
- Better code organization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 21:15:05 +01:00
d813c22df5 feature: Add pattern rotation with Konva Transformer
Implement comprehensive pattern rotation functionality:
- Use Konva Transformer for native rotation UI with visual handles
- Apply rotation transformation at upload time to stitch coordinates
- Two-layer preview system: original (draggable/rotatable) and uploaded (locked)
- Automatic position compensation for center shifts after rotation
- PEN encoding/decoding with proper bounds calculation from decoded stitches
- Comprehensive unit tests for rotation math and PEN round-trip
- Restore original unrotated pattern on delete

The rotation is applied by transforming stitch coordinates around the pattern's
geometric center, then re-encoding to PEN format. Position adjustments compensate
for center shifts caused by PEN encoder rounding to maintain visual alignment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 21:06:48 +01:00
dabfa3b35a feature: Add error badge with auto-opening popover for machine errors
- Replace error button with red error badge in AppHeader
- Add auto-open popover when any error occurs (machine, pairing, or Pyodide)
- Popover auto-closes when errors are cleared
- Respect user dismissals (won't reopen for same error)
- Remove error display from WorkflowStepper (single source of truth)
- Add shortName field to error definitions (max 15 chars)
- Add unit tests to validate error shortName length constraints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 16:48:47 +01:00
20 changed files with 1827 additions and 648 deletions

View file

@ -1,3 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from "../stores/useUIStore";
@ -7,6 +8,7 @@ import {
getStateVisualInfo,
getStatusIndicatorState,
} from "../utils/machineStateHelpers";
import { hasError, getErrorDetails } from "../utils/errorCodeHelpers";
import {
CheckCircleIcon,
BoltIcon,
@ -58,6 +60,15 @@ export function AppHeader() {
})),
);
// State management for error popover auto-open/close
const [errorPopoverOpen, setErrorPopoverOpen] = useState(false);
const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
null,
);
const prevMachineErrorRef = useRef<number | undefined>(undefined);
const prevErrorMessageRef = useRef<string | null>(null);
const prevPyodideErrorRef = useRef<string | null>(null);
// Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus);
const stateIcons = {
@ -75,6 +86,66 @@ export function AppHeader() {
? getStatusIndicatorState(machineStatus)
: "idle";
// Auto-open/close error popover based on error state changes
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
const currentError = machineError;
const prevError = prevMachineErrorRef.current;
const currentErrorMessage = machineErrorMessage;
const prevErrorMessage = prevErrorMessageRef.current;
const currentPyodideError = pyodideError;
const prevPyodideError = prevPyodideErrorRef.current;
// Check if there's any error now
const hasAnyError =
machineErrorMessage || pyodideError || hasError(currentError);
// Check if there was any error before
const hadAnyError =
prevErrorMessage || prevPyodideError || hasError(prevError);
// Auto-open popover when new error appears
const isNewMachineError =
hasError(currentError) &&
currentError !== prevError &&
currentError !== dismissedErrorCode;
const isNewErrorMessage =
currentErrorMessage && currentErrorMessage !== prevErrorMessage;
const isNewPyodideError =
currentPyodideError && currentPyodideError !== prevPyodideError;
if (isNewMachineError || isNewErrorMessage || isNewPyodideError) {
setErrorPopoverOpen(true);
}
// Auto-close popover when all errors are cleared
if (!hasAnyError && hadAnyError) {
setErrorPopoverOpen(false);
setDismissedErrorCode(null); // Reset dismissed tracking
}
// Update refs for next comparison
prevMachineErrorRef.current = currentError;
prevErrorMessageRef.current = currentErrorMessage;
prevPyodideErrorRef.current = currentPyodideError;
}, [machineError, machineErrorMessage, pyodideError, dismissedErrorCode]);
/* eslint-enable react-hooks/set-state-in-effect */
// Handle manual popover dismiss
const handlePopoverOpenChange = (open: boolean) => {
setErrorPopoverOpen(open);
// If user manually closes it, remember the current error state to prevent reopening
if (!open) {
// For machine errors, track the error code
if (hasError(machineError)) {
setDismissedErrorCode(machineError);
}
// Update refs so we don't reopen for the same error message/pyodide error
prevErrorMessageRef.current = machineErrorMessage;
prevPyodideErrorRef.current = pyodideError;
}
};
return (
<TooltipProvider>
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
@ -166,22 +237,22 @@ export function AppHeader() {
)}
{/* Error indicator - always render to prevent layout shift */}
<Popover>
<Popover
open={errorPopoverOpen}
onOpenChange={handlePopoverOpenChange}
>
<PopoverTrigger asChild>
<Button
size="sm"
variant="destructive"
<button
className={cn(
"gap-1.5 flex-shrink-0",
"inline-flex items-center rounded-full border border-transparent bg-destructive text-white px-2.5 py-1.5 text-xs font-semibold gap-1.5 cursor-pointer hover:bg-destructive/90 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2",
machineErrorMessage || pyodideError
? "animate-pulse hover:animate-none"
: "invisible pointer-events-none",
)}
aria-label="View error details"
disabled={!(machineErrorMessage || pyodideError)}
>
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span>
<span className="font-semibold">
{(() => {
if (pyodideError) return "Python Error";
if (isPairingError) return "Pairing Required";
@ -202,17 +273,19 @@ export function AppHeader() {
return "Pattern Error";
}
if (machineError !== undefined) {
return `Machine Error`;
// Get short name from error details
const errorDetails = getErrorDetails(machineError);
return errorDetails?.shortName || "Machine Error";
}
// Default fallback
return "Error";
})()}
</span>
</Button>
</button>
</PopoverTrigger>
{/* Error popover content */}
{/* Error popover content - unchanged */}
{(machineErrorMessage || pyodideError) && (
<ErrorPopoverContent
machineError={

View file

@ -11,6 +11,16 @@ import {
canUploadPattern,
getMachineStateCategory,
} from "../utils/machineStateHelpers";
import {
transformStitchesRotation,
calculateRotatedBounds,
} from "../utils/rotationUtils";
import { encodeStitchesToPen } from "../formats/pen/encoder";
import { decodePenData } from "../formats/pen/decoder";
import {
calculatePatternCenter,
calculateBoundsFromDecodedStitches,
} from "./PatternCanvas/patternCanvasHelpers";
import { PatternInfoSkeleton } from "./SkeletonLoader";
import { PatternInfo } from "./PatternInfo";
import {
@ -57,13 +67,17 @@ export function FileUpload() {
pesData: pesDataProp,
currentFileName,
patternOffset,
patternRotation,
setPattern,
setUploadedPattern,
} = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
currentFileName: state.currentFileName,
patternOffset: state.patternOffset,
patternRotation: state.patternRotation,
setPattern: state.setPattern,
setUploadedPattern: state.setUploadedPattern,
})),
);
@ -137,26 +151,115 @@ export function FileUpload() {
[fileService, setPattern, pyodideReady, initializePyodide],
);
const handleUpload = useCallback(() => {
const handleUpload = useCallback(async () => {
if (pesData && displayFileName) {
uploadPattern(pesData.penData, pesData, displayFileName, patternOffset);
}
}, [pesData, displayFileName, uploadPattern, patternOffset]);
let penDataToUpload = pesData.penData;
let pesDataForUpload = pesData;
// Check if pattern (with offset) fits within hoop bounds
// Apply rotation if needed
if (patternRotation && patternRotation !== 0) {
// Transform stitches
const rotatedStitches = transformStitchesRotation(
pesData.stitches,
patternRotation,
pesData.bounds,
);
// Encode to PEN (this will round coordinates)
const penResult = encodeStitchesToPen(rotatedStitches);
penDataToUpload = new Uint8Array(penResult.penBytes);
// Decode back to get the ACTUAL pattern (after PEN rounding)
const decoded = decodePenData(penDataToUpload);
// Calculate bounds from the DECODED stitches (the actual data that will be rendered)
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
// Calculate the center of the rotated pattern
const originalCenter = calculatePatternCenter(pesData.bounds);
const rotatedCenter = calculatePatternCenter(rotatedBounds);
const centerShiftX = rotatedCenter.x - originalCenter.x;
const centerShiftY = rotatedCenter.y - originalCenter.y;
// CRITICAL: Adjust position to compensate for the center shift!
// In Konva, visual position = (x - offsetX, y - offsetY).
// Original visual pos: (x - originalCenter.x, y - originalCenter.y)
// New visual pos: (newX - rotatedCenter.x, newY - rotatedCenter.y)
// For same visual position: newX = x + (rotatedCenter.x - originalCenter.x)
// So we need to add (rotatedCenter - originalCenter) to the position.
const adjustedOffset = {
x: patternOffset.x + centerShiftX,
y: patternOffset.y + centerShiftY,
};
// Create rotated PesPatternData for upload
pesDataForUpload = {
...pesData,
stitches: rotatedStitches,
penData: penDataToUpload,
penStitches: decoded,
bounds: rotatedBounds,
};
// Save uploaded pattern to store for preview BEFORE starting upload
// This allows the preview to show immediately when isUploading becomes true
setUploadedPattern(pesDataForUpload, adjustedOffset);
// Upload the pattern with offset
uploadPattern(
penDataToUpload,
pesDataForUpload,
displayFileName,
adjustedOffset,
);
return; // Early return to skip the upload below
}
// Save uploaded pattern to store BEFORE starting upload
// (same as original since no rotation)
setUploadedPattern(pesDataForUpload, patternOffset);
// Upload the pattern (no rotation case)
uploadPattern(
penDataToUpload,
pesDataForUpload,
displayFileName,
patternOffset,
);
}
}, [
pesData,
displayFileName,
uploadPattern,
patternOffset,
patternRotation,
setUploadedPattern,
]);
// Check if pattern (with offset and rotation) fits within hoop bounds
const checkPatternFitsInHoop = useCallback(() => {
if (!pesData || !machineInfo) {
return { fits: true, error: null };
}
const { bounds } = pesData;
// Calculate rotated bounds if rotation is applied
let bounds = pesData.bounds;
if (patternRotation && patternRotation !== 0) {
bounds = calculateRotatedBounds(pesData.bounds, patternRotation);
}
const { maxWidth, maxHeight } = machineInfo;
// Calculate pattern bounds with offset applied
const patternMinX = bounds.minX + patternOffset.x;
const patternMaxX = bounds.maxX + patternOffset.x;
const patternMinY = bounds.minY + patternOffset.y;
const patternMaxY = bounds.maxY + patternOffset.y;
// The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas)
// So we need to calculate bounds relative to the center
const center = calculatePatternCenter(bounds);
// Calculate actual bounds in world coordinates
const patternMinX = patternOffset.x - center.x + bounds.minX;
const patternMaxX = patternOffset.x - center.x + bounds.maxX;
const patternMinY = patternOffset.y - center.y + bounds.minY;
const patternMaxY = patternOffset.y - center.y + bounds.maxY;
// Hoop bounds (centered at origin)
const hoopMinX = -maxWidth / 2;
@ -196,7 +299,7 @@ export function FileUpload() {
}
return { fits: true, error: null };
}, [pesData, machineInfo, patternOffset]);
}, [pesData, machineInfo, patternOffset, patternRotation]);
const boundsCheck = checkPatternFitsInHoop();

View file

@ -1,542 +0,0 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
import { usePatternStore } from "../stores/usePatternStore";
import { Stage, Layer, Group } from "react-konva";
import Konva from "konva";
import {
PlusIcon,
MinusIcon,
ArrowPathIcon,
LockClosedIcon,
PhotoIcon,
ArrowsPointingInIcon,
} from "@heroicons/react/24/solid";
import type { PesPatternData } from "../formats/import/pesImporter";
import { calculateInitialScale } from "../utils/konvaRenderers";
import {
Grid,
Origin,
Hoop,
Stitches,
PatternBounds,
CurrentPosition,
} from "./KonvaComponents";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export function PatternCanvas() {
// Machine store
const { sewingProgress, machineInfo, isUploading } = useMachineStore(
useShallow((state) => ({
sewingProgress: state.sewingProgress,
machineInfo: state.machineInfo,
isUploading: state.isUploading,
})),
);
// Pattern store
const {
pesData,
patternOffset: initialPatternOffset,
setPatternOffset,
} = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
patternOffset: state.patternOffset,
setPatternOffset: state.setPatternOffset,
})),
);
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
const [stageScale, setStageScale] = useState(1);
const [localPatternOffset, setLocalPatternOffset] = useState(
initialPatternOffset || { x: 0, y: 0 },
);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const initialScaleRef = useRef<number>(1);
const prevPesDataRef = useRef<PesPatternData | null>(null);
// Update pattern offset when initialPatternOffset changes
if (
initialPatternOffset &&
(localPatternOffset.x !== initialPatternOffset.x ||
localPatternOffset.y !== initialPatternOffset.y)
) {
setLocalPatternOffset(initialPatternOffset);
console.log(
"[PatternCanvas] Restored pattern offset:",
initialPatternOffset,
);
}
// Track container size
useEffect(() => {
if (!containerRef.current) return;
const updateSize = () => {
if (containerRef.current) {
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
setContainerSize({ width, height });
}
};
// Initial size
updateSize();
// Watch for resize
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, []);
// Calculate and store initial scale when pattern or hoop changes
useEffect(() => {
if (!pesData || containerSize.width === 0) {
prevPesDataRef.current = null;
return;
}
// Only recalculate if pattern changed
if (prevPesDataRef.current !== pesData) {
prevPesDataRef.current = pesData;
const { bounds } = pesData;
const viewWidth = machineInfo
? machineInfo.maxWidth
: bounds.maxX - bounds.minX;
const viewHeight = machineInfo
? machineInfo.maxHeight
: bounds.maxY - bounds.minY;
const initialScale = calculateInitialScale(
containerSize.width,
containerSize.height,
viewWidth,
viewHeight,
);
initialScaleRef.current = initialScale;
// Reset view when pattern changes
// eslint-disable-next-line react-hooks/set-state-in-effect
setStageScale(initialScale);
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}
}, [pesData, machineInfo, containerSize]);
// Wheel zoom handler
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();
const stage = e.target.getStage();
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const scaleBy = 1.1;
const direction = e.evt.deltaY > 0 ? -1 : 1;
setStageScale((oldScale) => {
const newScale = Math.max(
0.1,
Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2),
);
// Zoom towards pointer
setStagePos((prevPos) => {
const mousePointTo = {
x: (pointer.x - prevPos.x) / oldScale,
y: (pointer.y - prevPos.y) / oldScale,
};
return {
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
};
});
return newScale;
});
}, []);
// Zoom control handlers
const handleZoomIn = useCallback(() => {
setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
// Zoom towards center of viewport
setStagePos((prevPos) => {
const centerX = containerSize.width / 2;
const centerY = containerSize.height / 2;
const mousePointTo = {
x: (centerX - prevPos.x) / oldScale,
y: (centerY - prevPos.y) / oldScale,
};
return {
x: centerX - mousePointTo.x * newScale,
y: centerY - mousePointTo.y * newScale,
};
});
return newScale;
});
}, [containerSize]);
const handleZoomOut = useCallback(() => {
setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
// Zoom towards center of viewport
setStagePos((prevPos) => {
const centerX = containerSize.width / 2;
const centerY = containerSize.height / 2;
const mousePointTo = {
x: (centerX - prevPos.x) / oldScale,
y: (centerY - prevPos.y) / oldScale,
};
return {
x: centerX - mousePointTo.x * newScale,
y: centerY - mousePointTo.y * newScale,
};
});
return newScale;
});
}, [containerSize]);
const handleZoomReset = useCallback(() => {
const initialScale = initialScaleRef.current;
setStageScale(initialScale);
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}, [containerSize]);
const handleCenterPattern = useCallback(() => {
if (!pesData) return;
const { bounds } = pesData;
const centerOffsetX = -(bounds.minX + bounds.maxX) / 2;
const centerOffsetY = -(bounds.minY + bounds.maxY) / 2;
setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY });
setPatternOffset(centerOffsetX, centerOffsetY);
}, [pesData, setPatternOffset]);
// Pattern drag handlers
const handlePatternDragEnd = useCallback(
(e: Konva.KonvaEventObject<DragEvent>) => {
const newOffset = {
x: e.target.x(),
y: e.target.y(),
};
setLocalPatternOffset(newOffset);
setPatternOffset(newOffset.x, newOffset.y);
},
[setPatternOffset],
);
const borderColor = pesData
? "border-tertiary-600 dark:border-tertiary-500"
: "border-gray-400 dark:border-gray-600";
const iconColor = pesData
? "text-tertiary-600 dark:text-tertiary-400"
: "text-gray-600 dark:text-gray-400";
return (
<Card
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
>
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Pattern Preview</CardTitle>
{pesData ? (
<CardDescription className="text-xs">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "}
×{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)}{" "}
mm
</CardDescription>
) : (
<CardDescription className="text-xs">
No pattern loaded
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col min-h-0">
<div
className="relative w-full flex-1 min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
ref={containerRef}
>
{containerSize.width > 0 && (
<Stage
width={containerSize.width}
height={containerSize.height}
x={stagePos.x}
y={stagePos.y}
scaleX={stageScale}
scaleY={stageScale}
draggable
onWheel={handleWheel}
onDragStart={() => {
if (stageRef.current) {
stageRef.current.container().style.cursor = "grabbing";
}
}}
onDragEnd={() => {
if (stageRef.current) {
stageRef.current.container().style.cursor = "grab";
}
}}
ref={(node) => {
stageRef.current = node;
if (node) {
node.container().style.cursor = "grab";
}
}}
>
{/* Background layer: grid, origin, hoop */}
<Layer>
{pesData && (
<>
<Grid
gridSize={100}
bounds={pesData.bounds}
machineInfo={machineInfo}
/>
<Origin />
{machineInfo && <Hoop machineInfo={machineInfo} />}
</>
)}
</Layer>
{/* Pattern layer: draggable stitches and bounds */}
<Layer>
{pesData && (
<Group
name="pattern-group"
draggable={!patternUploaded && !isUploading}
x={localPatternOffset.x}
y={localPatternOffset.y}
onDragEnd={handlePatternDragEnd}
onMouseEnter={(e) => {
const stage = e.target.getStage();
if (stage && !patternUploaded && !isUploading)
stage.container().style.cursor = "move";
}}
onMouseLeave={(e) => {
const stage = e.target.getStage();
if (stage && !patternUploaded && !isUploading)
stage.container().style.cursor = "grab";
}}
>
<Stitches
stitches={pesData.penStitches.stitches.map(
(s, i): [number, number, number, number] => {
// Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
const colorIndex =
pesData.penStitches.colorBlocks.find(
(b) => i >= b.startStitch && i <= b.endStitch,
)?.colorIndex ?? 0;
return [s.x, s.y, cmd, colorIndex];
},
)}
pesData={pesData}
currentStitchIndex={sewingProgress?.currentStitch || 0}
showProgress={patternUploaded || isUploading}
/>
<PatternBounds bounds={pesData.bounds} />
</Group>
)}
</Layer>
{/* Current position layer */}
<Layer>
{pesData &&
pesData.penStitches &&
sewingProgress &&
sewingProgress.currentStitch > 0 && (
<Group x={localPatternOffset.x} y={localPatternOffset.y}>
<CurrentPosition
currentStitchIndex={sewingProgress.currentStitch}
stitches={pesData.penStitches.stitches.map(
(s, i): [number, number, number, number] => {
const cmd = s.isJump ? 0x10 : 0;
const colorIndex =
pesData.penStitches.colorBlocks.find(
(b) => i >= b.startStitch && i <= b.endStitch,
)?.colorIndex ?? 0;
return [s.x, s.y, cmd, colorIndex];
},
)}
/>
</Group>
)}
</Layer>
</Stage>
)}
{/* Placeholder overlay when no pattern is loaded */}
{!pesData && (
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
Load a PES file to preview the pattern
</div>
)}
{/* Pattern info overlays */}
{pesData && (
<>
{/* Thread Legend Overlay */}
<div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
<h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
Colors
</h4>
{pesData.uniqueColors.map((color, idx) => {
// Primary metadata: brand and catalog number
const primaryMetadata = [
color.brand,
color.catalogNumber ? `#${color.catalogNumber}` : null,
]
.filter(Boolean)
.join(" ");
// Secondary metadata: chart and description
// Only show chart if it's different from catalogNumber
const secondaryMetadata = [
color.chart && color.chart !== color.catalogNumber
? color.chart
: null,
color.description,
]
.filter(Boolean)
.join(" ");
return (
<div
key={idx}
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
>
<div
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
style={{ backgroundColor: color.hex }}
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
Color {idx + 1}
</div>
{(primaryMetadata || secondaryMetadata) && (
<div className="text-xs 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>
{/* Pattern Offset Indicator */}
<div
className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
patternUploaded
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
: "bg-white/95 dark:bg-gray-800/95"
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Pattern Position:
</div>
{patternUploaded && (
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs font-bold">LOCKED</span>
</div>
)}
</div>
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
{(localPatternOffset.y / 10).toFixed(1)}mm
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
{patternUploaded
? "Pattern locked • Drag background to pan"
: "Drag pattern to move • Drag background to pan"}
</div>
</div>
{/* Zoom Controls Overlay */}
<div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleCenterPattern}
disabled={!pesData || patternUploaded || isUploading}
title="Center Pattern in Hoop"
>
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleZoomIn}
title="Zoom In"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
{Math.round(stageScale * 100)}%
</span>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleZoomOut}
title="Zoom Out"
>
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8 ml-1"
onClick={handleZoomReset}
title="Reset Zoom"
>
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
</div>
</>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -1,10 +1,10 @@
import { memo, useMemo } from "react";
import { Group, Line, Rect, Text, Circle } from "react-konva";
import type { PesPatternData } from "../formats/import/pesImporter";
import { getThreadColor } from "../formats/import/pesImporter";
import type { MachineInfo } from "../types/machine";
import { MOVE } from "../formats/import/constants";
import { canvasColors } from "../utils/cssVariables";
import type { PesPatternData } from "../../formats/import/pesImporter";
import { getThreadColor } from "../../formats/import/pesImporter";
import type { MachineInfo } from "../../types/machine";
import { MOVE } from "../../formats/import/constants";
import { canvasColors } from "../../utils/cssVariables";
interface GridProps {
gridSize: number;

View file

@ -0,0 +1,271 @@
import { useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import {
useMachineStore,
usePatternUploaded,
} from "../../stores/useMachineStore";
import { usePatternStore } from "../../stores/usePatternStore";
import { Stage, Layer } from "react-konva";
import Konva from "konva";
import { PhotoIcon } from "@heroicons/react/24/solid";
import { Grid, Origin, Hoop } from "./KonvaComponents";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { ThreadLegend } from "./ThreadLegend";
import { PatternPositionIndicator } from "./PatternPositionIndicator";
import { ZoomControls } from "./ZoomControls";
import { PatternLayer } from "./PatternLayer";
import { useCanvasViewport } from "../../hooks/useCanvasViewport";
import { usePatternTransform } from "../../hooks/usePatternTransform";
export function PatternCanvas() {
// Machine store
const { sewingProgress, machineInfo, isUploading } = useMachineStore(
useShallow((state) => ({
sewingProgress: state.sewingProgress,
machineInfo: state.machineInfo,
isUploading: state.isUploading,
})),
);
// Pattern store
const {
pesData,
patternOffset: initialPatternOffset,
patternRotation: initialPatternRotation,
uploadedPesData,
uploadedPatternOffset: initialUploadedPatternOffset,
setPatternOffset,
setPatternRotation,
} = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
patternOffset: state.patternOffset,
patternRotation: state.patternRotation,
uploadedPesData: state.uploadedPesData,
uploadedPatternOffset: state.uploadedPatternOffset,
setPatternOffset: state.setPatternOffset,
setPatternRotation: state.setPatternRotation,
})),
);
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
// Canvas viewport (zoom, pan, container size)
const {
stagePos,
stageScale,
containerSize,
handleWheel,
handleZoomIn,
handleZoomOut,
handleZoomReset,
} = useCanvasViewport({
containerRef,
pesData,
uploadedPesData,
machineInfo,
});
// Pattern transform (position, rotation, drag/transform)
const {
localPatternOffset,
localPatternRotation,
patternGroupRef,
transformerRef,
attachTransformer,
handleCenterPattern,
handlePatternDragEnd,
handleTransformEnd,
} = usePatternTransform({
pesData,
initialPatternOffset,
initialPatternRotation,
setPatternOffset,
setPatternRotation,
patternUploaded,
isUploading,
});
const hasPattern = pesData || uploadedPesData;
const borderColor = hasPattern
? "border-tertiary-600 dark:border-tertiary-500"
: "border-gray-400 dark:border-gray-600";
const iconColor = hasPattern
? "text-tertiary-600 dark:text-tertiary-400"
: "text-gray-600 dark:text-gray-400";
return (
<Card
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
>
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Pattern Preview</CardTitle>
{hasPattern ? (
<CardDescription className="text-xs">
{(() => {
const displayPattern = uploadedPesData || pesData;
return displayPattern ? (
<>
{(
(displayPattern.bounds.maxX -
displayPattern.bounds.minX) /
10
).toFixed(1)}{" "}
×{" "}
{(
(displayPattern.bounds.maxY -
displayPattern.bounds.minY) /
10
).toFixed(1)}{" "}
mm
</>
) : null;
})()}
</CardDescription>
) : (
<CardDescription className="text-xs">
No pattern loaded
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col min-h-0">
<div
className="relative w-full flex-1 min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
ref={containerRef}
>
{containerSize.width > 0 && (
<Stage
width={containerSize.width}
height={containerSize.height}
x={stagePos.x}
y={stagePos.y}
scaleX={stageScale}
scaleY={stageScale}
draggable
onWheel={handleWheel}
onDragStart={() => {
if (stageRef.current) {
stageRef.current.container().style.cursor = "grabbing";
}
}}
onDragEnd={() => {
if (stageRef.current) {
stageRef.current.container().style.cursor = "grab";
}
}}
ref={(node) => {
stageRef.current = node;
if (node) {
node.container().style.cursor = "grab";
}
}}
>
{/* Background layer: grid, origin, hoop */}
<Layer>
{hasPattern && (
<>
<Grid
gridSize={100}
bounds={(uploadedPesData || pesData)!.bounds}
machineInfo={machineInfo}
/>
<Origin />
{machineInfo && <Hoop machineInfo={machineInfo} />}
</>
)}
</Layer>
{/* Original pattern layer: draggable with transformer (shown before upload starts) */}
<Layer visible={!isUploading && !patternUploaded}>
{pesData && (
<PatternLayer
pesData={pesData}
offset={localPatternOffset}
rotation={localPatternRotation}
isInteractive={true}
showProgress={false}
currentStitchIndex={0}
patternGroupRef={patternGroupRef}
transformerRef={transformerRef}
onDragEnd={handlePatternDragEnd}
onTransformEnd={handleTransformEnd}
attachTransformer={attachTransformer}
/>
)}
</Layer>
{/* Uploaded pattern layer: locked, rotation baked in (shown during and after upload) */}
<Layer visible={isUploading || patternUploaded}>
{uploadedPesData && (
<PatternLayer
pesData={uploadedPesData}
offset={initialUploadedPatternOffset}
isInteractive={false}
showProgress={true}
currentStitchIndex={sewingProgress?.currentStitch || 0}
/>
)}
</Layer>
</Stage>
)}
{/* Placeholder overlay when no pattern is loaded */}
{!hasPattern && (
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
Load a PES file to preview the pattern
</div>
)}
{/* Pattern info overlays */}
{hasPattern &&
(() => {
const displayPattern = uploadedPesData || pesData;
return (
displayPattern && (
<>
<ThreadLegend colors={displayPattern.uniqueColors} />
<PatternPositionIndicator
offset={
isUploading || patternUploaded
? initialUploadedPatternOffset
: localPatternOffset
}
rotation={localPatternRotation}
isLocked={patternUploaded}
isUploading={isUploading}
/>
<ZoomControls
scale={stageScale}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onZoomReset={handleZoomReset}
onCenterPattern={handleCenterPattern}
canCenterPattern={
!!pesData && !patternUploaded && !isUploading
}
/>
</>
)
);
})()}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,146 @@
/**
* PatternLayer Component
*
* Unified component for rendering pattern layers (both original and uploaded)
* Handles both interactive (draggable/rotatable) and locked states
*/
import { useMemo, type RefObject } from "react";
import { Group, Transformer } from "react-konva";
import type Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node";
import type { PesPatternData } from "../../formats/import/pesImporter";
import {
calculatePatternCenter,
convertPenStitchesToPesFormat,
} from "./patternCanvasHelpers";
import { Stitches, PatternBounds, CurrentPosition } from "./KonvaComponents";
interface PatternLayerProps {
pesData: PesPatternData;
offset: { x: number; y: number };
rotation?: number;
isInteractive: boolean;
showProgress?: boolean;
currentStitchIndex?: number;
patternGroupRef?: RefObject<Konva.Group | null>;
transformerRef?: RefObject<Konva.Transformer | null>;
onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
onTransformEnd?: (e: KonvaEventObject<Event>) => void;
attachTransformer?: () => void;
}
export function PatternLayer({
pesData,
offset,
rotation = 0,
isInteractive,
showProgress = false,
currentStitchIndex = 0,
patternGroupRef,
transformerRef,
onDragEnd,
onTransformEnd,
attachTransformer,
}: PatternLayerProps) {
const center = useMemo(
() => calculatePatternCenter(pesData.bounds),
[pesData.bounds],
);
const stitches = useMemo(
() => convertPenStitchesToPesFormat(pesData.penStitches),
[pesData.penStitches],
);
const groupName = isInteractive ? "pattern-group" : "uploaded-pattern-group";
return (
<>
<Group
name={groupName}
ref={
isInteractive
? (node) => {
if (patternGroupRef) {
patternGroupRef.current = node;
}
// Set initial rotation from state
if (node && isInteractive) {
node.rotation(rotation);
// Try to attach transformer when group is mounted
if (attachTransformer) {
attachTransformer();
}
}
}
: undefined
}
draggable={isInteractive}
x={offset.x}
y={offset.y}
offsetX={center.x}
offsetY={center.y}
onDragEnd={isInteractive ? onDragEnd : undefined}
onTransformEnd={isInteractive ? onTransformEnd : undefined}
onMouseEnter={
isInteractive
? (e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = "move";
}
: undefined
}
onMouseLeave={
isInteractive
? (e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = "grab";
}
: undefined
}
>
<Stitches
stitches={stitches}
pesData={pesData}
currentStitchIndex={currentStitchIndex}
showProgress={showProgress}
/>
<PatternBounds bounds={pesData.bounds} />
</Group>
{/* Transformer only for interactive layer */}
{isInteractive && transformerRef && (
<Transformer
ref={(node) => {
if (transformerRef) {
transformerRef.current = node;
}
// Try to attach transformer when transformer is mounted
if (node && attachTransformer) {
attachTransformer();
}
}}
enabledAnchors={[]}
rotateEnabled={true}
borderEnabled={true}
borderStroke="#FF6B6B"
borderStrokeWidth={2}
rotationSnaps={[0, 45, 90, 135, 180, 225, 270, 315]}
ignoreStroke={true}
rotateAnchorOffset={20}
/>
)}
{/* Current position indicator (only for uploaded pattern with progress) */}
{!isInteractive && showProgress && currentStitchIndex > 0 && (
<Group x={offset.x} y={offset.y} offsetX={center.x} offsetY={center.y}>
<CurrentPosition
currentStitchIndex={currentStitchIndex}
stitches={stitches}
/>
</Group>
)}
</>
);
}

View file

@ -0,0 +1,61 @@
/**
* PatternPositionIndicator Component
*
* Displays the current pattern position and rotation
* Shows locked state when pattern is uploaded or being uploaded
*/
import { LockClosedIcon } from "@heroicons/react/24/solid";
interface PatternPositionIndicatorProps {
offset: { x: number; y: number };
rotation?: number;
isLocked: boolean;
isUploading: boolean;
}
export function PatternPositionIndicator({
offset,
rotation = 0,
isLocked,
isUploading,
}: PatternPositionIndicatorProps) {
return (
<div
className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
isUploading || isLocked
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
: "bg-white/95 dark:bg-gray-800/95"
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Pattern Position:
</div>
{(isUploading || isLocked) && (
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs font-bold">
{isUploading ? "UPLOADING" : "LOCKED"}
</span>
</div>
)}
</div>
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
X: {(offset.x / 10).toFixed(1)}mm, Y: {(offset.y / 10).toFixed(1)}mm
</div>
{!isUploading && !isLocked && rotation !== 0 && (
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
Rotation: {rotation.toFixed(1)}°
</div>
)}
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
{isUploading
? "Uploading pattern..."
: isLocked
? "Pattern locked • Drag background to pan"
: "Drag pattern to move • Drag background to pan"}
</div>
</div>
);
}

View file

@ -0,0 +1,74 @@
/**
* ThreadLegend Component
*
* Displays a legend of thread colors used in the embroidery pattern
* Shows color swatches with brand, catalog number, and description metadata
*/
interface ThreadColor {
hex: string;
brand?: string | null;
catalogNumber?: string | null;
chart?: string | null;
description?: string | null;
}
interface ThreadLegendProps {
colors: ThreadColor[];
}
export function ThreadLegend({ colors }: ThreadLegendProps) {
return (
<div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
<h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
Colors
</h4>
{colors.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
// Only show chart if it's different from catalogNumber
const secondaryMetadata = [
color.chart && color.chart !== color.catalogNumber
? color.chart
: null,
color.description,
]
.filter(Boolean)
.join(" ");
return (
<div
key={idx}
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
>
<div
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
style={{ backgroundColor: color.hex }}
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
Color {idx + 1}
</div>
{(primaryMetadata || secondaryMetadata) && (
<div className="text-xs 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>
);
}

View file

@ -0,0 +1,77 @@
/**
* ZoomControls Component
*
* Provides zoom and pan controls for the pattern canvas
* Includes zoom in/out, reset zoom, and center pattern buttons
*/
import {
PlusIcon,
MinusIcon,
ArrowPathIcon,
ArrowsPointingInIcon,
} from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
interface ZoomControlsProps {
scale: number;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomReset: () => void;
onCenterPattern: () => void;
canCenterPattern: boolean;
}
export function ZoomControls({
scale,
onZoomIn,
onZoomOut,
onZoomReset,
onCenterPattern,
canCenterPattern,
}: ZoomControlsProps) {
return (
<div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={onCenterPattern}
disabled={!canCenterPattern}
title="Center Pattern in Hoop"
>
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={onZoomIn}
title="Zoom In"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
{Math.round(scale * 100)}%
</span>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={onZoomOut}
title="Zoom Out"
>
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8 ml-1"
onClick={onZoomReset}
title="Reset Zoom"
>
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
</div>
);
}

View file

@ -0,0 +1 @@
export { PatternCanvas } from "./PatternCanvas";

View file

@ -0,0 +1,83 @@
/**
* Utility functions for PatternCanvas operations
*/
import type { DecodedPenData } from "../../formats/pen/types";
/**
* Calculate the geometric center of a pattern's bounds
*/
export function calculatePatternCenter(bounds: {
minX: number;
maxX: number;
minY: number;
maxY: number;
}): { x: number; y: number } {
return {
x: (bounds.minX + bounds.maxX) / 2,
y: (bounds.minY + bounds.maxY) / 2,
};
}
/**
* Convert PEN stitch format to PES stitch format
* PEN: {x, y, flags, isJump}
* PES: [x, y, cmd, colorIndex]
*/
export function convertPenStitchesToPesFormat(
penStitches: DecodedPenData,
): [number, number, number, number][] {
return penStitches.stitches.map((s, i) => {
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
const colorIndex =
penStitches.colorBlocks.find(
(b) => i >= b.startStitch && i <= b.endStitch,
)?.colorIndex ?? 0;
return [s.x, s.y, cmd, colorIndex];
});
}
/**
* Calculate axis-aligned bounding box from decoded PEN stitches
*/
export function calculateBoundsFromDecodedStitches(decoded: DecodedPenData): {
minX: number;
maxX: number;
minY: number;
maxY: number;
} {
let minX = Infinity,
maxX = -Infinity;
let minY = Infinity,
maxY = -Infinity;
for (const stitch of decoded.stitches) {
if (stitch.x < minX) minX = stitch.x;
if (stitch.x > maxX) maxX = stitch.x;
if (stitch.y < minY) minY = stitch.y;
if (stitch.y > maxY) maxY = stitch.y;
}
return { minX, maxX, minY, maxY };
}
/**
* Calculate new stage position for zooming towards a specific point
* Used for both wheel zoom and button zoom operations
*/
export function calculateZoomToPoint(
oldScale: number,
newScale: number,
targetPoint: { x: number; y: number },
currentPos: { x: number; y: number },
): { x: number; y: number } {
const mousePointTo = {
x: (targetPoint.x - currentPos.x) / oldScale,
y: (targetPoint.y - currentPos.y) / oldScale,
};
return {
x: targetPoint.x - mousePointTo.x * newScale,
y: targetPoint.y - mousePointTo.y * newScale,
};
}

View file

@ -8,7 +8,6 @@ import {
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../types/machine";
import { getErrorDetails, hasError } from "../utils/errorCodeHelpers";
interface Step {
id: number;
@ -28,38 +27,7 @@ const steps: Step[] = [
];
// Helper function to get guide content for a step
function getGuideContent(
stepId: number,
machineStatus: MachineStatus,
hasError: boolean,
errorCode?: number,
errorMessage?: string,
) {
// Check for errors first
if (hasError) {
const errorDetails = getErrorDetails(errorCode);
if (errorDetails?.isInformational) {
return {
type: "info" as const,
title: errorDetails.title,
description: errorDetails.description,
items: errorDetails.solutions || [],
};
}
return {
type: "error" as const,
title: errorDetails?.title || "Error Occurred",
description:
errorDetails?.description ||
errorMessage ||
"An error occurred. Please check the machine and try again.",
items: errorDetails?.solutions || [],
errorCode,
};
}
function getGuideContent(stepId: number, machineStatus: MachineStatus) {
// Return content based on step
switch (stepId) {
case 1:
@ -273,17 +241,10 @@ function getCurrentStep(
export function WorkflowStepper() {
// Machine store
const {
machineStatus,
isConnected,
machineError,
error: errorMessage,
} = useMachineStore(
const { machineStatus, isConnected } = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
isConnected: state.isConnected,
machineError: state.machineError,
error: state.error,
})),
);
@ -297,7 +258,6 @@ export function WorkflowStepper() {
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const hasPattern = pesData !== null;
const hasErrorFlag = hasError(machineError);
const currentStep = getCurrentStep(
machineStatus,
isConnected,
@ -443,13 +403,7 @@ export function WorkflowStepper() {
aria-label="Step guidance"
>
{(() => {
const content = getGuideContent(
popoverStep,
machineStatus,
hasErrorFlag,
machineError,
errorMessage || undefined,
);
const content = getGuideContent(popoverStep, machineStatus);
if (!content) return null;
const colorClasses = {
@ -497,7 +451,7 @@ export function WorkflowStepper() {
};
const Icon =
content.type === "error"
content.type === "warning"
? ExclamationTriangleIcon
: InformationCircleIcon;
@ -538,18 +492,6 @@ export function WorkflowStepper() {
))}
</ul>
)}
{content.type === "error" &&
content.errorCode !== undefined && (
<p
className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`}
>
Error Code: 0x
{content.errorCode
.toString(16)
.toUpperCase()
.padStart(2, "0")}
</p>
)}
</div>
</div>
</div>

View file

@ -0,0 +1,179 @@
/**
* useCanvasViewport Hook
*
* Manages canvas viewport state including zoom, pan, and container size
* Handles wheel zoom and button zoom operations
*/
import {
useState,
useEffect,
useCallback,
useRef,
type RefObject,
} from "react";
import type Konva from "konva";
import type { PesPatternData } from "../formats/import/pesImporter";
import type { MachineInfo } from "../types/machine";
import { calculateInitialScale } from "../utils/konvaRenderers";
import { calculateZoomToPoint } from "../components/PatternCanvas/patternCanvasHelpers";
interface UseCanvasViewportOptions {
containerRef: RefObject<HTMLDivElement | null>;
pesData: PesPatternData | null;
uploadedPesData: PesPatternData | null;
machineInfo: MachineInfo | null;
}
export function useCanvasViewport({
containerRef,
pesData,
uploadedPesData,
machineInfo,
}: UseCanvasViewportOptions) {
const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
const [stageScale, setStageScale] = useState(1);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const initialScaleRef = useRef<number>(1);
const prevPesDataRef = useRef<PesPatternData | null>(null);
// Track container size with ResizeObserver
useEffect(() => {
if (!containerRef.current) return;
const updateSize = () => {
if (containerRef.current) {
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
setContainerSize({ width, height });
}
};
// Initial size
updateSize();
// Watch for resize
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, [containerRef]);
// Calculate and store initial scale when pattern or hoop changes
useEffect(() => {
// Use whichever pattern is available (uploaded or original)
const currentPattern = uploadedPesData || pesData;
if (!currentPattern || containerSize.width === 0) {
prevPesDataRef.current = null;
return;
}
// Only recalculate if pattern changed
if (prevPesDataRef.current !== currentPattern) {
prevPesDataRef.current = currentPattern;
const { bounds } = currentPattern;
const viewWidth = machineInfo
? machineInfo.maxWidth
: bounds.maxX - bounds.minX;
const viewHeight = machineInfo
? machineInfo.maxHeight
: bounds.maxY - bounds.minY;
const initialScale = calculateInitialScale(
containerSize.width,
containerSize.height,
viewWidth,
viewHeight,
);
initialScaleRef.current = initialScale;
// Reset view when pattern changes
// eslint-disable-next-line react-hooks/set-state-in-effect
setStageScale(initialScale);
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}
}, [pesData, uploadedPesData, machineInfo, containerSize]);
// Wheel zoom handler
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();
const stage = e.target.getStage();
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const scaleBy = 1.1;
const direction = e.evt.deltaY > 0 ? -1 : 1;
setStageScale((oldScale) => {
const newScale = Math.max(
0.1,
Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2),
);
// Zoom towards pointer
setStagePos((prevPos) =>
calculateZoomToPoint(oldScale, newScale, pointer, prevPos),
);
return newScale;
});
}, []);
// Zoom control handlers
const handleZoomIn = useCallback(() => {
setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
// Zoom towards center of viewport
const center = {
x: containerSize.width / 2,
y: containerSize.height / 2,
};
setStagePos((prevPos) =>
calculateZoomToPoint(oldScale, newScale, center, prevPos),
);
return newScale;
});
}, [containerSize]);
const handleZoomOut = useCallback(() => {
setStageScale((oldScale) => {
const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
// Zoom towards center of viewport
const center = {
x: containerSize.width / 2,
y: containerSize.height / 2,
};
setStagePos((prevPos) =>
calculateZoomToPoint(oldScale, newScale, center, prevPos),
);
return newScale;
});
}, [containerSize]);
const handleZoomReset = useCallback(() => {
const initialScale = initialScaleRef.current;
setStageScale(initialScale);
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}, [containerSize]);
return {
// State
stagePos,
stageScale,
containerSize,
// Handlers
handleWheel,
handleZoomIn,
handleZoomOut,
handleZoomReset,
};
}

View file

@ -0,0 +1,151 @@
/**
* usePatternTransform Hook
*
* Manages pattern transformation state including position, rotation, and drag/transform handling
* Syncs local state with global pattern store
*/
import { useState, useEffect, useCallback, useRef } from "react";
import type Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node";
import type { PesPatternData } from "../formats/import/pesImporter";
interface UsePatternTransformOptions {
pesData: PesPatternData | null;
initialPatternOffset: { x: number; y: number };
initialPatternRotation: number;
setPatternOffset: (x: number, y: number) => void;
setPatternRotation: (rotation: number) => void;
patternUploaded: boolean;
isUploading: boolean;
}
export function usePatternTransform({
pesData,
initialPatternOffset,
initialPatternRotation,
setPatternOffset,
setPatternRotation,
patternUploaded,
isUploading,
}: UsePatternTransformOptions) {
const [localPatternOffset, setLocalPatternOffset] = useState(
initialPatternOffset || { x: 0, y: 0 },
);
const [localPatternRotation, setLocalPatternRotation] = useState(
initialPatternRotation || 0,
);
const patternGroupRef = useRef<Konva.Group | null>(null);
const transformerRef = useRef<Konva.Transformer | null>(null);
// Update pattern offset when initialPatternOffset changes
if (
initialPatternOffset &&
(localPatternOffset.x !== initialPatternOffset.x ||
localPatternOffset.y !== initialPatternOffset.y)
) {
setLocalPatternOffset(initialPatternOffset);
}
// Update pattern rotation when initialPatternRotation changes
if (
initialPatternRotation !== undefined &&
localPatternRotation !== initialPatternRotation
) {
setLocalPatternRotation(initialPatternRotation);
}
// Attach/detach transformer based on state
const attachTransformer = useCallback(() => {
if (!transformerRef.current || !patternGroupRef.current) {
return;
}
if (!patternUploaded && !isUploading) {
transformerRef.current.nodes([patternGroupRef.current]);
transformerRef.current.getLayer()?.batchDraw();
} else {
transformerRef.current.nodes([]);
}
}, [patternUploaded, isUploading]);
// Call attachTransformer when conditions change
useEffect(() => {
attachTransformer();
}, [attachTransformer, pesData]);
// Sync node rotation with state (important for when rotation is reset to 0 after upload)
useEffect(() => {
if (patternGroupRef.current) {
patternGroupRef.current.rotation(localPatternRotation);
}
}, [localPatternRotation]);
// Center pattern in hoop
const handleCenterPattern = useCallback(() => {
if (!pesData) return;
// Since the pattern Group uses offsetX/offsetY to set its pivot point at the pattern center,
// we just need to position it at the origin (0, 0) to center it in the hoop
const centerOffset = { x: 0, y: 0 };
setLocalPatternOffset(centerOffset);
setPatternOffset(centerOffset.x, centerOffset.y);
}, [pesData, setPatternOffset]);
// Pattern drag handlers
const handlePatternDragEnd = useCallback(
(e: Konva.KonvaEventObject<DragEvent>) => {
const newOffset = {
x: e.target.x(),
y: e.target.y(),
};
setLocalPatternOffset(newOffset);
setPatternOffset(newOffset.x, newOffset.y);
},
[setPatternOffset],
);
// Handle transformer rotation - just store the angle, apply at upload time
const handleTransformEnd = useCallback(
(e: KonvaEventObject<Event>) => {
if (!pesData) return;
const node = e.target;
// Read rotation from the node
const totalRotation = node.rotation();
const normalizedRotation = ((totalRotation % 360) + 360) % 360;
setLocalPatternRotation(normalizedRotation);
// Also read position in case the Transformer affected it
const newOffset = {
x: node.x(),
y: node.y(),
};
setLocalPatternOffset(newOffset);
// Store rotation angle and position
setPatternRotation(normalizedRotation);
setPatternOffset(newOffset.x, newOffset.y);
},
[setPatternRotation, setPatternOffset, pesData],
);
return {
// State
localPatternOffset,
localPatternRotation,
// Refs
patternGroupRef,
transformerRef,
// Handlers
attachTransformer,
handleCenterPattern,
handlePatternDragEnd,
handleTransformEnd,
};
}

View file

@ -14,6 +14,7 @@ import { uuidToString } from "../services/PatternCacheService";
import { createStorageService } from "../platform";
import type { IStorageService } from "../platform/interfaces/IStorageService";
import type { PesPatternData } from "../formats/import/pesImporter";
import { usePatternStore } from "./usePatternStore";
interface MachineState {
// Service instances
@ -441,6 +442,9 @@ export const useMachineStore = create<MachineState>((set, get) => ({
resumeFileName: null,
});
// Clear uploaded pattern data in pattern store
usePatternStore.getState().clearUploadedPattern();
await refreshStatus();
} catch (err) {
set({

View file

@ -2,68 +2,112 @@ import { create } from "zustand";
import type { PesPatternData } from "../formats/import/pesImporter";
interface PatternState {
// Pattern data
// Original pattern (pre-upload)
pesData: PesPatternData | null;
currentFileName: string;
patternOffset: { x: number; y: number };
patternRotation: number; // rotation in degrees (0-360)
// Uploaded pattern (post-upload, rotation baked in)
uploadedPesData: PesPatternData | null; // Pattern with rotation applied
uploadedPatternOffset: { x: number; y: number }; // Offset with center shift compensation
patternUploaded: boolean;
// Actions
setPattern: (data: PesPatternData, fileName: string) => void;
setPatternOffset: (x: number, y: number) => void;
setPatternUploaded: (uploaded: boolean) => void;
clearPattern: () => void;
setPatternRotation: (rotation: number) => void;
setUploadedPattern: (
uploadedData: PesPatternData,
uploadedOffset: { x: number; y: number },
) => void;
clearUploadedPattern: () => void;
resetPatternOffset: () => void;
resetRotation: () => void;
}
export const usePatternStore = create<PatternState>((set) => ({
// Initial state
// Initial state - original pattern
pesData: null,
currentFileName: "",
patternOffset: { x: 0, y: 0 },
patternRotation: 0,
// Uploaded pattern
uploadedPesData: null,
uploadedPatternOffset: { x: 0, y: 0 },
patternUploaded: false,
// Set pattern data and filename
// Set pattern data and filename (replaces current pattern)
setPattern: (data: PesPatternData, fileName: string) => {
set({
pesData: data,
currentFileName: fileName,
patternOffset: { x: 0, y: 0 }, // Reset offset when new pattern is loaded
patternOffset: { x: 0, y: 0 },
patternRotation: 0,
uploadedPesData: null, // Clear uploaded pattern when loading new
uploadedPatternOffset: { x: 0, y: 0 },
patternUploaded: false,
});
},
// Update pattern offset
// Update pattern offset (for original pattern only)
setPatternOffset: (x: number, y: number) => {
set({ patternOffset: { x, y } });
console.log("[PatternStore] Pattern offset changed:", { x, y });
},
// Mark pattern as uploaded/not uploaded
setPatternUploaded: (uploaded: boolean) => {
set({ patternUploaded: uploaded });
// Set pattern rotation (for original pattern only)
setPatternRotation: (rotation: number) => {
set({ patternRotation: rotation % 360 });
console.log("[PatternStore] Pattern rotation changed:", rotation);
},
// Clear pattern (but keep data visible for re-editing)
clearPattern: () => {
// Set uploaded pattern data (called after upload completes)
setUploadedPattern: (
uploadedData: PesPatternData,
uploadedOffset: { x: number; y: number },
) => {
set({
patternUploaded: false,
// Note: We intentionally DON'T clear pesData or currentFileName
// so the pattern remains visible in the canvas for re-editing
uploadedPesData: uploadedData,
uploadedPatternOffset: uploadedOffset,
patternUploaded: true,
});
console.log("[PatternStore] Uploaded pattern set");
},
// Clear uploaded pattern (called when deleting from machine)
clearUploadedPattern: () => {
set({
uploadedPesData: null,
uploadedPatternOffset: { x: 0, y: 0 },
patternUploaded: false,
});
console.log("[PatternStore] Uploaded pattern cleared");
},
// Reset pattern offset to default
resetPatternOffset: () => {
set({ patternOffset: { x: 0, y: 0 } });
},
// Reset pattern rotation to default
resetRotation: () => {
set({ patternRotation: 0 });
},
}));
// Selector hooks for common use cases
export const usePesData = () => usePatternStore((state) => state.pesData);
export const useUploadedPesData = () =>
usePatternStore((state) => state.uploadedPesData);
export const usePatternFileName = () =>
usePatternStore((state) => state.currentFileName);
export const usePatternOffset = () =>
usePatternStore((state) => state.patternOffset);
export const usePatternUploaded = () =>
usePatternStore((state) => state.patternUploaded);
export const useUploadedPatternOffset = () =>
usePatternStore((state) => state.uploadedPatternOffset);
export const usePatternRotation = () =>
usePatternStore((state) => state.patternRotation);

View file

@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { getErrorDetails, SewingMachineError } from "./errorCodeHelpers";
describe("errorCodeHelpers", () => {
describe("shortName validation", () => {
it("should ensure all error shortNames are 15 characters or less", () => {
// Get all error codes except None (0xDD) and Unknown (0xEE) since they might not have details
const errorCodes = Object.values(SewingMachineError).filter(
(code) =>
code !== SewingMachineError.None &&
code !== SewingMachineError.Unknown &&
code !== SewingMachineError.OtherError,
);
const violations: Array<{
code: number;
shortName: string;
length: number;
}> = [];
errorCodes.forEach((code) => {
const details = getErrorDetails(code);
if (details?.shortName) {
const length = details.shortName.length;
if (length > 15) {
violations.push({
code,
shortName: details.shortName,
length,
});
}
}
});
// If there are violations, create a helpful error message
if (violations.length > 0) {
const violationMessages = violations
.map(
(v) =>
`Error code 0x${v.code.toString(16).toUpperCase()}: "${v.shortName}" (${v.length} chars)`,
)
.join("\n ");
expect.fail(
`The following error shortNames exceed 15 characters:\n ${violationMessages}`,
);
}
// Assertion to confirm test ran
expect(violations).toHaveLength(0);
});
it("should ensure all error details have a shortName", () => {
// Get all error codes except None (0xDD) and Unknown (0xEE)
const errorCodes = Object.values(SewingMachineError).filter(
(code) =>
code !== SewingMachineError.None &&
code !== SewingMachineError.Unknown &&
code !== SewingMachineError.OtherError,
);
const missing: number[] = [];
errorCodes.forEach((code) => {
const details = getErrorDetails(code);
if (details && !details.shortName) {
missing.push(code);
}
});
if (missing.length > 0) {
const missingCodes = missing
.map((code) => `0x${code.toString(16).toUpperCase()}`)
.join(", ");
expect.fail(
`The following error codes are missing shortName: ${missingCodes}`,
);
}
expect(missing).toHaveLength(0);
});
});
});

View file

@ -42,6 +42,8 @@ export const SewingMachineError = {
*/
interface ErrorInfo {
title: string;
/** Short name for badge display (max 15 characters) */
shortName: string;
description: string;
solutions: string[];
/** If true, this "error" is really just an informational step, not a real error */
@ -55,12 +57,14 @@ interface ErrorInfo {
const ERROR_DETAILS: Record<number, ErrorInfo> = {
[SewingMachineError.NeedlePositionError]: {
title: "The Needle is Down",
shortName: "Needle Down",
description:
"The needle is in the down position and needs to be raised before continuing.",
solutions: ["Press the needle position switch to raise the needle"],
},
[SewingMachineError.SafetyError]: {
title: "Safety Error",
shortName: "Safety Error",
description: "The machine is sensing an operational issue.",
solutions: [
"Remove the thread on the top of the fabric and then remove the needle",
@ -72,21 +76,25 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.LowerThreadSafetyError]: {
title: "Lower Thread Safety Error",
shortName: "Lower Thread",
description: "The bobbin winder safety device is activated.",
solutions: ["Check if the thread is tangled"],
},
[SewingMachineError.LowerThreadFreeError]: {
title: "Lower Thread Free Error",
shortName: "Lower Thread",
description: "Problem with lower thread.",
solutions: ["Slide the bobbin winder shaft toward the front"],
},
[SewingMachineError.RestartError10]: {
title: "Restart Required",
shortName: "Restart Needed",
description: "A malfunction occurred.",
solutions: ["Turn the machine off, then on again"],
},
[SewingMachineError.RestartError11]: {
title: "Restart Required (M519411)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519411",
solutions: [
"Turn the machine off, then on again",
@ -95,6 +103,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError12]: {
title: "Restart Required (M519412)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519412",
solutions: [
"Turn the machine off, then on again",
@ -103,6 +112,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError13]: {
title: "Restart Required (M519413)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519413",
solutions: [
"Turn the machine off, then on again",
@ -111,6 +121,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError14]: {
title: "Restart Required (M519414)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519414",
solutions: [
"Turn the machine off, then on again",
@ -119,6 +130,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError15]: {
title: "Restart Required (M519415)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519415",
solutions: [
"Turn the machine off, then on again",
@ -127,6 +139,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError16]: {
title: "Restart Required (M519416)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519416",
solutions: [
"Turn the machine off, then on again",
@ -135,6 +148,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError17]: {
title: "Restart Required (M519417)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519417",
solutions: [
"Turn the machine off, then on again",
@ -143,6 +157,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError18]: {
title: "Restart Required (M519418)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519418",
solutions: [
"Turn the machine off, then on again",
@ -151,6 +166,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError19]: {
title: "Restart Required (M519419)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519419",
solutions: [
"Turn the machine off, then on again",
@ -159,6 +175,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError1A]: {
title: "Restart Required (M51941A)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M51941A",
solutions: [
"Turn the machine off, then on again",
@ -167,6 +184,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError1B]: {
title: "Restart Required (M51941B)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M51941B",
solutions: [
"Turn the machine off, then on again",
@ -175,6 +193,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError1C]: {
title: "Restart Required (M51941C)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M51941C",
solutions: [
"Turn the machine off, then on again",
@ -183,6 +202,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.NeedlePlateError]: {
title: "Needle Plate Error",
shortName: "Needle Plate",
description: "Check the needle plate cover.",
solutions: [
"Reattach the needle plate cover",
@ -191,11 +211,13 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.ThreadLeverError]: {
title: "Thread Lever Error",
shortName: "Thread Lever",
description: "The needle threading lever is not in its original position.",
solutions: ["Return the needle threading lever to its original position"],
},
[SewingMachineError.UpperThreadError]: {
title: "Upper Thread Error",
shortName: "Upper Thread",
description: "Check and rethread the upper thread.",
solutions: [
"Check the upper thread and rethread it",
@ -204,6 +226,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.LowerThreadError]: {
title: "Lower Thread Error",
shortName: "Lower Thread",
description: "The bobbin thread is almost empty.",
solutions: [
"Replace the bobbin thread",
@ -212,6 +235,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.UpperThreadSewingStartError]: {
title: "Upper Thread Error at Sewing Start",
shortName: "Upper Thread",
description: "Check and rethread the upper thread.",
solutions: [
"Press the Accept button to resolve the error",
@ -221,21 +245,25 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.PRWiperError]: {
title: "PR Wiper Error",
shortName: "PR Wiper",
description: "PR Wiper Error.",
solutions: ["Press the Accept button to resolve the error"],
},
[SewingMachineError.HoopError]: {
title: "Hoop Error",
shortName: "Hoop Error",
description: "This embroidery frame cannot be used.",
solutions: ["Use another frame that fits the pattern"],
},
[SewingMachineError.NoHoopError]: {
title: "No Hoop Detected",
shortName: "No Hoop",
description: "No hoop attached.",
solutions: ["Attach the embroidery hoop"],
},
[SewingMachineError.InitialHoopError]: {
title: "Machine Initialization Required",
shortName: "Init Required",
description: "An initial homing procedure must be performed.",
solutions: [
"Remove the embroidery hoop from the machine completely",
@ -248,12 +276,14 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RegularInspectionError]: {
title: "Regular Inspection Required",
shortName: "Inspection Due",
description:
"Preventive maintenance is recommended. This message is displayed when maintenance is due.",
solutions: ["Please contact the service center"],
},
[SewingMachineError.Setting]: {
title: "Settings Error",
shortName: "Settings Error",
description: "Stitch count cannot be changed.",
solutions: ["This setting cannot be modified at this time"],
},
@ -358,6 +388,7 @@ export function getErrorDetails(
if (errorTitle) {
return {
title: errorTitle,
shortName: errorTitle.length > 15 ? "Machine Error" : errorTitle,
description: "Please check the machine display for more information.",
solutions: [
"Consult your machine manual for specific troubleshooting steps",
@ -370,6 +401,7 @@ export function getErrorDetails(
// Unknown error code
return {
title: `Machine Error 0x${errorCode.toString(16).toUpperCase().padStart(2, "0")}`,
shortName: "Machine Error",
description:
"The machine has reported an error code that is not recognized.",
solutions: [

View file

@ -0,0 +1,314 @@
import { describe, it, expect } from "vitest";
import {
rotatePoint,
transformStitchesRotation,
calculateRotatedBounds,
normalizeAngle,
} from "./rotationUtils";
import { encodeStitchesToPen } from "../formats/pen/encoder";
import { decodePenData } from "../formats/pen/decoder";
describe("rotationUtils", () => {
describe("rotatePoint", () => {
it("should rotate 90° correctly", () => {
const result = rotatePoint(100, 0, 0, 0, 90);
expect(result.x).toBeCloseTo(0, 1);
expect(result.y).toBeCloseTo(100, 1);
});
it("should rotate 180° correctly", () => {
const result = rotatePoint(100, 50, 0, 0, 180);
expect(result.x).toBeCloseTo(-100, 1);
expect(result.y).toBeCloseTo(-50, 1);
});
it("should handle 0° rotation (no change)", () => {
const result = rotatePoint(100, 50, 0, 0, 0);
expect(result.x).toBe(100);
expect(result.y).toBe(50);
});
it("should rotate 45° correctly", () => {
const result = rotatePoint(100, 0, 0, 0, 45);
expect(result.x).toBeCloseTo(70.71, 1);
expect(result.y).toBeCloseTo(70.71, 1);
});
it("should rotate around a custom center", () => {
const result = rotatePoint(150, 100, 100, 100, 90);
expect(result.x).toBeCloseTo(100, 1);
expect(result.y).toBeCloseTo(150, 1);
});
});
describe("transformStitchesRotation", () => {
it("should rotate stitches around pattern center (centered pattern)", () => {
const stitches = [
[100, 0, 0, 0],
[0, 100, 0, 0],
[-100, 0, 0, 0],
[0, -100, 0, 0],
];
const bounds = { minX: -100, maxX: 100, minY: -100, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 90, bounds);
expect(rotated[0][0]).toBeCloseTo(0, 0);
expect(rotated[0][1]).toBeCloseTo(100, 0);
expect(rotated[1][0]).toBeCloseTo(-100, 0);
expect(rotated[1][1]).toBeCloseTo(0, 0);
});
it("should preserve command and color data", () => {
const stitches = [[100, 50, 0x10, 2]];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 45, bounds);
expect(rotated[0][2]).toBe(0x10); // Command unchanged
expect(rotated[0][3]).toBe(2); // Color unchanged
});
it("should handle 0° as no-op", () => {
const stitches = [[100, 50, 0, 0]];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 0, bounds);
expect(rotated).toBe(stitches); // Same reference
});
it("should handle 360° as no-op", () => {
const stitches = [[100, 50, 0, 0]];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 360, bounds);
expect(rotated).toBe(stitches); // Same reference
});
it("should round coordinates to integers", () => {
const stitches = [[100, 0, 0, 0]];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 45, bounds);
// Coordinates should be integers
expect(Number.isInteger(rotated[0][0])).toBe(true);
expect(Number.isInteger(rotated[0][1])).toBe(true);
});
it("should rotate off-center pattern around its own center", () => {
// Pattern bounds not centered at origin (like the real-world case)
const bounds = { minX: -23, maxX: 751, minY: -369, maxY: 485 };
const centerX = (bounds.minX + bounds.maxX) / 2; // 364
const centerY = (bounds.minY + bounds.maxY) / 2; // 58
// Stitch at the pattern's center
const stitches = [[centerX, centerY, 0, 0]];
// Rotate by any angle - center point should stay at center
const rotated = transformStitchesRotation(stitches, 90, bounds);
// Center stitch should remain at center (within rounding)
expect(rotated[0][0]).toBeCloseTo(centerX, 0);
expect(rotated[0][1]).toBeCloseTo(centerY, 0);
});
it("should rotate off-center pattern corners correctly", () => {
// Off-center pattern
const bounds = { minX: 100, maxX: 300, minY: 200, maxY: 400 };
// Test all four corners
const stitches = [
[100, 200, 0, 0], // top-left
[300, 200, 0, 0], // top-right
[100, 400, 0, 0], // bottom-left
[300, 400, 0, 0], // bottom-right
];
const rotated = transformStitchesRotation(stitches, 90, bounds);
// After 90° rotation around center (200, 300):
// top-left (100, 200) -> relative (-100, -100) -> rotated (100, -100) -> absolute (300, 200)
expect(rotated[0][0]).toBeCloseTo(300, 0);
expect(rotated[0][1]).toBeCloseTo(200, 0);
// top-right (300, 200) -> relative (100, -100) -> rotated (100, 100) -> absolute (300, 400)
expect(rotated[1][0]).toBeCloseTo(300, 0);
expect(rotated[1][1]).toBeCloseTo(400, 0);
// bottom-left (100, 400) -> relative (-100, 100) -> rotated (-100, -100) -> absolute (100, 200)
expect(rotated[2][0]).toBeCloseTo(100, 0);
expect(rotated[2][1]).toBeCloseTo(200, 0);
// bottom-right (300, 400) -> relative (100, 100) -> rotated (-100, 100) -> absolute (100, 400)
expect(rotated[3][0]).toBeCloseTo(100, 0);
expect(rotated[3][1]).toBeCloseTo(400, 0);
});
it("should handle real-world off-center pattern (actual user case)", () => {
const bounds = { minX: -23, maxX: 751, minY: -369, maxY: 485 };
const centerX = 364;
const centerY = 58;
// A stitch at the top-right corner
const stitches = [[751, -369, 0, 0]];
const rotated = transformStitchesRotation(stitches, 45, bounds);
// Distance from center: sqrt((751-364)^2 + (-369-58)^2) = sqrt(149769 + 182329) = 576.4
// This distance should be preserved after rotation
const origDist = Math.sqrt(
Math.pow(751 - centerX, 2) + Math.pow(-369 - centerY, 2),
);
const rotDist = Math.sqrt(
Math.pow(rotated[0][0] - centerX, 2) +
Math.pow(rotated[0][1] - centerY, 2),
);
expect(rotDist).toBeCloseTo(origDist, 0);
});
});
describe("calculateRotatedBounds", () => {
it("should expand bounds after 45° rotation", () => {
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
const rotated = calculateRotatedBounds(bounds, 45);
// After 45° rotation, bounds should expand
expect(Math.abs(rotated.minX)).toBeGreaterThan(100);
expect(Math.abs(rotated.minY)).toBeGreaterThan(50);
});
it("should maintain bounds for 0° rotation", () => {
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
const rotated = calculateRotatedBounds(bounds, 0);
expect(rotated).toEqual(bounds);
});
it("should maintain bounds for 360° rotation", () => {
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
const rotated = calculateRotatedBounds(bounds, 360);
expect(rotated).toEqual(bounds);
});
it("should handle 90° rotation symmetrically", () => {
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
const rotated = calculateRotatedBounds(bounds, 90);
// X and Y bounds swap
expect(rotated.minX).toBeCloseTo(-50, 0);
expect(rotated.maxX).toBeCloseTo(50, 0);
expect(rotated.minY).toBeCloseTo(-100, 0);
expect(rotated.maxY).toBeCloseTo(100, 0);
});
it("should handle asymmetric bounds correctly", () => {
const bounds = { minX: 0, maxX: 200, minY: 0, maxY: 100 };
const rotated = calculateRotatedBounds(bounds, 90);
const centerX = (bounds.minX + bounds.maxX) / 2;
const centerY = (bounds.minY + bounds.maxY) / 2;
// After 90° rotation around center
expect(rotated.minX).toBeCloseTo(centerX - 50, 0);
expect(rotated.maxX).toBeCloseTo(centerX + 50, 0);
expect(rotated.minY).toBeCloseTo(centerY - 100, 0);
expect(rotated.maxY).toBeCloseTo(centerY + 100, 0);
});
});
describe("normalizeAngle", () => {
it("should normalize negative angles", () => {
expect(normalizeAngle(-45)).toBe(315);
expect(normalizeAngle(-90)).toBe(270);
expect(normalizeAngle(-180)).toBe(180);
});
it("should normalize angles > 360", () => {
expect(normalizeAngle(405)).toBe(45);
expect(normalizeAngle(720)).toBe(0);
expect(normalizeAngle(450)).toBe(90);
});
it("should keep valid angles unchanged", () => {
expect(normalizeAngle(0)).toBe(0);
expect(normalizeAngle(45)).toBe(45);
expect(normalizeAngle(180)).toBe(180);
expect(normalizeAngle(359)).toBe(359);
});
it("should handle very large angles", () => {
expect(normalizeAngle(1080)).toBe(0);
expect(normalizeAngle(1125)).toBe(45);
});
});
describe("PEN encode/decode round-trip with rotation", () => {
it("should preserve rotated stitches through encode-decode cycle", () => {
// Create simple square pattern
const stitches = [
[0, 0, 0, 0],
[100, 0, 0, 0],
[100, 100, 0, 0],
[0, 100, 0, 0],
];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
// Rotate 45°
const rotated = transformStitchesRotation(stitches, 45, bounds);
// Encode to PEN
const encoded = encodeStitchesToPen(rotated);
// Decode back
const decoded = decodePenData(new Uint8Array(encoded.penBytes));
// Verify stitch count preserved (note: lock stitches are added)
expect(decoded.stitches.length).toBeGreaterThan(0);
});
it("should handle rotation with multiple colors", () => {
const stitches = [
[0, 0, 0, 0],
[100, 0, 0, 0],
[100, 100, 0, 1], // Color change
[0, 100, 0, 1],
];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 90, bounds);
const encoded = encodeStitchesToPen(rotated);
const decoded = decodePenData(new Uint8Array(encoded.penBytes));
// Verify color blocks preserved
expect(decoded.colorBlocks.length).toBeGreaterThan(0);
});
it("should handle negative coordinates after rotation", () => {
const stitches = [
[0, 0, 0, 0],
[100, 0, 0, 0],
];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
// Rotate 180° will produce negative coordinates
const rotated = transformStitchesRotation(stitches, 180, bounds);
// Encode to PEN
const encoded = encodeStitchesToPen(rotated);
const decoded = decodePenData(new Uint8Array(encoded.penBytes));
// Should not crash and should produce valid output
expect(decoded.stitches.length).toBeGreaterThan(0);
});
});
});

View file

@ -0,0 +1,82 @@
import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers";
/**
* Rotate a single point around a center
*/
export function rotatePoint(
x: number,
y: number,
centerX: number,
centerY: number,
angleDegrees: number,
): { x: number; y: number } {
const angleRad = (angleDegrees * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const dx = x - centerX;
const dy = y - centerY;
return {
x: centerX + dx * cos - dy * sin,
y: centerY + dx * sin + dy * cos,
};
}
/**
* Transform all stitches by rotation around pattern center
*/
export function transformStitchesRotation(
stitches: number[][],
angleDegrees: number,
bounds: { minX: number; maxX: number; minY: number; maxY: number },
): number[][] {
if (angleDegrees === 0 || angleDegrees === 360) return stitches;
const center = calculatePatternCenter(bounds);
return stitches.map(([x, y, cmd, colorIndex]) => {
const rotated = rotatePoint(x, y, center.x, center.y, angleDegrees);
return [Math.round(rotated.x), Math.round(rotated.y), cmd, colorIndex];
});
}
/**
* Calculate axis-aligned bounding box of rotated bounds
*/
export function calculateRotatedBounds(
bounds: { minX: number; maxX: number; minY: number; maxY: number },
angleDegrees: number,
): { minX: number; maxX: number; minY: number; maxY: number } {
if (angleDegrees === 0 || angleDegrees === 360) return bounds;
const center = calculatePatternCenter(bounds);
// Rotate all four corners
const corners = [
[bounds.minX, bounds.minY],
[bounds.maxX, bounds.minY],
[bounds.minX, bounds.maxY],
[bounds.maxX, bounds.maxY],
];
const rotatedCorners = corners.map(([x, y]) =>
rotatePoint(x, y, center.x, center.y, angleDegrees),
);
return {
minX: Math.min(...rotatedCorners.map((p) => p.x)),
maxX: Math.max(...rotatedCorners.map((p) => p.x)),
minY: Math.min(...rotatedCorners.map((p) => p.y)),
maxY: Math.max(...rotatedCorners.map((p) => p.y)),
};
}
/**
* Normalize angle to 0-360 range
*/
export function normalizeAngle(degrees: number): number {
let normalized = degrees % 360;
if (normalized < 0) normalized += 360;
return normalized;
}