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

Feature: pattern rotation
This commit is contained in:
Jan-Henrik Bruhn 2025-12-25 21:55:07 +01:00 committed by GitHub
commit 7fd31d209c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1624 additions and 576 deletions

View file

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

View file

@ -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>
);
}

View file

@ -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;

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1 @@
export { PatternCanvas } from "./PatternCanvas";

View 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,
};
}

View 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,
};
}

View 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,
};
}

View file

@ -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({

View file

@ -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);

View file

@ -0,0 +1,314 @@
import { describe, it, expect } from "vitest";
import {
rotatePoint,
transformStitchesRotation,
calculateRotatedBounds,
normalizeAngle,
} from "./rotationUtils";
import { encodeStitchesToPen } from "../formats/pen/encoder";
import { decodePenData } from "../formats/pen/decoder";
describe("rotationUtils", () => {
describe("rotatePoint", () => {
it("should rotate 90° correctly", () => {
const result = rotatePoint(100, 0, 0, 0, 90);
expect(result.x).toBeCloseTo(0, 1);
expect(result.y).toBeCloseTo(100, 1);
});
it("should rotate 180° correctly", () => {
const result = rotatePoint(100, 50, 0, 0, 180);
expect(result.x).toBeCloseTo(-100, 1);
expect(result.y).toBeCloseTo(-50, 1);
});
it("should handle 0° rotation (no change)", () => {
const result = rotatePoint(100, 50, 0, 0, 0);
expect(result.x).toBe(100);
expect(result.y).toBe(50);
});
it("should rotate 45° correctly", () => {
const result = rotatePoint(100, 0, 0, 0, 45);
expect(result.x).toBeCloseTo(70.71, 1);
expect(result.y).toBeCloseTo(70.71, 1);
});
it("should rotate around a custom center", () => {
const result = rotatePoint(150, 100, 100, 100, 90);
expect(result.x).toBeCloseTo(100, 1);
expect(result.y).toBeCloseTo(150, 1);
});
});
describe("transformStitchesRotation", () => {
it("should rotate stitches around pattern center (centered pattern)", () => {
const stitches = [
[100, 0, 0, 0],
[0, 100, 0, 0],
[-100, 0, 0, 0],
[0, -100, 0, 0],
];
const bounds = { minX: -100, maxX: 100, minY: -100, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 90, bounds);
expect(rotated[0][0]).toBeCloseTo(0, 0);
expect(rotated[0][1]).toBeCloseTo(100, 0);
expect(rotated[1][0]).toBeCloseTo(-100, 0);
expect(rotated[1][1]).toBeCloseTo(0, 0);
});
it("should preserve command and color data", () => {
const stitches = [[100, 50, 0x10, 2]];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 45, bounds);
expect(rotated[0][2]).toBe(0x10); // Command unchanged
expect(rotated[0][3]).toBe(2); // Color unchanged
});
it("should handle 0° as no-op", () => {
const stitches = [[100, 50, 0, 0]];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 0, bounds);
expect(rotated).toBe(stitches); // Same reference
});
it("should handle 360° as no-op", () => {
const stitches = [[100, 50, 0, 0]];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 360, bounds);
expect(rotated).toBe(stitches); // Same reference
});
it("should round coordinates to integers", () => {
const stitches = [[100, 0, 0, 0]];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 45, bounds);
// Coordinates should be integers
expect(Number.isInteger(rotated[0][0])).toBe(true);
expect(Number.isInteger(rotated[0][1])).toBe(true);
});
it("should rotate off-center pattern around its own center", () => {
// Pattern bounds not centered at origin (like the real-world case)
const bounds = { minX: -23, maxX: 751, minY: -369, maxY: 485 };
const centerX = (bounds.minX + bounds.maxX) / 2; // 364
const centerY = (bounds.minY + bounds.maxY) / 2; // 58
// Stitch at the pattern's center
const stitches = [[centerX, centerY, 0, 0]];
// Rotate by any angle - center point should stay at center
const rotated = transformStitchesRotation(stitches, 90, bounds);
// Center stitch should remain at center (within rounding)
expect(rotated[0][0]).toBeCloseTo(centerX, 0);
expect(rotated[0][1]).toBeCloseTo(centerY, 0);
});
it("should rotate off-center pattern corners correctly", () => {
// Off-center pattern
const bounds = { minX: 100, maxX: 300, minY: 200, maxY: 400 };
// Test all four corners
const stitches = [
[100, 200, 0, 0], // top-left
[300, 200, 0, 0], // top-right
[100, 400, 0, 0], // bottom-left
[300, 400, 0, 0], // bottom-right
];
const rotated = transformStitchesRotation(stitches, 90, bounds);
// After 90° rotation around center (200, 300):
// top-left (100, 200) -> relative (-100, -100) -> rotated (100, -100) -> absolute (300, 200)
expect(rotated[0][0]).toBeCloseTo(300, 0);
expect(rotated[0][1]).toBeCloseTo(200, 0);
// top-right (300, 200) -> relative (100, -100) -> rotated (100, 100) -> absolute (300, 400)
expect(rotated[1][0]).toBeCloseTo(300, 0);
expect(rotated[1][1]).toBeCloseTo(400, 0);
// bottom-left (100, 400) -> relative (-100, 100) -> rotated (-100, -100) -> absolute (100, 200)
expect(rotated[2][0]).toBeCloseTo(100, 0);
expect(rotated[2][1]).toBeCloseTo(200, 0);
// bottom-right (300, 400) -> relative (100, 100) -> rotated (-100, 100) -> absolute (100, 400)
expect(rotated[3][0]).toBeCloseTo(100, 0);
expect(rotated[3][1]).toBeCloseTo(400, 0);
});
it("should handle real-world off-center pattern (actual user case)", () => {
const bounds = { minX: -23, maxX: 751, minY: -369, maxY: 485 };
const centerX = 364;
const centerY = 58;
// A stitch at the top-right corner
const stitches = [[751, -369, 0, 0]];
const rotated = transformStitchesRotation(stitches, 45, bounds);
// Distance from center: sqrt((751-364)^2 + (-369-58)^2) = sqrt(149769 + 182329) = 576.4
// This distance should be preserved after rotation
const origDist = Math.sqrt(
Math.pow(751 - centerX, 2) + Math.pow(-369 - centerY, 2),
);
const rotDist = Math.sqrt(
Math.pow(rotated[0][0] - centerX, 2) +
Math.pow(rotated[0][1] - centerY, 2),
);
expect(rotDist).toBeCloseTo(origDist, 0);
});
});
describe("calculateRotatedBounds", () => {
it("should expand bounds after 45° rotation", () => {
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
const rotated = calculateRotatedBounds(bounds, 45);
// After 45° rotation, bounds should expand
expect(Math.abs(rotated.minX)).toBeGreaterThan(100);
expect(Math.abs(rotated.minY)).toBeGreaterThan(50);
});
it("should maintain bounds for 0° rotation", () => {
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
const rotated = calculateRotatedBounds(bounds, 0);
expect(rotated).toEqual(bounds);
});
it("should maintain bounds for 360° rotation", () => {
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
const rotated = calculateRotatedBounds(bounds, 360);
expect(rotated).toEqual(bounds);
});
it("should handle 90° rotation symmetrically", () => {
const bounds = { minX: -100, maxX: 100, minY: -50, maxY: 50 };
const rotated = calculateRotatedBounds(bounds, 90);
// X and Y bounds swap
expect(rotated.minX).toBeCloseTo(-50, 0);
expect(rotated.maxX).toBeCloseTo(50, 0);
expect(rotated.minY).toBeCloseTo(-100, 0);
expect(rotated.maxY).toBeCloseTo(100, 0);
});
it("should handle asymmetric bounds correctly", () => {
const bounds = { minX: 0, maxX: 200, minY: 0, maxY: 100 };
const rotated = calculateRotatedBounds(bounds, 90);
const centerX = (bounds.minX + bounds.maxX) / 2;
const centerY = (bounds.minY + bounds.maxY) / 2;
// After 90° rotation around center
expect(rotated.minX).toBeCloseTo(centerX - 50, 0);
expect(rotated.maxX).toBeCloseTo(centerX + 50, 0);
expect(rotated.minY).toBeCloseTo(centerY - 100, 0);
expect(rotated.maxY).toBeCloseTo(centerY + 100, 0);
});
});
describe("normalizeAngle", () => {
it("should normalize negative angles", () => {
expect(normalizeAngle(-45)).toBe(315);
expect(normalizeAngle(-90)).toBe(270);
expect(normalizeAngle(-180)).toBe(180);
});
it("should normalize angles > 360", () => {
expect(normalizeAngle(405)).toBe(45);
expect(normalizeAngle(720)).toBe(0);
expect(normalizeAngle(450)).toBe(90);
});
it("should keep valid angles unchanged", () => {
expect(normalizeAngle(0)).toBe(0);
expect(normalizeAngle(45)).toBe(45);
expect(normalizeAngle(180)).toBe(180);
expect(normalizeAngle(359)).toBe(359);
});
it("should handle very large angles", () => {
expect(normalizeAngle(1080)).toBe(0);
expect(normalizeAngle(1125)).toBe(45);
});
});
describe("PEN encode/decode round-trip with rotation", () => {
it("should preserve rotated stitches through encode-decode cycle", () => {
// Create simple square pattern
const stitches = [
[0, 0, 0, 0],
[100, 0, 0, 0],
[100, 100, 0, 0],
[0, 100, 0, 0],
];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
// Rotate 45°
const rotated = transformStitchesRotation(stitches, 45, bounds);
// Encode to PEN
const encoded = encodeStitchesToPen(rotated);
// Decode back
const decoded = decodePenData(new Uint8Array(encoded.penBytes));
// Verify stitch count preserved (note: lock stitches are added)
expect(decoded.stitches.length).toBeGreaterThan(0);
});
it("should handle rotation with multiple colors", () => {
const stitches = [
[0, 0, 0, 0],
[100, 0, 0, 0],
[100, 100, 0, 1], // Color change
[0, 100, 0, 1],
];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
const rotated = transformStitchesRotation(stitches, 90, bounds);
const encoded = encodeStitchesToPen(rotated);
const decoded = decodePenData(new Uint8Array(encoded.penBytes));
// Verify color blocks preserved
expect(decoded.colorBlocks.length).toBeGreaterThan(0);
});
it("should handle negative coordinates after rotation", () => {
const stitches = [
[0, 0, 0, 0],
[100, 0, 0, 0],
];
const bounds = { minX: 0, maxX: 100, minY: 0, maxY: 100 };
// Rotate 180° will produce negative coordinates
const rotated = transformStitchesRotation(stitches, 180, bounds);
// Encode to PEN
const encoded = encodeStitchesToPen(rotated);
const decoded = decodePenData(new Uint8Array(encoded.penBytes));
// Should not crash and should produce valid output
expect(decoded.stitches.length).toBeGreaterThan(0);
});
});
});

View file

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