diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index f4ec242..2f7e54e 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -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(); diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx deleted file mode 100644 index 098c022..0000000 --- a/src/components/PatternCanvas.tsx +++ /dev/null @@ -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(null); - const stageRef = useRef(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(1); - const prevPesDataRef = useRef(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) => { - 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) => { - 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 ( - - -
- -
- Pattern Preview - {pesData ? ( - - {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "} - ×{" "} - {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)}{" "} - mm - - ) : ( - - No pattern loaded - - )} -
-
-
- -
- {containerSize.width > 0 && ( - { - 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 */} - - {pesData && ( - <> - - - {machineInfo && } - - )} - - - {/* Pattern layer: draggable stitches and bounds */} - - {pesData && ( - { - 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"; - }} - > - { - // 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} - /> - - - )} - - - {/* Current position layer */} - - {pesData && - pesData.penStitches && - sewingProgress && - sewingProgress.currentStitch > 0 && ( - - { - 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]; - }, - )} - /> - - )} - - - )} - - {/* Placeholder overlay when no pattern is loaded */} - {!pesData && ( -
- Load a PES file to preview the pattern -
- )} - - {/* Pattern info overlays */} - {pesData && ( - <> - {/* Thread Legend Overlay */} -
-

- Colors -

- {pesData.uniqueColors.map((color, idx) => { - // Primary metadata: brand and catalog number - const primaryMetadata = [ - color.brand, - color.catalogNumber ? `#${color.catalogNumber}` : null, - ] - .filter(Boolean) - .join(" "); - - // Secondary metadata: chart and description - // 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 ( -
-
-
-
- Color {idx + 1} -
- {(primaryMetadata || secondaryMetadata) && ( -
- {primaryMetadata} - {primaryMetadata && secondaryMetadata && ( - - )} - {secondaryMetadata} -
- )} -
-
- ); - })} -
- - {/* Pattern Offset Indicator */} -
-
-
- Pattern Position: -
- {patternUploaded && ( -
- - LOCKED -
- )} -
-
- X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "} - {(localPatternOffset.y / 10).toFixed(1)}mm -
-
- {patternUploaded - ? "Pattern locked • Drag background to pan" - : "Drag pattern to move • Drag background to pan"} -
-
- - {/* Zoom Controls Overlay */} -
- - - - {Math.round(stageScale * 100)}% - - - -
- - )} -
- - - ); -} diff --git a/src/components/KonvaComponents.tsx b/src/components/PatternCanvas/KonvaComponents.tsx similarity index 95% rename from src/components/KonvaComponents.tsx rename to src/components/PatternCanvas/KonvaComponents.tsx index a8e15cb..d75430a 100644 --- a/src/components/KonvaComponents.tsx +++ b/src/components/PatternCanvas/KonvaComponents.tsx @@ -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; diff --git a/src/components/PatternCanvas/PatternCanvas.tsx b/src/components/PatternCanvas/PatternCanvas.tsx new file mode 100644 index 0000000..b44d3a9 --- /dev/null +++ b/src/components/PatternCanvas/PatternCanvas.tsx @@ -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(null); + const stageRef = useRef(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 ( + + +
+ +
+ Pattern Preview + {hasPattern ? ( + + {(() => { + 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; + })()} + + ) : ( + + No pattern loaded + + )} +
+
+
+ +
+ {containerSize.width > 0 && ( + { + 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 */} + + {hasPattern && ( + <> + + + {machineInfo && } + + )} + + + {/* Original pattern layer: draggable with transformer (shown before upload starts) */} + + {pesData && ( + + )} + + + {/* Uploaded pattern layer: locked, rotation baked in (shown during and after upload) */} + + {uploadedPesData && ( + + )} + + + )} + + {/* Placeholder overlay when no pattern is loaded */} + {!hasPattern && ( +
+ Load a PES file to preview the pattern +
+ )} + + {/* Pattern info overlays */} + {hasPattern && + (() => { + const displayPattern = uploadedPesData || pesData; + return ( + displayPattern && ( + <> + + + + + + + ) + ); + })()} +
+
+
+ ); +} diff --git a/src/components/PatternCanvas/PatternLayer.tsx b/src/components/PatternCanvas/PatternLayer.tsx new file mode 100644 index 0000000..d104877 --- /dev/null +++ b/src/components/PatternCanvas/PatternLayer.tsx @@ -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; + transformerRef?: RefObject; + onDragEnd?: (e: Konva.KonvaEventObject) => void; + onTransformEnd?: (e: KonvaEventObject) => 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 ( + <> + { + 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 + } + > + + + + + {/* Transformer only for interactive layer */} + {isInteractive && transformerRef && ( + { + 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 && ( + + + + )} + + ); +} diff --git a/src/components/PatternCanvas/PatternPositionIndicator.tsx b/src/components/PatternCanvas/PatternPositionIndicator.tsx new file mode 100644 index 0000000..6e7474c --- /dev/null +++ b/src/components/PatternCanvas/PatternPositionIndicator.tsx @@ -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 ( +
+
+
+ Pattern Position: +
+ {(isUploading || isLocked) && ( +
+ + + {isUploading ? "UPLOADING" : "LOCKED"} + +
+ )} +
+
+ X: {(offset.x / 10).toFixed(1)}mm, Y: {(offset.y / 10).toFixed(1)}mm +
+ {!isUploading && !isLocked && rotation !== 0 && ( +
+ Rotation: {rotation.toFixed(1)}° +
+ )} +
+ {isUploading + ? "Uploading pattern..." + : isLocked + ? "Pattern locked • Drag background to pan" + : "Drag pattern to move • Drag background to pan"} +
+
+ ); +} diff --git a/src/components/PatternCanvas/ThreadLegend.tsx b/src/components/PatternCanvas/ThreadLegend.tsx new file mode 100644 index 0000000..4f07738 --- /dev/null +++ b/src/components/PatternCanvas/ThreadLegend.tsx @@ -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 ( +
+

+ Colors +

+ {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 ( +
+
+
+
+ Color {idx + 1} +
+ {(primaryMetadata || secondaryMetadata) && ( +
+ {primaryMetadata} + {primaryMetadata && secondaryMetadata && ( + + )} + {secondaryMetadata} +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/src/components/PatternCanvas/ZoomControls.tsx b/src/components/PatternCanvas/ZoomControls.tsx new file mode 100644 index 0000000..9d5d59f --- /dev/null +++ b/src/components/PatternCanvas/ZoomControls.tsx @@ -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 ( +
+ + + + {Math.round(scale * 100)}% + + + +
+ ); +} diff --git a/src/components/PatternCanvas/index.ts b/src/components/PatternCanvas/index.ts new file mode 100644 index 0000000..ff58a71 --- /dev/null +++ b/src/components/PatternCanvas/index.ts @@ -0,0 +1 @@ +export { PatternCanvas } from "./PatternCanvas"; diff --git a/src/components/PatternCanvas/patternCanvasHelpers.ts b/src/components/PatternCanvas/patternCanvasHelpers.ts new file mode 100644 index 0000000..a13f794 --- /dev/null +++ b/src/components/PatternCanvas/patternCanvasHelpers.ts @@ -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, + }; +} diff --git a/src/hooks/useCanvasViewport.ts b/src/hooks/useCanvasViewport.ts new file mode 100644 index 0000000..b300945 --- /dev/null +++ b/src/hooks/useCanvasViewport.ts @@ -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; + 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(1); + const prevPesDataRef = useRef(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) => { + 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, + }; +} diff --git a/src/hooks/usePatternTransform.ts b/src/hooks/usePatternTransform.ts new file mode 100644 index 0000000..1f13f50 --- /dev/null +++ b/src/hooks/usePatternTransform.ts @@ -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(null); + const transformerRef = useRef(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) => { + 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) => { + 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, + }; +} diff --git a/src/stores/useMachineStore.ts b/src/stores/useMachineStore.ts index b33a3ee..0ecef31 100644 --- a/src/stores/useMachineStore.ts +++ b/src/stores/useMachineStore.ts @@ -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((set, get) => ({ resumeFileName: null, }); + // Clear uploaded pattern data in pattern store + usePatternStore.getState().clearUploadedPattern(); + await refreshStatus(); } catch (err) { set({ diff --git a/src/stores/usePatternStore.ts b/src/stores/usePatternStore.ts index 1c20dc2..3e1a850 100644 --- a/src/stores/usePatternStore.ts +++ b/src/stores/usePatternStore.ts @@ -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((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); diff --git a/src/utils/rotationUtils.test.ts b/src/utils/rotationUtils.test.ts new file mode 100644 index 0000000..25db9e2 --- /dev/null +++ b/src/utils/rotationUtils.test.ts @@ -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); + }); + }); +}); diff --git a/src/utils/rotationUtils.ts b/src/utils/rotationUtils.ts new file mode 100644 index 0000000..bcd9d1a --- /dev/null +++ b/src/utils/rotationUtils.ts @@ -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; +}