mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
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:
parent
ea879640a2
commit
d813c22df5
7 changed files with 1196 additions and 211 deletions
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
314
src/utils/rotationUtils.test.ts
Normal file
314
src/utils/rotationUtils.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
82
src/utils/rotationUtils.ts
Normal file
82
src/utils/rotationUtils.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue