Merge pull request #49 from jhbruhn/fix/48-cached-pattern-disappears-on-reconnect

fix: Store original and uploaded pattern data to prevent rotation inconsistencies
This commit is contained in:
Jan-Henrik Bruhn 2025-12-26 22:50:57 +01:00 committed by GitHub
commit a3cb6a4e5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 324 additions and 37 deletions

View file

@ -4,7 +4,12 @@
"Bash(npm run build:*)",
"Bash(npm run lint)",
"Bash(cat:*)",
"Bash(npm run dev:electron:*)"
"Bash(npm run dev:electron:*)",
"Bash(npm run lint:*)",
"Bash(npm test:*)",
"Bash(npm run:*)",
"Bash(gh issue create:*)",
"Bash(gh label create:*)"
],
"deny": [],
"ask": []

View file

@ -8,6 +8,13 @@ import { LeftSidebar } from "./components/LeftSidebar";
import { PatternCanvas } from "./components/PatternCanvas";
import { PatternCanvasPlaceholder } from "./components/PatternCanvasPlaceholder";
import { BluetoothDevicePicker } from "./components/BluetoothDevicePicker";
import { transformStitchesRotation } from "./utils/rotationUtils";
import { encodeStitchesToPen } from "./formats/pen/encoder";
import { decodePenData } from "./formats/pen/decoder";
import {
calculatePatternCenter,
calculateBoundsFromDecodedStitches,
} from "./components/PatternCanvas/patternCanvasHelpers";
import "./App.css";
function App() {
@ -25,10 +32,20 @@ function App() {
);
// Pattern store - for auto-loading cached pattern
const { pesData, setPattern, setPatternOffset } = usePatternStore(
const {
pesData,
uploadedPesData,
setPattern,
setUploadedPattern,
setPatternRotation,
setPatternOffset,
} = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
uploadedPesData: state.uploadedPesData,
setPattern: state.setPattern,
setUploadedPattern: state.setUploadedPattern,
setPatternRotation: state.setPatternRotation,
setPatternOffset: state.setPatternOffset,
})),
);
@ -47,23 +64,130 @@ function App() {
// Auto-load cached pattern when available
useEffect(() => {
if (resumedPattern && !pesData) {
// Only auto-load if we have a resumed pattern and haven't already loaded it
if (resumedPattern && !uploadedPesData && !pesData) {
if (!resumedPattern.pesData) {
console.error(
"[App] ERROR: resumedPattern has no pesData!",
resumedPattern,
);
return;
}
console.log(
"[App] Loading resumed pattern:",
resumeFileName,
"Offset:",
resumedPattern.patternOffset,
"Rotation:",
resumedPattern.patternRotation,
"Has stitches:",
resumedPattern.pesData.stitches?.length || 0,
"Has cached uploaded data:",
!!resumedPattern.uploadedPesData,
);
setPattern(resumedPattern.pesData, resumeFileName || "");
// Restore the cached pattern offset
if (resumedPattern.patternOffset) {
setPatternOffset(
resumedPattern.patternOffset.x,
resumedPattern.patternOffset.y,
const originalPesData = resumedPattern.pesData;
const cachedUploadedPesData = resumedPattern.uploadedPesData;
const rotation = resumedPattern.patternRotation || 0;
const originalOffset = resumedPattern.patternOffset || { x: 0, y: 0 };
// Set the original pattern data for editing
setPattern(originalPesData, resumeFileName || "");
// Restore the original offset (setPattern resets it to 0,0)
setPatternOffset(originalOffset.x, originalOffset.y);
// Set rotation if present
if (rotation !== 0) {
setPatternRotation(rotation);
}
// Use cached uploadedPesData if available, otherwise recalculate
if (cachedUploadedPesData) {
// Use the exact uploaded data from cache
// Calculate the adjusted offset (same logic as upload)
if (rotation !== 0) {
const originalCenter = calculatePatternCenter(originalPesData.bounds);
const rotatedCenter = calculatePatternCenter(
cachedUploadedPesData.bounds,
);
const centerShiftX = rotatedCenter.x - originalCenter.x;
const centerShiftY = rotatedCenter.y - originalCenter.y;
const adjustedOffset = {
x: originalOffset.x + centerShiftX,
y: originalOffset.y + centerShiftY,
};
setUploadedPattern(
cachedUploadedPesData,
adjustedOffset,
resumeFileName || undefined,
);
} else {
setUploadedPattern(
cachedUploadedPesData,
originalOffset,
resumeFileName || undefined,
);
}
} else if (rotation !== 0) {
// Fallback: recalculate if no cached uploaded data (shouldn't happen for new uploads)
console.warn("[App] No cached uploaded data, recalculating rotation");
const rotatedStitches = transformStitchesRotation(
originalPesData.stitches,
rotation,
originalPesData.bounds,
);
const penResult = encodeStitchesToPen(rotatedStitches);
const penData = new Uint8Array(penResult.penBytes);
const decoded = decodePenData(penData);
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
const originalCenter = calculatePatternCenter(originalPesData.bounds);
const rotatedCenter = calculatePatternCenter(rotatedBounds);
const centerShiftX = rotatedCenter.x - originalCenter.x;
const centerShiftY = rotatedCenter.y - originalCenter.y;
const adjustedOffset = {
x: originalOffset.x + centerShiftX,
y: originalOffset.y + centerShiftY,
};
const rotatedPesData = {
...originalPesData,
stitches: rotatedStitches,
penData,
penStitches: decoded,
bounds: rotatedBounds,
};
setUploadedPattern(
rotatedPesData,
adjustedOffset,
resumeFileName || undefined,
);
} else {
// No rotation - uploaded pattern is same as original
setUploadedPattern(
originalPesData,
originalOffset,
resumeFileName || undefined,
);
}
}
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);
}, [
resumedPattern,
resumeFileName,
uploadedPesData,
pesData,
setPattern,
setUploadedPattern,
setPatternRotation,
setPatternOffset,
]);
return (
<div className="h-screen flex flex-col bg-gray-100 dark:bg-gray-900 overflow-hidden">
@ -76,7 +200,11 @@ function App() {
{/* Right Column - Pattern Preview */}
<div className="flex flex-col lg:overflow-hidden lg:h-full">
{pesData ? <PatternCanvas /> : <PatternCanvasPlaceholder />}
{pesData || uploadedPesData ? (
<PatternCanvas />
) : (
<PatternCanvasPlaceholder />
)}
</div>
</div>

View file

@ -206,11 +206,14 @@ export function FileUpload() {
setUploadedPattern(pesDataForUpload, adjustedOffset);
// Upload the pattern with offset
// IMPORTANT: Pass original unrotated pesData for caching, rotated pesData for upload
uploadPattern(
penDataToUpload,
pesDataForUpload,
displayFileName,
adjustedOffset,
patternRotation,
pesData, // Original unrotated pattern for caching
);
return; // Early return to skip the upload below
@ -226,6 +229,8 @@ export function FileUpload() {
pesDataForUpload,
displayFileName,
patternOffset,
0, // No rotation
// No need to pass originalPesData since it's the same as pesDataForUpload
);
}
}, [

View file

@ -13,14 +13,16 @@ export function LeftSidebar() {
})),
);
const { pesData } = usePatternStore(
const { pesData, uploadedPesData } = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
uploadedPesData: state.uploadedPesData,
})),
);
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const hasPattern = pesData || uploadedPesData;
return (
<div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
@ -31,7 +33,7 @@ export function LeftSidebar() {
{isConnected && !patternUploaded && <FileUpload />}
{/* Compact Pattern Summary - Show after upload (during sewing stages) */}
{isConnected && patternUploaded && pesData && <PatternSummaryCard />}
{isConnected && patternUploaded && hasPattern && <PatternSummaryCard />}
{/* Progress Monitor - Show when pattern is uploaded */}
{isConnected && patternUploaded && (

View file

@ -190,7 +190,9 @@ export function PatternCanvas() {
</Layer>
{/* Original pattern layer: draggable with transformer (shown before upload starts) */}
<Layer visible={!isUploading && !patternUploaded}>
<Layer
visible={!isUploading && !patternUploaded && !uploadedPesData}
>
{pesData && (
<PatternLayer
pesData={pesData}
@ -209,7 +211,9 @@ export function PatternCanvas() {
</Layer>
{/* Uploaded pattern layer: locked, rotation baked in (shown during and after upload) */}
<Layer visible={isUploading || patternUploaded}>
<Layer
visible={isUploading || patternUploaded || !!uploadedPesData}
>
{uploadedPesData && (
<PatternLayer
pesData={uploadedPesData}
@ -241,12 +245,12 @@ export function PatternCanvas() {
<PatternPositionIndicator
offset={
isUploading || patternUploaded
isUploading || patternUploaded || uploadedPesData
? initialUploadedPatternOffset
: localPatternOffset
}
rotation={localPatternRotation}
isLocked={patternUploaded}
isLocked={patternUploaded || !!uploadedPesData}
isUploading={isUploading}
/>
@ -257,7 +261,10 @@ export function PatternCanvas() {
onZoomReset={handleZoomReset}
onCenterPattern={handleCenterPattern}
canCenterPattern={
!!pesData && !patternUploaded && !isUploading
!!pesData &&
!patternUploaded &&
!isUploading &&
!uploadedPesData
}
/>
</>

View file

@ -25,14 +25,16 @@ export function PatternSummaryCard() {
);
// Pattern store
const { pesData, currentFileName } = usePatternStore(
const { pesData, uploadedPesData, currentFileName } = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
uploadedPesData: state.uploadedPesData,
currentFileName: state.currentFileName,
})),
);
if (!pesData) return null;
const displayPattern = uploadedPesData || pesData;
if (!displayPattern) return null;
const canDelete = canDeletePattern(machineStatus);
return (
@ -52,7 +54,7 @@ export function PatternSummaryCard() {
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4">
<PatternInfo pesData={pesData} />
<PatternInfo pesData={displayPattern} />
{canDelete && (
<Button

View file

@ -52,6 +52,8 @@ export function ProgressMonitor() {
// Pattern store
const pesData = usePatternStore((state) => state.pesData);
const uploadedPesData = usePatternStore((state) => state.uploadedPesData);
const displayPattern = uploadedPesData || pesData;
const currentBlockRef = useRef<HTMLDivElement>(null);
// State indicators
@ -60,8 +62,8 @@ export function ProgressMonitor() {
// Use PEN stitch count as fallback when machine reports 0 total stitches
const totalStitches = patternInfo
? patternInfo.totalStitches === 0 && pesData?.penStitches
? pesData.penStitches.stitches.length
? patternInfo.totalStitches === 0 && displayPattern?.penStitches
? displayPattern.penStitches.stitches.length
: patternInfo.totalStitches
: 0;
@ -72,7 +74,7 @@ export function ProgressMonitor() {
// Calculate color block information from decoded penStitches
const colorBlocks = useMemo(() => {
if (!pesData || !pesData.penStitches) return [];
if (!displayPattern || !displayPattern.penStitches) return [];
const blocks: Array<{
colorIndex: number;
@ -87,8 +89,8 @@ export function ProgressMonitor() {
}> = [];
// Use the pre-computed color blocks from decoded PEN data
for (const penBlock of pesData.penStitches.colorBlocks) {
const thread = pesData.threads[penBlock.colorIndex];
for (const penBlock of displayPattern.penStitches.colorBlocks) {
const thread = displayPattern.threads[penBlock.colorIndex];
blocks.push({
colorIndex: penBlock.colorIndex,
threadHex: thread?.hex || "#000000",
@ -103,7 +105,7 @@ export function ProgressMonitor() {
}
return blocks;
}, [pesData]);
}, [displayPattern]);
// Determine current color block based on current stitch
const currentStitch = sewingProgress?.currentStitch || 0;

View file

@ -15,8 +15,17 @@ export class BrowserStorageService implements IStorageService {
pesData: PesPatternData,
fileName: string,
patternOffset?: { x: number; y: number },
patternRotation?: number,
uploadedPesData?: PesPatternData,
): Promise<void> {
PatternCacheService.savePattern(uuid, pesData, fileName, patternOffset);
PatternCacheService.savePattern(
uuid,
pesData,
fileName,
patternOffset,
patternRotation,
uploadedPesData,
);
}
async getPatternByUUID(uuid: string): Promise<ICachedPattern | null> {

View file

@ -20,6 +20,8 @@ export class ElectronStorageService implements IStorageService {
pesData: PesPatternData,
fileName: string,
patternOffset?: { x: number; y: number },
patternRotation?: number,
uploadedPesData?: PesPatternData,
): Promise<void> {
// Convert Uint8Array to array for JSON serialization over IPC
const serializable = {
@ -28,9 +30,16 @@ export class ElectronStorageService implements IStorageService {
...pesData,
penData: Array.from(pesData.penData),
},
uploadedPesData: uploadedPesData
? {
...uploadedPesData,
penData: Array.from(uploadedPesData.penData),
}
: undefined,
fileName,
timestamp: Date.now(),
patternOffset,
patternRotation,
};
// Fire and forget (sync-like behavior to match interface)
@ -51,6 +60,17 @@ export class ElectronStorageService implements IStorageService {
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
}
if (
pattern &&
pattern.uploadedPesData &&
Array.isArray(pattern.uploadedPesData.penData)
) {
// Restore Uint8Array from array for uploadedPesData
pattern.uploadedPesData.penData = new Uint8Array(
pattern.uploadedPesData.penData,
);
}
return pattern;
} catch (err) {
console.error("[ElectronStorage] Failed to get pattern:", err);
@ -69,6 +89,17 @@ export class ElectronStorageService implements IStorageService {
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
}
if (
pattern &&
pattern.uploadedPesData &&
Array.isArray(pattern.uploadedPesData.penData)
) {
// Restore Uint8Array from array for uploadedPesData
pattern.uploadedPesData.penData = new Uint8Array(
pattern.uploadedPesData.penData,
);
}
return pattern;
} catch (err) {
console.error("[ElectronStorage] Failed to get latest pattern:", err);

View file

@ -2,10 +2,12 @@ import type { PesPatternData } from "../../formats/import/pesImporter";
export interface ICachedPattern {
uuid: string;
pesData: PesPatternData;
pesData: PesPatternData; // Original unrotated pattern data
uploadedPesData?: PesPatternData; // Pattern with rotation applied (what was uploaded to machine)
fileName: string;
timestamp: number;
patternOffset?: { x: number; y: number };
patternRotation?: number; // Rotation angle in degrees
}
export interface IStorageService {
@ -14,6 +16,8 @@ export interface IStorageService {
pesData: PesPatternData,
fileName: string,
patternOffset?: { x: number; y: number },
patternRotation?: number,
uploadedPesData?: PesPatternData,
): Promise<void>;
getPatternByUUID(uuid: string): Promise<ICachedPattern | null>;

View file

@ -2,10 +2,12 @@ import type { PesPatternData } from "../formats/import/pesImporter";
interface CachedPattern {
uuid: string;
pesData: PesPatternData;
pesData: PesPatternData; // Original unrotated pattern data
uploadedPesData?: PesPatternData; // Pattern with rotation applied (what was uploaded to machine)
fileName: string;
timestamp: number;
patternOffset?: { x: number; y: number };
patternRotation?: number; // Rotation angle in degrees
}
const CACHE_KEY = "brother_pattern_cache";
@ -39,6 +41,8 @@ export class PatternCacheService {
pesData: PesPatternData,
fileName: string,
patternOffset?: { x: number; y: number },
patternRotation?: number,
uploadedPesData?: PesPatternData,
): void {
try {
// Convert penData Uint8Array to array for JSON serialization
@ -47,12 +51,24 @@ export class PatternCacheService {
penData: Array.from(pesData.penData) as unknown as Uint8Array,
};
// Also convert uploadedPesData if present
const uploadedPesDataWithArrayPenData = uploadedPesData
? {
...uploadedPesData,
penData: Array.from(
uploadedPesData.penData,
) as unknown as Uint8Array,
}
: undefined;
const cached: CachedPattern = {
uuid,
pesData: pesDataWithArrayPenData,
uploadedPesData: uploadedPesDataWithArrayPenData,
fileName,
timestamp: Date.now(),
patternOffset,
patternRotation,
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
@ -63,6 +79,10 @@ export class PatternCacheService {
uuid,
"Offset:",
patternOffset,
"Rotation:",
patternRotation,
"Has uploaded data:",
!!uploadedPesData,
);
} catch (err) {
console.error("[PatternCache] Failed to save pattern:", err);
@ -101,11 +121,23 @@ export class PatternCacheService {
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
}
// Restore Uint8Array from array inside uploadedPesData if present
if (
pattern.uploadedPesData &&
Array.isArray(pattern.uploadedPesData.penData)
) {
pattern.uploadedPesData.penData = new Uint8Array(
pattern.uploadedPesData.penData,
);
}
console.log(
"[PatternCache] Found cached pattern:",
pattern.fileName,
"UUID:",
uuid,
"Has uploaded data:",
!!pattern.uploadedPesData,
);
return pattern;
} catch (err) {
@ -131,6 +163,16 @@ export class PatternCacheService {
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
}
// Restore Uint8Array from array inside uploadedPesData if present
if (
pattern.uploadedPesData &&
Array.isArray(pattern.uploadedPesData.penData)
) {
pattern.uploadedPesData.penData = new Uint8Array(
pattern.uploadedPesData.penData,
);
}
return pattern;
} catch (err) {
console.error("[PatternCache] Failed to retrieve pattern:", err);

View file

@ -43,7 +43,9 @@ interface MachineState {
resumeFileName: string | null;
resumedPattern: {
pesData: PesPatternData;
uploadedPesData?: PesPatternData;
patternOffset?: { x: number; y: number };
patternRotation?: number;
} | null;
// Error state
@ -67,9 +69,11 @@ interface MachineState {
refreshServiceCount: () => Promise<void>;
uploadPattern: (
penData: Uint8Array,
pesData: PesPatternData,
uploadedPesData: PesPatternData, // Pattern with rotation applied (for machine upload)
fileName: string,
patternOffset?: { x: number; y: number },
patternRotation?: number,
originalPesData?: PesPatternData, // Original unrotated pattern (for caching)
) => Promise<void>;
startMaskTrace: () => Promise<void>;
startSewing: () => Promise<void>;
@ -78,7 +82,9 @@ interface MachineState {
checkResume: () => Promise<PesPatternData | null>;
loadCachedPattern: () => Promise<{
pesData: PesPatternData;
uploadedPesData?: PesPatternData;
patternOffset?: { x: number; y: number };
patternRotation?: number;
} | null>;
// Internal methods
@ -137,6 +143,10 @@ export const useMachineStore = create<MachineState>((set, get) => ({
cached.fileName,
"Offset:",
cached.patternOffset,
"Rotation:",
cached.patternRotation,
"Has uploaded data:",
!!cached.uploadedPesData,
);
console.log("[Resume] Auto-loading cached pattern...");
set({
@ -144,7 +154,9 @@ export const useMachineStore = create<MachineState>((set, get) => ({
resumeFileName: cached.fileName,
resumedPattern: {
pesData: cached.pesData,
uploadedPesData: cached.uploadedPesData,
patternOffset: cached.patternOffset,
patternRotation: cached.patternRotation,
},
});
@ -302,9 +314,11 @@ export const useMachineStore = create<MachineState>((set, get) => ({
// Upload pattern to machine
uploadPattern: async (
penData: Uint8Array,
pesData: PesPatternData,
uploadedPesData: PesPatternData, // Pattern with rotation applied (for machine upload)
fileName: string,
patternOffset?: { x: number; y: number },
patternRotation?: number,
originalPesData?: PesPatternData, // Original unrotated pattern (for caching)
) => {
const {
isConnected,
@ -321,20 +335,31 @@ export const useMachineStore = create<MachineState>((set, get) => ({
try {
set({ error: null, uploadProgress: 0, isUploading: true });
// Upload to machine using the rotated bounds
const uuid = await service.uploadPattern(
penData,
(progress) => {
set({ uploadProgress: progress });
},
pesData.bounds,
uploadedPesData.bounds,
patternOffset,
);
set({ uploadProgress: 100 });
// Cache the pattern with its UUID and offset
// Cache the ORIGINAL unrotated pattern with rotation angle AND the uploaded data
// This allows us to restore the editable state correctly and ensures the exact
// uploaded data is used on resume (prevents inconsistencies from version updates)
const pesDataToCache = originalPesData || uploadedPesData;
const uuidStr = uuidToString(uuid);
storageService.savePattern(uuidStr, pesData, fileName, patternOffset);
storageService.savePattern(
uuidStr,
pesDataToCache,
fileName,
patternOffset,
patternRotation,
uploadedPesData, // Cache the exact uploaded data
);
console.log(
"[Cache] Saved pattern:",
fileName,
@ -342,6 +367,9 @@ export const useMachineStore = create<MachineState>((set, get) => ({
uuidStr,
"Offset:",
patternOffset,
"Rotation:",
patternRotation,
"(cached original unrotated data + uploaded data)",
);
// Clear resume state since we just uploaded
@ -440,6 +468,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
uploadProgress: 0,
resumeAvailable: false,
resumeFileName: null,
resumedPattern: null, // Clear this to prevent auto-reload
});
// Clear uploaded pattern data in pattern store
@ -458,7 +487,9 @@ export const useMachineStore = create<MachineState>((set, get) => ({
// Load cached pattern
loadCachedPattern: async (): Promise<{
pesData: PesPatternData;
uploadedPesData?: PesPatternData;
patternOffset?: { x: number; y: number };
patternRotation?: number;
} | null> => {
const { resumeAvailable, service, storageService, refreshPatternInfo } =
get();
@ -477,9 +508,18 @@ export const useMachineStore = create<MachineState>((set, get) => ({
cached.fileName,
"Offset:",
cached.patternOffset,
"Rotation:",
cached.patternRotation,
"Has uploaded data:",
!!cached.uploadedPesData,
);
await refreshPatternInfo();
return { pesData: cached.pesData, patternOffset: cached.patternOffset };
return {
pesData: cached.pesData,
uploadedPesData: cached.uploadedPesData,
patternOffset: cached.patternOffset,
patternRotation: cached.patternRotation,
};
}
return null;

View file

@ -21,6 +21,7 @@ interface PatternState {
setUploadedPattern: (
uploadedData: PesPatternData,
uploadedOffset: { x: number; y: number },
fileName?: string,
) => void;
clearUploadedPattern: () => void;
resetPatternOffset: () => void;
@ -69,23 +70,32 @@ export const usePatternStore = create<PatternState>((set) => ({
setUploadedPattern: (
uploadedData: PesPatternData,
uploadedOffset: { x: number; y: number },
fileName?: string,
) => {
set({
uploadedPesData: uploadedData,
uploadedPatternOffset: uploadedOffset,
patternUploaded: true,
// Optionally set filename if provided (for resume/reconnect scenarios)
...(fileName && { currentFileName: fileName }),
});
console.log("[PatternStore] Uploaded pattern set");
},
// Clear uploaded pattern (called when deleting from machine)
// This reverts to pre-upload state, keeping pesData so user can re-adjust and re-upload
clearUploadedPattern: () => {
console.log("[PatternStore] CLEARING uploaded pattern...");
set({
uploadedPesData: null,
uploadedPatternOffset: { x: 0, y: 0 },
patternUploaded: false,
// Keep pesData, currentFileName, patternOffset, patternRotation
// so user can adjust and re-upload
});
console.log("[PatternStore] Uploaded pattern cleared");
console.log(
"[PatternStore] Uploaded pattern cleared - back to editable mode",
);
},
// Reset pattern offset to default