Compare commits

..

No commits in common. "7fd31d209cbd5784ffe5c515c8a0506b74f3eed2" and "ea879640a2e3581697b91427dd8be271dfb03b96" have entirely different histories.

20 changed files with 646 additions and 1825 deletions

View file

@ -1,4 +1,3 @@
import { useState, useEffect, useRef } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore"; import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from "../stores/useUIStore"; import { useUIStore } from "../stores/useUIStore";
@ -8,7 +7,6 @@ import {
getStateVisualInfo, getStateVisualInfo,
getStatusIndicatorState, getStatusIndicatorState,
} from "../utils/machineStateHelpers"; } from "../utils/machineStateHelpers";
import { hasError, getErrorDetails } from "../utils/errorCodeHelpers";
import { import {
CheckCircleIcon, CheckCircleIcon,
BoltIcon, BoltIcon,
@ -60,15 +58,6 @@ 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 // Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus); const stateVisual = getStateVisualInfo(machineStatus);
const stateIcons = { const stateIcons = {
@ -86,66 +75,6 @@ export function AppHeader() {
? getStatusIndicatorState(machineStatus) ? getStatusIndicatorState(machineStatus)
: "idle"; : "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 ( return (
<TooltipProvider> <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"> <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">
@ -237,22 +166,22 @@ export function AppHeader() {
)} )}
{/* Error indicator - always render to prevent layout shift */} {/* Error indicator - always render to prevent layout shift */}
<Popover <Popover>
open={errorPopoverOpen}
onOpenChange={handlePopoverOpenChange}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <Button
size="sm"
variant="destructive"
className={cn( className={cn(
"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", "gap-1.5 flex-shrink-0",
machineErrorMessage || pyodideError machineErrorMessage || pyodideError
? "animate-pulse hover:animate-none" ? "animate-pulse hover:animate-none"
: "invisible pointer-events-none", : "invisible pointer-events-none",
)} )}
aria-label="View error details" aria-label="View error details"
disabled={!(machineErrorMessage || pyodideError)}
> >
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" /> <ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span className="font-semibold"> <span>
{(() => { {(() => {
if (pyodideError) return "Python Error"; if (pyodideError) return "Python Error";
if (isPairingError) return "Pairing Required"; if (isPairingError) return "Pairing Required";
@ -273,19 +202,17 @@ export function AppHeader() {
return "Pattern Error"; return "Pattern Error";
} }
if (machineError !== undefined) { if (machineError !== undefined) {
// Get short name from error details return `Machine Error`;
const errorDetails = getErrorDetails(machineError);
return errorDetails?.shortName || "Machine Error";
} }
// Default fallback // Default fallback
return "Error"; return "Error";
})()} })()}
</span> </span>
</button> </Button>
</PopoverTrigger> </PopoverTrigger>
{/* Error popover content - unchanged */} {/* Error popover content */}
{(machineErrorMessage || pyodideError) && ( {(machineErrorMessage || pyodideError) && (
<ErrorPopoverContent <ErrorPopoverContent
machineError={ machineError={

View file

@ -11,16 +11,6 @@ import {
canUploadPattern, canUploadPattern,
getMachineStateCategory, getMachineStateCategory,
} from "../utils/machineStateHelpers"; } 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 { PatternInfoSkeleton } from "./SkeletonLoader";
import { PatternInfo } from "./PatternInfo"; import { PatternInfo } from "./PatternInfo";
import { import {
@ -67,17 +57,13 @@ export function FileUpload() {
pesData: pesDataProp, pesData: pesDataProp,
currentFileName, currentFileName,
patternOffset, patternOffset,
patternRotation,
setPattern, setPattern,
setUploadedPattern,
} = usePatternStore( } = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
currentFileName: state.currentFileName, currentFileName: state.currentFileName,
patternOffset: state.patternOffset, patternOffset: state.patternOffset,
patternRotation: state.patternRotation,
setPattern: state.setPattern, setPattern: state.setPattern,
setUploadedPattern: state.setUploadedPattern,
})), })),
); );
@ -151,115 +137,26 @@ export function FileUpload() {
[fileService, setPattern, pyodideReady, initializePyodide], [fileService, setPattern, pyodideReady, initializePyodide],
); );
const handleUpload = useCallback(async () => { const handleUpload = useCallback(() => {
if (pesData && displayFileName) { if (pesData && displayFileName) {
let penDataToUpload = pesData.penData; uploadPattern(pesData.penData, pesData, displayFileName, patternOffset);
let pesDataForUpload = pesData;
// 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]);
pesData,
displayFileName,
uploadPattern,
patternOffset,
patternRotation,
setUploadedPattern,
]);
// Check if pattern (with offset and rotation) fits within hoop bounds // Check if pattern (with offset) fits within hoop bounds
const checkPatternFitsInHoop = useCallback(() => { const checkPatternFitsInHoop = useCallback(() => {
if (!pesData || !machineInfo) { if (!pesData || !machineInfo) {
return { fits: true, error: null }; return { fits: true, error: null };
} }
// Calculate rotated bounds if rotation is applied const { bounds } = pesData;
let bounds = pesData.bounds;
if (patternRotation && patternRotation !== 0) {
bounds = calculateRotatedBounds(pesData.bounds, patternRotation);
}
const { maxWidth, maxHeight } = machineInfo; const { maxWidth, maxHeight } = machineInfo;
// The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas) // Calculate pattern bounds with offset applied
// So we need to calculate bounds relative to the center const patternMinX = bounds.minX + patternOffset.x;
const center = calculatePatternCenter(bounds); const patternMaxX = bounds.maxX + patternOffset.x;
const patternMinY = bounds.minY + patternOffset.y;
// Calculate actual bounds in world coordinates const patternMaxY = bounds.maxY + patternOffset.y;
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) // Hoop bounds (centered at origin)
const hoopMinX = -maxWidth / 2; const hoopMinX = -maxWidth / 2;
@ -299,7 +196,7 @@ export function FileUpload() {
} }
return { fits: true, error: null }; return { fits: true, error: null };
}, [pesData, machineInfo, patternOffset, patternRotation]); }, [pesData, machineInfo, patternOffset]);
const boundsCheck = checkPatternFitsInHoop(); const boundsCheck = checkPatternFitsInHoop();

View file

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

View file

@ -0,0 +1,542 @@
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,271 +0,0 @@
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

@ -1,146 +0,0 @@
/**
* 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

@ -1,61 +0,0 @@
/**
* 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

@ -1,74 +0,0 @@
/**
* 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

@ -1,77 +0,0 @@
/**
* 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

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

View file

@ -1,83 +0,0 @@
/**
* 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,6 +8,7 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
} from "@heroicons/react/24/solid"; } from "@heroicons/react/24/solid";
import { MachineStatus } from "../types/machine"; import { MachineStatus } from "../types/machine";
import { getErrorDetails, hasError } from "../utils/errorCodeHelpers";
interface Step { interface Step {
id: number; id: number;
@ -27,7 +28,38 @@ const steps: Step[] = [
]; ];
// Helper function to get guide content for a step // Helper function to get guide content for a step
function getGuideContent(stepId: number, machineStatus: MachineStatus) { 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,
};
}
// Return content based on step // Return content based on step
switch (stepId) { switch (stepId) {
case 1: case 1:
@ -241,10 +273,17 @@ function getCurrentStep(
export function WorkflowStepper() { export function WorkflowStepper() {
// Machine store // Machine store
const { machineStatus, isConnected } = useMachineStore( const {
machineStatus,
isConnected,
machineError,
error: errorMessage,
} = useMachineStore(
useShallow((state) => ({ useShallow((state) => ({
machineStatus: state.machineStatus, machineStatus: state.machineStatus,
isConnected: state.isConnected, isConnected: state.isConnected,
machineError: state.machineError,
error: state.error,
})), })),
); );
@ -258,6 +297,7 @@ export function WorkflowStepper() {
// Derived state: pattern is uploaded if machine has pattern info // Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded(); const patternUploaded = usePatternUploaded();
const hasPattern = pesData !== null; const hasPattern = pesData !== null;
const hasErrorFlag = hasError(machineError);
const currentStep = getCurrentStep( const currentStep = getCurrentStep(
machineStatus, machineStatus,
isConnected, isConnected,
@ -403,7 +443,13 @@ export function WorkflowStepper() {
aria-label="Step guidance" aria-label="Step guidance"
> >
{(() => { {(() => {
const content = getGuideContent(popoverStep, machineStatus); const content = getGuideContent(
popoverStep,
machineStatus,
hasErrorFlag,
machineError,
errorMessage || undefined,
);
if (!content) return null; if (!content) return null;
const colorClasses = { const colorClasses = {
@ -451,7 +497,7 @@ export function WorkflowStepper() {
}; };
const Icon = const Icon =
content.type === "warning" content.type === "error"
? ExclamationTriangleIcon ? ExclamationTriangleIcon
: InformationCircleIcon; : InformationCircleIcon;
@ -492,6 +538,18 @@ export function WorkflowStepper() {
))} ))}
</ul> </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> </div>
</div> </div>

View file

@ -1,179 +0,0 @@
/**
* 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

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

View file

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

View file

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

View file

@ -1,314 +0,0 @@
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

@ -1,82 +0,0 @@
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;
}