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,
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();

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 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<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 { 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<HTMLDivElement>(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 [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<number>(1);
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
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<WheelEvent>) => {
@ -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<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-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() {
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Pattern Preview</CardTitle>
{pesData ? (
{hasPattern ? (
<CardDescription className="text-xs">
{((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;
})()}
</CardDescription>
) : (
<CardDescription className="text-xs">
@ -317,11 +425,11 @@ export function PatternCanvas() {
>
{/* Background layer: grid, origin, hoop */}
<Layer>
{pesData && (
{hasPattern && (
<>
<Grid
gridSize={100}
bounds={pesData.bounds}
bounds={(uploadedPesData || pesData)!.bounds}
machineInfo={machineInfo}
/>
<Origin />
@ -330,61 +438,166 @@ export function PatternCanvas() {
)}
</Layer>
{/* Pattern layer: draggable stitches and bounds */}
<Layer>
{pesData && (
<Group
name="pattern-group"
draggable={!patternUploaded && !isUploading}
x={localPatternOffset.x}
y={localPatternOffset.y}
onDragEnd={handlePatternDragEnd}
onMouseEnter={(e) => {
const stage = e.target.getStage();
if (stage && !patternUploaded && !isUploading)
stage.container().style.cursor = "move";
}}
onMouseLeave={(e) => {
const stage = e.target.getStage();
if (stage && !patternUploaded && !isUploading)
stage.container().style.cursor = "grab";
}}
>
<Stitches
stitches={pesData.penStitches.stitches.map(
(s, i): [number, number, number, number] => {
// Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
const colorIndex =
pesData.penStitches.colorBlocks.find(
(b) => i >= b.startStitch && i <= b.endStitch,
)?.colorIndex ?? 0;
return [s.x, s.y, cmd, colorIndex];
},
)}
pesData={pesData}
currentStitchIndex={sewingProgress?.currentStitch || 0}
showProgress={patternUploaded || isUploading}
/>
<PatternBounds bounds={pesData.bounds} />
</Group>
)}
{/* Original pattern layer: draggable with transformer (shown before upload starts) */}
<Layer visible={!isUploading && !patternUploaded}>
{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
name="pattern-group"
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}
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";
}}
>
<Stitches
stitches={pesData.penStitches.stitches.map(
(s, i): [number, number, number, number] => {
// Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
const colorIndex =
pesData.penStitches.colorBlocks.find(
(b) =>
i >= b.startStitch && i <= b.endStitch,
)?.colorIndex ?? 0;
return [s.x, s.y, cmd, colorIndex];
},
)}
pesData={pesData}
currentStitchIndex={0}
showProgress={false}
/>
<PatternBounds bounds={pesData.bounds} />
</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>
{/* Current position layer */}
<Layer>
{pesData &&
pesData.penStitches &&
{/* Uploaded pattern layer: locked, rotation baked in (shown during and after upload) */}
<Layer visible={isUploading || patternUploaded}>
{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 (
<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] => {
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}
/>
<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={localPatternOffset.x} y={localPatternOffset.y}>
<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={pesData.penStitches.stitches.map(
stitches={uploadedPesData.penStitches.stitches.map(
(s, i): [number, number, number, number] => {
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 && (
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
Load a PES file to preview the pattern
</div>
)}
{/* Pattern info overlays */}
{pesData && (
<>
{/* Thread Legend Overlay */}
<div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
<h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
Colors
</h4>
{pesData.uniqueColors.map((color, idx) => {
// Primary metadata: brand and catalog number
const primaryMetadata = [
color.brand,
color.catalogNumber ? `#${color.catalogNumber}` : null,
]
.filter(Boolean)
.join(" ");
{hasPattern &&
(() => {
const displayPattern = uploadedPesData || pesData;
return (
displayPattern && (
<>
{/* Thread Legend Overlay */}
<div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
<h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
Colors
</h4>
{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 (
<div
key={idx}
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
>
<div
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
style={{ backgroundColor: color.hex }}
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
Color {idx + 1}
</div>
{(primaryMetadata || secondaryMetadata) && (
<div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
{primaryMetadata}
{primaryMetadata && secondaryMetadata && (
<span className="mx-1"></span>
)}
{secondaryMetadata}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Pattern Offset Indicator */}
<div
key={idx}
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
isUploading || patternUploaded
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
: "bg-white/95 dark:bg-gray-800/95"
}`}
>
<div
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
style={{ backgroundColor: color.hex }}
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
Color {idx + 1}
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Pattern Position:
</div>
{(primaryMetadata || secondaryMetadata) && (
<div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
{primaryMetadata}
{primaryMetadata && secondaryMetadata && (
<span className="mx-1"></span>
)}
{secondaryMetadata}
{(isUploading || patternUploaded) && (
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs font-bold">
{isUploading ? "UPLOADING" : "LOCKED"}
</span>
</div>
)}
</div>
<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:{" "}
{(localPatternOffset.y / 10).toFixed(1)}mm
</>
)}
</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">
{isUploading
? "Uploading pattern..."
: patternUploaded
? "Pattern locked • Drag background to pan"
: "Drag pattern to move • Drag background to pan"}
</div>
</div>
);
})}
</div>
{/* Pattern Offset Indicator */}
<div
className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
patternUploaded
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
: "bg-white/95 dark:bg-gray-800/95"
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Pattern Position:
</div>
{patternUploaded && (
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs font-bold">LOCKED</span>
{/* Zoom Controls Overlay */}
<div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleCenterPattern}
disabled={!pesData || patternUploaded || isUploading}
title="Center Pattern in Hoop"
>
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleZoomIn}
title="Zoom In"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
{Math.round(stageScale * 100)}%
</span>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleZoomOut}
title="Zoom Out"
>
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8 ml-1"
onClick={handleZoomReset}
title="Reset Zoom"
>
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
</div>
)}
</div>
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
{(localPatternOffset.y / 10).toFixed(1)}mm
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
{patternUploaded
? "Pattern locked • Drag background to pan"
: "Drag pattern to move • Drag background to pan"}
</div>
</div>
{/* Zoom Controls Overlay */}
<div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleCenterPattern}
disabled={!pesData || patternUploaded || isUploading}
title="Center Pattern in Hoop"
>
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleZoomIn}
title="Zoom In"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
{Math.round(stageScale * 100)}%
</span>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleZoomOut}
title="Zoom Out"
>
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8 ml-1"
onClick={handleZoomReset}
title="Reset Zoom"
>
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
</div>
</>
)}
</>
)
);
})()}
</div>
</CardContent>
</Card>

View file

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

View file

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

View file

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