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