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/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/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; diff --git a/src/stores/usePatternStore.test.ts b/src/stores/usePatternStore.test.ts new file mode 100644 index 0000000..04ebb26 --- /dev/null +++ b/src/stores/usePatternStore.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + usePatternStore, + selectPatternCenter, + selectUploadedPatternCenter, + selectRotatedBounds, + selectRotationCenterShift, +} 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: [], + bounds: { minX: 0, maxX: 0, minY: 0, maxY: 0 }, + }, + 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); + }); + }); +}); diff --git a/src/stores/usePatternStore.ts b/src/stores/usePatternStore.ts index 610fe34..4a28fea 100644 --- a/src/stores/usePatternStore.ts +++ b/src/stores/usePatternStore.ts @@ -1,6 +1,9 @@ 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"; +import { calculateRotatedBounds } from "../utils/rotationUtils"; interface PatternState { // Original pattern (pre-upload) @@ -29,6 +32,24 @@ 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 const usePatternStore = create((set) => ({ // Initial state - original pattern pesData: null, @@ -123,6 +144,88 @@ 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, + }; +}; + +/** + * Hook to get pattern center (memoized with shallow comparison) + */ +export const usePatternCenter = () => + usePatternStore(useShallow(selectPatternCenter)); + +/** + * Hook to get uploaded pattern center (memoized with shallow comparison) + */ +export const useUploadedPatternCenter = () => + usePatternStore(useShallow(selectUploadedPatternCenter)); + +/** + * Hook to get rotated bounds (memoized with shallow comparison) + */ +export const useRotatedBounds = () => + usePatternStore(useShallow(selectRotatedBounds)); + // 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