mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
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>
This commit is contained in:
parent
20fff4cdfb
commit
1d79ffb2a4
4 changed files with 524 additions and 64 deletions
|
|
@ -43,6 +43,9 @@ export const PatternLayer = memo(function PatternLayer({
|
||||||
onTransformEnd,
|
onTransformEnd,
|
||||||
attachTransformer,
|
attachTransformer,
|
||||||
}: PatternLayerProps) {
|
}: 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(
|
const center = useMemo(
|
||||||
() => calculatePatternCenter(pesData.bounds),
|
() => calculatePatternCenter(pesData.bounds),
|
||||||
[pesData.bounds],
|
[pesData.bounds],
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
import type { PesPatternData } from "../../formats/import/pesImporter";
|
||||||
import type { MachineInfo } from "../../types/machine";
|
import type { MachineInfo } from "../../types/machine";
|
||||||
import { calculateRotatedBounds } from "../../utils/rotationUtils";
|
import { usePatternValidationFromStore } from "../../stores/usePatternStore";
|
||||||
import { calculatePatternCenter } from "../../components/PatternCanvas/patternCanvasHelpers";
|
|
||||||
|
|
||||||
export interface PatternBoundsCheckResult {
|
export interface PatternBoundsCheckResult {
|
||||||
fits: boolean;
|
fits: boolean;
|
||||||
|
|
@ -12,8 +11,10 @@ export interface PatternBoundsCheckResult {
|
||||||
export interface UsePatternValidationParams {
|
export interface UsePatternValidationParams {
|
||||||
pesData: PesPatternData | null;
|
pesData: PesPatternData | null;
|
||||||
machineInfo: MachineInfo | null;
|
machineInfo: MachineInfo | null;
|
||||||
patternOffset: { x: number; y: number };
|
// Note: patternOffset and patternRotation are read from the store
|
||||||
patternRotation: number;
|
// 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
|
* Checks if the pattern (with rotation and offset applied) fits within
|
||||||
* the machine's hoop bounds and provides detailed error messages if not.
|
* 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
|
* @param params - Pattern and machine configuration
|
||||||
* @returns Bounds check result with fit status and error message
|
* @returns Bounds check result with fit status and error message
|
||||||
*/
|
*/
|
||||||
export function usePatternValidation({
|
export function usePatternValidation({
|
||||||
pesData,
|
pesData,
|
||||||
machineInfo,
|
machineInfo,
|
||||||
patternOffset,
|
|
||||||
patternRotation,
|
|
||||||
}: UsePatternValidationParams): PatternBoundsCheckResult {
|
}: 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 => {
|
return useMemo((): PatternBoundsCheckResult => {
|
||||||
if (!pesData || !machineInfo) {
|
if (!pesData || !machineInfo) {
|
||||||
return { fits: true, error: null };
|
return { fits: true, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate rotated bounds if rotation is applied
|
// Use the validation from store which already has all the logic
|
||||||
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 {
|
return {
|
||||||
fits: false,
|
fits: validationFromStore.fits,
|
||||||
error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`,
|
error: validationFromStore.error,
|
||||||
};
|
};
|
||||||
}
|
}, [
|
||||||
|
pesData,
|
||||||
return { fits: true, error: null };
|
machineInfo,
|
||||||
}, [pesData, machineInfo, patternOffset, patternRotation]);
|
validationFromStore.fits,
|
||||||
|
validationFromStore.error,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
300
src/stores/usePatternStore.test.ts
Normal file
300
src/stores/usePatternStore.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
import { onPatternDeleted } from "./storeEvents";
|
import { onPatternDeleted } from "./storeEvents";
|
||||||
|
import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers";
|
||||||
|
import { calculateRotatedBounds } from "../utils/rotationUtils";
|
||||||
|
|
||||||
interface PatternState {
|
interface PatternState {
|
||||||
// Original pattern (pre-upload)
|
// Original pattern (pre-upload)
|
||||||
|
|
@ -29,6 +31,35 @@ interface PatternState {
|
||||||
resetRotation: () => void;
|
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<PatternState>((set) => ({
|
export const usePatternStore = create<PatternState>((set) => ({
|
||||||
// Initial state - original pattern
|
// Initial state - original pattern
|
||||||
pesData: null,
|
pesData: null,
|
||||||
|
|
@ -123,6 +154,168 @@ export const useUploadedPatternOffset = () =>
|
||||||
export const usePatternRotation = () =>
|
export const usePatternRotation = () =>
|
||||||
usePatternStore((state) => state.patternRotation);
|
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.
|
// Subscribe to pattern deleted event.
|
||||||
// This subscription is intended to persist for the lifetime of the application,
|
// This subscription is intended to persist for the lifetime of the application,
|
||||||
// so the unsubscribe function returned by `onPatternDeleted` is intentionally
|
// so the unsubscribe function returned by `onPatternDeleted` is intentionally
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue