feature: Add pattern rotation with Konva Transformer

Implement comprehensive pattern rotation functionality:
- Use Konva Transformer for native rotation UI with visual handles
- Apply rotation transformation at upload time to stitch coordinates
- Two-layer preview system: original (draggable/rotatable) and uploaded (locked)
- Automatic position compensation for center shifts after rotation
- PEN encoding/decoding with proper bounds calculation from decoded stitches
- Comprehensive unit tests for rotation math and PEN round-trip
- Restore original unrotated pattern on delete

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-12-25 21:06:48 +01:00
parent ea879640a2
commit d813c22df5
7 changed files with 1196 additions and 211 deletions

View file

@ -11,6 +11,12 @@ import {
canUploadPattern, canUploadPattern,
getMachineStateCategory, getMachineStateCategory,
} from "../utils/machineStateHelpers"; } from "../utils/machineStateHelpers";
import {
transformStitchesRotation,
calculateRotatedBounds,
} from "../utils/rotationUtils";
import { encodeStitchesToPen } from "../formats/pen/encoder";
import { decodePenData } from "../formats/pen/decoder";
import { PatternInfoSkeleton } from "./SkeletonLoader"; import { PatternInfoSkeleton } from "./SkeletonLoader";
import { PatternInfo } from "./PatternInfo"; import { PatternInfo } from "./PatternInfo";
import { import {
@ -57,13 +63,17 @@ export function FileUpload() {
pesData: pesDataProp, pesData: pesDataProp,
currentFileName, currentFileName,
patternOffset, patternOffset,
patternRotation,
setPattern, setPattern,
setUploadedPattern,
} = usePatternStore( } = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
currentFileName: state.currentFileName, currentFileName: state.currentFileName,
patternOffset: state.patternOffset, patternOffset: state.patternOffset,
patternRotation: state.patternRotation,
setPattern: state.setPattern, setPattern: state.setPattern,
setUploadedPattern: state.setUploadedPattern,
})), })),
); );
@ -137,26 +147,169 @@ export function FileUpload() {
[fileService, setPattern, pyodideReady, initializePyodide], [fileService, setPattern, pyodideReady, initializePyodide],
); );
const handleUpload = useCallback(() => { const handleUpload = useCallback(async () => {
if (pesData && displayFileName) { if (pesData && displayFileName) {
uploadPattern(pesData.penData, pesData, displayFileName, patternOffset); let penDataToUpload = pesData.penData;
} let pesDataForUpload = pesData;
}, [pesData, displayFileName, uploadPattern, patternOffset]);
// 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(() => { const checkPatternFitsInHoop = useCallback(() => {
if (!pesData || !machineInfo) { if (!pesData || !machineInfo) {
return { fits: true, error: null }; 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; const { maxWidth, maxHeight } = machineInfo;
// Calculate pattern bounds with offset applied // The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas)
const patternMinX = bounds.minX + patternOffset.x; // So we need to calculate bounds relative to the center
const patternMaxX = bounds.maxX + patternOffset.x; const centerX = (bounds.minX + bounds.maxX) / 2;
const patternMinY = bounds.minY + patternOffset.y; const centerY = (bounds.minY + bounds.maxY) / 2;
const patternMaxY = bounds.maxY + patternOffset.y;
// 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) // Hoop bounds (centered at origin)
const hoopMinX = -maxWidth / 2; const hoopMinX = -maxWidth / 2;
@ -196,7 +349,7 @@ export function FileUpload() {
} }
return { fits: true, error: null }; return { fits: true, error: null };
}, [pesData, machineInfo, patternOffset]); }, [pesData, machineInfo, patternOffset, patternRotation]);
const boundsCheck = checkPatternFitsInHoop(); const boundsCheck = checkPatternFitsInHoop();

View file

