From 20fff4cdfb2580cab92c415e8a401c66760dc666 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Dec 2025 16:47:35 +0000 Subject: [PATCH 1/6] Initial plan From 1d79ffb2a4ba7ce8625badf3b8248996f439e58f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Dec 2025 16:56:54 +0000 Subject: [PATCH 2/6] feature: Add computed selectors to usePatternStore for pattern transformations - Add selectPatternCenter for pattern center calculation - Add selectRotatedBounds for rotated bounds with memoization - Add selectRotationCenterShift for center shift calculation - Add selectPatternValidation for bounds checking against hoop - Add comprehensive tests for all selectors - Update usePatternValidation to use store selectors - All tests passing and linter clean Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com> --- src/components/PatternCanvas/PatternLayer.tsx | 3 + src/hooks/domain/usePatternValidation.ts | 92 ++---- src/stores/usePatternStore.test.ts | 300 ++++++++++++++++++ src/stores/usePatternStore.ts | 193 +++++++++++ 4 files changed, 524 insertions(+), 64 deletions(-) create mode 100644 src/stores/usePatternStore.test.ts diff --git a/src/components/PatternCanvas/PatternLayer.tsx b/src/components/PatternCanvas/PatternLayer.tsx index c9d61f3..e6d1f1a 100644 --- a/src/components/PatternCanvas/PatternLayer.tsx +++ b/src/components/PatternCanvas/PatternLayer.tsx @@ -43,6 +43,9 @@ export const PatternLayer = memo(function PatternLayer({ onTransformEnd, attachTransformer, }: PatternLayerProps) { + // Memoize center calculation - this is the pattern center calculation + // Note: We keep this local calculation here since the component receives + // pesData as a prop which could be either original or uploaded pattern const center = useMemo( () => calculatePatternCenter(pesData.bounds), [pesData.bounds], diff --git a/src/hooks/domain/usePatternValidation.ts b/src/hooks/domain/usePatternValidation.ts index 8ea8c64..6f54139 100644 --- a/src/hooks/domain/usePatternValidation.ts +++ b/src/hooks/domain/usePatternValidation.ts @@ -1,8 +1,7 @@ import { useMemo } from "react"; import type { PesPatternData } from "../../formats/import/pesImporter"; import type { MachineInfo } from "../../types/machine"; -import { calculateRotatedBounds } from "../../utils/rotationUtils"; -import { calculatePatternCenter } from "../../components/PatternCanvas/patternCanvasHelpers"; +import { usePatternValidationFromStore } from "../../stores/usePatternStore"; export interface PatternBoundsCheckResult { fits: boolean; @@ -12,8 +11,10 @@ export interface PatternBoundsCheckResult { export interface UsePatternValidationParams { pesData: PesPatternData | null; machineInfo: MachineInfo | null; - patternOffset: { x: number; y: number }; - patternRotation: number; + // Note: patternOffset and patternRotation are read from the store + // These params are kept for backward compatibility but are not used + patternOffset?: { x: number; y: number }; + patternRotation?: number; } /** @@ -22,76 +23,39 @@ export interface UsePatternValidationParams { * Checks if the pattern (with rotation and offset applied) fits within * the machine's hoop bounds and provides detailed error messages if not. * + * This hook now uses the computed selector from the pattern store for + * consistent validation logic across the application. + * * @param params - Pattern and machine configuration * @returns Bounds check result with fit status and error message */ export function usePatternValidation({ pesData, machineInfo, - patternOffset, - patternRotation, }: UsePatternValidationParams): PatternBoundsCheckResult { - // Memoize the bounds check calculation to avoid unnecessary recalculations + // Use the computed selector from the store for validation + // The store selector uses the current state (patternOffset, patternRotation) + const validationFromStore = usePatternValidationFromStore( + machineInfo + ? { maxWidth: machineInfo.maxWidth, maxHeight: machineInfo.maxHeight } + : null, + ); + + // Memoize the result to avoid unnecessary recalculations return useMemo((): PatternBoundsCheckResult => { if (!pesData || !machineInfo) { return { fits: true, error: null }; } - // Calculate rotated bounds if rotation is applied - let bounds = pesData.bounds; - if (patternRotation && patternRotation !== 0) { - bounds = calculateRotatedBounds(pesData.bounds, patternRotation); - } - - const { maxWidth, maxHeight } = machineInfo; - - // The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas) - // So we need to calculate bounds relative to the center - const center = calculatePatternCenter(bounds); - - // Calculate actual bounds in world coordinates - const patternMinX = patternOffset.x - center.x + bounds.minX; - const patternMaxX = patternOffset.x - center.x + bounds.maxX; - const patternMinY = patternOffset.y - center.y + bounds.minY; - const patternMaxY = patternOffset.y - center.y + bounds.maxY; - - // Hoop bounds (centered at origin) - const hoopMinX = -maxWidth / 2; - const hoopMaxX = maxWidth / 2; - const hoopMinY = -maxHeight / 2; - const hoopMaxY = maxHeight / 2; - - // Check if pattern exceeds hoop bounds - const exceedsLeft = patternMinX < hoopMinX; - const exceedsRight = patternMaxX > hoopMaxX; - const exceedsTop = patternMinY < hoopMinY; - const exceedsBottom = patternMaxY > hoopMaxY; - - if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) { - const directions = []; - if (exceedsLeft) - directions.push( - `left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`, - ); - if (exceedsRight) - directions.push( - `right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`, - ); - if (exceedsTop) - directions.push( - `top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`, - ); - if (exceedsBottom) - directions.push( - `bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`, - ); - - return { - fits: false, - error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`, - }; - } - - return { fits: true, error: null }; - }, [pesData, machineInfo, patternOffset, patternRotation]); + // Use the validation from store which already has all the logic + return { + fits: validationFromStore.fits, + error: validationFromStore.error, + }; + }, [ + pesData, + machineInfo, + validationFromStore.fits, + validationFromStore.error, + ]); } diff --git a/src/stores/usePatternStore.test.ts b/src/stores/usePatternStore.test.ts new file mode 100644 index 0000000..72550e3 --- /dev/null +++ b/src/stores/usePatternStore.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + usePatternStore, + selectPatternCenter, + selectUploadedPatternCenter, + selectRotatedBounds, + selectRotationCenterShift, + selectPatternValidation, +} from "./usePatternStore"; +import type { PesPatternData } from "../formats/import/pesImporter"; + +// Mock pattern data for testing +const createMockPesData = ( + bounds = { + minX: -100, + maxX: 100, + minY: -50, + maxY: 50, + }, +): PesPatternData => ({ + stitches: [[0, 0, 0, 0]], + threads: [], + uniqueColors: [], + penData: new Uint8Array(), + penStitches: { + stitches: [], + colorBlocks: [], + }, + colorCount: 1, + stitchCount: 1, + bounds, +}); + +describe("usePatternStore selectors", () => { + beforeEach(() => { + // Reset store state before each test + const state = usePatternStore.getState(); + state.setPattern(createMockPesData(), "test.pes"); + state.resetPatternOffset(); + state.resetRotation(); + state.clearUploadedPattern(); + }); + + describe("selectPatternCenter", () => { + it("should return null when no pattern is loaded", () => { + // Clear the pattern + usePatternStore.setState({ pesData: null }); + + const center = selectPatternCenter(usePatternStore.getState()); + expect(center).toBeNull(); + }); + + it("should calculate center correctly for symmetric bounds", () => { + const state = usePatternStore.getState(); + const center = selectPatternCenter(state); + + expect(center).not.toBeNull(); + expect(center!.x).toBe(0); // (minX + maxX) / 2 = (-100 + 100) / 2 = 0 + expect(center!.y).toBe(0); // (minY + maxY) / 2 = (-50 + 50) / 2 = 0 + }); + + it("should calculate center correctly for asymmetric bounds", () => { + const pesData = createMockPesData({ + minX: 0, + maxX: 200, + minY: 0, + maxY: 100, + }); + usePatternStore.setState({ pesData }); + + const state = usePatternStore.getState(); + const center = selectPatternCenter(state); + + expect(center).not.toBeNull(); + expect(center!.x).toBe(100); // (0 + 200) / 2 + expect(center!.y).toBe(50); // (0 + 100) / 2 + }); + }); + + describe("selectUploadedPatternCenter", () => { + it("should return null when no uploaded pattern", () => { + const state = usePatternStore.getState(); + const center = selectUploadedPatternCenter(state); + + expect(center).toBeNull(); + }); + + it("should calculate center of uploaded pattern", () => { + const uploadedData = createMockPesData({ + minX: 50, + maxX: 150, + minY: 25, + maxY: 75, + }); + usePatternStore + .getState() + .setUploadedPattern(uploadedData, { x: 0, y: 0 }); + + const state = usePatternStore.getState(); + const center = selectUploadedPatternCenter(state); + + expect(center).not.toBeNull(); + expect(center!.x).toBe(100); // (50 + 150) / 2 + expect(center!.y).toBe(50); // (25 + 75) / 2 + }); + }); + + describe("selectRotatedBounds", () => { + it("should return original bounds when no rotation", () => { + const state = usePatternStore.getState(); + const result = selectRotatedBounds(state); + + expect(result).not.toBeNull(); + expect(result!.bounds).toEqual({ + minX: -100, + maxX: 100, + minY: -50, + maxY: 50, + }); + expect(result!.center).toEqual({ x: 0, y: 0 }); + }); + + it("should return null when no pattern", () => { + usePatternStore.setState({ pesData: null }); + const state = usePatternStore.getState(); + const result = selectRotatedBounds(state); + + expect(result).toBeNull(); + }); + + it("should calculate rotated bounds for 90 degree rotation", () => { + usePatternStore.getState().setPatternRotation(90); + const state = usePatternStore.getState(); + const result = selectRotatedBounds(state); + + expect(result).not.toBeNull(); + // After 90° rotation, X and Y bounds should swap + expect(result!.bounds.minX).toBeCloseTo(-50, 0); + expect(result!.bounds.maxX).toBeCloseTo(50, 0); + expect(result!.bounds.minY).toBeCloseTo(-100, 0); + expect(result!.bounds.maxY).toBeCloseTo(100, 0); + }); + + it("should expand bounds for 45 degree rotation", () => { + usePatternStore.getState().setPatternRotation(45); + const state = usePatternStore.getState(); + const result = selectRotatedBounds(state); + + expect(result).not.toBeNull(); + // After 45° rotation, bounds should expand + expect(Math.abs(result!.bounds.minX)).toBeGreaterThan(100); + expect(Math.abs(result!.bounds.minY)).toBeGreaterThan(50); + }); + }); + + describe("selectRotationCenterShift", () => { + it("should return zero shift when no rotation", () => { + const state = usePatternStore.getState(); + const rotatedBounds = state.pesData!.bounds; + const shift = selectRotationCenterShift(state, rotatedBounds); + + expect(shift).toEqual({ x: 0, y: 0 }); + }); + + it("should return null when no pattern", () => { + usePatternStore.setState({ pesData: null }); + const state = usePatternStore.getState(); + const shift = selectRotationCenterShift(state, { + minX: 0, + maxX: 100, + minY: 0, + maxY: 100, + }); + + expect(shift).toBeNull(); + }); + + it("should calculate center shift for asymmetric pattern", () => { + const pesData = createMockPesData({ + minX: 0, + maxX: 200, + minY: 0, + maxY: 100, + }); + usePatternStore.setState({ pesData }); + usePatternStore.getState().setPatternRotation(90); + + const state = usePatternStore.getState(); + const rotatedBounds = selectRotatedBounds(state)!.bounds; + const shift = selectRotationCenterShift(state, rotatedBounds); + + expect(shift).not.toBeNull(); + // Original center: (100, 50) + // After 90° rotation around center, new center should be slightly different + // due to the asymmetric bounds + expect(shift!.x).toBeCloseTo(0, 0); + expect(shift!.y).toBeCloseTo(0, 0); + }); + }); + + describe("selectPatternValidation", () => { + const machineInfo = { maxWidth: 1000, maxHeight: 800 }; + + it("should return fits=true when no pattern", () => { + usePatternStore.setState({ pesData: null }); + const state = usePatternStore.getState(); + const result = selectPatternValidation(state, machineInfo); + + expect(result.fits).toBe(true); + expect(result.error).toBeNull(); + }); + + it("should return fits=true when no machine info", () => { + const state = usePatternStore.getState(); + const result = selectPatternValidation(state, null); + + expect(result.fits).toBe(true); + expect(result.error).toBeNull(); + }); + + it("should return fits=true when pattern fits in hoop", () => { + // Pattern bounds: -100 to 100 (200 wide), -50 to 50 (100 high) + // Hoop: 1000 wide, 800 high (centered at origin) + const state = usePatternStore.getState(); + const result = selectPatternValidation(state, machineInfo); + + expect(result.fits).toBe(true); + expect(result.error).toBeNull(); + }); + + it("should detect when pattern exceeds hoop bounds", () => { + // Create a pattern that's too large + const pesData = createMockPesData({ + minX: -600, + maxX: 600, + minY: -500, + maxY: 500, + }); + usePatternStore.setState({ pesData }); + + const state = usePatternStore.getState(); + const result = selectPatternValidation(state, machineInfo); + + expect(result.fits).toBe(false); + expect(result.error).not.toBeNull(); + expect(result.error).toContain("exceeds hoop bounds"); + }); + + it("should account for pattern offset when validating", () => { + // Pattern bounds: -100 to 100 (200 wide), -50 to 50 (100 high) + // Hoop: 1000 wide (-500 to 500), 800 high (-400 to 400) + // Pattern fits, but when offset by 450, max edge is at 550 (exceeds 500) + usePatternStore.getState().setPatternOffset(450, 0); + + const state = usePatternStore.getState(); + const result = selectPatternValidation(state, machineInfo); + + expect(result.fits).toBe(false); + expect(result.error).toContain("right"); + }); + + it("should account for rotation when validating", () => { + // Pattern that fits normally but exceeds when rotated 45° + const pesData = createMockPesData({ + minX: -450, + maxX: 450, + minY: -50, + maxY: 50, + }); + usePatternStore.setState({ pesData }); + usePatternStore.getState().setPatternRotation(45); + + const state = usePatternStore.getState(); + const result = selectPatternValidation(state, machineInfo); + + // After 45° rotation, the bounds expand and may exceed + expect(result).toBeDefined(); + }); + + it("should provide detailed error messages with directions", () => { + // Pattern that definitely exceeds on the left side + // Hoop: -500 to 500 (X), -400 to 400 (Y) + // Pattern with minX at -600 and maxX at 600 will exceed both bounds + const pesData = createMockPesData({ + minX: -600, + maxX: 600, + minY: -50, + maxY: 50, + }); + usePatternStore.setState({ pesData }); + + const state = usePatternStore.getState(); + const result = selectPatternValidation(state, machineInfo); + + expect(result.fits).toBe(false); + expect(result.error).toContain("left"); + expect(result.error).toContain("mm"); + }); + }); +}); diff --git a/src/stores/usePatternStore.ts b/src/stores/usePatternStore.ts index 610fe34..5fbe967 100644 --- a/src/stores/usePatternStore.ts +++ b/src/stores/usePatternStore.ts @@ -1,6 +1,8 @@ import { create } from "zustand"; import type { PesPatternData } from "../formats/import/pesImporter"; import { onPatternDeleted } from "./storeEvents"; +import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers"; +import { calculateRotatedBounds } from "../utils/rotationUtils"; interface PatternState { // Original pattern (pre-upload) @@ -29,6 +31,35 @@ interface PatternState { resetRotation: () => void; } +// Computed value types +export interface PatternCenter { + x: number; + y: number; +} + +export interface PatternBounds { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +export interface TransformedBounds { + bounds: PatternBounds; + center: PatternCenter; +} + +export interface PatternValidationResult { + fits: boolean; + error: string | null; + worldBounds: { + minX: number; + maxX: number; + minY: number; + maxY: number; + } | null; +} + export const usePatternStore = create((set) => ({ // Initial state - original pattern pesData: null, @@ -123,6 +154,168 @@ export const useUploadedPatternOffset = () => export const usePatternRotation = () => usePatternStore((state) => state.patternRotation); +// Computed selectors (memoized by Zustand) +// These provide single source of truth for derived state + +/** + * Select the geometric center of the pattern's bounds + */ +export const selectPatternCenter = ( + state: PatternState, +): PatternCenter | null => { + if (!state.pesData) return null; + return calculatePatternCenter(state.pesData.bounds); +}; + +/** + * Select the center of the uploaded pattern's bounds + */ +export const selectUploadedPatternCenter = ( + state: PatternState, +): PatternCenter | null => { + if (!state.uploadedPesData) return null; + return calculatePatternCenter(state.uploadedPesData.bounds); +}; + +/** + * Select the rotated bounds of the current pattern + * Returns original bounds if no rotation or no pattern + */ +export const selectRotatedBounds = ( + state: PatternState, +): TransformedBounds | null => { + if (!state.pesData) return null; + + const bounds = + state.patternRotation && state.patternRotation !== 0 + ? calculateRotatedBounds(state.pesData.bounds, state.patternRotation) + : state.pesData.bounds; + + const center = calculatePatternCenter(bounds); + + return { bounds, center }; +}; + +/** + * Select the center shift caused by rotation + * This is used to adjust the offset when rotation is applied + * Returns null if no pattern or no rotation + */ +export const selectRotationCenterShift = ( + state: PatternState, + rotatedBounds: PatternBounds, +): { x: number; y: number } | null => { + if (!state.pesData) return null; + if (!state.patternRotation || state.patternRotation === 0) + return { x: 0, y: 0 }; + + const originalCenter = calculatePatternCenter(state.pesData.bounds); + const rotatedCenter = calculatePatternCenter(rotatedBounds); + + return { + x: rotatedCenter.x - originalCenter.x, + y: rotatedCenter.y - originalCenter.y, + }; +}; + +/** + * Select pattern validation against machine hoop bounds + * Returns whether pattern fits and error message if not + */ +export const selectPatternValidation = ( + state: PatternState, + machineInfo: { maxWidth: number; maxHeight: number } | null, +): PatternValidationResult => { + if (!state.pesData || !machineInfo) { + return { fits: true, error: null, worldBounds: null }; + } + + // Get rotated bounds + const transformedBounds = selectRotatedBounds(state); + if (!transformedBounds) { + return { fits: true, error: null, worldBounds: null }; + } + + const { bounds, center } = transformedBounds; + const { maxWidth, maxHeight } = machineInfo; + + // Calculate actual bounds in world coordinates + // The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas) + const patternMinX = state.patternOffset.x - center.x + bounds.minX; + const patternMaxX = state.patternOffset.x - center.x + bounds.maxX; + const patternMinY = state.patternOffset.y - center.y + bounds.minY; + const patternMaxY = state.patternOffset.y - center.y + bounds.maxY; + + const worldBounds = { + minX: patternMinX, + maxX: patternMaxX, + minY: patternMinY, + maxY: patternMaxY, + }; + + // Hoop bounds (centered at origin) + const hoopMinX = -maxWidth / 2; + const hoopMaxX = maxWidth / 2; + const hoopMinY = -maxHeight / 2; + const hoopMaxY = maxHeight / 2; + + // Check if pattern exceeds hoop bounds + const exceedsLeft = patternMinX < hoopMinX; + const exceedsRight = patternMaxX > hoopMaxX; + const exceedsTop = patternMinY < hoopMinY; + const exceedsBottom = patternMaxY > hoopMaxY; + + if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) { + const directions = []; + if (exceedsLeft) + directions.push( + `left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`, + ); + if (exceedsRight) + directions.push( + `right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`, + ); + if (exceedsTop) + directions.push(`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`); + if (exceedsBottom) + directions.push( + `bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`, + ); + + return { + fits: false, + error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`, + worldBounds, + }; + } + + return { fits: true, error: null, worldBounds }; +}; + +/** + * Hook to get pattern center (memoized) + */ +export const usePatternCenter = () => usePatternStore(selectPatternCenter); + +/** + * Hook to get uploaded pattern center (memoized) + */ +export const useUploadedPatternCenter = () => + usePatternStore(selectUploadedPatternCenter); + +/** + * Hook to get rotated bounds (memoized) + */ +export const useRotatedBounds = () => usePatternStore(selectRotatedBounds); + +/** + * Hook to get pattern validation result (requires machineInfo) + * Use this with caution as it requires external state + */ +export const usePatternValidationFromStore = ( + machineInfo: { maxWidth: number; maxHeight: number } | null, +) => usePatternStore((state) => selectPatternValidation(state, machineInfo)); + // Subscribe to pattern deleted event. // This subscription is intended to persist for the lifetime of the application, // so the unsubscribe function returned by `onPatternDeleted` is intentionally From fb94591f788bf4524affe7726927b033583a6426 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Dec 2025 16:58:52 +0000 Subject: [PATCH 3/6] feature: Document consistent use of pattern helpers across codebase - Add documentation to usePatternRotationUpload about using same helpers as store - Add documentation to App.tsx pattern resume logic - Mark PatternLayer to continue using local memoization (receives props) - All tests passing Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com> --- src/App.tsx | 5 ++++- src/hooks/domain/usePatternRotationUpload.ts | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e2ed838..5a29a59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,8 +112,9 @@ function App() { // Use cached uploadedPesData if available, otherwise recalculate if (cachedUploadedPesData) { // Use the exact uploaded data from cache - // Calculate the adjusted offset (same logic as upload) + // Calculate the adjusted offset (same logic as upload hook) if (rotation !== 0) { + // Calculate center shift using the same helper as store selectors const originalCenter = calculatePatternCenter(originalPesData.bounds); const rotatedCenter = calculatePatternCenter( cachedUploadedPesData.bounds, @@ -140,6 +141,7 @@ function App() { } } else if (rotation !== 0) { // Fallback: recalculate if no cached uploaded data (shouldn't happen for new uploads) + // This uses the same transformation logic as usePatternRotationUpload hook console.warn("[App] No cached uploaded data, recalculating rotation"); const rotatedStitches = transformStitchesRotation( originalPesData.stitches, @@ -152,6 +154,7 @@ function App() { const decoded = decodePenData(penData); const rotatedBounds = calculateBoundsFromDecodedStitches(decoded); + // Calculate center shift using the same helper as store selectors const originalCenter = calculatePatternCenter(originalPesData.bounds); const rotatedCenter = calculatePatternCenter(rotatedBounds); const centerShiftX = rotatedCenter.x - originalCenter.x; diff --git a/src/hooks/domain/usePatternRotationUpload.ts b/src/hooks/domain/usePatternRotationUpload.ts index 92d89d3..34b6260 100644 --- a/src/hooks/domain/usePatternRotationUpload.ts +++ b/src/hooks/domain/usePatternRotationUpload.ts @@ -42,6 +42,10 @@ export interface UsePatternRotationUploadReturn { * - Center shift calculation to maintain visual position * - Upload orchestration with proper caching * + * Note: This hook operates on passed parameters rather than store state, + * allowing it to be used as a callback handler. The center calculations + * use the same helpers as the store selectors for consistency. + * * @param params - Upload and store functions * @returns Upload handler function */ @@ -78,7 +82,8 @@ export function usePatternRotationUpload({ // Calculate bounds from the DECODED stitches (the actual data that will be rendered) const rotatedBounds = calculateBoundsFromDecodedStitches(decoded); - // Calculate the center of the rotated pattern + // Calculate the center shift caused by rotation + // Uses the same calculatePatternCenter helper as store selectors const originalCenter = calculatePatternCenter(pesData.bounds); const rotatedCenter = calculatePatternCenter(rotatedBounds); const centerShiftX = rotatedCenter.x - originalCenter.x; From 6fbb3ebf1a391cf937c36057deb18f5230605fed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Dec 2025 17:00:05 +0000 Subject: [PATCH 4/6] fix: Add missing bounds field to mock PenStitches in tests Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com> --- src/stores/usePatternStore.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/stores/usePatternStore.test.ts b/src/stores/usePatternStore.test.ts index 72550e3..511ba78 100644 --- a/src/stores/usePatternStore.test.ts +++ b/src/stores/usePatternStore.test.ts @@ -25,6 +25,7 @@ const createMockPesData = ( penStitches: { stitches: [], colorBlocks: [], + bounds: { minX: 0, maxX: 0, minY: 0, maxY: 0 }, }, colorCount: 1, stitchCount: 1, From bcb5ea1786ab5497552e16bf493be18e41556dab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 08:10:40 +0000 Subject: [PATCH 5/6] fix: Use shallow comparison in selector hooks to prevent infinite re-renders The selectors were creating new object references on every call, causing Zustand's subscription system to detect changes and trigger infinite re-render loops. This was particularly evident in usePatternValidationFromStore. Solution: - Import useShallow from zustand/react/shallow - Wrap all selector hooks (usePatternCenter, useUploadedPatternCenter, useRotatedBounds, usePatternValidationFromStore) with useShallow - useShallow performs shallow comparison on returned objects, preventing re-renders when values haven't actually changed This follows the established pattern in the codebase where useShallow is already used extensively (App.tsx, FileUpload.tsx, etc). Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com> --- src/stores/usePatternStore.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/stores/usePatternStore.ts b/src/stores/usePatternStore.ts index 5fbe967..d6c26c1 100644 --- a/src/stores/usePatternStore.ts +++ b/src/stores/usePatternStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; import type { PesPatternData } from "../formats/import/pesImporter"; import { onPatternDeleted } from "./storeEvents"; import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers"; @@ -293,28 +294,33 @@ export const selectPatternValidation = ( }; /** - * Hook to get pattern center (memoized) + * Hook to get pattern center (memoized with shallow comparison) */ -export const usePatternCenter = () => usePatternStore(selectPatternCenter); +export const usePatternCenter = () => + usePatternStore(useShallow(selectPatternCenter)); /** - * Hook to get uploaded pattern center (memoized) + * Hook to get uploaded pattern center (memoized with shallow comparison) */ export const useUploadedPatternCenter = () => - usePatternStore(selectUploadedPatternCenter); + usePatternStore(useShallow(selectUploadedPatternCenter)); /** - * Hook to get rotated bounds (memoized) + * Hook to get rotated bounds (memoized with shallow comparison) */ -export const useRotatedBounds = () => usePatternStore(selectRotatedBounds); +export const useRotatedBounds = () => + usePatternStore(useShallow(selectRotatedBounds)); /** * Hook to get pattern validation result (requires machineInfo) - * Use this with caution as it requires external state + * Uses shallow comparison to prevent infinite re-renders from new object references */ export const usePatternValidationFromStore = ( machineInfo: { maxWidth: number; maxHeight: number } | null, -) => usePatternStore((state) => selectPatternValidation(state, machineInfo)); +) => + usePatternStore( + useShallow((state) => selectPatternValidation(state, machineInfo)), + ); // Subscribe to pattern deleted event. // This subscription is intended to persist for the lifetime of the application, From 5296590a45e86c6e1a1e113d87cd6b08bc731101 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 08:17:20 +0000 Subject: [PATCH 6/6] fix: Revert pattern validation to local hook to prevent infinite re-renders The selectPatternValidation selector doesn't work well with Zustand because: 1. It requires machineInfo parameter from a different store 2. Passing machineInfo creates new object references on every render 3. This breaks Zustand's memoization and causes infinite loops Solution: - Reverted usePatternValidation.ts to original implementation with useMemo - Removed selectPatternValidation and usePatternValidationFromStore - Removed PatternValidationResult type (not needed) - Updated tests to remove validation selector tests (12 tests remain) The store selectors (selectPatternCenter, selectRotatedBounds, etc) are still useful for components that only need those specific values, but validation logic that depends on external state should stay local. Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com> --- src/hooks/domain/usePatternValidation.ts | 92 ++++++++++++++------- src/stores/usePatternStore.test.ts | 101 ----------------------- src/stores/usePatternStore.ts | 96 --------------------- 3 files changed, 64 insertions(+), 225 deletions(-) diff --git a/src/hooks/domain/usePatternValidation.ts b/src/hooks/domain/usePatternValidation.ts index 6f54139..8ea8c64 100644 --- a/src/hooks/domain/usePatternValidation.ts +++ b/src/hooks/domain/usePatternValidation.ts @@ -1,7 +1,8 @@ import { useMemo } from "react"; import type { PesPatternData } from "../../formats/import/pesImporter"; import type { MachineInfo } from "../../types/machine"; -import { usePatternValidationFromStore } from "../../stores/usePatternStore"; +import { calculateRotatedBounds } from "../../utils/rotationUtils"; +import { calculatePatternCenter } from "../../components/PatternCanvas/patternCanvasHelpers"; export interface PatternBoundsCheckResult { fits: boolean; @@ -11,10 +12,8 @@ export interface PatternBoundsCheckResult { export interface UsePatternValidationParams { pesData: PesPatternData | null; machineInfo: MachineInfo | null; - // Note: patternOffset and patternRotation are read from the store - // These params are kept for backward compatibility but are not used - patternOffset?: { x: number; y: number }; - patternRotation?: number; + patternOffset: { x: number; y: number }; + patternRotation: number; } /** @@ -23,39 +22,76 @@ export interface UsePatternValidationParams { * Checks if the pattern (with rotation and offset applied) fits within * the machine's hoop bounds and provides detailed error messages if not. * - * This hook now uses the computed selector from the pattern store for - * consistent validation logic across the application. - * * @param params - Pattern and machine configuration * @returns Bounds check result with fit status and error message */ export function usePatternValidation({ pesData, machineInfo, + patternOffset, + patternRotation, }: UsePatternValidationParams): PatternBoundsCheckResult { - // Use the computed selector from the store for validation - // The store selector uses the current state (patternOffset, patternRotation) - const validationFromStore = usePatternValidationFromStore( - machineInfo - ? { maxWidth: machineInfo.maxWidth, maxHeight: machineInfo.maxHeight } - : null, - ); - - // Memoize the result to avoid unnecessary recalculations + // Memoize the bounds check calculation to avoid unnecessary recalculations return useMemo((): PatternBoundsCheckResult => { if (!pesData || !machineInfo) { return { fits: true, error: null }; } - // Use the validation from store which already has all the logic - return { - fits: validationFromStore.fits, - error: validationFromStore.error, - }; - }, [ - pesData, - machineInfo, - validationFromStore.fits, - validationFromStore.error, - ]); + // Calculate rotated bounds if rotation is applied + let bounds = pesData.bounds; + if (patternRotation && patternRotation !== 0) { + bounds = calculateRotatedBounds(pesData.bounds, patternRotation); + } + + const { maxWidth, maxHeight } = machineInfo; + + // The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas) + // So we need to calculate bounds relative to the center + const center = calculatePatternCenter(bounds); + + // Calculate actual bounds in world coordinates + const patternMinX = patternOffset.x - center.x + bounds.minX; + const patternMaxX = patternOffset.x - center.x + bounds.maxX; + const patternMinY = patternOffset.y - center.y + bounds.minY; + const patternMaxY = patternOffset.y - center.y + bounds.maxY; + + // Hoop bounds (centered at origin) + const hoopMinX = -maxWidth / 2; + const hoopMaxX = maxWidth / 2; + const hoopMinY = -maxHeight / 2; + const hoopMaxY = maxHeight / 2; + + // Check if pattern exceeds hoop bounds + const exceedsLeft = patternMinX < hoopMinX; + const exceedsRight = patternMaxX > hoopMaxX; + const exceedsTop = patternMinY < hoopMinY; + const exceedsBottom = patternMaxY > hoopMaxY; + + if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) { + const directions = []; + if (exceedsLeft) + directions.push( + `left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`, + ); + if (exceedsRight) + directions.push( + `right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`, + ); + if (exceedsTop) + directions.push( + `top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`, + ); + if (exceedsBottom) + directions.push( + `bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`, + ); + + return { + fits: false, + error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`, + }; + } + + return { fits: true, error: null }; + }, [pesData, machineInfo, patternOffset, patternRotation]); } diff --git a/src/stores/usePatternStore.test.ts b/src/stores/usePatternStore.test.ts index 511ba78..04ebb26 100644 --- a/src/stores/usePatternStore.test.ts +++ b/src/stores/usePatternStore.test.ts @@ -5,7 +5,6 @@ import { selectUploadedPatternCenter, selectRotatedBounds, selectRotationCenterShift, - selectPatternValidation, } from "./usePatternStore"; import type { PesPatternData } from "../formats/import/pesImporter"; @@ -198,104 +197,4 @@ describe("usePatternStore selectors", () => { expect(shift!.y).toBeCloseTo(0, 0); }); }); - - describe("selectPatternValidation", () => { - const machineInfo = { maxWidth: 1000, maxHeight: 800 }; - - it("should return fits=true when no pattern", () => { - usePatternStore.setState({ pesData: null }); - const state = usePatternStore.getState(); - const result = selectPatternValidation(state, machineInfo); - - expect(result.fits).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should return fits=true when no machine info", () => { - const state = usePatternStore.getState(); - const result = selectPatternValidation(state, null); - - expect(result.fits).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should return fits=true when pattern fits in hoop", () => { - // Pattern bounds: -100 to 100 (200 wide), -50 to 50 (100 high) - // Hoop: 1000 wide, 800 high (centered at origin) - const state = usePatternStore.getState(); - const result = selectPatternValidation(state, machineInfo); - - expect(result.fits).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should detect when pattern exceeds hoop bounds", () => { - // Create a pattern that's too large - const pesData = createMockPesData({ - minX: -600, - maxX: 600, - minY: -500, - maxY: 500, - }); - usePatternStore.setState({ pesData }); - - const state = usePatternStore.getState(); - const result = selectPatternValidation(state, machineInfo); - - expect(result.fits).toBe(false); - expect(result.error).not.toBeNull(); - expect(result.error).toContain("exceeds hoop bounds"); - }); - - it("should account for pattern offset when validating", () => { - // Pattern bounds: -100 to 100 (200 wide), -50 to 50 (100 high) - // Hoop: 1000 wide (-500 to 500), 800 high (-400 to 400) - // Pattern fits, but when offset by 450, max edge is at 550 (exceeds 500) - usePatternStore.getState().setPatternOffset(450, 0); - - const state = usePatternStore.getState(); - const result = selectPatternValidation(state, machineInfo); - - expect(result.fits).toBe(false); - expect(result.error).toContain("right"); - }); - - it("should account for rotation when validating", () => { - // Pattern that fits normally but exceeds when rotated 45° - const pesData = createMockPesData({ - minX: -450, - maxX: 450, - minY: -50, - maxY: 50, - }); - usePatternStore.setState({ pesData }); - usePatternStore.getState().setPatternRotation(45); - - const state = usePatternStore.getState(); - const result = selectPatternValidation(state, machineInfo); - - // After 45° rotation, the bounds expand and may exceed - expect(result).toBeDefined(); - }); - - it("should provide detailed error messages with directions", () => { - // Pattern that definitely exceeds on the left side - // Hoop: -500 to 500 (X), -400 to 400 (Y) - // Pattern with minX at -600 and maxX at 600 will exceed both bounds - const pesData = createMockPesData({ - minX: -600, - maxX: 600, - minY: -50, - maxY: 50, - }); - usePatternStore.setState({ pesData }); - - const state = usePatternStore.getState(); - const result = selectPatternValidation(state, machineInfo); - - expect(result.fits).toBe(false); - expect(result.error).toContain("left"); - expect(result.error).toContain("mm"); - }); - }); }); diff --git a/src/stores/usePatternStore.ts b/src/stores/usePatternStore.ts index d6c26c1..4a28fea 100644 --- a/src/stores/usePatternStore.ts +++ b/src/stores/usePatternStore.ts @@ -50,17 +50,6 @@ export interface TransformedBounds { center: PatternCenter; } -export interface PatternValidationResult { - fits: boolean; - error: string | null; - worldBounds: { - minX: number; - maxX: number; - minY: number; - maxY: number; - } | null; -} - export const usePatternStore = create((set) => ({ // Initial state - original pattern pesData: null, @@ -219,80 +208,6 @@ export const selectRotationCenterShift = ( }; }; -/** - * Select pattern validation against machine hoop bounds - * Returns whether pattern fits and error message if not - */ -export const selectPatternValidation = ( - state: PatternState, - machineInfo: { maxWidth: number; maxHeight: number } | null, -): PatternValidationResult => { - if (!state.pesData || !machineInfo) { - return { fits: true, error: null, worldBounds: null }; - } - - // Get rotated bounds - const transformedBounds = selectRotatedBounds(state); - if (!transformedBounds) { - return { fits: true, error: null, worldBounds: null }; - } - - const { bounds, center } = transformedBounds; - const { maxWidth, maxHeight } = machineInfo; - - // Calculate actual bounds in world coordinates - // The patternOffset represents the pattern's CENTER position (due to offsetX/offsetY in canvas) - const patternMinX = state.patternOffset.x - center.x + bounds.minX; - const patternMaxX = state.patternOffset.x - center.x + bounds.maxX; - const patternMinY = state.patternOffset.y - center.y + bounds.minY; - const patternMaxY = state.patternOffset.y - center.y + bounds.maxY; - - const worldBounds = { - minX: patternMinX, - maxX: patternMaxX, - minY: patternMinY, - maxY: patternMaxY, - }; - - // Hoop bounds (centered at origin) - const hoopMinX = -maxWidth / 2; - const hoopMaxX = maxWidth / 2; - const hoopMinY = -maxHeight / 2; - const hoopMaxY = maxHeight / 2; - - // Check if pattern exceeds hoop bounds - const exceedsLeft = patternMinX < hoopMinX; - const exceedsRight = patternMaxX > hoopMaxX; - const exceedsTop = patternMinY < hoopMinY; - const exceedsBottom = patternMaxY > hoopMaxY; - - if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) { - const directions = []; - if (exceedsLeft) - directions.push( - `left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`, - ); - if (exceedsRight) - directions.push( - `right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`, - ); - if (exceedsTop) - directions.push(`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`); - if (exceedsBottom) - directions.push( - `bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`, - ); - - return { - fits: false, - error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`, - worldBounds, - }; - } - - return { fits: true, error: null, worldBounds }; -}; - /** * Hook to get pattern center (memoized with shallow comparison) */ @@ -311,17 +226,6 @@ export const useUploadedPatternCenter = () => export const useRotatedBounds = () => usePatternStore(useShallow(selectRotatedBounds)); -/** - * Hook to get pattern validation result (requires machineInfo) - * Uses shallow comparison to prevent infinite re-renders from new object references - */ -export const usePatternValidationFromStore = ( - machineInfo: { maxWidth: number; maxHeight: number } | null, -) => - usePatternStore( - useShallow((state) => selectPatternValidation(state, machineInfo)), - ); - // Subscribe to pattern deleted event. // This subscription is intended to persist for the lifetime of the application, // so the unsubscribe function returned by `onPatternDeleted` is intentionally