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:
Jan-Henrik 2025-12-05 22:27:22 +01:00
parent e0fadf69da
commit 0f40cec8ec
7 changed files with 1003 additions and 262 deletions

View file

@ -257,12 +257,15 @@ button:disabled {
transition: width 0.3s;
}
.pattern-canvas {
/* Canvas container with Konva */
.canvas-container {
position: relative;
width: 100%;
height: 600px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: #fafafa;
overflow: hidden;
}
.canvas-placeholder {
@ -274,6 +277,161 @@ button:disabled {
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 {
padding: 1rem;
border-radius: 4px;

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useBrotherMachine } from './hooks/useBrotherMachine';
import { MachineConnection } from './components/MachineConnection';
import { FileUpload } from './components/FileUpload';
@ -13,6 +13,7 @@ function App() {
const [pesData, setPesData] = useState<PesPatternData | null>(null);
const [pyodideReady, setPyodideReady] = useState(false);
const [pyodideError, setPyodideError] = useState<string | null>(null);
const [patternOffset, setPatternOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
// Initialize Pyodide on mount
useEffect(() => {
@ -36,9 +37,16 @@ function App() {
}
}, [machine.resumedPattern, pesData, machine.resumeFileName]);
const handlePatternLoaded = (data: PesPatternData) => {
const handlePatternLoaded = useCallback((data: PesPatternData) => {
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 (
<div className="app">
@ -78,6 +86,7 @@ function App() {
onPatternLoaded={handlePatternLoaded}
onUpload={machine.uploadPattern}
pyodideReady={pyodideReady}
patternOffset={patternOffset}
/>
<ProgressMonitor
@ -97,6 +106,7 @@ function App() {
pesData={pesData}
sewingProgress={machine.sewingProgress}
machineInfo={machine.machineInfo}
onPatternOffsetChange={handlePatternOffsetChange}
/>
</div>
</div>

View file

@ -8,8 +8,9 @@ interface FileUploadProps {
machineStatus: MachineStatus;
uploadProgress: number;
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;
patternOffset: { x: number; y: number };
}
export function FileUpload({
@ -19,6 +20,7 @@ export function FileUpload({
onPatternLoaded,
onUpload,
pyodideReady,
patternOffset,
}: FileUploadProps) {
const [pesData, setPesData] = useState<PesPatternData | null>(null);
const [fileName, setFileName] = useState<string>('');
@ -55,9 +57,9 @@ export function FileUpload({
const handleUpload = useCallback(() => {
if (pesData && fileName) {
onUpload(pesData.penData, pesData, fileName);
onUpload(pesData.penData, pesData, fileName, patternOffset);
}
}, [pesData, fileName, onUpload]);
}, [pesData, fileName, onUpload, patternOffset]);
return (
<div className="file-upload-panel">

View file

@ -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 { getThreadColor } from '../utils/pystitchConverter';
import type { SewingProgress, MachineInfo } from '../types/machine';
import {
renderGrid,
renderOrigin,
renderHoop,
renderStitches,
renderPatternBounds,
renderCurrentPosition,
calculateInitialScale,
} from '../utils/konvaRenderers';
interface PatternCanvasProps {
pesData: PesPatternData | null;
sewingProgress: SewingProgress | null;
machineInfo: MachineInfo | null;
onPatternOffsetChange?: (offsetX: number, offsetY: number) => void;
}
export function PatternCanvas({ pesData, sewingProgress, machineInfo }: PatternCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternOffsetChange }: PatternCanvasProps) {
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(() => {
if (!canvasRef.current || !pesData) return;
if (!containerRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const container = containerRef.current;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Create stage
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 { 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
let scale: number;
let viewWidth: number;
let viewHeight: number;
// Store ref
patternGroupRef.current = patternGroup;
if (machineInfo) {
// Use hoop dimensions to determine scale
viewWidth = machineInfo.maxWidth;
viewHeight = machineInfo.maxHeight;
} else {
// Fallback to pattern dimensions
viewWidth = patternWidth;
viewHeight = patternHeight;
// Render pattern elements into the group (initial render with currentStitch = 0)
const currentStitch = sewingProgress?.currentStitch || 0;
renderStitches(patternGroup, stitches, pesData, currentStitch);
renderPatternBounds(patternGroup, bounds);
// Handle drag events
patternGroup.on('dragstart', () => {
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;
const scaleY = (canvas.height - 2 * padding) / viewHeight;
scale = Math.min(scaleX, scaleY);
// Re-render stitches with updated progress
renderStitches(patternGroup, stitches, pesData, currentStitch);
patternGroup.getLayer()?.batchDraw();
}, [sewingProgress, pesData]);
// Center the view (hoop or pattern) in canvas
// The origin (0,0) should be at the center of the hoop
const offsetX = canvas.width / 2;
const offsetY = canvas.height / 2;
// Draw grid
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();
// Separate effect to update pattern position when offset changes externally (not during drag)
useEffect(() => {
const patternGroup = patternGroupRef.current;
if (patternGroup && !isDraggingRef.current) {
patternGroup.position({ x: patternOffset.x, y: patternOffset.y });
patternGroup.getLayer()?.batchDraw();
}
}, [patternOffset.x, patternOffset.y]);
// Draw origin
ctx.strokeStyle = '#888';
ctx.lineWidth = 2;
ctx.beginPath();
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();
// Render current position layer (updates frequently, follows pattern offset)
useEffect(() => {
const layer = currentPosLayerRef.current;
if (!layer || !pesData) return;
// Draw hoop boundary (if machine info available)
if (machineInfo) {
// Machine info stores dimensions in 0.1mm units
const hoopWidth = machineInfo.maxWidth;
const hoopHeight = machineInfo.maxHeight;
layer.destroyChildren();
// Hoop is centered at origin (0, 0)
const hoopLeft = -hoopWidth / 2;
const hoopTop = -hoopHeight / 2;
const hoopRight = hoopWidth / 2;
const hoopBottom = hoopHeight / 2;
const currentStitch = sewingProgress?.currentStitch || 0;
const { stitches } = pesData;
// 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) {
// Draw a pulsing circle at current position
ctx.strokeStyle = '#ff0000';
ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
ctx.lineWidth = 3;
ctx.setLineDash([]);
// Create group at pattern offset
const posGroup = new Konva.Group({
x: patternOffset.x,
y: patternOffset.y,
});
ctx.beginPath();
ctx.arc(currentPosX, currentPosY, 8, 0, 2 * Math.PI);
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();
renderCurrentPosition(posGroup, currentStitch, stitches);
layer.add(posGroup);
}
// Draw bounds
ctx.strokeStyle = '#ff0000';
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]);
layer.batchDraw();
}, [pesData, sewingProgress, patternOffset.x, patternOffset.y]);
return (
<div className="canvas-panel">
<h2>Pattern Preview</h2>
<canvas
ref={canvasRef}
width={800}
height={600}
className="pattern-canvas"
/>
<div className="canvas-container" ref={containerRef}>
{!pesData && (
<div className="canvas-placeholder">
Load a PES file to preview the pattern
</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>
);
}

View file

@ -193,7 +193,7 @@ export function useBrotherMachine() {
}, [service, resumeAvailable, refreshPatternInfo]);
const uploadPattern = useCallback(
async (penData: Uint8Array, pesData: PesPatternData, fileName: string) => {
async (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => {
if (!isConnected) {
setError("Not connected to machine");
return;
@ -208,6 +208,7 @@ export function useBrotherMachine() {
setUploadProgress(progress);
},
pesData.bounds,
patternOffset,
);
setUploadProgress(100);

View file

@ -518,6 +518,7 @@ export class BrotherPP1Service {
data: Uint8Array,
onProgress?: (progress: number) => void,
bounds?: { minX: number; maxX: number; minY: number; maxY: number },
patternOffset?: { x: number; y: number },
): Promise<Uint8Array> {
// Calculate checksum
const checksum = data.reduce((sum, byte) => sum + byte, 0) & 0xffff;
@ -560,21 +561,43 @@ export class BrotherPP1Service {
const patternWidth = boundRight - boundLeft;
const patternHeight = boundBottom - boundTop;
// Calculate center offset to position pattern at machine center
// Machine embroidery area center is at (0, 0)
// Pattern center should align with machine center
// Calculate move offset based on user-defined pattern offset or auto-center
let moveX: number;
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 patternCenterY = (boundTop + boundBottom) / 2;
// moveX/moveY shift the pattern so its center aligns with origin
const moveX = -patternCenterX;
const moveY = -patternCenterY;
// moveX/moveY define where the pattern center should be
// offset.x/y is where user dragged the pattern to (relative to hoop center)
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
// sizeX/sizeY are scaling factors (100 = 100% = no scaling)
await this.sendLayout(
Math.round(moveX), // moveX - center the pattern
Math.round(moveY), // moveY - center the pattern
Math.round(moveX), // moveX - position the pattern
Math.round(moveY), // moveY - position the pattern
100, // sizeX (100% - no scaling)
100, // sizeY (100% - no scaling)
0, // rotate

389
src/utils/konvaRenderers.ts Normal file
View 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);
}