@ -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 { Group, Line, Rect, Text, Circle } from "react-konva";
import type { KonvaEventObject } from "konva/lib/Node";
import type { PesPatternData } from "../formats/import/pesImporter"; import type { PesPatternData } from "../formats/import/pesImporter";
import { getThreadColor } from "../formats/import/pesImporter"; import { getThreadColor } from "../formats/import/pesImporter";
import type { MachineInfo } from "../types/machine"; import type { MachineInfo } from "../types/machine";
@ -293,3 +294,147 @@ export const CurrentPosition = memo(
); );
CurrentPosition.displayName = "CurrentPosition"; 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<MouseEvent>) => {
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<MouseEvent>) => {
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<MouseEvent>) => {
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 (
<Group
name="rotationHandle"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{/* Line from center to handle */}
<Line
points={[centerX, centerY, handleX, handleY]}
stroke="#FF6B6B"
strokeWidth={1}
dash={[5, 5]}
opacity={0.5}
/>
{/* Handle circle */}
<Circle
x={handleX}
y={handleY}
radius={10}
fill="#FF6B6B"
stroke="white"
strokeWidth={2}
onMouseDown={handleMouseDown}
onMouseEnter={(e) => {
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 && (
<Text
x={handleX + 15}
y={handleY - 20}
text={`${rotation.toFixed(0)}°`}
fontSize={12}
fill="black"
padding={4}
/>
)}
</Group>
);
},
);
RotationHandle.displayName = "RotationHandle";

View file

