diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index f4ec242..3ad24c4 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -11,6 +11,12 @@ 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 { PatternInfoSkeleton } from "./SkeletonLoader"; import { PatternInfo } from "./PatternInfo"; import { @@ -57,13 +63,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 +147,169 @@ 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) { + console.log( + "[FileUpload] Applying rotation before upload:", + patternRotation, + ); + + // 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) + let decodedMinX = Infinity, + decodedMaxX = -Infinity; + let decodedMinY = Infinity, + decodedMaxY = -Infinity; + for (const stitch of decoded.stitches) { + if (stitch.x < decodedMinX) decodedMinX = stitch.x; + if (stitch.x > decodedMaxX) decodedMaxX = stitch.x; + if (stitch.y < decodedMinY) decodedMinY = stitch.y; + if (stitch.y > decodedMaxY) decodedMaxY = stitch.y; + } + const rotatedBounds = { + minX: decodedMinX, + maxX: decodedMaxX, + minY: decodedMinY, + maxY: decodedMaxY, + }; + + // Calculate the center of the rotated pattern + const originalCenterX = (pesData.bounds.minX + pesData.bounds.maxX) / 2; + const originalCenterY = (pesData.bounds.minY + pesData.bounds.maxY) / 2; + const rotatedCenterX = (rotatedBounds.minX + rotatedBounds.maxX) / 2; + const rotatedCenterY = (rotatedBounds.minY + rotatedBounds.maxY) / 2; + const centerShiftX = rotatedCenterX - originalCenterX; + const centerShiftY = rotatedCenterY - originalCenterY; + + console.log("[FileUpload] Pattern centers:", { + originalCenter: { x: originalCenterX, y: originalCenterY }, + rotatedCenter: { x: rotatedCenterX, y: rotatedCenterY }, + centerShift: { x: centerShiftX, y: centerShiftY }, + }); + + // CRITICAL: Adjust position to compensate for the center shift! + // In Konva, visual position = (x - offsetX, y - offsetY). + // Original visual pos: (x - originalCenterX, y - originalCenterY) + // New visual pos: (newX - rotatedCenterX, newY - rotatedCenterY) + // For same visual position: newX = x + (rotatedCenterX - originalCenterX) + // So we need to add (rotatedCenter - originalCenter) to the position. + const adjustedOffset = { + x: patternOffset.x + centerShiftX, + y: patternOffset.y + centerShiftY, + }; + + console.log( + "[FileUpload] Adjusting position to compensate for center shift:", + { + originalPosition: patternOffset, + adjustedPosition: adjustedOffset, + shift: { x: centerShiftX, 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 + console.log("[FileUpload] Saving uploaded pattern for preview"); + 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 centerX = (bounds.minX + bounds.maxX) / 2; + const centerY = (bounds.minY + bounds.maxY) / 2; + + // Calculate actual bounds in world coordinates + const patternMinX = patternOffset.x - centerX + bounds.minX; + const patternMaxX = patternOffset.x - centerX + bounds.maxX; + const patternMinY = patternOffset.y - centerY + bounds.minY; + const patternMaxY = patternOffset.y - centerY + bounds.maxY; + + console.log("[Bounds Check] Pattern center:", { centerX, centerY }); + console.log("[Bounds Check] Offset (center position):", patternOffset); + console.log("[Bounds Check] Pattern bounds with offset:", { + minX: patternMinX, + maxX: patternMaxX, + minY: patternMinY, + maxY: patternMaxY, + }); + console.log("[Bounds Check] Hoop bounds:", { + minX: -maxWidth / 2, + maxX: maxWidth / 2, + minY: -maxHeight / 2, + maxY: maxHeight / 2, + }); // Hoop bounds (centered at origin) const hoopMinX = -maxWidth / 2; @@ -196,7 +349,7 @@ export function FileUpload() { } return { fits: true, error: null }; - }, [pesData, machineInfo, patternOffset]); + }, [pesData, machineInfo, patternOffset, patternRotation]); const boundsCheck = checkPatternFitsInHoop(); diff --git a/src/components/KonvaComponents.tsx b/src/components/KonvaComponents.tsx index a8e15cb..a486628 100644 --- a/src/components/KonvaComponents.tsx +++ b/src/components/KonvaComponents.tsx @@ -1,5 +1,6 @@ -import { memo, useMemo } from "react"; +import { memo, useMemo, useState, useCallback } from "react"; import { Group, Line, Rect, Text, Circle } from "react-konva"; +import type { KonvaEventObject } from "konva/lib/Node"; import type { PesPatternData } from "../formats/import/pesImporter"; import { getThreadColor } from "../formats/import/pesImporter"; import type { MachineInfo } from "../types/machine"; @@ -293,3 +294,147 @@ export const CurrentPosition = memo( ); CurrentPosition.displayName = "CurrentPosition"; + +interface RotationHandleProps { + bounds: { minX: number; maxX: number; minY: number; maxY: number }; + rotation: number; + onRotationChange: (angle: number) => void; + onRotationEnd: (angle: number) => void; + disabled?: boolean; +} + +export const RotationHandle = memo( + ({ + bounds, + rotation, + onRotationChange, + onRotationEnd, + disabled, + }: RotationHandleProps) => { + const [isDragging, setIsDragging] = useState(false); + const [startAngle, setStartAngle] = useState(0); + + const centerX = (bounds.minX + bounds.maxX) / 2; + const centerY = (bounds.minY + bounds.maxY) / 2; + + // Calculate handle position based on rotation angle + // Start position is top-right corner (maxX, minY), which corresponds to -45° in standard coords + const radius = Math.sqrt( + Math.pow(bounds.maxX - centerX, 2) + Math.pow(bounds.minY - centerY, 2), + ); + const baseAngle = Math.atan2(bounds.minY - centerY, bounds.maxX - centerX); + const currentAngleRad = baseAngle + (rotation * Math.PI) / 180; + const handleX = centerX + radius * Math.cos(currentAngleRad); + const handleY = centerY + radius * Math.sin(currentAngleRad); + + const handleMouseDown = useCallback( + (e: KonvaEventObject) => { + if (disabled) return; + setIsDragging(true); + + const stage = e.target.getStage(); + if (!stage) return; + const pos = stage.getPointerPosition(); + if (!pos) return; + const angle = + (Math.atan2(pos.y - centerY, pos.x - centerX) * 180) / Math.PI; + setStartAngle(angle - rotation); + }, + [disabled, centerX, centerY, rotation], + ); + + const handleMouseMove = useCallback( + (e: KonvaEventObject) => { + if (disabled || !isDragging) return; + + const stage = e.target.getStage(); + if (!stage) return; + const pos = stage.getPointerPosition(); + if (!pos) return; + let angle = + (Math.atan2(pos.y - centerY, pos.x - centerX) * 180) / Math.PI; + angle = angle - startAngle; + + // Snap to 15° if Shift key held + if (e.evt.shiftKey) { + angle = Math.round(angle / 15) * 15; + } + + const normalized = ((angle % 360) + 360) % 360; + onRotationChange(normalized); + }, + [disabled, isDragging, centerX, centerY, startAngle, onRotationChange], + ); + + const handleMouseUp = useCallback( + (e: KonvaEventObject) => { + if (disabled || !isDragging) return; + setIsDragging(false); + + const stage = e.target.getStage(); + if (!stage) return; + const pos = stage.getPointerPosition(); + if (!pos) return; + let angle = + (Math.atan2(pos.y - centerY, pos.x - centerX) * 180) / Math.PI; + angle = angle - startAngle; + + const normalized = ((angle % 360) + 360) % 360; + onRotationEnd(normalized); + }, + [disabled, isDragging, centerX, centerY, startAngle, onRotationEnd], + ); + + if (disabled) return null; + + return ( + + {/* Line from center to handle */} + + + {/* Handle circle */} + { + const container = e.target.getStage()?.container(); + if (container) container.style.cursor = "grab"; + }} + onMouseLeave={(e) => { + const container = e.target.getStage()?.container(); + if (container) container.style.cursor = "default"; + }} + /> + + {/* Angle text */} + {isDragging && ( + + )} + + ); + }, +); + +RotationHandle.displayName = "RotationHandle"; diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx index 098c022..5ad18e4 100644 --- a/src/components/PatternCanvas.tsx +++ b/src/components/PatternCanvas.tsx @@ -2,8 +2,9 @@ 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 { Stage, Layer, Group, Transformer } from "react-konva"; import Konva from "konva"; +import type { KonvaEventObject } from "konva/lib/Node"; import { PlusIcon, MinusIcon, @@ -45,12 +46,20 @@ export function PatternCanvas() { 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, })), ); @@ -58,12 +67,17 @@ export function PatternCanvas() { const patternUploaded = usePatternUploaded(); const containerRef = useRef(null); const stageRef = useRef(null); + const patternGroupRef = useRef(null); + const transformerRef = 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 [localPatternRotation, setLocalPatternRotation] = useState( + initialPatternRotation || 0, + ); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const initialScaleRef = useRef(1); const prevPesDataRef = useRef(null); @@ -81,6 +95,14 @@ export function PatternCanvas() { ); } + // Update pattern rotation when initialPatternRotation changes + if ( + initialPatternRotation !== undefined && + localPatternRotation !== initialPatternRotation + ) { + setLocalPatternRotation(initialPatternRotation); + } + // Track container size useEffect(() => { if (!containerRef.current) return; @@ -105,16 +127,18 @@ export function PatternCanvas() { // Calculate and store initial scale when pattern or hoop changes useEffect(() => { - if (!pesData || containerSize.width === 0) { + // 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 !== pesData) { - prevPesDataRef.current = pesData; + if (prevPesDataRef.current !== currentPattern) { + prevPesDataRef.current = currentPattern; - const { bounds } = pesData; + const { bounds } = currentPattern; const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX; @@ -135,7 +159,7 @@ export function PatternCanvas() { setStageScale(initialScale); setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 }); } - }, [pesData, machineInfo, containerSize]); + }, [pesData, uploadedPesData, machineInfo, containerSize]); // Wheel zoom handler const handleWheel = useCallback((e: Konva.KonvaEventObject) => { @@ -252,10 +276,79 @@ export function PatternCanvas() { [setPatternOffset], ); - const borderColor = pesData + // Attach/detach transformer based on state + const attachTransformer = useCallback(() => { + if (!transformerRef.current || !patternGroupRef.current) { + console.log( + "[PatternCanvas] Cannot attach transformer - refs not ready", + { + hasTransformer: !!transformerRef.current, + hasPatternGroup: !!patternGroupRef.current, + }, + ); + return; + } + + if (!patternUploaded && !isUploading) { + console.log("[PatternCanvas] Attaching transformer"); + transformerRef.current.nodes([patternGroupRef.current]); + transformerRef.current.getLayer()?.batchDraw(); + } else { + console.log("[PatternCanvas] Detaching transformer"); + 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]); + + // 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); + + console.log( + "[Canvas] Transform end - rotation:", + normalizedRotation, + "degrees, position:", + newOffset, + ); + }, + [setPatternRotation, setPatternOffset, pesData], + ); + + const hasPattern = pesData || uploadedPesData; + const borderColor = hasPattern ? "border-tertiary-600 dark:border-tertiary-500" : "border-gray-400 dark:border-gray-600"; - const iconColor = pesData + const iconColor = hasPattern ? "text-tertiary-600 dark:text-tertiary-400" : "text-gray-600 dark:text-gray-400"; @@ -268,12 +361,27 @@ export function PatternCanvas() {
Pattern Preview - {pesData ? ( + {hasPattern ? ( - {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "} - ×{" "} - {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)}{" "} - mm + {(() => { + 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; + })()} ) : ( @@ -317,11 +425,11 @@ export function PatternCanvas() { > {/* Background layer: grid, origin, hoop */} - {pesData && ( + {hasPattern && ( <> @@ -330,61 +438,166 @@ export function PatternCanvas() { )} - {/* 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} - /> - - - )} + {/* Original pattern layer: draggable with transformer (shown before upload starts) */} + + {pesData && + (() => { + const originalCenterX = + (pesData.bounds.minX + pesData.bounds.maxX) / 2; + const originalCenterY = + (pesData.bounds.minY + pesData.bounds.maxY) / 2; + console.log("[Canvas] Rendering original pattern:", { + position: localPatternOffset, + rotation: localPatternRotation, + center: { x: originalCenterX, y: originalCenterY }, + bounds: pesData.bounds, + }); + return ( + <> + { + patternGroupRef.current = node; + // Set initial rotation from state + if (node) { + node.rotation(localPatternRotation); + // Try to attach transformer when group is mounted + attachTransformer(); + } + }} + draggable={!isUploading} + x={localPatternOffset.x} + y={localPatternOffset.y} + offsetX={originalCenterX} + offsetY={originalCenterY} + onDragEnd={handlePatternDragEnd} + onTransformEnd={handleTransformEnd} + onMouseEnter={(e) => { + const stage = e.target.getStage(); + if (stage && !isUploading) + stage.container().style.cursor = "move"; + }} + onMouseLeave={(e) => { + const stage = e.target.getStage(); + if (stage && !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={0} + showProgress={false} + /> + + + { + transformerRef.current = node; + // Try to attach transformer when transformer is mounted + if (node) { + 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 layer */} - - {pesData && - pesData.penStitches && + {/* Uploaded pattern layer: locked, rotation baked in (shown during and after upload) */} + + {uploadedPesData && + (() => { + const uploadedCenterX = + (uploadedPesData.bounds.minX + + uploadedPesData.bounds.maxX) / + 2; + const uploadedCenterY = + (uploadedPesData.bounds.minY + + uploadedPesData.bounds.maxY) / + 2; + console.log("[Canvas] Rendering uploaded pattern:", { + position: initialUploadedPatternOffset, + center: { x: uploadedCenterX, y: uploadedCenterY }, + bounds: uploadedPesData.bounds, + }); + return ( + + { + const cmd = s.isJump ? 0x10 : 0; + const colorIndex = + uploadedPesData.penStitches.colorBlocks.find( + (b) => i >= b.startStitch && i <= b.endStitch, + )?.colorIndex ?? 0; + return [s.x, s.y, cmd, colorIndex]; + }, + )} + pesData={uploadedPesData} + currentStitchIndex={ + sewingProgress?.currentStitch || 0 + } + showProgress={true} + /> + + + ); + })()} + + + {/* Current position layer (for uploaded pattern during sewing) */} + + {uploadedPesData && sewingProgress && sewingProgress.currentStitch > 0 && ( - + { const cmd = s.isJump ? 0x10 : 0; const colorIndex = - pesData.penStitches.colorBlocks.find( + uploadedPesData.penStitches.colorBlocks.find( (b) => i >= b.startStitch && i <= b.endStitch, )?.colorIndex ?? 0; return [s.x, s.y, cmd, colorIndex]; @@ -398,143 +611,173 @@ export function PatternCanvas() { )} {/* Placeholder overlay when no pattern is loaded */} - {!pesData && ( + {!hasPattern && (
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(" "); + {hasPattern && + (() => { + const displayPattern = uploadedPesData || pesData; + return ( + displayPattern && ( + <> + {/* Thread Legend Overlay */} +
+

+ Colors +

+ {displayPattern.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(" "); + // 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 ( + return ( +
+
+
+
+ Color {idx + 1} +
+ {(primaryMetadata || secondaryMetadata) && ( +
+ {primaryMetadata} + {primaryMetadata && secondaryMetadata && ( + + )} + {secondaryMetadata} +
+ )} +
+
+ ); + })} +
+ + {/* Pattern Offset Indicator */}
-
-
-
- Color {idx + 1} +
+
+ Pattern Position:
- {(primaryMetadata || secondaryMetadata) && ( -
- {primaryMetadata} - {primaryMetadata && secondaryMetadata && ( - - )} - {secondaryMetadata} + {(isUploading || patternUploaded) && ( +
+ + + {isUploading ? "UPLOADING" : "LOCKED"} +
)}
+
+ {isUploading || patternUploaded ? ( + <> + X:{" "} + {(initialUploadedPatternOffset.x / 10).toFixed(1)} + mm, Y:{" "} + {(initialUploadedPatternOffset.y / 10).toFixed(1)}mm + + ) : ( + <> + X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "} + {(localPatternOffset.y / 10).toFixed(1)}mm + + )} +
+ {!isUploading && + !patternUploaded && + localPatternRotation !== 0 && ( +
+ Rotation: {localPatternRotation.toFixed(1)}° +
+ )} +
+ {isUploading + ? "Uploading pattern..." + : patternUploaded + ? "Pattern locked • Drag background to pan" + : "Drag pattern to move • Drag background to pan"} +
- ); - })} -
- {/* Pattern Offset Indicator */} -
-
-
- Pattern Position: -
- {patternUploaded && ( -
- - LOCKED + {/* Zoom Controls Overlay */} +
+ + + + {Math.round(stageScale * 100)}% + + +
- )} -
-
- 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/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..9724602 --- /dev/null +++ b/src/utils/rotationUtils.ts @@ -0,0 +1,82 @@ +/** + * 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 centerX = (bounds.minX + bounds.maxX) / 2; + const centerY = (bounds.minY + bounds.maxY) / 2; + + return stitches.map(([x, y, cmd, colorIndex]) => { + const rotated = rotatePoint(x, y, centerX, centerY, 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 centerX = (bounds.minX + bounds.maxX) / 2; + const centerY = (bounds.minY + bounds.maxY) / 2; + + // 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, centerX, centerY, 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; +}