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,
|
canUploadPattern,
|
||||||
getMachineStateCategory,
|
getMachineStateCategory,
|
||||||
} from "../utils/machineStateHelpers";
|
} from "../utils/machineStateHelpers";
|
||||||
|
import {
|
||||||
|
transformStitchesRotation,
|
||||||
|
calculateRotatedBounds,
|
||||||
|
} from "../utils/rotationUtils";
|
||||||
|
import { encodeStitchesToPen } from "../formats/pen/encoder";
|
||||||
|
import { decodePenData } from "../formats/pen/decoder";
|
||||||
|
import {
|
||||||
|
calculatePatternCenter,
|
||||||
|
calculateBoundsFromDecodedStitches,
|
||||||
|
} from "./PatternCanvas/patternCanvasHelpers";
|
||||||
import { PatternInfoSkeleton } from "./SkeletonLoader";
|
import { PatternInfoSkeleton } from "./SkeletonLoader";
|
||||||
import { PatternInfo } from "./PatternInfo";
|
import { PatternInfo } from "./PatternInfo";
|
||||||
import {
|
import {
|
||||||
|
|
@ -57,13 +67,17 @@ export function FileUpload() {
|
||||||
pesData: pesDataProp,
|
pesData: pesDataProp,
|
||||||
currentFileName,
|
currentFileName,
|
||||||
patternOffset,
|
patternOffset,
|
||||||
|
patternRotation,
|
||||||
setPattern,
|
setPattern,
|
||||||
|
setUploadedPattern,
|
||||||
} = usePatternStore(
|
} = usePatternStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
pesData: state.pesData,
|
pesData: state.pesData,
|
||||||
currentFileName: state.currentFileName,
|
currentFileName: state.currentFileName,
|
||||||
patternOffset: state.patternOffset,
|
patternOffset: state.patternOffset,
|
||||||
|
patternRotation: state.patternRotation,
|
||||||
setPattern: state.setPattern,
|
setPattern: state.setPattern,
|
||||||
|
setUploadedPattern: state.setUploadedPattern,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -137,26 +151,115 @@ export function FileUpload() {
|
||||||
[fileService, setPattern, pyodideReady, initializePyodide],
|
[fileService, setPattern, pyodideReady, initializePyodide],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpload = useCallback(() => {
|
const handleUpload = useCallback(async () => {
|
||||||
if (pesData && displayFileName) {
|
if (pesData && displayFileName) {
|
||||||
uploadPattern(pesData.penData, pesData, displayFileName, patternOffset);
|
let penDataToUpload = pesData.penData;
|
||||||
}
|
let pesDataForUpload = pesData;
|
||||||
}, [pesData, displayFileName, uploadPattern, patternOffset]);
|
|
||||||
|
|
||||||
// Check if pattern (with offset) fits within hoop bounds
|
// Apply rotation if needed
|
||||||
|
if (patternRotation && patternRotation !== 0) {
|
||||||
|
// 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(() => {
|
const checkPatternFitsInHoop = useCallback(() => {
|
||||||
if (!pesData || !machineInfo) {
|
if (!pesData || !machineInfo) {
|
||||||
return { fits: true, error: null };
|
return { fits: true, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bounds } = pesData;
|
// Calculate rotated bounds if rotation is applied
|
||||||
|
let bounds = pesData.bounds;
|
||||||
|
if (patternRotation && patternRotation !== 0) {
|
||||||
|
bounds = calculateRotatedBounds(pesData.bounds, patternRotation);
|
||||||
|
}
|
||||||
|
|
||||||
const { maxWidth, maxHeight } = machineInfo;
|
const { maxWidth, maxHeight } = machineInfo;
|
||||||
|
|
||||||
// Calculate pattern bounds with offset applied
|
// The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas)
|
||||||
const patternMinX = bounds.minX + patternOffset.x;
|
// So we need to calculate bounds relative to the center
|
||||||
const patternMaxX = bounds.maxX + patternOffset.x;
|
const center = calculatePatternCenter(bounds);
|
||||||
const patternMinY = bounds.minY + patternOffset.y;
|
|
||||||
const patternMaxY = bounds.maxY + patternOffset.y;
|
// 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)
|
// Hoop bounds (centered at origin)
|
||||||
const hoopMinX = -maxWidth / 2;
|
const hoopMinX = -maxWidth / 2;
|
||||||
|
|
@ -196,7 +299,7 @@ export function FileUpload() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return { fits: true, error: null };
|
return { fits: true, error: null };
|
||||||
}, [pesData, machineInfo, patternOffset]);
|
}, [pesData, machineInfo, patternOffset, patternRotation]);
|
||||||
|
|
||||||
const boundsCheck = checkPatternFitsInHoop();
|
const boundsCheck = checkPatternFitsInHoop();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,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 { memo, useMemo } from "react";
|
||||||
import { Group, Line, Rect, Text, Circle } from "react-konva";
|
import { Group, Line, Rect, Text, Circle } from "react-konva";
|
||||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
import type { PesPatternData } from "../../formats/import/pesImporter";
|
||||||
import { getThreadColor } from "../formats/import/pesImporter";
|
import { getThreadColor } from "../../formats/import/pesImporter";
|
||||||
import type { MachineInfo } from "../types/machine";
|
import type { MachineInfo } from "../../types/machine";
|
||||||
import { MOVE } from "../formats/import/constants";
|
import { MOVE } from "../../formats/import/constants";
|
||||||
import { canvasColors } from "../utils/cssVariables";
|
import { canvasColors } from "../../utils/cssVariables";
|
||||||
|
|
||||||
interface GridProps {
|
interface GridProps {
|
||||||
gridSize: number;
|
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 { createStorageService } from "../platform";
|
||||||
import type { IStorageService } from "../platform/interfaces/IStorageService";
|
import type { IStorageService } from "../platform/interfaces/IStorageService";
|
||||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
|
import { usePatternStore } from "./usePatternStore";
|
||||||
|
|
||||||
interface MachineState {
|
interface MachineState {
|
||||||
// Service instances
|
// Service instances
|
||||||
|
|
@ -441,6 +442,9 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
||||||
resumeFileName: null,
|
resumeFileName: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear uploaded pattern data in pattern store
|
||||||
|
usePatternStore.getState().clearUploadedPattern();
|
||||||
|
|
||||||
await refreshStatus();
|
await refreshStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
|
|
|
||||||
|
|
@ -2,68 +2,112 @@ import { create } from "zustand";
|
||||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
|
|
||||||
interface PatternState {
|
interface PatternState {
|
||||||
// Pattern data
|
// Original pattern (pre-upload)
|
||||||
pesData: PesPatternData | null;
|
pesData: PesPatternData | null;
|
||||||
currentFileName: string;
|
currentFileName: string;
|
||||||
patternOffset: { x: number; y: number };
|
patternOffset: { x: number; y: number };
|
||||||
|
patternRotation: number; // rotation in degrees (0-360)
|
||||||
|
|
||||||
|
// Uploaded pattern (post-upload, rotation baked in)
|
||||||
|
uploadedPesData: PesPatternData | null; // Pattern with rotation applied
|
||||||
|
uploadedPatternOffset: { x: number; y: number }; // Offset with center shift compensation
|
||||||
|
|
||||||
patternUploaded: boolean;
|
patternUploaded: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setPattern: (data: PesPatternData, fileName: string) => void;
|
setPattern: (data: PesPatternData, fileName: string) => void;
|
||||||
setPatternOffset: (x: number, y: number) => void;
|
setPatternOffset: (x: number, y: number) => void;
|
||||||
setPatternUploaded: (uploaded: boolean) => void;
|
setPatternRotation: (rotation: number) => void;
|
||||||
clearPattern: () => void;
|
setUploadedPattern: (
|
||||||
|
uploadedData: PesPatternData,
|
||||||
|
uploadedOffset: { x: number; y: number },
|
||||||
|
) => void;
|
||||||
|
clearUploadedPattern: () => void;
|
||||||
resetPatternOffset: () => void;
|
resetPatternOffset: () => void;
|
||||||
|
resetRotation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePatternStore = create<PatternState>((set) => ({
|
export const usePatternStore = create<PatternState>((set) => ({
|
||||||
// Initial state
|
// Initial state - original pattern
|
||||||
pesData: null,
|
pesData: null,
|
||||||
currentFileName: "",
|
currentFileName: "",
|
||||||
patternOffset: { x: 0, y: 0 },
|
patternOffset: { x: 0, y: 0 },
|
||||||
|
patternRotation: 0,
|
||||||
|
|
||||||
|
// Uploaded pattern
|
||||||
|
uploadedPesData: null,
|
||||||
|
uploadedPatternOffset: { x: 0, y: 0 },
|
||||||
|
|
||||||
patternUploaded: false,
|
patternUploaded: false,
|
||||||
|
|
||||||
// Set pattern data and filename
|
// Set pattern data and filename (replaces current pattern)
|
||||||
setPattern: (data: PesPatternData, fileName: string) => {
|
setPattern: (data: PesPatternData, fileName: string) => {
|
||||||
set({
|
set({
|
||||||
pesData: data,
|
pesData: data,
|
||||||
currentFileName: fileName,
|
currentFileName: fileName,
|
||||||
patternOffset: { x: 0, y: 0 }, // Reset offset when new pattern is loaded
|
patternOffset: { x: 0, y: 0 },
|
||||||
|
patternRotation: 0,
|
||||||
|
uploadedPesData: null, // Clear uploaded pattern when loading new
|
||||||
|
uploadedPatternOffset: { x: 0, y: 0 },
|
||||||
patternUploaded: false,
|
patternUploaded: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update pattern offset
|
// Update pattern offset (for original pattern only)
|
||||||
setPatternOffset: (x: number, y: number) => {
|
setPatternOffset: (x: number, y: number) => {
|
||||||
set({ patternOffset: { x, y } });
|
set({ patternOffset: { x, y } });
|
||||||
console.log("[PatternStore] Pattern offset changed:", { x, y });
|
console.log("[PatternStore] Pattern offset changed:", { x, y });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mark pattern as uploaded/not uploaded
|
// Set pattern rotation (for original pattern only)
|
||||||
setPatternUploaded: (uploaded: boolean) => {
|
setPatternRotation: (rotation: number) => {
|
||||||
set({ patternUploaded: uploaded });
|
set({ patternRotation: rotation % 360 });
|
||||||
|
console.log("[PatternStore] Pattern rotation changed:", rotation);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clear pattern (but keep data visible for re-editing)
|
// Set uploaded pattern data (called after upload completes)
|
||||||
clearPattern: () => {
|
setUploadedPattern: (
|
||||||
|
uploadedData: PesPatternData,
|
||||||
|
uploadedOffset: { x: number; y: number },
|
||||||
|
) => {
|
||||||
set({
|
set({
|
||||||
patternUploaded: false,
|
uploadedPesData: uploadedData,
|
||||||
// Note: We intentionally DON'T clear pesData or currentFileName
|
uploadedPatternOffset: uploadedOffset,
|
||||||
// so the pattern remains visible in the canvas for re-editing
|
patternUploaded: true,
|
||||||
});
|
});
|
||||||
|
console.log("[PatternStore] Uploaded pattern set");
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear uploaded pattern (called when deleting from machine)
|
||||||
|
clearUploadedPattern: () => {
|
||||||
|
set({
|
||||||
|
uploadedPesData: null,
|
||||||
|
uploadedPatternOffset: { x: 0, y: 0 },
|
||||||
|
patternUploaded: false,
|
||||||
|
});
|
||||||
|
console.log("[PatternStore] Uploaded pattern cleared");
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reset pattern offset to default
|
// Reset pattern offset to default
|
||||||
resetPatternOffset: () => {
|
resetPatternOffset: () => {
|
||||||
set({ patternOffset: { x: 0, y: 0 } });
|
set({ patternOffset: { x: 0, y: 0 } });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Reset pattern rotation to default
|
||||||
|
resetRotation: () => {
|
||||||
|
set({ patternRotation: 0 });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Selector hooks for common use cases
|
// Selector hooks for common use cases
|
||||||
export const usePesData = () => usePatternStore((state) => state.pesData);
|
export const usePesData = () => usePatternStore((state) => state.pesData);
|
||||||
|
export const useUploadedPesData = () =>
|
||||||
|
usePatternStore((state) => state.uploadedPesData);
|
||||||
export const usePatternFileName = () =>
|
export const usePatternFileName = () =>
|
||||||
usePatternStore((state) => state.currentFileName);
|
usePatternStore((state) => state.currentFileName);
|
||||||
export const usePatternOffset = () =>
|
export const usePatternOffset = () =>
|
||||||
usePatternStore((state) => state.patternOffset);
|
usePatternStore((state) => state.patternOffset);
|
||||||
export const usePatternUploaded = () =>
|
export const useUploadedPatternOffset = () =>
|
||||||
usePatternStore((state) => state.patternUploaded);
|
usePatternStore((state) => state.uploadedPatternOffset);
|
||||||
|
export const usePatternRotation = () =>
|
||||||
|
usePatternStore((state) => state.patternRotation);
|
||||||
|
|
|
||||||
314
src/utils/rotationUtils.test.ts
Normal file
314
src/utils/rotationUtils.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
rotatePoint,
|
||||||
|
transformStitchesRotation,
|
||||||
|
calculateRotatedBounds,
|
||||||
|
normalizeAngle,
|
||||||
|
} from "./rotationUtils";
|
||||||
|
import { encodeStitchesToPen } from "../formats/pen/encoder";
|
||||||
|
import { decodePenData } from "../formats/pen/decoder";
|
||||||
|
|
||||||
|
describe("rotationUtils", () => {
|
||||||
|
describe("rotatePoint", () => {
|
||||||
|
it("should rotate 90° correctly", () => {
|
||||||
|
const result = rotatePoint(100, 0, 0, 0, 90);
|
||||||
|
expect(result.x).toBeCloseTo(0, 1);
|
||||||
|
expect(result.y).toBeCloseTo(100, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rotate 180° correctly", () => {
|
||||||
|
const result = rotatePoint(100, 50, 0, 0, 180);
|
||||||
|
expect(result.x).toBeCloseTo(-100, 1);
|
||||||
|
expect(result.y).toBeCloseTo(-50, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 0° rotation (no change)", () => {
|
||||||
|
const result = rotatePoint(100, 50, 0, 0, 0);
|
||||||
|
expect(result.x).toBe(100);
|
||||||
|
expect(result.y).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rotate 45° correctly", () => {
|
||||||
|
const result = rotatePoint(100, 0, 0, 0, 45);
|
||||||
|
expect(result.x).toBeCloseTo(70.71, 1);
|
||||||
|
expect(result.y).toBeCloseTo(70.71, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rotate around a custom center", () => {
|
||||||
|
const result = rotatePoint(150, 100, 100, 100, 90);
|
||||||
|
expect(result.x).toBeCloseTo(100, 1);
|
||||||
|
expect(result.y).toBeCloseTo(150, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transformStitchesRotation", () => {
|
||||||
|
it("should rotate stitches around pattern center (centered pattern)", () => {
|
||||||
|
const stitches = [
|
||||||
|
[100, 0, 0, 0],
|
||||||
|
[0, 100, 0, 0],
|
||||||
|
[-100, 0, 0, 0],
|
||||||
|
[0, -100, 0, 0],
|
||||||
|
];
|
||||||
|
const bounds = { minX: -100, maxX: 100, minY: -100, maxY: 100 };
|
||||||
|
|
||||||
|
const rotated = transformStitchesRotation(stitches, 90, bounds);
|
||||||
|
|
||||||
|
expect(rotated[0][0]).toBeCloseTo(0, 0);
|
||||||
|
expect(rotated[0][1]).toBeCloseTo(100, 0);
|
||||||
|
expect(rotated[1][0]).toBeCloseTo(-100, 0);
|
||||||
|
expect(rotated[1][1]).toBeCloseTo(0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve command and color data", () => {
|
||||||
|
const stitches = [[100, 50, 0x10, 2]];
|
||||||
|
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
|
||||||
|
|
||||||
|
const rotated = transformStitchesRotation(stitches, 45, bounds);
|
||||||
|
|
||||||
|
expect(rotated[0][2]).toBe(0x10); // Command unchanged
|
||||||
|
expect(rotated[0][3]).toBe(2); // Color unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 0° as no-op", () => {
|
||||||
|
const stitches = [[100, 50, 0, 0]];
|
||||||
|
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
|
||||||
|
|
||||||
|
const rotated = transformStitchesRotation(stitches, 0, bounds);
|
||||||
|
|
||||||
|
expect(rotated).toBe(stitches); // Same reference
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 360° as no-op", () => {
|
||||||
|
const stitches = [[100, 50, 0, 0]];
|
||||||
|
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
|
||||||
|
|
||||||
|
const rotated = transformStitchesRotation(stitches, 360, bounds);
|
||||||
|
|
||||||
|
expect(rotated).toBe(stitches); // Same reference
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should round coordinates to integers", () => {
|
||||||
|
const stitches = [[100, 0, 0, 0]];
|
||||||
|
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
|
||||||
|
|
||||||
|
const rotated = transformStitchesRotation(stitches, 45, bounds);
|
||||||
|
|
||||||
|
// Coordinates should be integers
|
||||||
|
expect(Number.isInteger(rotated[0][0])).toBe(true);
|
||||||
|
expect(Number.isInteger(rotated[0][1])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rotate off-center pattern around its own center", () => {
|
||||||
|
// Pattern bounds not centered at origin (like the real-world case)
|
||||||
|
const bounds = { minX: -23, maxX: 751, minY: -369, maxY: 485 };
|
||||||
|
const centerX = (bounds.minX + bounds.maxX) / 2; // 364
|
||||||
|
const centerY = (bounds.minY + bounds.maxY) / 2; // 58
|
||||||
|
|
||||||
|
// Stitch at the pattern's center
|
||||||
|
const stitches = [[centerX, centerY, 0, 0]];
|
||||||
|
|
||||||
|
// Rotate by any angle - center point should stay at center
|
||||||
|
const rotated = transformStitchesRotation(stitches, 90, bounds);
|
||||||
|
|
||||||
|
// Center stitch should remain at center (within rounding)
|
||||||
|
expect(rotated[0][0]).toBeCloseTo(centerX, 0);
|
||||||
|
expect(rotated[0][1]).toBeCloseTo(centerY, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rotate off-center pattern corners correctly", () => {
|
||||||
|
// Off-center pattern
|
||||||
|
const bounds = { minX: 100, maxX: 300, minY: 200, maxY: 400 };
|
||||||
|
|
||||||
|
// Test all four corners
|
||||||
|
const stitches = [
|
||||||
|
[100, 200, 0, 0], // top-left
|
||||||
|
[300, 200, 0, 0], // top-right
|
||||||
|
[100, 400, 0, 0], // bottom-left
|
||||||
|
[300, 400, 0, 0], // bottom-right
|
||||||
|
];
|
||||||
|
|
||||||
|
const rotated = transformStitchesRotation(stitches, 90, bounds);
|
||||||
|
|
||||||
|
// After 90° rotation around center (200, 300):
|
||||||
|
// top-left (100, 200) -> relative (-100, -100) -> rotated (100, -100) -> absolute (300, 200)
|
||||||
|
expect(rotated[0][0]).toBeCloseTo(300, 0);
|
||||||
|
expect(rotated[0][1]).toBeCloseTo(200, 0);
|
||||||
|
|
||||||
|
// top-right (300, 200) -> relative (100, -100) -> rotated (100, 100) -> absolute (300, 400)
|
||||||
|
expect(rotated[1][0]).toBeCloseTo(300, 0);
|
||||||
|
expect(rotated[1][1]).toBeCloseTo(400, 0);
|
||||||
|
|
||||||
|
// bottom-left (100, 400) -> relative (-100, 100) -> rotated (-100, -100) -> absolute (100, 200)
|
||||||
|
expect(rotated[2][0]).toBeCloseTo(100, 0);
|
||||||
|
expect(rotated[2][1]).toBeCloseTo(200, 0);
|
||||||
|
|
||||||
|
// bottom-right (300, 400) -> relative (100, 100) -> rotated (-100, 100) -> absolute (100, 400)
|
||||||
|
expect(rotated[3][0]).toBeCloseTo(100, 0);
|
||||||
|
expect(rotated[3][1]).toBeCloseTo(400, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle real-world off-center pattern (actual user case)", () => {
|
||||||
|
const bounds = { minX: -23, maxX: 751, minY: -369, maxY: 485 };
|
||||||
|
const centerX = 364;
|
||||||
|
const centerY = 58;
|
||||||
|
|
||||||
|
// A stitch at the top-right corner
|
||||||
|
const stitches = [[751, -369, 0, 0]];
|
||||||
|
|
||||||
|
const rotated = transformStitchesRotation(stitches, 45, bounds);
|
||||||
|
|
||||||
|
// Distance from center: sqrt((751-364)^2 + (-369-58)^2) = sqrt(149769 + 182329) = 576.4
|
||||||
|
// This distance should be preserved after rotation
|
||||||
|
const origDist = Math.sqrt(
|
||||||
|
Math.pow(751 - centerX, 2) + Math.pow(-369 - centerY, 2),
|
||||||
|
);
|
||||||
|
const rotDist = Math.sqrt(
|
||||||
|
Math.pow(rotated[0][0] - centerX, 2) +
|
||||||
|
Math.pow(rotated[0][1] - centerY, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rotDist).toBeCloseTo(origDist, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateRotatedBounds", () => {
|
||||||
|
it("should expand bounds after 45° rotation", () => {
|
||||||
|
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
|
||||||
|
|
||||||
|
const rotated = calculateRotatedBounds(bounds, 45);
|
||||||
|
|
||||||
|
// After 45° rotation, bounds should expand
|
||||||
|
expect(Math.abs(rotated.minX)).toBeGreaterThan(100);
|
||||||
|
expect(Math.abs(rotated.minY)).toBeGreaterThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain bounds for 0° rotation", () => {
|
||||||
|
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
|
||||||
|
|
||||||
|
const rotated = calculateRotatedBounds(bounds, 0);
|
||||||
|
|
||||||
|
expect(rotated).toEqual(bounds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain bounds for 360° rotation", () => {
|
||||||
|
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
|
||||||
|
|
||||||
|
const rotated = calculateRotatedBounds(bounds, 360);
|
||||||
|
|
||||||
|
expect(rotated).toEqual(bounds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 90° rotation symmetrically", () => {
|
||||||
|
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
|
||||||
|
|
||||||
|
const rotated = calculateRotatedBounds(bounds, 90);
|
||||||
|
|
||||||
|
// X and Y bounds swap
|
||||||
|
expect(rotated.minX).toBeCloseTo(-50, 0);
|
||||||
|
expect(rotated.maxX).toBeCloseTo(50, 0);
|
||||||
|
expect(rotated.minY).toBeCloseTo(-100, 0);
|
||||||
|
expect(rotated.maxY).toBeCloseTo(100, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle asymmetric bounds correctly", () => {
|
||||||
|
const bounds = { minX: 0, maxX: 200, minY: 0, maxY: 100 };
|
||||||
|
|
||||||
|
const rotated = calculateRotatedBounds(bounds, 90);
|
||||||
|
|
||||||
|
const centerX = (bounds.minX + bounds.maxX) / 2;
|
||||||
|
const centerY = (bounds.minY + bounds.maxY) / 2;
|
||||||
|
|
||||||
|
// After 90° rotation around center
|
||||||
|
expect(rotated.minX).toBeCloseTo(centerX - 50, 0);
|
||||||
|
expect(rotated.maxX).toBeCloseTo(centerX + 50, 0);
|
||||||
|
expect(rotated.minY).toBeCloseTo(centerY - 100, 0);
|
||||||
|
expect(rotated.maxY).toBeCloseTo(centerY + 100, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeAngle", () => {
|
||||||
|
it("should normalize negative angles", () => {
|
||||||
|
expect(normalizeAngle(-45)).toBe(315);
|
||||||
|
expect(normalizeAngle(-90)).toBe(270);
|
||||||
|
expect(normalizeAngle(-180)).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize angles > 360", () => {
|
||||||
|
expect(normalizeAngle(405)).toBe(45);
|
||||||
|
expect(normalizeAngle(720)).toBe(0);
|
||||||
|
expect(normalizeAngle(450)).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep valid angles unchanged", () => {
|
||||||
|
expect(normalizeAngle(0)).toBe(0);
|
||||||
|
expect(normalizeAngle(45)).toBe(45);
|
||||||
|
expect(normalizeAngle(180)).toBe(180);
|
||||||
|
expect(normalizeAngle(359)).toBe(359);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle very large angles", () => {
|
||||||
|
expect(normalizeAngle(1080)).toBe(0);
|
||||||
|
expect(normalizeAngle(1125)).toBe(45);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PEN encode/decode round-trip with rotation", () => {
|
||||||
|
it("should preserve rotated stitches through encode-decode cycle", () => {
|
||||||
|
// Create simple square pattern
|
||||||
|
const stitches = [
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[100, 0, 0, 0],
|
||||||
|
[100, 100, 0, 0],
|
||||||
|
[0, 100, 0, 0],
|
||||||
|
];
|
||||||
|
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
|
||||||
|
|
||||||
|
// Rotate 45°
|
||||||
|
const rotated = transformStitchesRotation(stitches, 45, bounds);
|
||||||
|
|
||||||
|
// Encode to PEN
|
||||||
|
const encoded = encodeStitchesToPen(rotated);
|
||||||
|
|
||||||
|
// Decode back
|
||||||
|
const decoded = decodePenData(new Uint8Array(encoded.penBytes));
|
||||||
|
|
||||||
|
// Verify stitch count preserved (note: lock stitches are added)
|
||||||
|
expect(decoded.stitches.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle rotation with multiple colors", () => {
|
||||||
|
const stitches = [
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[100, 0, 0, 0],
|
||||||
|
[100, 100, 0, 1], // Color change
|
||||||
|
[0, 100, 0, 1],
|
||||||
|
];
|
||||||
|
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
|
||||||
|
|
||||||
|
const rotated = transformStitchesRotation(stitches, 90, bounds);
|
||||||
|
const encoded = encodeStitchesToPen(rotated);
|
||||||
|
const decoded = decodePenData(new Uint8Array(encoded.penBytes));
|
||||||
|
|
||||||
|
// Verify color blocks preserved
|
||||||
|
expect(decoded.colorBlocks.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle negative coordinates after rotation", () => {
|
||||||
|
const stitches = [
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[100, 0, 0, 0],
|
||||||
|
];
|
||||||
|
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
|
||||||
|
|
||||||
|
// Rotate 180° will produce negative coordinates
|
||||||
|
const rotated = transformStitchesRotation(stitches, 180, bounds);
|
||||||
|
|
||||||
|
// Encode to PEN
|
||||||
|
const encoded = encodeStitchesToPen(rotated);
|
||||||
|
const decoded = decodePenData(new Uint8Array(encoded.penBytes));
|
||||||
|
|
||||||
|
// Should not crash and should produce valid output
|
||||||
|
expect(decoded.stitches.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
82
src/utils/rotationUtils.ts
Normal file
82
src/utils/rotationUtils.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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