@ -2,8 +2,9 @@ import { useEffect, useRef, useState, useCallback } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
import { usePatternStore } from "../stores/usePatternStore"; 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 Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node";
import { import {
PlusIcon, PlusIcon,
MinusIcon, MinusIcon,
@ -45,12 +46,20 @@ export function PatternCanvas() {
const { const {
pesData, pesData,
patternOffset: initialPatternOffset, patternOffset: initialPatternOffset,
patternRotation: initialPatternRotation,
uploadedPesData,
uploadedPatternOffset: initialUploadedPatternOffset,
setPatternOffset, setPatternOffset,
setPatternRotation,
} = usePatternStore( } = usePatternStore(
useShallow((state) => ({ useShallow((state) => ({
pesData: state.pesData, pesData: state.pesData,
patternOffset: state.patternOffset, patternOffset: state.patternOffset,
patternRotation: state.patternRotation,
uploadedPesData: state.uploadedPesData,
uploadedPatternOffset: state.uploadedPatternOffset,
setPatternOffset: state.setPatternOffset, setPatternOffset: state.setPatternOffset,
setPatternRotation: state.setPatternRotation,
})), })),
); );
@ -58,12 +67,17 @@ export function PatternCanvas() {
const patternUploaded = usePatternUploaded(); const patternUploaded = usePatternUploaded();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null); const stageRef = useRef<Konva.Stage | null>(null);
const patternGroupRef = useRef<Konva.Group | null>(null);
const transformerRef = useRef<Konva.Transformer | null>(null);
const [stagePos, setStagePos] = useState({ x: 0, y: 0 }); const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
const [stageScale, setStageScale] = useState(1); const [stageScale, setStageScale] = useState(1);
const [localPatternOffset, setLocalPatternOffset] = useState( const [localPatternOffset, setLocalPatternOffset] = useState(
initialPatternOffset || { x: 0, y: 0 }, initialPatternOffset || { x: 0, y: 0 },
); );
const [localPatternRotation, setLocalPatternRotation] = useState(
initialPatternRotation || 0,
);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const initialScaleRef = useRef<number>(1); const initialScaleRef = useRef<number>(1);
const prevPesDataRef = useRef<PesPatternData | null>(null); const prevPesDataRef = useRef<PesPatternData | null>(null);
@ -81,6 +95,14 @@ export function PatternCanvas() {
); );
} }
// Update pattern rotation when initialPatternRotation changes
if (
initialPatternRotation !== undefined &&
localPatternRotation !== initialPatternRotation
) {
setLocalPatternRotation(initialPatternRotation);
}
// Track container size // Track container size
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@ -105,16 +127,18 @@ export function PatternCanvas() {
// Calculate and store initial scale when pattern or hoop changes // Calculate and store initial scale when pattern or hoop changes
useEffect(() => { 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; prevPesDataRef.current = null;
return; return;
} }
// Only recalculate if pattern changed // Only recalculate if pattern changed
if (prevPesDataRef.current !== pesData) { if (prevPesDataRef.current !== currentPattern) {
prevPesDataRef.current = pesData; prevPesDataRef.current = currentPattern;
const { bounds } = pesData; const { bounds } = currentPattern;
const viewWidth = machineInfo const viewWidth = machineInfo
? machineInfo.maxWidth ? machineInfo.maxWidth
: bounds.maxX - bounds.minX; : bounds.maxX - bounds.minX;
@ -135,7 +159,7 @@ export function PatternCanvas() {
setStageScale(initialScale); setStageScale(initialScale);
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 }); setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
} }
}, [pesData, machineInfo, containerSize]); }, [pesData, uploadedPesData, machineInfo, containerSize]);
// Wheel zoom handler // Wheel zoom handler
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => { const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
@ -252,10 +276,79 @@ export function PatternCanvas() {
[setPatternOffset], [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<Event>) => {
if (!pesData) return;
const node = e.target;
// Read rotation from the node
const totalRotation = node.rotation();
const normalizedRotation = ((totalRotation % 360) + 360) % 360;
setLocalPatternRotation(normalizedRotation);
// Also read position in case the Transformer affected it
const newOffset = {
x: node.x(),
y: node.y(),
};
setLocalPatternOffset(newOffset);
// Store rotation angle and position
setPatternRotation(normalizedRotation);
setPatternOffset(newOffset.x, newOffset.y);
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-tertiary-600 dark:border-tertiary-500"
: "border-gray-400 dark:border-gray-600"; : "border-gray-400 dark:border-gray-600";
const iconColor = pesData const iconColor = hasPattern
? "text-tertiary-600 dark:text-tertiary-400" ? "text-tertiary-600 dark:text-tertiary-400"
: "text-gray-600 dark:text-gray-400"; : "text-gray-600 dark:text-gray-400";
@ -268,12 +361,27 @@ export function PatternCanvas() {
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} /> <PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<CardTitle className="text-sm">Pattern Preview</CardTitle> <CardTitle className="text-sm">Pattern Preview</CardTitle>
{pesData ? ( {hasPattern ? (
<CardDescription className="text-xs"> <CardDescription className="text-xs">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "} {(() => {
const displayPattern = uploadedPesData || pesData;
return displayPattern ? (
<>
{(
(displayPattern.bounds.maxX -
displayPattern.bounds.minX) /
10
).toFixed(1)}{" "}
×{" "} ×{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)}{" "} {(
(displayPattern.bounds.maxY -
displayPattern.bounds.minY) /
10
).toFixed(1)}{" "}
mm mm
</>
) : null;
})()}
</CardDescription> </CardDescription>
) : ( ) : (
<CardDescription className="text-xs"> <CardDescription className="text-xs">
@ -317,11 +425,11 @@ export function PatternCanvas() {
> >
{/* Background layer: grid, origin, hoop */} {/* Background layer: grid, origin, hoop */}
<Layer> <Layer>
{pesData && ( {hasPattern && (
<> <>
<Grid <Grid
gridSize={100} gridSize={100}
bounds={pesData.bounds} bounds={(uploadedPesData || pesData)!.bounds}
machineInfo={machineInfo} machineInfo={machineInfo}
/> />
<Origin /> <Origin />
@ -330,23 +438,48 @@ export function PatternCanvas() {
)} )}
</Layer> </Layer>
{/* Pattern layer: draggable stitches and bounds */} {/* Original pattern layer: draggable with transformer (shown before upload starts) */}
<Layer> <Layer visible={!isUploading && !patternUploaded}>
{pesData && ( {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 (
<>
<Group <Group
name="pattern-group" name="pattern-group"
draggable={!patternUploaded && !isUploading} ref={(node) => {
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} x={localPatternOffset.x}
y={localPatternOffset.y} y={localPatternOffset.y}
offsetX={originalCenterX}
offsetY={originalCenterY}
onDragEnd={handlePatternDragEnd} onDragEnd={handlePatternDragEnd}
onTransformEnd={handleTransformEnd}
onMouseEnter={(e) => { onMouseEnter={(e) => {
const stage = e.target.getStage(); const stage = e.target.getStage();
if (stage && !patternUploaded && !isUploading) if (stage && !isUploading)
stage.container().style.cursor = "move"; stage.container().style.cursor = "move";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
const stage = e.target.getStage(); const stage = e.target.getStage();
if (stage && !patternUploaded && !isUploading) if (stage && !isUploading)
stage.container().style.cursor = "grab"; stage.container().style.cursor = "grab";
}} }}
> >
@ -357,34 +490,114 @@ export function PatternCanvas() {
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
const colorIndex = const colorIndex =
pesData.penStitches.colorBlocks.find( pesData.penStitches.colorBlocks.find(
(b) => i >= b.startStitch && i <= b.endStitch, (b) =>
i >= b.startStitch && i <= b.endStitch,
)?.colorIndex ?? 0; )?.colorIndex ?? 0;
return [s.x, s.y, cmd, colorIndex]; return [s.x, s.y, cmd, colorIndex];
}, },
)} )}
pesData={pesData} pesData={pesData}
currentStitchIndex={sewingProgress?.currentStitch || 0} currentStitchIndex={0}
showProgress={patternUploaded || isUploading} showProgress={false}
/> />
<PatternBounds bounds={pesData.bounds} /> <PatternBounds bounds={pesData.bounds} />
</Group> </Group>
)} <Transformer
ref={(node) => {
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}
/>
</>
);
})()}
</Layer> </Layer>
{/* Current position layer */} {/* Uploaded pattern layer: locked, rotation baked in (shown during and after upload) */}
<Layer> <Layer visible={isUploading || patternUploaded}>
{pesData && {uploadedPesData &&
pesData.penStitches && (() => {
sewingProgress && const uploadedCenterX =
sewingProgress.currentStitch > 0 && ( (uploadedPesData.bounds.minX +
<Group x={localPatternOffset.x} y={localPatternOffset.y}> uploadedPesData.bounds.maxX) /
<CurrentPosition 2;
currentStitchIndex={sewingProgress.currentStitch} const uploadedCenterY =
stitches={pesData.penStitches.stitches.map( (uploadedPesData.bounds.minY +
uploadedPesData.bounds.maxY) /
2;
console.log("[Canvas] Rendering uploaded pattern:", {
position: initialUploadedPatternOffset,
center: { x: uploadedCenterX, y: uploadedCenterY },
bounds: uploadedPesData.bounds,
});
return (
<Group
name="uploaded-pattern-group"
x={initialUploadedPatternOffset.x}
y={initialUploadedPatternOffset.y}
offsetX={uploadedCenterX}
offsetY={uploadedCenterY}
>
<Stitches
stitches={uploadedPesData.penStitches.stitches.map(
(s, i): [number, number, number, number] => { (s, i): [number, number, number, number] => {
const cmd = s.isJump ? 0x10 : 0; const cmd = s.isJump ? 0x10 : 0;
const colorIndex = 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];
},
)}
pesData={uploadedPesData}
currentStitchIndex={
sewingProgress?.currentStitch || 0
}
showProgress={true}
/>
<PatternBounds bounds={uploadedPesData.bounds} />
</Group>
);
})()}
</Layer>
{/* Current position layer (for uploaded pattern during sewing) */}
<Layer visible={isUploading || patternUploaded}>
{uploadedPesData &&
sewingProgress &&
sewingProgress.currentStitch > 0 && (
<Group
x={initialUploadedPatternOffset.x}
y={initialUploadedPatternOffset.y}
offsetX={
(uploadedPesData.bounds.minX +
uploadedPesData.bounds.maxX) /
2
}
offsetY={
(uploadedPesData.bounds.minY +
uploadedPesData.bounds.maxY) /
2
}
>
<CurrentPosition
currentStitchIndex={sewingProgress.currentStitch}
stitches={uploadedPesData.penStitches.stitches.map(
(s, i): [number, number, number, number] => {
const cmd = s.isJump ? 0x10 : 0;
const colorIndex =
uploadedPesData.penStitches.colorBlocks.find(
(b) => i >= b.startStitch && i <= b.endStitch, (b) => i >= b.startStitch && i <= b.endStitch,
)?.colorIndex ?? 0; )?.colorIndex ?? 0;
return [s.x, s.y, cmd, colorIndex]; return [s.x, s.y, cmd, colorIndex];
@ -398,25 +611,31 @@ export function PatternCanvas() {
)} )}
{/* Placeholder overlay when no pattern is loaded */} {/* Placeholder overlay when no pattern is loaded */}
{!pesData && ( {!hasPattern && (
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic"> <div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
Load a PES file to preview the pattern Load a PES file to preview the pattern
</div> </div>
)} )}
{/* Pattern info overlays */} {/* Pattern info overlays */}
{pesData && ( {hasPattern &&
(() => {
const displayPattern = uploadedPesData || pesData;
return (
displayPattern && (
<> <>
{/* Thread Legend Overlay */} {/* Thread Legend Overlay */}
<div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]"> <div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
<h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5"> <h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
Colors Colors
</h4> </h4>
{pesData.uniqueColors.map((color, idx) => { {displayPattern.uniqueColors.map((color, idx) => {
// Primary metadata: brand and catalog number // Primary metadata: brand and catalog number
const primaryMetadata = [ const primaryMetadata = [
color.brand, color.brand,
color.catalogNumber ? `#${color.catalogNumber}` : null, color.catalogNumber
? `#${color.catalogNumber}`
: null,
] ]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
@ -463,7 +682,7 @@ export function PatternCanvas() {
{/* Pattern Offset Indicator */} {/* Pattern Offset Indicator */}
<div <div
className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${ className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
patternUploaded isUploading || patternUploaded
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600" ? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
: "bg-white/95 dark:bg-gray-800/95" : "bg-white/95 dark:bg-gray-800/95"
}`} }`}
@ -472,19 +691,41 @@ export function PatternCanvas() {
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider"> <div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Pattern Position: Pattern Position:
</div> </div>
{patternUploaded && ( {(isUploading || patternUploaded) && (
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400"> <div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" /> <LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs font-bold">LOCKED</span> <span className="text-xs font-bold">
{isUploading ? "UPLOADING" : "LOCKED"}
</span>
</div> </div>
)} )}
</div> </div>
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1"> <div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
{isUploading || patternUploaded ? (
<>
X:{" "}
{(initialUploadedPatternOffset.x / 10).toFixed(1)}
mm, Y:{" "}
{(initialUploadedPatternOffset.y / 10).toFixed(1)}mm
</>
) : (
<>
X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "} X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
{(localPatternOffset.y / 10).toFixed(1)}mm {(localPatternOffset.y / 10).toFixed(1)}mm
</>
)}
</div> </div>
{!isUploading &&
!patternUploaded &&
localPatternRotation !== 0 && (
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
Rotation: {localPatternRotation.toFixed(1)}°
</div>
)}
<div className="text-xs text-gray-600 dark:text-gray-400 italic"> <div className="text-xs text-gray-600 dark:text-gray-400 italic">
{patternUploaded {isUploading
? "Uploading pattern..."
: patternUploaded
? "Pattern locked • Drag background to pan" ? "Pattern locked • Drag background to pan"
: "Drag pattern to move • Drag background to pan"} : "Drag pattern to move • Drag background to pan"}
</div> </div>
@ -534,7 +775,9 @@ export function PatternCanvas() {
</Button> </Button>
</div> </div>
</> </>
)} )
);
})()}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

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

View file

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

View file

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

View file

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