mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
Merge pull request #28 from jhbruhn/feature/pattern-rotation
Some checks failed
Build, Test, and Lint / Build, Test, and Lint (push) Has been cancelled
Draft Release / Draft Release (push) Has been cancelled
Draft Release / Build Web App (push) Has been cancelled
Draft Release / Build Release - macos-latest (push) Has been cancelled
Draft Release / Build Release - ubuntu-latest (push) Has been cancelled
Draft Release / Build Release - windows-latest (push) Has been cancelled
Draft Release / Upload to GitHub Release (push) Has been cancelled
Some checks failed
Build, Test, and Lint / Build, Test, and Lint (push) Has been cancelled
Draft Release / Draft Release (push) Has been cancelled
Draft Release / Build Web App (push) Has been cancelled
Draft Release / Build Release - macos-latest (push) Has been cancelled
Draft Release / Build Release - ubuntu-latest (push) Has been cancelled
Draft Release / Build Release - windows-latest (push) Has been cancelled
Draft Release / Upload to GitHub Release (push) Has been cancelled
Feature: pattern rotation
This commit is contained in:
commit
7fd31d209c
16 changed files with 1624 additions and 576 deletions
|
|
@ -11,6 +11,16 @@ 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 {
|
||||
calculatePatternCenter,
|
||||
calculateBoundsFromDecodedStitches,
|
||||
} from "./PatternCanvas/patternCanvasHelpers";
|
||||
import { PatternInfoSkeleton } from "./SkeletonLoader";
|
||||
import { PatternInfo } from "./PatternInfo";
|
||||
import {
|
||||
|
|
@ -57,13 +67,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 +151,115 @@ 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) {
|
||||
// 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)
|
||||
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
|
||||
|
||||
// Calculate the center of the rotated pattern
|
||||
const originalCenter = calculatePatternCenter(pesData.bounds);
|
||||
const rotatedCenter = calculatePatternCenter(rotatedBounds);
|
||||
const centerShiftX = rotatedCenter.x - originalCenter.x;
|
||||
const centerShiftY = rotatedCenter.y - originalCenter.y;
|
||||
|
||||
// CRITICAL: Adjust position to compensate for the center shift!
|
||||
// In Konva, visual position = (x - offsetX, y - offsetY).
|
||||
// Original visual pos: (x - originalCenter.x, y - originalCenter.y)
|
||||
// New visual pos: (newX - rotatedCenter.x, newY - rotatedCenter.y)
|
||||
// For same visual position: newX = x + (rotatedCenter.x - originalCenter.x)
|
||||
// So we need to add (rotatedCenter - originalCenter) to the position.
|
||||
const adjustedOffset = {
|
||||
x: patternOffset.x + centerShiftX,
|
||||
y: patternOffset.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
|
||||
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 center = calculatePatternCenter(bounds);
|
||||
|
||||
// Calculate actual bounds in world coordinates
|
||||
const patternMinX = patternOffset.x - center.x + bounds.minX;
|
||||
const patternMaxX = patternOffset.x - center.x + bounds.maxX;
|
||||
const patternMinY = patternOffset.y - center.y + bounds.minY;
|
||||
const patternMaxY = patternOffset.y - center.y + bounds.maxY;
|
||||
|
||||
// Hoop bounds (centered at origin)
|
||||
const hoopMinX = -maxWidth / 2;
|
||||
|
|
@ -196,7 +299,7 @@ export function FileUpload() {
|
|||
}
|
||||
|
||||
return { fits: true, error: null };
|
||||
}, [pesData, machineInfo, patternOffset]);
|
||||
}, [pesData, machineInfo, patternOffset, patternRotation]);
|
||||
|
||||
const boundsCheck = checkPatternFitsInHoop();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,542 +0,0 @@
|
|||
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 Konva from "konva";
|
||||
import {
|
||||
PlusIcon,
|
||||
MinusIcon,
|
||||
ArrowPathIcon,
|
||||
LockClosedIcon,
|
||||
PhotoIcon,
|
||||
ArrowsPointingInIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
import { calculateInitialScale } from "../utils/konvaRenderers";
|
||||
import {
|
||||
Grid,
|
||||
Origin,
|
||||
Hoop,
|
||||
Stitches,
|
||||
PatternBounds,
|
||||
CurrentPosition,
|
||||
} from "./KonvaComponents";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function PatternCanvas() {
|
||||
// Machine store
|
||||
const { sewingProgress, machineInfo, isUploading } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
sewingProgress: state.sewingProgress,
|
||||
machineInfo: state.machineInfo,
|
||||
isUploading: state.isUploading,
|
||||
})),
|
||||
);
|
||||
|
||||
// Pattern store
|
||||
const {
|
||||
pesData,
|
||||
patternOffset: initialPatternOffset,
|
||||
setPatternOffset,
|
||||
} = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
patternOffset: state.patternOffset,
|
||||
setPatternOffset: state.setPatternOffset,
|
||||
})),
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
const patternUploaded = usePatternUploaded();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<Konva.Stage | 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 [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const initialScaleRef = useRef<number>(1);
|
||||
const prevPesDataRef = useRef<PesPatternData | null>(null);
|
||||
|
||||
// Update pattern offset when initialPatternOffset changes
|
||||
if (
|
||||
initialPatternOffset &&
|
||||
(localPatternOffset.x !== initialPatternOffset.x ||
|
||||
localPatternOffset.y !== initialPatternOffset.y)
|
||||
) {
|
||||
setLocalPatternOffset(initialPatternOffset);
|
||||
console.log(
|
||||
"[PatternCanvas] Restored pattern offset:",
|
||||
initialPatternOffset,
|
||||
);
|
||||
}
|
||||
|
||||
// Track container size
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.clientWidth;
|
||||
const height = containerRef.current.clientHeight;
|
||||
setContainerSize({ width, height });
|
||||
}
|
||||
};
|
||||
|
||||
// Initial size
|
||||
updateSize();
|
||||
|
||||
// Watch for resize
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Calculate and store initial scale when pattern or hoop changes
|
||||
useEffect(() => {
|
||||
if (!pesData || containerSize.width === 0) {
|
||||
prevPesDataRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only recalculate if pattern changed
|
||||
if (prevPesDataRef.current !== pesData) {
|
||||
prevPesDataRef.current = pesData;
|
||||
|
||||
const { bounds } = pesData;
|
||||
const viewWidth = machineInfo
|
||||
? machineInfo.maxWidth
|
||||
: bounds.maxX - bounds.minX;
|
||||
const viewHeight = machineInfo
|
||||
? machineInfo.maxHeight
|
||||
: bounds.maxY - bounds.minY;
|
||||
|
||||
const initialScale = calculateInitialScale(
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
viewWidth,
|
||||
viewHeight,
|
||||
);
|
||||
initialScaleRef.current = initialScale;
|
||||
|
||||
// Reset view when pattern changes
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStageScale(initialScale);
|
||||
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
|
||||
}
|
||||
}, [pesData, machineInfo, containerSize]);
|
||||
|
||||
// Wheel zoom handler
|
||||
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
e.evt.preventDefault();
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const scaleBy = 1.1;
|
||||
const direction = e.evt.deltaY > 0 ? -1 : 1;
|
||||
|
||||
setStageScale((oldScale) => {
|
||||
const newScale = Math.max(
|
||||
0.1,
|
||||
Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2),
|
||||
);
|
||||
|
||||
// Zoom towards pointer
|
||||
setStagePos((prevPos) => {
|
||||
const mousePointTo = {
|
||||
x: (pointer.x - prevPos.x) / oldScale,
|
||||
y: (pointer.y - prevPos.y) / oldScale,
|
||||
};
|
||||
|
||||
return {
|
||||
x: pointer.x - mousePointTo.x * newScale,
|
||||
y: pointer.y - mousePointTo.y * newScale,
|
||||
};
|
||||
});
|
||||
|
||||
return newScale;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Zoom control handlers
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setStageScale((oldScale) => {
|
||||
const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
|
||||
|
||||
// Zoom towards center of viewport
|
||||
setStagePos((prevPos) => {
|
||||
const centerX = containerSize.width / 2;
|
||||
const centerY = containerSize.height / 2;
|
||||
|
||||
const mousePointTo = {
|
||||
x: (centerX - prevPos.x) / oldScale,
|
||||
y: (centerY - prevPos.y) / oldScale,
|
||||
};
|
||||
|
||||
return {
|
||||
x: centerX - mousePointTo.x * newScale,
|
||||
y: centerY - mousePointTo.y * newScale,
|
||||
};
|
||||
});
|
||||
|
||||
return newScale;
|
||||
});
|
||||
}, [containerSize]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setStageScale((oldScale) => {
|
||||
const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
|
||||
|
||||
// Zoom towards center of viewport
|
||||
setStagePos((prevPos) => {
|
||||
const centerX = containerSize.width / 2;
|
||||
const centerY = containerSize.height / 2;
|
||||
|
||||
const mousePointTo = {
|
||||
x: (centerX - prevPos.x) / oldScale,
|
||||
y: (centerY - prevPos.y) / oldScale,
|
||||
};
|
||||
|
||||
return {
|
||||
x: centerX - mousePointTo.x * newScale,
|
||||
y: centerY - mousePointTo.y * newScale,
|
||||
};
|
||||
});
|
||||
|
||||
return newScale;
|
||||
});
|
||||
}, [containerSize]);
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
const initialScale = initialScaleRef.current;
|
||||
setStageScale(initialScale);
|
||||
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
|
||||
}, [containerSize]);
|
||||
|
||||
const handleCenterPattern = useCallback(() => {
|
||||
if (!pesData) return;
|
||||
|
||||
const { bounds } = pesData;
|
||||
const centerOffsetX = -(bounds.minX + bounds.maxX) / 2;
|
||||
const centerOffsetY = -(bounds.minY + bounds.maxY) / 2;
|
||||
|
||||
setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY });
|
||||
setPatternOffset(centerOffsetX, centerOffsetY);
|
||||
}, [pesData, setPatternOffset]);
|
||||
|
||||
// Pattern drag handlers
|
||||
const handlePatternDragEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const newOffset = {
|
||||
x: e.target.x(),
|
||||
y: e.target.y(),
|
||||
};
|
||||
setLocalPatternOffset(newOffset);
|
||||
setPatternOffset(newOffset.x, newOffset.y);
|
||||
},
|
||||
[setPatternOffset],
|
||||
);
|
||||
|
||||
const borderColor = pesData
|
||||
? "border-tertiary-600 dark:border-tertiary-500"
|
||||
: "border-gray-400 dark:border-gray-600";
|
||||
const iconColor = pesData
|
||||
? "text-tertiary-600 dark:text-tertiary-400"
|
||||
: "text-gray-600 dark:text-gray-400";
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
|
||||
>
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<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 ? (
|
||||
<CardDescription className="text-xs">
|
||||
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "}
|
||||
×{" "}
|
||||
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)}{" "}
|
||||
mm
|
||||
</CardDescription>
|
||||
) : (
|
||||
<CardDescription className="text-xs">
|
||||
No pattern loaded
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col min-h-0">
|
||||
<div
|
||||
className="relative w-full flex-1 min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
|
||||
ref={containerRef}
|
||||
>
|
||||
{containerSize.width > 0 && (
|
||||
<Stage
|
||||
width={containerSize.width}
|
||||
height={containerSize.height}
|
||||
x={stagePos.x}
|
||||
y={stagePos.y}
|
||||
scaleX={stageScale}
|
||||
scaleY={stageScale}
|
||||
draggable
|
||||
onWheel={handleWheel}
|
||||
onDragStart={() => {
|
||||
if (stageRef.current) {
|
||||
stageRef.current.container().style.cursor = "grabbing";
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (stageRef.current) {
|
||||
stageRef.current.container().style.cursor = "grab";
|
||||
}
|
||||
}}
|
||||
ref={(node) => {
|
||||
stageRef.current = node;
|
||||
if (node) {
|
||||
node.container().style.cursor = "grab";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background layer: grid, origin, hoop */}
|
||||
<Layer>
|
||||
{pesData && (
|
||||
<>
|
||||
<Grid
|
||||
gridSize={100}
|
||||
bounds={pesData.bounds}
|
||||
machineInfo={machineInfo}
|
||||
/>
|
||||
<Origin />
|
||||
{machineInfo && <Hoop machineInfo={machineInfo} />}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</Layer>
|
||||
|
||||
{/* Current position layer */}
|
||||
<Layer>
|
||||
{pesData &&
|
||||
pesData.penStitches &&
|
||||
sewingProgress &&
|
||||
sewingProgress.currentStitch > 0 && (
|
||||
<Group x={localPatternOffset.x} y={localPatternOffset.y}>
|
||||
<CurrentPosition
|
||||
currentStitchIndex={sewingProgress.currentStitch}
|
||||
stitches={pesData.penStitches.stitches.map(
|
||||
(s, i): [number, number, number, number] => {
|
||||
const cmd = s.isJump ? 0x10 : 0;
|
||||
const colorIndex =
|
||||
pesData.penStitches.colorBlocks.find(
|
||||
(b) => i >= b.startStitch && i <= b.endStitch,
|
||||
)?.colorIndex ?? 0;
|
||||
return [s.x, s.y, cmd, colorIndex];
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Layer>
|
||||
</Stage>
|
||||
)}
|
||||
|
||||
{/* Placeholder overlay when no pattern is loaded */}
|
||||
{!pesData && (
|
||||
<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(" ");
|
||||
|
||||
// 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 (
|
||||
<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
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { memo, useMemo } from "react";
|
||||
import { Group, Line, Rect, Text, Circle } from "react-konva";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
import { getThreadColor } from "../formats/import/pesImporter";
|
||||
import type { MachineInfo } from "../types/machine";
|
||||
import { MOVE } from "../formats/import/constants";
|
||||
import { canvasColors } from "../utils/cssVariables";
|
||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
||||
import { getThreadColor } from "../../formats/import/pesImporter";
|
||||
import type { MachineInfo } from "../../types/machine";
|
||||
import { MOVE } from "../../formats/import/constants";
|
||||
import { canvasColors } from "../../utils/cssVariables";
|
||||
|
||||
interface GridProps {
|
||||
gridSize: number;
|
||||
271
src/components/PatternCanvas/PatternCanvas.tsx
Normal file
271
src/components/PatternCanvas/PatternCanvas.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { useRef } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import {
|
||||
useMachineStore,
|
||||
usePatternUploaded,
|
||||
} from "../../stores/useMachineStore";
|
||||
import { usePatternStore } from "../../stores/usePatternStore";
|
||||
import { Stage, Layer } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import { PhotoIcon } from "@heroicons/react/24/solid";
|
||||
import { Grid, Origin, Hoop } from "./KonvaComponents";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { ThreadLegend } from "./ThreadLegend";
|
||||
import { PatternPositionIndicator } from "./PatternPositionIndicator";
|
||||
import { ZoomControls } from "./ZoomControls";
|
||||
import { PatternLayer } from "./PatternLayer";
|
||||
import { useCanvasViewport } from "../../hooks/useCanvasViewport";
|
||||
import { usePatternTransform } from "../../hooks/usePatternTransform";
|
||||
|
||||
export function PatternCanvas() {
|
||||
// Machine store
|
||||
const { sewingProgress, machineInfo, isUploading } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
sewingProgress: state.sewingProgress,
|
||||
machineInfo: state.machineInfo,
|
||||
isUploading: state.isUploading,
|
||||
})),
|
||||
);
|
||||
|
||||
// Pattern store
|
||||
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,
|
||||
})),
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
const patternUploaded = usePatternUploaded();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<Konva.Stage | null>(null);
|
||||
|
||||
// Canvas viewport (zoom, pan, container size)
|
||||
const {
|
||||
stagePos,
|
||||
stageScale,
|
||||
containerSize,
|
||||
handleWheel,
|
||||
handleZoomIn,
|
||||
handleZoomOut,
|
||||
handleZoomReset,
|
||||
} = useCanvasViewport({
|
||||
containerRef,
|
||||
pesData,
|
||||
uploadedPesData,
|
||||
machineInfo,
|
||||
});
|
||||
|
||||
// Pattern transform (position, rotation, drag/transform)
|
||||
const {
|
||||
localPatternOffset,
|
||||
localPatternRotation,
|
||||
patternGroupRef,
|
||||
transformerRef,
|
||||
attachTransformer,
|
||||
handleCenterPattern,
|
||||
handlePatternDragEnd,
|
||||
handleTransformEnd,
|
||||
} = usePatternTransform({
|
||||
pesData,
|
||||
initialPatternOffset,
|
||||
initialPatternRotation,
|
||||
setPatternOffset,
|
||||
setPatternRotation,
|
||||
patternUploaded,
|
||||
isUploading,
|
||||
});
|
||||
|
||||
const hasPattern = pesData || uploadedPesData;
|
||||
const borderColor = hasPattern
|
||||
? "border-tertiary-600 dark:border-tertiary-500"
|
||||
: "border-gray-400 dark:border-gray-600";
|
||||
const iconColor = hasPattern
|
||||
? "text-tertiary-600 dark:text-tertiary-400"
|
||||
: "text-gray-600 dark:text-gray-400";
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
|
||||
>
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<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>
|
||||
{hasPattern ? (
|
||||
<CardDescription className="text-xs">
|
||||
{(() => {
|
||||
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">
|
||||
No pattern loaded
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col min-h-0">
|
||||
<div
|
||||
className="relative w-full flex-1 min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
|
||||
ref={containerRef}
|
||||
>
|
||||
{containerSize.width > 0 && (
|
||||
<Stage
|
||||
width={containerSize.width}
|
||||
height={containerSize.height}
|
||||
x={stagePos.x}
|
||||
y={stagePos.y}
|
||||
scaleX={stageScale}
|
||||
scaleY={stageScale}
|
||||
draggable
|
||||
onWheel={handleWheel}
|
||||
onDragStart={() => {
|
||||
if (stageRef.current) {
|
||||
stageRef.current.container().style.cursor = "grabbing";
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (stageRef.current) {
|
||||
stageRef.current.container().style.cursor = "grab";
|
||||
}
|
||||
}}
|
||||
ref={(node) => {
|
||||
stageRef.current = node;
|
||||
if (node) {
|
||||
node.container().style.cursor = "grab";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background layer: grid, origin, hoop */}
|
||||
<Layer>
|
||||
{hasPattern && (
|
||||
<>
|
||||
<Grid
|
||||
gridSize={100}
|
||||
bounds={(uploadedPesData || pesData)!.bounds}
|
||||
machineInfo={machineInfo}
|
||||
/>
|
||||
<Origin />
|
||||
{machineInfo && <Hoop machineInfo={machineInfo} />}
|
||||
</>
|
||||
)}
|
||||
</Layer>
|
||||
|
||||
{/* Original pattern layer: draggable with transformer (shown before upload starts) */}
|
||||
<Layer visible={!isUploading && !patternUploaded}>
|
||||
{pesData && (
|
||||
<PatternLayer
|
||||
pesData={pesData}
|
||||
offset={localPatternOffset}
|
||||
rotation={localPatternRotation}
|
||||
isInteractive={true}
|
||||
showProgress={false}
|
||||
currentStitchIndex={0}
|
||||
patternGroupRef={patternGroupRef}
|
||||
transformerRef={transformerRef}
|
||||
onDragEnd={handlePatternDragEnd}
|
||||
onTransformEnd={handleTransformEnd}
|
||||
attachTransformer={attachTransformer}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
|
||||
{/* Uploaded pattern layer: locked, rotation baked in (shown during and after upload) */}
|
||||
<Layer visible={isUploading || patternUploaded}>
|
||||
{uploadedPesData && (
|
||||
<PatternLayer
|
||||
pesData={uploadedPesData}
|
||||
offset={initialUploadedPatternOffset}
|
||||
isInteractive={false}
|
||||
showProgress={true}
|
||||
currentStitchIndex={sewingProgress?.currentStitch || 0}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
</Stage>
|
||||
)}
|
||||
|
||||
{/* Placeholder overlay when no pattern is loaded */}
|
||||
{!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 */}
|
||||
{hasPattern &&
|
||||
(() => {
|
||||
const displayPattern = uploadedPesData || pesData;
|
||||
return (
|
||||
displayPattern && (
|
||||
<>
|
||||
<ThreadLegend colors={displayPattern.uniqueColors} />
|
||||
|
||||
<PatternPositionIndicator
|
||||
offset={
|
||||
isUploading || patternUploaded
|
||||
? initialUploadedPatternOffset
|
||||
: localPatternOffset
|
||||
}
|
||||
rotation={localPatternRotation}
|
||||
isLocked={patternUploaded}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
|
||||
<ZoomControls
|
||||
scale={stageScale}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onZoomReset={handleZoomReset}
|
||||
onCenterPattern={handleCenterPattern}
|
||||
canCenterPattern={
|
||||
!!pesData && !patternUploaded && !isUploading
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
146
src/components/PatternCanvas/PatternLayer.tsx
Normal file
146
src/components/PatternCanvas/PatternLayer.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* PatternLayer Component
|
||||
*
|
||||
* Unified component for rendering pattern layers (both original and uploaded)
|
||||
* Handles both interactive (draggable/rotatable) and locked states
|
||||
*/
|
||||
|
||||
import { useMemo, type RefObject } from "react";
|
||||
import { Group, Transformer } from "react-konva";
|
||||
import type Konva from "konva";
|
||||
import type { KonvaEventObject } from "konva/lib/Node";
|
||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
||||
import {
|
||||
calculatePatternCenter,
|
||||
convertPenStitchesToPesFormat,
|
||||
} from "./patternCanvasHelpers";
|
||||
import { Stitches, PatternBounds, CurrentPosition } from "./KonvaComponents";
|
||||
|
||||
interface PatternLayerProps {
|
||||
pesData: PesPatternData;
|
||||
offset: { x: number; y: number };
|
||||
rotation?: number;
|
||||
isInteractive: boolean;
|
||||
showProgress?: boolean;
|
||||
currentStitchIndex?: number;
|
||||
patternGroupRef?: RefObject<Konva.Group | null>;
|
||||
transformerRef?: RefObject<Konva.Transformer | null>;
|
||||
onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
onTransformEnd?: (e: KonvaEventObject<Event>) => void;
|
||||
attachTransformer?: () => void;
|
||||
}
|
||||
|
||||
export function PatternLayer({
|
||||
pesData,
|
||||
offset,
|
||||
rotation = 0,
|
||||
isInteractive,
|
||||
showProgress = false,
|
||||
currentStitchIndex = 0,
|
||||
patternGroupRef,
|
||||
transformerRef,
|
||||
onDragEnd,
|
||||
onTransformEnd,
|
||||
attachTransformer,
|
||||
}: PatternLayerProps) {
|
||||
const center = useMemo(
|
||||
() => calculatePatternCenter(pesData.bounds),
|
||||
[pesData.bounds],
|
||||
);
|
||||
|
||||
const stitches = useMemo(
|
||||
() => convertPenStitchesToPesFormat(pesData.penStitches),
|
||||
[pesData.penStitches],
|
||||
);
|
||||
|
||||
const groupName = isInteractive ? "pattern-group" : "uploaded-pattern-group";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group
|
||||
name={groupName}
|
||||
ref={
|
||||
isInteractive
|
||||
? (node) => {
|
||||
if (patternGroupRef) {
|
||||
patternGroupRef.current = node;
|
||||
}
|
||||
// Set initial rotation from state
|
||||
if (node && isInteractive) {
|
||||
node.rotation(rotation);
|
||||
// Try to attach transformer when group is mounted
|
||||
if (attachTransformer) {
|
||||
attachTransformer();
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={isInteractive}
|
||||
x={offset.x}
|
||||
y={offset.y}
|
||||
offsetX={center.x}
|
||||
offsetY={center.y}
|
||||
onDragEnd={isInteractive ? onDragEnd : undefined}
|
||||
onTransformEnd={isInteractive ? onTransformEnd : undefined}
|
||||
onMouseEnter={
|
||||
isInteractive
|
||||
? (e) => {
|
||||
const stage = e.target.getStage();
|
||||
if (stage) stage.container().style.cursor = "move";
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={
|
||||
isInteractive
|
||||
? (e) => {
|
||||
const stage = e.target.getStage();
|
||||
if (stage) stage.container().style.cursor = "grab";
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Stitches
|
||||
stitches={stitches}
|
||||
pesData={pesData}
|
||||
currentStitchIndex={currentStitchIndex}
|
||||
showProgress={showProgress}
|
||||
/>
|
||||
<PatternBounds bounds={pesData.bounds} />
|
||||
</Group>
|
||||
|
||||
{/* Transformer only for interactive layer */}
|
||||
{isInteractive && transformerRef && (
|
||||
<Transformer
|
||||
ref={(node) => {
|
||||
if (transformerRef) {
|
||||
transformerRef.current = node;
|
||||
}
|
||||
// Try to attach transformer when transformer is mounted
|
||||
if (node && attachTransformer) {
|
||||
attachTransformer();
|
||||
}
|
||||
}}
|
||||
enabledAnchors={[]}
|
||||
rotateEnabled={true}
|
||||
borderEnabled={true}
|
||||
borderStroke="#FF6B6B"
|
||||
borderStrokeWidth={2}
|
||||
rotationSnaps={[0, 45, 90, 135, 180, 225, 270, 315]}
|
||||
ignoreStroke={true}
|
||||
rotateAnchorOffset={20}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Current position indicator (only for uploaded pattern with progress) */}
|
||||
{!isInteractive && showProgress && currentStitchIndex > 0 && (
|
||||
<Group x={offset.x} y={offset.y} offsetX={center.x} offsetY={center.y}>
|
||||
<CurrentPosition
|
||||
currentStitchIndex={currentStitchIndex}
|
||||
stitches={stitches}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
src/components/PatternCanvas/PatternPositionIndicator.tsx
Normal file
61
src/components/PatternCanvas/PatternPositionIndicator.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* PatternPositionIndicator Component
|
||||
*
|
||||
* Displays the current pattern position and rotation
|
||||
* Shows locked state when pattern is uploaded or being uploaded
|
||||
*/
|
||||
|
||||
import { LockClosedIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
interface PatternPositionIndicatorProps {
|
||||
offset: { x: number; y: number };
|
||||
rotation?: number;
|
||||
isLocked: boolean;
|
||||
isUploading: boolean;
|
||||
}
|
||||
|
||||
export function PatternPositionIndicator({
|
||||
offset,
|
||||
rotation = 0,
|
||||
isLocked,
|
||||
isUploading,
|
||||
}: PatternPositionIndicatorProps) {
|
||||
return (
|
||||
<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 ${
|
||||
isUploading || isLocked
|
||||
? "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>
|
||||
{(isUploading || isLocked) && (
|
||||
<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">
|
||||
X: {(offset.x / 10).toFixed(1)}mm, Y: {(offset.y / 10).toFixed(1)}mm
|
||||
</div>
|
||||
{!isUploading && !isLocked && rotation !== 0 && (
|
||||
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
|
||||
Rotation: {rotation.toFixed(1)}°
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
|
||||
{isUploading
|
||||
? "Uploading pattern..."
|
||||
: isLocked
|
||||
? "Pattern locked • Drag background to pan"
|
||||
: "Drag pattern to move • Drag background to pan"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/PatternCanvas/ThreadLegend.tsx
Normal file
74
src/components/PatternCanvas/ThreadLegend.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* ThreadLegend Component
|
||||
*
|
||||
* Displays a legend of thread colors used in the embroidery pattern
|
||||
* Shows color swatches with brand, catalog number, and description metadata
|
||||
*/
|
||||
|
||||
interface ThreadColor {
|
||||
hex: string;
|
||||
brand?: string | null;
|
||||
catalogNumber?: string | null;
|
||||
chart?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
interface ThreadLegendProps {
|
||||
colors: ThreadColor[];
|
||||
}
|
||||
|
||||
export function ThreadLegend({ colors }: ThreadLegendProps) {
|
||||
return (
|
||||
<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>
|
||||
{colors.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(" ");
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
77
src/components/PatternCanvas/ZoomControls.tsx
Normal file
77
src/components/PatternCanvas/ZoomControls.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* ZoomControls Component
|
||||
*
|
||||
* Provides zoom and pan controls for the pattern canvas
|
||||
* Includes zoom in/out, reset zoom, and center pattern buttons
|
||||
*/
|
||||
|
||||
import {
|
||||
PlusIcon,
|
||||
MinusIcon,
|
||||
ArrowPathIcon,
|
||||
ArrowsPointingInIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ZoomControlsProps {
|
||||
scale: number;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomReset: () => void;
|
||||
onCenterPattern: () => void;
|
||||
canCenterPattern: boolean;
|
||||
}
|
||||
|
||||
export function ZoomControls({
|
||||
scale,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onZoomReset,
|
||||
onCenterPattern,
|
||||
canCenterPattern,
|
||||
}: ZoomControlsProps) {
|
||||
return (
|
||||
<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={onCenterPattern}
|
||||
disabled={!canCenterPattern}
|
||||
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={onZoomIn}
|
||||
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(scale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="w-7 h-7 sm:w-8 sm:h-8"
|
||||
onClick={onZoomOut}
|
||||
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={onZoomReset}
|
||||
title="Reset Zoom"
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/components/PatternCanvas/index.ts
Normal file
1
src/components/PatternCanvas/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { PatternCanvas } from "./PatternCanvas";
|
||||
83
src/components/PatternCanvas/patternCanvasHelpers.ts
Normal file
83
src/components/PatternCanvas/patternCanvasHelpers.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Utility functions for PatternCanvas operations
|
||||
*/
|
||||
|
||||
import type { DecodedPenData } from "../../formats/pen/types";
|
||||
|
||||
/**
|
||||
* Calculate the geometric center of a pattern's bounds
|
||||
*/
|
||||
export function calculatePatternCenter(bounds: {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}): { x: number; y: number } {
|
||||
return {
|
||||
x: (bounds.minX + bounds.maxX) / 2,
|
||||
y: (bounds.minY + bounds.maxY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PEN stitch format to PES stitch format
|
||||
* PEN: {x, y, flags, isJump}
|
||||
* PES: [x, y, cmd, colorIndex]
|
||||
*/
|
||||
export function convertPenStitchesToPesFormat(
|
||||
penStitches: DecodedPenData,
|
||||
): [number, number, number, number][] {
|
||||
return penStitches.stitches.map((s, i) => {
|
||||
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
|
||||
const colorIndex =
|
||||
penStitches.colorBlocks.find(
|
||||
(b) => i >= b.startStitch && i <= b.endStitch,
|
||||
)?.colorIndex ?? 0;
|
||||
return [s.x, s.y, cmd, colorIndex];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate axis-aligned bounding box from decoded PEN stitches
|
||||
*/
|
||||
export function calculateBoundsFromDecodedStitches(decoded: DecodedPenData): {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
} {
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity;
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
for (const stitch of decoded.stitches) {
|
||||
if (stitch.x < minX) minX = stitch.x;
|
||||
if (stitch.x > maxX) maxX = stitch.x;
|
||||
if (stitch.y < minY) minY = stitch.y;
|
||||
if (stitch.y > maxY) maxY = stitch.y;
|
||||
}
|
||||
|
||||
return { minX, maxX, minY, maxY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate new stage position for zooming towards a specific point
|
||||
* Used for both wheel zoom and button zoom operations
|
||||
*/
|
||||
export function calculateZoomToPoint(
|
||||
oldScale: number,
|
||||
newScale: number,
|
||||
targetPoint: { x: number; y: number },
|
||||
currentPos: { x: number; y: number },
|
||||
): { x: number; y: number } {
|
||||
const mousePointTo = {
|
||||
x: (targetPoint.x - currentPos.x) / oldScale,
|
||||
y: (targetPoint.y - currentPos.y) / oldScale,
|
||||
};
|
||||
|
||||
return {
|
||||
x: targetPoint.x - mousePointTo.x * newScale,
|
||||
y: targetPoint.y - mousePointTo.y * newScale,
|
||||
};
|
||||
}
|
||||
179
src/hooks/useCanvasViewport.ts
Normal file
179
src/hooks/useCanvasViewport.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* useCanvasViewport Hook
|
||||
*
|
||||
* Manages canvas viewport state including zoom, pan, and container size
|
||||
* Handles wheel zoom and button zoom operations
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import type Konva from "konva";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
import type { MachineInfo } from "../types/machine";
|
||||
import { calculateInitialScale } from "../utils/konvaRenderers";
|
||||
import { calculateZoomToPoint } from "../components/PatternCanvas/patternCanvasHelpers";
|
||||
|
||||
interface UseCanvasViewportOptions {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
pesData: PesPatternData | null;
|
||||
uploadedPesData: PesPatternData | null;
|
||||
machineInfo: MachineInfo | null;
|
||||
}
|
||||
|
||||
export function useCanvasViewport({
|
||||
containerRef,
|
||||
pesData,
|
||||
uploadedPesData,
|
||||
machineInfo,
|
||||
}: UseCanvasViewportOptions) {
|
||||
const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
|
||||
const [stageScale, setStageScale] = useState(1);
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const initialScaleRef = useRef<number>(1);
|
||||
const prevPesDataRef = useRef<PesPatternData | null>(null);
|
||||
|
||||
// Track container size with ResizeObserver
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.clientWidth;
|
||||
const height = containerRef.current.clientHeight;
|
||||
setContainerSize({ width, height });
|
||||
}
|
||||
};
|
||||
|
||||
// Initial size
|
||||
updateSize();
|
||||
|
||||
// Watch for resize
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [containerRef]);
|
||||
|
||||
// Calculate and store initial scale when pattern or hoop changes
|
||||
useEffect(() => {
|
||||
// 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 !== currentPattern) {
|
||||
prevPesDataRef.current = currentPattern;
|
||||
|
||||
const { bounds } = currentPattern;
|
||||
const viewWidth = machineInfo
|
||||
? machineInfo.maxWidth
|
||||
: bounds.maxX - bounds.minX;
|
||||
const viewHeight = machineInfo
|
||||
? machineInfo.maxHeight
|
||||
: bounds.maxY - bounds.minY;
|
||||
|
||||
const initialScale = calculateInitialScale(
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
viewWidth,
|
||||
viewHeight,
|
||||
);
|
||||
initialScaleRef.current = initialScale;
|
||||
|
||||
// Reset view when pattern changes
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStageScale(initialScale);
|
||||
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
|
||||
}
|
||||
}, [pesData, uploadedPesData, machineInfo, containerSize]);
|
||||
|
||||
// Wheel zoom handler
|
||||
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
e.evt.preventDefault();
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const scaleBy = 1.1;
|
||||
const direction = e.evt.deltaY > 0 ? -1 : 1;
|
||||
|
||||
setStageScale((oldScale) => {
|
||||
const newScale = Math.max(
|
||||
0.1,
|
||||
Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2),
|
||||
);
|
||||
|
||||
// Zoom towards pointer
|
||||
setStagePos((prevPos) =>
|
||||
calculateZoomToPoint(oldScale, newScale, pointer, prevPos),
|
||||
);
|
||||
|
||||
return newScale;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Zoom control handlers
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setStageScale((oldScale) => {
|
||||
const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
|
||||
|
||||
// Zoom towards center of viewport
|
||||
const center = {
|
||||
x: containerSize.width / 2,
|
||||
y: containerSize.height / 2,
|
||||
};
|
||||
setStagePos((prevPos) =>
|
||||
calculateZoomToPoint(oldScale, newScale, center, prevPos),
|
||||
);
|
||||
|
||||
return newScale;
|
||||
});
|
||||
}, [containerSize]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setStageScale((oldScale) => {
|
||||
const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
|
||||
|
||||
// Zoom towards center of viewport
|
||||
const center = {
|
||||
x: containerSize.width / 2,
|
||||
y: containerSize.height / 2,
|
||||
};
|
||||
setStagePos((prevPos) =>
|
||||
calculateZoomToPoint(oldScale, newScale, center, prevPos),
|
||||
);
|
||||
|
||||
return newScale;
|
||||
});
|
||||
}, [containerSize]);
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
const initialScale = initialScaleRef.current;
|
||||
setStageScale(initialScale);
|
||||
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
|
||||
}, [containerSize]);
|
||||
|
||||
return {
|
||||
// State
|
||||
stagePos,
|
||||
stageScale,
|
||||
containerSize,
|
||||
|
||||
// Handlers
|
||||
handleWheel,
|
||||
handleZoomIn,
|
||||
handleZoomOut,
|
||||
handleZoomReset,
|
||||
};
|
||||
}
|
||||
151
src/hooks/usePatternTransform.ts
Normal file
151
src/hooks/usePatternTransform.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* usePatternTransform Hook
|
||||
*
|
||||
* Manages pattern transformation state including position, rotation, and drag/transform handling
|
||||
* Syncs local state with global pattern store
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type Konva from "konva";
|
||||
import type { KonvaEventObject } from "konva/lib/Node";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
|
||||
interface UsePatternTransformOptions {
|
||||
pesData: PesPatternData | null;
|
||||
initialPatternOffset: { x: number; y: number };
|
||||
initialPatternRotation: number;
|
||||
setPatternOffset: (x: number, y: number) => void;
|
||||
setPatternRotation: (rotation: number) => void;
|
||||
patternUploaded: boolean;
|
||||
isUploading: boolean;
|
||||
}
|
||||
|
||||
export function usePatternTransform({
|
||||
pesData,
|
||||
initialPatternOffset,
|
||||
initialPatternRotation,
|
||||
setPatternOffset,
|
||||
setPatternRotation,
|
||||
patternUploaded,
|
||||
isUploading,
|
||||
}: UsePatternTransformOptions) {
|
||||
const [localPatternOffset, setLocalPatternOffset] = useState(
|
||||
initialPatternOffset || { x: 0, y: 0 },
|
||||
);
|
||||
const [localPatternRotation, setLocalPatternRotation] = useState(
|
||||
initialPatternRotation || 0,
|
||||
);
|
||||
|
||||
const patternGroupRef = useRef<Konva.Group | null>(null);
|
||||
const transformerRef = useRef<Konva.Transformer | null>(null);
|
||||
|
||||
// Update pattern offset when initialPatternOffset changes
|
||||
if (
|
||||
initialPatternOffset &&
|
||||
(localPatternOffset.x !== initialPatternOffset.x ||
|
||||
localPatternOffset.y !== initialPatternOffset.y)
|
||||
) {
|
||||
setLocalPatternOffset(initialPatternOffset);
|
||||
}
|
||||
|
||||
// Update pattern rotation when initialPatternRotation changes
|
||||
if (
|
||||
initialPatternRotation !== undefined &&
|
||||
localPatternRotation !== initialPatternRotation
|
||||
) {
|
||||
setLocalPatternRotation(initialPatternRotation);
|
||||
}
|
||||
|
||||
// Attach/detach transformer based on state
|
||||
const attachTransformer = useCallback(() => {
|
||||
if (!transformerRef.current || !patternGroupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!patternUploaded && !isUploading) {
|
||||
transformerRef.current.nodes([patternGroupRef.current]);
|
||||
transformerRef.current.getLayer()?.batchDraw();
|
||||
} else {
|
||||
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]);
|
||||
|
||||
// Center pattern in hoop
|
||||
const handleCenterPattern = useCallback(() => {
|
||||
if (!pesData) return;
|
||||
|
||||
// Since the pattern Group uses offsetX/offsetY to set its pivot point at the pattern center,
|
||||
// we just need to position it at the origin (0, 0) to center it in the hoop
|
||||
const centerOffset = { x: 0, y: 0 };
|
||||
|
||||
setLocalPatternOffset(centerOffset);
|
||||
setPatternOffset(centerOffset.x, centerOffset.y);
|
||||
}, [pesData, setPatternOffset]);
|
||||
|
||||
// Pattern drag handlers
|
||||
const handlePatternDragEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const newOffset = {
|
||||
x: e.target.x(),
|
||||
y: e.target.y(),
|
||||
};
|
||||
setLocalPatternOffset(newOffset);
|
||||
setPatternOffset(newOffset.x, newOffset.y);
|
||||
},
|
||||
[setPatternOffset],
|
||||
);
|
||||
|
||||
// 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);
|
||||
},
|
||||
[setPatternRotation, setPatternOffset, pesData],
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
localPatternOffset,
|
||||
localPatternRotation,
|
||||
|
||||
// Refs
|
||||
patternGroupRef,
|
||||
transformerRef,
|
||||
|
||||
// Handlers
|
||||
attachTransformer,
|
||||
handleCenterPattern,
|
||||
handlePatternDragEnd,
|
||||
handleTransformEnd,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 @@
|
|||
import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers";
|
||||
|
||||
/**
|
||||
* 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 center = calculatePatternCenter(bounds);
|
||||
|
||||
return stitches.map(([x, y, cmd, colorIndex]) => {
|
||||
const rotated = rotatePoint(x, y, center.x, center.y, 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 center = calculatePatternCenter(bounds);
|
||||
|
||||
// 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, center.x, center.y, 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