mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
Implement interactive pattern canvas with Konva.js
- Replace vanilla canvas with Konva.js for better interactivity - Add pan and zoom functionality (mouse wheel zoom, drag to pan) - Make pattern draggable within hoop coordinate system - Center canvas on embroidery origin (0,0) - Default zoom shows entire 100x100mm hoop - Pass user-defined pattern offset to LAYOUT command - Replace auto-centering with manual pattern positioning - Add visual overlays for thread legend, dimensions, pattern position, and zoom controls - Fix effect dependencies to prevent drag interruption on machine status updates - Memoize callbacks to prevent unnecessary re-renders - Create konvaRenderers.ts utility for rendering grid, hoop, stitches, etc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e0fadf69da
commit
0f40cec8ec
7 changed files with 1003 additions and 262 deletions
160
src/App.css
160
src/App.css
|
|
@ -257,12 +257,15 @@ button:disabled {
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pattern-canvas {
|
/* Canvas container with Konva */
|
||||||
|
.canvas-container {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 600px;
|
height: 600px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-placeholder {
|
.canvas-placeholder {
|
||||||
|
|
@ -274,6 +277,161 @@ button:disabled {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Canvas overlay elements */
|
||||||
|
.canvas-legend {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-legend h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #000;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-dimensions {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 165px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 11;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-offset-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 11;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offset-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offset-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offset-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom controls */
|
||||||
|
.zoom-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn:hover:not(:disabled) {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 102, 204, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-level {
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-reset {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.status-message {
|
.status-message {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
|
||||||
16
src/App.tsx
16
src/App.tsx
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useBrotherMachine } from './hooks/useBrotherMachine';
|
import { useBrotherMachine } from './hooks/useBrotherMachine';
|
||||||
import { MachineConnection } from './components/MachineConnection';
|
import { MachineConnection } from './components/MachineConnection';
|
||||||
import { FileUpload } from './components/FileUpload';
|
import { FileUpload } from './components/FileUpload';
|
||||||
|
|
@ -13,6 +13,7 @@ function App() {
|
||||||
const [pesData, setPesData] = useState<PesPatternData | null>(null);
|
const [pesData, setPesData] = useState<PesPatternData | null>(null);
|
||||||
const [pyodideReady, setPyodideReady] = useState(false);
|
const [pyodideReady, setPyodideReady] = useState(false);
|
||||||
const [pyodideError, setPyodideError] = useState<string | null>(null);
|
const [pyodideError, setPyodideError] = useState<string | null>(null);
|
||||||
|
const [patternOffset, setPatternOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
|
|
||||||
// Initialize Pyodide on mount
|
// Initialize Pyodide on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -36,9 +37,16 @@ function App() {
|
||||||
}
|
}
|
||||||
}, [machine.resumedPattern, pesData, machine.resumeFileName]);
|
}, [machine.resumedPattern, pesData, machine.resumeFileName]);
|
||||||
|
|
||||||
const handlePatternLoaded = (data: PesPatternData) => {
|
const handlePatternLoaded = useCallback((data: PesPatternData) => {
|
||||||
setPesData(data);
|
setPesData(data);
|
||||||
};
|
// Reset pattern offset when new pattern is loaded
|
||||||
|
setPatternOffset({ x: 0, y: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePatternOffsetChange = useCallback((offsetX: number, offsetY: number) => {
|
||||||
|
setPatternOffset({ x: offsetX, y: offsetY });
|
||||||
|
console.log('[App] Pattern offset changed:', { x: offsetX, y: offsetY });
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
|
|
@ -78,6 +86,7 @@ function App() {
|
||||||
onPatternLoaded={handlePatternLoaded}
|
onPatternLoaded={handlePatternLoaded}
|
||||||
onUpload={machine.uploadPattern}
|
onUpload={machine.uploadPattern}
|
||||||
pyodideReady={pyodideReady}
|
pyodideReady={pyodideReady}
|
||||||
|
patternOffset={patternOffset}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProgressMonitor
|
<ProgressMonitor
|
||||||
|
|
@ -97,6 +106,7 @@ function App() {
|
||||||
pesData={pesData}
|
pesData={pesData}
|
||||||
sewingProgress={machine.sewingProgress}
|
sewingProgress={machine.sewingProgress}
|
||||||
machineInfo={machine.machineInfo}
|
machineInfo={machine.machineInfo}
|
||||||
|
onPatternOffsetChange={handlePatternOffsetChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ interface FileUploadProps {
|
||||||
machineStatus: MachineStatus;
|
machineStatus: MachineStatus;
|
||||||
uploadProgress: number;
|
uploadProgress: number;
|
||||||
onPatternLoaded: (pesData: PesPatternData) => void;
|
onPatternLoaded: (pesData: PesPatternData) => void;
|
||||||
onUpload: (penData: Uint8Array, pesData: PesPatternData, fileName: string) => void;
|
onUpload: (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => void;
|
||||||
pyodideReady: boolean;
|
pyodideReady: boolean;
|
||||||
|
patternOffset: { x: number; y: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileUpload({
|
export function FileUpload({
|
||||||
|
|
@ -19,6 +20,7 @@ export function FileUpload({
|
||||||
onPatternLoaded,
|
onPatternLoaded,
|
||||||
onUpload,
|
onUpload,
|
||||||
pyodideReady,
|
pyodideReady,
|
||||||
|
patternOffset,
|
||||||
}: FileUploadProps) {
|
}: FileUploadProps) {
|
||||||
const [pesData, setPesData] = useState<PesPatternData | null>(null);
|
const [pesData, setPesData] = useState<PesPatternData | null>(null);
|
||||||
const [fileName, setFileName] = useState<string>('');
|
const [fileName, setFileName] = useState<string>('');
|
||||||
|
|
@ -55,9 +57,9 @@ export function FileUpload({
|
||||||
|
|
||||||
const handleUpload = useCallback(() => {
|
const handleUpload = useCallback(() => {
|
||||||
if (pesData && fileName) {
|
if (pesData && fileName) {
|
||||||
onUpload(pesData.penData, pesData, fileName);
|
onUpload(pesData.penData, pesData, fileName, patternOffset);
|
||||||
}
|
}
|
||||||
}, [pesData, fileName, onUpload]);
|
}, [pesData, fileName, onUpload, patternOffset]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-upload-panel">
|
<div className="file-upload-panel">
|
||||||
|
|
|
||||||
|
|
@ -1,284 +1,442 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import Konva from 'konva';
|
||||||
import type { PesPatternData } from '../utils/pystitchConverter';
|
import type { PesPatternData } from '../utils/pystitchConverter';
|
||||||
import { getThreadColor } from '../utils/pystitchConverter';
|
|
||||||
import type { SewingProgress, MachineInfo } from '../types/machine';
|
import type { SewingProgress, MachineInfo } from '../types/machine';
|
||||||
|
import {
|
||||||
|
renderGrid,
|
||||||
|
renderOrigin,
|
||||||
|
renderHoop,
|
||||||
|
renderStitches,
|
||||||
|
renderPatternBounds,
|
||||||
|
renderCurrentPosition,
|
||||||
|
calculateInitialScale,
|
||||||
|
} from '../utils/konvaRenderers';
|
||||||
|
|
||||||
interface PatternCanvasProps {
|
interface PatternCanvasProps {
|
||||||
pesData: PesPatternData | null;
|
pesData: PesPatternData | null;
|
||||||
sewingProgress: SewingProgress | null;
|
sewingProgress: SewingProgress | null;
|
||||||
machineInfo: MachineInfo | null;
|
machineInfo: MachineInfo | null;
|
||||||
|
onPatternOffsetChange?: (offsetX: number, offsetY: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PatternCanvas({ pesData, sewingProgress, machineInfo }: PatternCanvasProps) {
|
export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternOffsetChange }: PatternCanvasProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const stageRef = useRef<Konva.Stage | null>(null);
|
||||||
|
const backgroundLayerRef = useRef<Konva.Layer | null>(null);
|
||||||
|
const patternLayerRef = useRef<Konva.Layer | null>(null);
|
||||||
|
const currentPosLayerRef = useRef<Konva.Layer | null>(null);
|
||||||
|
const patternGroupRef = useRef<Konva.Group | null>(null);
|
||||||
|
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
|
const [patternOffset, setPatternOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const initialScaleRef = useRef<number>(1);
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
|
||||||
|
// Initialize Konva stage and layers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvasRef.current || !pesData) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const container = containerRef.current;
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
// Clear canvas
|
// Create stage
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
const stage = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: container.offsetWidth,
|
||||||
|
height: container.offsetHeight,
|
||||||
|
draggable: false, // Stage itself is not draggable
|
||||||
|
});
|
||||||
|
|
||||||
const currentStitch = sewingProgress?.currentStitch || 0;
|
// Configure stage to center on embroidery origin (0,0)
|
||||||
|
// Simply position the stage so that (0,0) appears at the center
|
||||||
|
stage.position({ x: stage.width() / 2, y: stage.height() / 2 });
|
||||||
|
|
||||||
|
// Create layers
|
||||||
|
const backgroundLayer = new Konva.Layer();
|
||||||
|
const patternLayer = new Konva.Layer();
|
||||||
|
const currentPosLayer = new Konva.Layer();
|
||||||
|
|
||||||
|
stage.add(backgroundLayer, patternLayer, currentPosLayer);
|
||||||
|
|
||||||
|
// Store refs
|
||||||
|
stageRef.current = stage;
|
||||||
|
backgroundLayerRef.current = backgroundLayer;
|
||||||
|
patternLayerRef.current = patternLayer;
|
||||||
|
currentPosLayerRef.current = currentPosLayer;
|
||||||
|
|
||||||
|
// Set initial cursor - grab for panning
|
||||||
|
stage.container().style.cursor = 'grab';
|
||||||
|
|
||||||
|
// Make stage draggable for panning
|
||||||
|
stage.draggable(true);
|
||||||
|
|
||||||
|
// Update cursor on drag
|
||||||
|
stage.on('dragstart', () => {
|
||||||
|
stage.container().style.cursor = 'grabbing';
|
||||||
|
});
|
||||||
|
|
||||||
|
stage.on('dragend', () => {
|
||||||
|
stage.container().style.cursor = 'grab';
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stage.destroy();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle responsive resizing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || !stageRef.current) return;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width, height } = entry.contentRect;
|
||||||
|
const stage = stageRef.current;
|
||||||
|
|
||||||
|
if (stage) {
|
||||||
|
// Keep the current pan/zoom, just update size
|
||||||
|
const oldWidth = stage.width();
|
||||||
|
const oldHeight = stage.height();
|
||||||
|
const oldPos = stage.position();
|
||||||
|
|
||||||
|
stage.width(width);
|
||||||
|
stage.height(height);
|
||||||
|
|
||||||
|
// Adjust position to maintain center point
|
||||||
|
stage.position({
|
||||||
|
x: oldPos.x + (width - oldWidth) / 2,
|
||||||
|
y: oldPos.y + (height - oldHeight) / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
stage.batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mouse wheel zoom handler
|
||||||
|
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||||
|
e.evt.preventDefault();
|
||||||
|
|
||||||
|
const stage = e.target.getStage();
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
const oldScale = stage.scaleX();
|
||||||
|
const pointer = stage.getPointerPosition();
|
||||||
|
if (!pointer) return;
|
||||||
|
|
||||||
|
const scaleBy = 1.1;
|
||||||
|
const direction = e.evt.deltaY > 0 ? -1 : 1;
|
||||||
|
let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;
|
||||||
|
|
||||||
|
// Apply constraints
|
||||||
|
newScale = Math.max(0.1, Math.min(10, newScale));
|
||||||
|
|
||||||
|
// Zoom towards pointer
|
||||||
|
const mousePointTo = {
|
||||||
|
x: (pointer.x - stage.x()) / oldScale,
|
||||||
|
y: (pointer.y - stage.y()) / oldScale,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newPos = {
|
||||||
|
x: pointer.x - mousePointTo.x * newScale,
|
||||||
|
y: pointer.y - mousePointTo.y * newScale,
|
||||||
|
};
|
||||||
|
|
||||||
|
stage.scale({ x: newScale, y: newScale });
|
||||||
|
stage.position(newPos);
|
||||||
|
setZoomLevel(newScale);
|
||||||
|
stage.batchDraw();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Attach wheel event handler
|
||||||
|
useEffect(() => {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
stage.on('wheel', handleWheel);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stage.off('wheel', handleWheel);
|
||||||
|
};
|
||||||
|
}, [handleWheel]);
|
||||||
|
|
||||||
|
// Helper function to zoom to a specific point
|
||||||
|
const zoomToPoint = useCallback(
|
||||||
|
(point: { x: number; y: number }, newScale: number) => {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
const oldScale = stage.scaleX();
|
||||||
|
|
||||||
|
const mousePointTo = {
|
||||||
|
x: (point.x - stage.x()) / oldScale,
|
||||||
|
y: (point.y - stage.y()) / oldScale,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newPos = {
|
||||||
|
x: point.x - mousePointTo.x * newScale,
|
||||||
|
y: point.y - mousePointTo.y * newScale,
|
||||||
|
};
|
||||||
|
|
||||||
|
stage.scale({ x: newScale, y: newScale });
|
||||||
|
stage.position(newPos);
|
||||||
|
setZoomLevel(newScale);
|
||||||
|
stage.batchDraw();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zoom control handlers
|
||||||
|
const handleZoomIn = useCallback(() => {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
const newScale = Math.min(stage.scaleX() * 1.2, 10);
|
||||||
|
const center = {
|
||||||
|
x: stage.width() / 2,
|
||||||
|
y: stage.height() / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
zoomToPoint(center, newScale);
|
||||||
|
}, [zoomToPoint]);
|
||||||
|
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
const newScale = Math.max(stage.scaleX() / 1.2, 0.1);
|
||||||
|
const center = {
|
||||||
|
x: stage.width() / 2,
|
||||||
|
y: stage.height() / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
zoomToPoint(center, newScale);
|
||||||
|
}, [zoomToPoint]);
|
||||||
|
|
||||||
|
const handleZoomReset = useCallback(() => {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
const initialScale = initialScaleRef.current;
|
||||||
|
|
||||||
|
stage.scale({ x: initialScale, y: initialScale });
|
||||||
|
stage.position({ x: stage.width() / 2, y: stage.height() / 2 });
|
||||||
|
setZoomLevel(initialScale);
|
||||||
|
stage.batchDraw();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render background layer (grid, origin, hoop)
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = backgroundLayerRef.current;
|
||||||
|
const stage = stageRef.current;
|
||||||
|
if (!layer || !stage || !pesData) return;
|
||||||
|
|
||||||
|
layer.destroyChildren();
|
||||||
|
|
||||||
|
const { bounds } = pesData;
|
||||||
|
|
||||||
|
// Determine view dimensions - always fit to hoop if available, otherwise fit to pattern
|
||||||
|
const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX;
|
||||||
|
const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY;
|
||||||
|
|
||||||
|
// Calculate and store initial scale
|
||||||
|
const initialScale = calculateInitialScale(stage.width(), stage.height(), viewWidth, viewHeight);
|
||||||
|
initialScaleRef.current = initialScale;
|
||||||
|
|
||||||
|
// Always reset to initial scale when background is re-rendered (e.g., when pattern or hoop changes)
|
||||||
|
stage.scale({ x: initialScale, y: initialScale });
|
||||||
|
stage.position({ x: stage.width() / 2, y: stage.height() / 2 });
|
||||||
|
setZoomLevel(initialScale);
|
||||||
|
|
||||||
|
// Render background elements
|
||||||
|
const gridSize = 100; // 10mm grid (100 units in 0.1mm)
|
||||||
|
renderGrid(layer, gridSize, bounds, machineInfo);
|
||||||
|
renderOrigin(layer);
|
||||||
|
|
||||||
|
if (machineInfo) {
|
||||||
|
renderHoop(layer, machineInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the background layer for performance
|
||||||
|
layer.cache();
|
||||||
|
layer.batchDraw();
|
||||||
|
}, [machineInfo, pesData, zoomLevel]);
|
||||||
|
|
||||||
|
// Render pattern layer (stitches and bounds in a draggable group)
|
||||||
|
// This effect only runs when the pattern changes, NOT when sewing progress changes
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = patternLayerRef.current;
|
||||||
|
if (!layer || !pesData) return;
|
||||||
|
|
||||||
|
layer.destroyChildren();
|
||||||
|
|
||||||
const { stitches, bounds } = pesData;
|
const { stitches, bounds } = pesData;
|
||||||
const { minX, maxX, minY, maxY } = bounds;
|
|
||||||
const patternWidth = maxX - minX;
|
|
||||||
const patternHeight = maxY - minY;
|
|
||||||
|
|
||||||
const padding = 40;
|
// Create a draggable group for the pattern
|
||||||
|
const patternGroup = new Konva.Group({
|
||||||
|
draggable: true,
|
||||||
|
x: patternOffset.x,
|
||||||
|
y: patternOffset.y,
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate scale based on hoop size if available, otherwise pattern size
|
// Store ref
|
||||||
let scale: number;
|
patternGroupRef.current = patternGroup;
|
||||||
let viewWidth: number;
|
|
||||||
let viewHeight: number;
|
|
||||||
|
|
||||||
if (machineInfo) {
|
// Render pattern elements into the group (initial render with currentStitch = 0)
|
||||||
// Use hoop dimensions to determine scale
|
const currentStitch = sewingProgress?.currentStitch || 0;
|
||||||
viewWidth = machineInfo.maxWidth;
|
renderStitches(patternGroup, stitches, pesData, currentStitch);
|
||||||
viewHeight = machineInfo.maxHeight;
|
renderPatternBounds(patternGroup, bounds);
|
||||||
} else {
|
|
||||||
// Fallback to pattern dimensions
|
// Handle drag events
|
||||||
viewWidth = patternWidth;
|
patternGroup.on('dragstart', () => {
|
||||||
viewHeight = patternHeight;
|
isDraggingRef.current = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
patternGroup.on('dragend', () => {
|
||||||
|
isDraggingRef.current = false;
|
||||||
|
const newOffset = {
|
||||||
|
x: patternGroup.x(),
|
||||||
|
y: patternGroup.y(),
|
||||||
|
};
|
||||||
|
setPatternOffset(newOffset);
|
||||||
|
|
||||||
|
// Notify parent component of offset change
|
||||||
|
if (onPatternOffsetChange) {
|
||||||
|
onPatternOffsetChange(newOffset.x, newOffset.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add visual feedback on hover
|
||||||
|
patternGroup.on('mouseenter', () => {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
if (stage) stage.container().style.cursor = 'move';
|
||||||
|
});
|
||||||
|
|
||||||
|
patternGroup.on('mouseleave', () => {
|
||||||
|
if (!isDraggingRef.current) {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
if (stage) stage.container().style.cursor = 'grab';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(patternGroup);
|
||||||
|
layer.batchDraw();
|
||||||
|
}, [pesData, onPatternOffsetChange]); // Removed sewingProgress from dependencies
|
||||||
|
|
||||||
|
// Separate effect to update stitches when sewing progress changes
|
||||||
|
// This only updates the stitch rendering, not the entire group
|
||||||
|
useEffect(() => {
|
||||||
|
const patternGroup = patternGroupRef.current;
|
||||||
|
if (!patternGroup || !pesData || isDraggingRef.current) return;
|
||||||
|
|
||||||
|
const currentStitch = sewingProgress?.currentStitch || 0;
|
||||||
|
const { stitches } = pesData;
|
||||||
|
|
||||||
|
// Remove old stitches group and re-render
|
||||||
|
const oldStitchesGroup = patternGroup.findOne('.stitches');
|
||||||
|
if (oldStitchesGroup) {
|
||||||
|
oldStitchesGroup.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
const scaleX = (canvas.width - 2 * padding) / viewWidth;
|
// Re-render stitches with updated progress
|
||||||
const scaleY = (canvas.height - 2 * padding) / viewHeight;
|
renderStitches(patternGroup, stitches, pesData, currentStitch);
|
||||||
scale = Math.min(scaleX, scaleY);
|
patternGroup.getLayer()?.batchDraw();
|
||||||
|
}, [sewingProgress, pesData]);
|
||||||
|
|
||||||
// Center the view (hoop or pattern) in canvas
|
// Separate effect to update pattern position when offset changes externally (not during drag)
|
||||||
// The origin (0,0) should be at the center of the hoop
|
useEffect(() => {
|
||||||
const offsetX = canvas.width / 2;
|
const patternGroup = patternGroupRef.current;
|
||||||
const offsetY = canvas.height / 2;
|
if (patternGroup && !isDraggingRef.current) {
|
||||||
|
patternGroup.position({ x: patternOffset.x, y: patternOffset.y });
|
||||||
// Draw grid
|
patternGroup.getLayer()?.batchDraw();
|
||||||
ctx.strokeStyle = '#e0e0e0';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
const gridSize = 100; // 10mm grid (100 units in 0.1mm)
|
|
||||||
|
|
||||||
// Determine grid bounds based on hoop or pattern
|
|
||||||
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : minX;
|
|
||||||
const gridMaxX = machineInfo ? machineInfo.maxWidth / 2 : maxX;
|
|
||||||
const gridMinY = machineInfo ? -machineInfo.maxHeight / 2 : minY;
|
|
||||||
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : maxY;
|
|
||||||
|
|
||||||
for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) {
|
|
||||||
const canvasX = x * scale + offsetX;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(canvasX, padding);
|
|
||||||
ctx.lineTo(canvasX, canvas.height - padding);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) {
|
|
||||||
const canvasY = y * scale + offsetY;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(padding, canvasY);
|
|
||||||
ctx.lineTo(canvas.width - padding, canvasY);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
}, [patternOffset.x, patternOffset.y]);
|
||||||
|
|
||||||
// Draw origin
|
// Render current position layer (updates frequently, follows pattern offset)
|
||||||
ctx.strokeStyle = '#888';
|
useEffect(() => {
|
||||||
ctx.lineWidth = 2;
|
const layer = currentPosLayerRef.current;
|
||||||
ctx.beginPath();
|
if (!layer || !pesData) return;
|
||||||
ctx.moveTo(offsetX - 10, offsetY);
|
|
||||||
ctx.lineTo(offsetX + 10, offsetY);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(offsetX, offsetY - 10);
|
|
||||||
ctx.lineTo(offsetX, offsetY + 10);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw hoop boundary (if machine info available)
|
layer.destroyChildren();
|
||||||
if (machineInfo) {
|
|
||||||
// Machine info stores dimensions in 0.1mm units
|
|
||||||
const hoopWidth = machineInfo.maxWidth;
|
|
||||||
const hoopHeight = machineInfo.maxHeight;
|
|
||||||
|
|
||||||
// Hoop is centered at origin (0, 0)
|
const currentStitch = sewingProgress?.currentStitch || 0;
|
||||||
const hoopLeft = -hoopWidth / 2;
|
const { stitches } = pesData;
|
||||||
const hoopTop = -hoopHeight / 2;
|
|
||||||
const hoopRight = hoopWidth / 2;
|
|
||||||
const hoopBottom = hoopHeight / 2;
|
|
||||||
|
|
||||||
// Draw hoop boundary
|
|
||||||
ctx.strokeStyle = '#2196F3';
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.setLineDash([10, 5]);
|
|
||||||
ctx.strokeRect(
|
|
||||||
hoopLeft * scale + offsetX,
|
|
||||||
hoopTop * scale + offsetY,
|
|
||||||
hoopWidth * scale,
|
|
||||||
hoopHeight * scale
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw hoop label
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
ctx.fillStyle = '#2196F3';
|
|
||||||
ctx.font = 'bold 14px sans-serif';
|
|
||||||
ctx.fillText(
|
|
||||||
`Hoop: ${(hoopWidth / 10).toFixed(0)} x ${(hoopHeight / 10).toFixed(0)} mm`,
|
|
||||||
hoopLeft * scale + offsetX + 10,
|
|
||||||
hoopTop * scale + offsetY + 25
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw stitches
|
|
||||||
// stitches is number[][], each stitch is [x, y, command, colorIndex]
|
|
||||||
const MOVE = 0x10;
|
|
||||||
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
let lastX = 0;
|
|
||||||
let lastY = 0;
|
|
||||||
let threadColor = getThreadColor(pesData, 0);
|
|
||||||
let currentPosX = 0;
|
|
||||||
let currentPosY = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < stitches.length; i++) {
|
|
||||||
const stitch = stitches[i];
|
|
||||||
const x = stitch[0] * scale + offsetX;
|
|
||||||
const y = stitch[1] * scale + offsetY;
|
|
||||||
const cmd = stitch[2];
|
|
||||||
const colorIndex = stitch[3]; // Color index from PyStitch
|
|
||||||
|
|
||||||
// Update thread color based on stitch's color index
|
|
||||||
threadColor = getThreadColor(pesData, colorIndex);
|
|
||||||
|
|
||||||
// Track current position for highlighting
|
|
||||||
if (i === currentStitch) {
|
|
||||||
currentPosX = x;
|
|
||||||
currentPosY = y;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i > 0) {
|
|
||||||
const isCompleted = i < currentStitch;
|
|
||||||
const isCurrent = i === currentStitch;
|
|
||||||
|
|
||||||
if ((cmd & MOVE) !== 0) {
|
|
||||||
// Draw jump as dashed line
|
|
||||||
ctx.strokeStyle = isCompleted ? '#cccccc' : '#e8e8e8';
|
|
||||||
ctx.setLineDash([3, 3]);
|
|
||||||
} else {
|
|
||||||
// Draw stitch as solid line with actual thread color
|
|
||||||
// Dim pending stitches
|
|
||||||
if (isCompleted) {
|
|
||||||
ctx.strokeStyle = threadColor;
|
|
||||||
ctx.globalAlpha = 1.0;
|
|
||||||
} else {
|
|
||||||
ctx.strokeStyle = threadColor;
|
|
||||||
ctx.globalAlpha = 0.3;
|
|
||||||
}
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(lastX, lastY);
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.globalAlpha = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastX = x;
|
|
||||||
lastY = y;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw current position indicator
|
|
||||||
if (currentStitch > 0 && currentStitch < stitches.length) {
|
if (currentStitch > 0 && currentStitch < stitches.length) {
|
||||||
// Draw a pulsing circle at current position
|
// Create group at pattern offset
|
||||||
ctx.strokeStyle = '#ff0000';
|
const posGroup = new Konva.Group({
|
||||||
ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
|
x: patternOffset.x,
|
||||||
ctx.lineWidth = 3;
|
y: patternOffset.y,
|
||||||
ctx.setLineDash([]);
|
});
|
||||||
|
|
||||||
ctx.beginPath();
|
renderCurrentPosition(posGroup, currentStitch, stitches);
|
||||||
ctx.arc(currentPosX, currentPosY, 8, 0, 2 * Math.PI);
|
layer.add(posGroup);
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw crosshair
|
|
||||||
ctx.strokeStyle = '#ff0000';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(currentPosX - 12, currentPosY);
|
|
||||||
ctx.lineTo(currentPosX - 3, currentPosY);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(currentPosX + 12, currentPosY);
|
|
||||||
ctx.lineTo(currentPosX + 3, currentPosY);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(currentPosX, currentPosY - 12);
|
|
||||||
ctx.lineTo(currentPosX, currentPosY - 3);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(currentPosX, currentPosY + 12);
|
|
||||||
ctx.lineTo(currentPosX, currentPosY + 3);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw bounds
|
layer.batchDraw();
|
||||||
ctx.strokeStyle = '#ff0000';
|
}, [pesData, sewingProgress, patternOffset.x, patternOffset.y]);
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.setLineDash([5, 5]);
|
|
||||||
ctx.strokeRect(
|
|
||||||
minX * scale + offsetX,
|
|
||||||
minY * scale + offsetY,
|
|
||||||
patternWidth * scale,
|
|
||||||
patternHeight * scale
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw color legend using actual thread colors
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
let legendY = 20;
|
|
||||||
|
|
||||||
// Draw legend for each thread
|
|
||||||
for (let i = 0; i < pesData.threads.length; i++) {
|
|
||||||
const color = getThreadColor(pesData, i);
|
|
||||||
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillRect(10, legendY, 20, 20);
|
|
||||||
ctx.strokeStyle = '#000';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.strokeRect(10, legendY, 20, 20);
|
|
||||||
|
|
||||||
ctx.fillStyle = '#000';
|
|
||||||
ctx.font = '12px sans-serif';
|
|
||||||
ctx.fillText(
|
|
||||||
`Thread ${i + 1}`,
|
|
||||||
35,
|
|
||||||
legendY + 15
|
|
||||||
);
|
|
||||||
legendY += 25;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw dimensions
|
|
||||||
ctx.fillStyle = '#000';
|
|
||||||
ctx.font = '14px sans-serif';
|
|
||||||
ctx.fillText(
|
|
||||||
`${(patternWidth / 10).toFixed(1)} x ${(patternHeight / 10).toFixed(1)} mm`,
|
|
||||||
canvas.width - 120,
|
|
||||||
canvas.height - 10
|
|
||||||
);
|
|
||||||
}, [pesData, sewingProgress, machineInfo]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="canvas-panel">
|
<div className="canvas-panel">
|
||||||
<h2>Pattern Preview</h2>
|
<h2>Pattern Preview</h2>
|
||||||
<canvas
|
<div className="canvas-container" ref={containerRef}>
|
||||||
ref={canvasRef}
|
|
||||||
width={800}
|
|
||||||
height={600}
|
|
||||||
className="pattern-canvas"
|
|
||||||
/>
|
|
||||||
{!pesData && (
|
{!pesData && (
|
||||||
<div className="canvas-placeholder">
|
<div className="canvas-placeholder">
|
||||||
Load a PES file to preview the pattern
|
Load a PES file to preview the pattern
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{pesData && (
|
||||||
|
<>
|
||||||
|
{/* Thread Legend Overlay */}
|
||||||
|
<div className="canvas-legend">
|
||||||
|
<h4>Threads</h4>
|
||||||
|
{pesData.threads.map((thread, index) => (
|
||||||
|
<div key={index} className="legend-item">
|
||||||
|
<div
|
||||||
|
className="legend-swatch"
|
||||||
|
style={{ backgroundColor: thread.hex }}
|
||||||
|
/>
|
||||||
|
<span className="legend-label">Thread {index + 1}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pattern Dimensions Overlay */}
|
||||||
|
<div className="canvas-dimensions">
|
||||||
|
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
|
||||||
|
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pattern Offset Indicator */}
|
||||||
|
<div className="canvas-offset-info">
|
||||||
|
<div className="offset-label">Pattern Position:</div>
|
||||||
|
<div className="offset-value">
|
||||||
|
X: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.y / 10).toFixed(1)}mm
|
||||||
|
</div>
|
||||||
|
<div className="offset-hint">
|
||||||
|
Drag pattern to move • Drag background to pan
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom Controls Overlay */}
|
||||||
|
<div className="zoom-controls">
|
||||||
|
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom In">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<span className="zoom-level">{Math.round(zoomLevel * 100)}%</span>
|
||||||
|
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom Out">
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button className="zoom-btn zoom-reset" onClick={handleZoomReset} title="Reset Zoom">
|
||||||
|
⟲
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ export function useBrotherMachine() {
|
||||||
}, [service, resumeAvailable, refreshPatternInfo]);
|
}, [service, resumeAvailable, refreshPatternInfo]);
|
||||||
|
|
||||||
const uploadPattern = useCallback(
|
const uploadPattern = useCallback(
|
||||||
async (penData: Uint8Array, pesData: PesPatternData, fileName: string) => {
|
async (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
setError("Not connected to machine");
|
setError("Not connected to machine");
|
||||||
return;
|
return;
|
||||||
|
|
@ -208,6 +208,7 @@ export function useBrotherMachine() {
|
||||||
setUploadProgress(progress);
|
setUploadProgress(progress);
|
||||||
},
|
},
|
||||||
pesData.bounds,
|
pesData.bounds,
|
||||||
|
patternOffset,
|
||||||
);
|
);
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,7 @@ export class BrotherPP1Service {
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
onProgress?: (progress: number) => void,
|
onProgress?: (progress: number) => void,
|
||||||
bounds?: { minX: number; maxX: number; minY: number; maxY: number },
|
bounds?: { minX: number; maxX: number; minY: number; maxY: number },
|
||||||
|
patternOffset?: { x: number; y: number },
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
// Calculate checksum
|
// Calculate checksum
|
||||||
const checksum = data.reduce((sum, byte) => sum + byte, 0) & 0xffff;
|
const checksum = data.reduce((sum, byte) => sum + byte, 0) & 0xffff;
|
||||||
|
|
@ -560,21 +561,43 @@ export class BrotherPP1Service {
|
||||||
const patternWidth = boundRight - boundLeft;
|
const patternWidth = boundRight - boundLeft;
|
||||||
const patternHeight = boundBottom - boundTop;
|
const patternHeight = boundBottom - boundTop;
|
||||||
|
|
||||||
// Calculate center offset to position pattern at machine center
|
// Calculate move offset based on user-defined pattern offset or auto-center
|
||||||
// Machine embroidery area center is at (0, 0)
|
let moveX: number;
|
||||||
// Pattern center should align with machine center
|
let moveY: number;
|
||||||
|
|
||||||
|
if (patternOffset) {
|
||||||
|
// Use user-defined offset from canvas dragging
|
||||||
|
// Pattern offset is in canvas coordinates (0,0 at hoop center)
|
||||||
|
// We need to calculate the move that positions pattern's center at the offset position
|
||||||
const patternCenterX = (boundLeft + boundRight) / 2;
|
const patternCenterX = (boundLeft + boundRight) / 2;
|
||||||
const patternCenterY = (boundTop + boundBottom) / 2;
|
const patternCenterY = (boundTop + boundBottom) / 2;
|
||||||
|
|
||||||
// moveX/moveY shift the pattern so its center aligns with origin
|
// moveX/moveY define where the pattern center should be
|
||||||
const moveX = -patternCenterX;
|
// offset.x/y is where user dragged the pattern to (relative to hoop center)
|
||||||
const moveY = -patternCenterY;
|
moveX = patternOffset.x - patternCenterX;
|
||||||
|
moveY = patternOffset.y - patternCenterY;
|
||||||
|
|
||||||
|
console.log('[LAYOUT] Using user-defined offset:', {
|
||||||
|
patternOffset,
|
||||||
|
patternCenter: { x: patternCenterX, y: patternCenterY },
|
||||||
|
moveX,
|
||||||
|
moveY,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Auto-center: position pattern center at machine center (0, 0)
|
||||||
|
const patternCenterX = (boundLeft + boundRight) / 2;
|
||||||
|
const patternCenterY = (boundTop + boundBottom) / 2;
|
||||||
|
moveX = -patternCenterX;
|
||||||
|
moveY = -patternCenterY;
|
||||||
|
|
||||||
|
console.log('[LAYOUT] Auto-centering pattern:', { moveX, moveY });
|
||||||
|
}
|
||||||
|
|
||||||
// Send layout with actual pattern bounds
|
// Send layout with actual pattern bounds
|
||||||
// sizeX/sizeY are scaling factors (100 = 100% = no scaling)
|
// sizeX/sizeY are scaling factors (100 = 100% = no scaling)
|
||||||
await this.sendLayout(
|
await this.sendLayout(
|
||||||
Math.round(moveX), // moveX - center the pattern
|
Math.round(moveX), // moveX - position the pattern
|
||||||
Math.round(moveY), // moveY - center the pattern
|
Math.round(moveY), // moveY - position the pattern
|
||||||
100, // sizeX (100% - no scaling)
|
100, // sizeX (100% - no scaling)
|
||||||
100, // sizeY (100% - no scaling)
|
100, // sizeY (100% - no scaling)
|
||||||
0, // rotate
|
0, // rotate
|
||||||
|
|
|
||||||
389
src/utils/konvaRenderers.ts
Normal file
389
src/utils/konvaRenderers.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
import Konva from 'konva';
|
||||||
|
import type { PesPatternData } from './pystitchConverter';
|
||||||
|
import { getThreadColor } from './pystitchConverter';
|
||||||
|
import type { MachineInfo } from '../types/machine';
|
||||||
|
|
||||||
|
const MOVE = 0x10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a grid with specified spacing
|
||||||
|
*/
|
||||||
|
export function renderGrid(
|
||||||
|
layer: Konva.Layer,
|
||||||
|
gridSize: number,
|
||||||
|
bounds: { minX: number; maxX: number; minY: number; maxY: number },
|
||||||
|
machineInfo: MachineInfo | null
|
||||||
|
): void {
|
||||||
|
const gridGroup = new Konva.Group({ name: 'grid' });
|
||||||
|
|
||||||
|
// Determine grid bounds based on hoop or pattern
|
||||||
|
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX;
|
||||||
|
const gridMaxX = machineInfo ? machineInfo.maxWidth / 2 : bounds.maxX;
|
||||||
|
const gridMinY = machineInfo ? -machineInfo.maxHeight / 2 : bounds.minY;
|
||||||
|
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY;
|
||||||
|
|
||||||
|
// Vertical lines
|
||||||
|
for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) {
|
||||||
|
const line = new Konva.Line({
|
||||||
|
points: [x, gridMinY, x, gridMaxY],
|
||||||
|
stroke: '#e0e0e0',
|
||||||
|
strokeWidth: 1,
|
||||||
|
});
|
||||||
|
gridGroup.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal lines
|
||||||
|
for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) {
|
||||||
|
const line = new Konva.Line({
|
||||||
|
points: [gridMinX, y, gridMaxX, y],
|
||||||
|
stroke: '#e0e0e0',
|
||||||
|
strokeWidth: 1,
|
||||||
|
});
|
||||||
|
gridGroup.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.add(gridGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the origin crosshair at (0,0)
|
||||||
|
*/
|
||||||
|
export function renderOrigin(layer: Konva.Layer): void {
|
||||||
|
const originGroup = new Konva.Group({ name: 'origin' });
|
||||||
|
|
||||||
|
// Horizontal line
|
||||||
|
const hLine = new Konva.Line({
|
||||||
|
points: [-10, 0, 10, 0],
|
||||||
|
stroke: '#888',
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vertical line
|
||||||
|
const vLine = new Konva.Line({
|
||||||
|
points: [0, -10, 0, 10],
|
||||||
|
stroke: '#888',
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
originGroup.add(hLine, vLine);
|
||||||
|
layer.add(originGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the hoop boundary and label
|
||||||
|
*/
|
||||||
|
export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
|
||||||
|
const hoopGroup = new Konva.Group({ name: 'hoop' });
|
||||||
|
|
||||||
|
const hoopWidth = machineInfo.maxWidth;
|
||||||
|
const hoopHeight = machineInfo.maxHeight;
|
||||||
|
|
||||||
|
// Hoop is centered at origin (0, 0)
|
||||||
|
const hoopLeft = -hoopWidth / 2;
|
||||||
|
const hoopTop = -hoopHeight / 2;
|
||||||
|
|
||||||
|
// Hoop boundary rectangle
|
||||||
|
const rect = new Konva.Rect({
|
||||||
|
x: hoopLeft,
|
||||||
|
y: hoopTop,
|
||||||
|
width: hoopWidth,
|
||||||
|
height: hoopHeight,
|
||||||
|
stroke: '#2196F3',
|
||||||
|
strokeWidth: 3,
|
||||||
|
dash: [10, 5],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hoop label
|
||||||
|
const label = new Konva.Text({
|
||||||
|
x: hoopLeft + 10,
|
||||||
|
y: hoopTop + 10,
|
||||||
|
text: `Hoop: ${(hoopWidth / 10).toFixed(0)} x ${(hoopHeight / 10).toFixed(0)} mm`,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontStyle: 'bold',
|
||||||
|
fill: '#2196F3',
|
||||||
|
});
|
||||||
|
|
||||||
|
hoopGroup.add(rect, label);
|
||||||
|
layer.add(hoopGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders embroidery stitches consolidated by color and completion status
|
||||||
|
*/
|
||||||
|
export function renderStitches(
|
||||||
|
container: Konva.Layer | Konva.Group,
|
||||||
|
stitches: number[][],
|
||||||
|
pesData: PesPatternData,
|
||||||
|
currentStitchIndex: number
|
||||||
|
): void {
|
||||||
|
const stitchesGroup = new Konva.Group({ name: 'stitches' });
|
||||||
|
|
||||||
|
// Group stitches by color, completion status, and type (stitch vs jump)
|
||||||
|
interface StitchGroup {
|
||||||
|
color: string;
|
||||||
|
points: number[];
|
||||||
|
completed: boolean;
|
||||||
|
isJump: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: StitchGroup[] = [];
|
||||||
|
let currentGroup: StitchGroup | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < stitches.length; i++) {
|
||||||
|
const stitch = stitches[i];
|
||||||
|
const [x, y, cmd, colorIndex] = stitch;
|
||||||
|
const isCompleted = i < currentStitchIndex;
|
||||||
|
const isJump = (cmd & MOVE) !== 0;
|
||||||
|
const color = getThreadColor(pesData, colorIndex);
|
||||||
|
|
||||||
|
// Start new group if color/status/type changes, or if it's the first stitch
|
||||||
|
if (
|
||||||
|
!currentGroup ||
|
||||||
|
currentGroup.color !== color ||
|
||||||
|
currentGroup.completed !== isCompleted ||
|
||||||
|
currentGroup.isJump !== isJump
|
||||||
|
) {
|
||||||
|
currentGroup = {
|
||||||
|
color,
|
||||||
|
points: [x, y],
|
||||||
|
completed: isCompleted,
|
||||||
|
isJump,
|
||||||
|
};
|
||||||
|
groups.push(currentGroup);
|
||||||
|
} else {
|
||||||
|
// Continue the current group
|
||||||
|
currentGroup.points.push(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Konva.Line for each group
|
||||||
|
groups.forEach((group) => {
|
||||||
|
if (group.isJump) {
|
||||||
|
// Jump stitches - dashed gray lines
|
||||||
|
const line = new Konva.Line({
|
||||||
|
points: group.points,
|
||||||
|
stroke: group.completed ? '#cccccc' : '#e8e8e8',
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
lineCap: 'round',
|
||||||
|
lineJoin: 'round',
|
||||||
|
dash: [3, 3],
|
||||||
|
});
|
||||||
|
stitchesGroup.add(line);
|
||||||
|
} else {
|
||||||
|
// Regular stitches - solid lines with actual thread color
|
||||||
|
const line = new Konva.Line({
|
||||||
|
points: group.points,
|
||||||
|
stroke: group.color,
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
lineCap: 'round',
|
||||||
|
lineJoin: 'round',
|
||||||
|
opacity: group.completed ? 1.0 : 0.3,
|
||||||
|
});
|
||||||
|
stitchesGroup.add(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container.add(stitchesGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders pattern bounds rectangle
|
||||||
|
*/
|
||||||
|
export function renderPatternBounds(
|
||||||
|
container: Konva.Layer | Konva.Group,
|
||||||
|
bounds: { minX: number; maxX: number; minY: number; maxY: number }
|
||||||
|
): void {
|
||||||
|
const { minX, maxX, minY, maxY } = bounds;
|
||||||
|
const patternWidth = maxX - minX;
|
||||||
|
const patternHeight = maxY - minY;
|
||||||
|
|
||||||
|
const rect = new Konva.Rect({
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: patternWidth,
|
||||||
|
height: patternHeight,
|
||||||
|
stroke: '#ff0000',
|
||||||
|
strokeWidth: 2,
|
||||||
|
dash: [5, 5],
|
||||||
|
});
|
||||||
|
|
||||||
|
container.add(rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the current position indicator
|
||||||
|
*/
|
||||||
|
export function renderCurrentPosition(
|
||||||
|
container: Konva.Layer | Konva.Group,
|
||||||
|
currentStitchIndex: number,
|
||||||
|
stitches: number[][]
|
||||||
|
): void {
|
||||||
|
if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) return;
|
||||||
|
|
||||||
|
const stitch = stitches[currentStitchIndex];
|
||||||
|
const [x, y] = stitch;
|
||||||
|
|
||||||
|
const posGroup = new Konva.Group({ name: 'currentPosition' });
|
||||||
|
|
||||||
|
// Circle with fill
|
||||||
|
const circle = new Konva.Circle({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
radius: 8,
|
||||||
|
fill: 'rgba(255, 0, 0, 0.3)',
|
||||||
|
stroke: '#ff0000',
|
||||||
|
strokeWidth: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crosshair lines
|
||||||
|
const hLine1 = new Konva.Line({
|
||||||
|
points: [x - 12, y, x - 3, y],
|
||||||
|
stroke: '#ff0000',
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hLine2 = new Konva.Line({
|
||||||
|
points: [x + 12, y, x + 3, y],
|
||||||
|
stroke: '#ff0000',
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vLine1 = new Konva.Line({
|
||||||
|
points: [x, y - 12, x, y - 3],
|
||||||
|
stroke: '#ff0000',
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vLine2 = new Konva.Line({
|
||||||
|
points: [x, y + 12, x, y + 3],
|
||||||
|
stroke: '#ff0000',
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
posGroup.add(circle, hLine1, hLine2, vLine1, vLine2);
|
||||||
|
container.add(posGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders thread color legend (positioned at top-left of viewport)
|
||||||
|
*/
|
||||||
|
export function renderLegend(
|
||||||
|
layer: Konva.Layer,
|
||||||
|
pesData: PesPatternData,
|
||||||
|
_stageWidth: number,
|
||||||
|
_stageHeight: number
|
||||||
|
): void {
|
||||||
|
const legendGroup = new Konva.Group({ name: 'legend' });
|
||||||
|
|
||||||
|
// Semi-transparent background for better readability
|
||||||
|
const bgPadding = 8;
|
||||||
|
const itemHeight = 25;
|
||||||
|
const legendHeight = pesData.threads.length * itemHeight + bgPadding * 2;
|
||||||
|
|
||||||
|
const background = new Konva.Rect({
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
width: 100,
|
||||||
|
height: legendHeight,
|
||||||
|
fill: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
cornerRadius: 4,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.2)',
|
||||||
|
shadowBlur: 4,
|
||||||
|
shadowOffset: { x: 0, y: 2 },
|
||||||
|
});
|
||||||
|
legendGroup.add(background);
|
||||||
|
|
||||||
|
let legendY = 10 + bgPadding;
|
||||||
|
|
||||||
|
// Draw legend for each thread
|
||||||
|
for (let i = 0; i < pesData.threads.length; i++) {
|
||||||
|
const color = getThreadColor(pesData, i);
|
||||||
|
|
||||||
|
// Color swatch
|
||||||
|
const swatch = new Konva.Rect({
|
||||||
|
x: 18,
|
||||||
|
y: legendY,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
fill: color,
|
||||||
|
stroke: '#000',
|
||||||
|
strokeWidth: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thread label
|
||||||
|
const label = new Konva.Text({
|
||||||
|
x: 43,
|
||||||
|
y: legendY + 5,
|
||||||
|
text: `Thread ${i + 1}`,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fill: '#000',
|
||||||
|
});
|
||||||
|
|
||||||
|
legendGroup.add(swatch, label);
|
||||||
|
legendY += itemHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.add(legendGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders pattern dimensions text (positioned at bottom-right of viewport)
|
||||||
|
*/
|
||||||
|
export function renderDimensions(
|
||||||
|
layer: Konva.Layer,
|
||||||
|
patternWidth: number,
|
||||||
|
patternHeight: number,
|
||||||
|
stageWidth: number,
|
||||||
|
stageHeight: number
|
||||||
|
): void {
|
||||||
|
const dimensionText = `${(patternWidth / 10).toFixed(1)} x ${(patternHeight / 10).toFixed(1)} mm`;
|
||||||
|
|
||||||
|
// Background for better readability
|
||||||
|
const textWidth = 140;
|
||||||
|
const textHeight = 30;
|
||||||
|
const padding = 8;
|
||||||
|
|
||||||
|
const background = new Konva.Rect({
|
||||||
|
x: stageWidth - textWidth - padding - 10,
|
||||||
|
y: stageHeight - textHeight - padding - 80, // Above zoom controls
|
||||||
|
width: textWidth,
|
||||||
|
height: textHeight,
|
||||||
|
fill: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
cornerRadius: 4,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.2)',
|
||||||
|
shadowBlur: 4,
|
||||||
|
shadowOffset: { x: 0, y: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = new Konva.Text({
|
||||||
|
x: stageWidth - textWidth - 10,
|
||||||
|
y: stageHeight - textHeight - 80,
|
||||||
|
width: textWidth,
|
||||||
|
height: textHeight,
|
||||||
|
text: dimensionText,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fill: '#000',
|
||||||
|
align: 'center',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(background, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates initial scale to fit the view (hoop or pattern)
|
||||||
|
*/
|
||||||
|
export function calculateInitialScale(
|
||||||
|
stageWidth: number,
|
||||||
|
stageHeight: number,
|
||||||
|
viewWidth: number,
|
||||||
|
viewHeight: number,
|
||||||
|
padding: number = 40
|
||||||
|
): number {
|
||||||
|
const scaleX = (stageWidth - 2 * padding) / viewWidth;
|
||||||
|
const scaleY = (stageHeight - 2 * padding) / viewHeight;
|
||||||
|
return Math.min(scaleX, scaleY);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue