diff --git a/src/App.css b/src/App.css index 7ab2332..4fb4c6c 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/App.tsx b/src/App.tsx index 248c4d4..68f7368 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const [pyodideReady, setPyodideReady] = useState(false); const [pyodideError, setPyodideError] = useState(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 (
@@ -78,6 +86,7 @@ function App() { onPatternLoaded={handlePatternLoaded} onUpload={machine.uploadPattern} pyodideReady={pyodideReady} + patternOffset={patternOffset} />
diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index 76a62bf..5a172fa 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -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(null); const [fileName, setFileName] = useState(''); @@ -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 (
diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx index 8467109..07148d6 100644 --- a/src/components/PatternCanvas.tsx +++ b/src/components/PatternCanvas.tsx @@ -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(null); +export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternOffsetChange }: PatternCanvasProps) { + const containerRef = useRef(null); + const stageRef = useRef(null); + const backgroundLayerRef = useRef(null); + const patternLayerRef = useRef(null); + const currentPosLayerRef = useRef(null); + const patternGroupRef = useRef(null); + const [zoomLevel, setZoomLevel] = useState(1); + const [patternOffset, setPatternOffset] = useState({ x: 0, y: 0 }); + const initialScaleRef = useRef(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) => { + 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); - const scaleX = (canvas.width - 2 * padding) / viewWidth; - const scaleY = (canvas.height - 2 * padding) / viewHeight; - scale = Math.min(scaleX, scaleY); + // Handle drag events + patternGroup.on('dragstart', () => { + isDraggingRef.current = true; + }); - // 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; + patternGroup.on('dragend', () => { + isDraggingRef.current = false; + const newOffset = { + x: patternGroup.x(), + y: patternGroup.y(), + }; + setPatternOffset(newOffset); - // 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(); - } - - // 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(); - - // 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; - - // Hoop is centered at origin (0, 0) - const hoopLeft = -hoopWidth / 2; - 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; + // Notify parent component of offset change + if (onPatternOffsetChange) { + onPatternOffsetChange(newOffset.x, newOffset.y); } + }); - if (i > 0) { - const isCompleted = i < currentStitch; - const isCurrent = i === currentStitch; + // Add visual feedback on hover + patternGroup.on('mouseenter', () => { + const stage = stageRef.current; + if (stage) stage.container().style.cursor = 'move'; + }); - 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; + patternGroup.on('mouseleave', () => { + if (!isDraggingRef.current) { + const stage = stageRef.current; + if (stage) stage.container().style.cursor = 'grab'; } + }); - lastX = x; - lastY = y; + 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(); } - // Draw current position indicator + // Re-render stitches with updated progress + renderStitches(patternGroup, stitches, pesData, currentStitch); + patternGroup.getLayer()?.batchDraw(); + }, [sewingProgress, pesData]); + + // 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]); + + // Render current position layer (updates frequently, follows pattern offset) + useEffect(() => { + const layer = currentPosLayerRef.current; + if (!layer || !pesData) return; + + layer.destroyChildren(); + + const currentStitch = sewingProgress?.currentStitch || 0; + const { stitches } = pesData; + 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 (

Pattern Preview

- - {!pesData && ( -
- Load a PES file to preview the pattern -
- )} +
+ {!pesData && ( +
+ Load a PES file to preview the pattern +
+ )} + {pesData && ( + <> + {/* Thread Legend Overlay */} +
+

Threads

+ {pesData.threads.map((thread, index) => ( +
+
+ Thread {index + 1} +
+ ))} +
+ + {/* Pattern Dimensions Overlay */} +
+ {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '} + {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm +
+ + {/* Pattern Offset Indicator */} +
+
Pattern Position:
+
+ X: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.y / 10).toFixed(1)}mm +
+
+ Drag pattern to move • Drag background to pan +
+
+ + {/* Zoom Controls Overlay */} +
+ + {Math.round(zoomLevel * 100)}% + + +
+ + )} +
); } diff --git a/src/hooks/useBrotherMachine.ts b/src/hooks/useBrotherMachine.ts index f2e57b3..3d6e490 100644 --- a/src/hooks/useBrotherMachine.ts +++ b/src/hooks/useBrotherMachine.ts @@ -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); diff --git a/src/services/BrotherPP1Service.ts b/src/services/BrotherPP1Service.ts index 21c87c4..d86785e 100644 --- a/src/services/BrotherPP1Service.ts +++ b/src/services/BrotherPP1Service.ts @@ -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 { // 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 - const patternCenterX = (boundLeft + boundRight) / 2; - const patternCenterY = (boundTop + boundBottom) / 2; + // Calculate move offset based on user-defined pattern offset or auto-center + let moveX: number; + let moveY: number; - // moveX/moveY shift the pattern so its center aligns with origin - const moveX = -patternCenterX; - const moveY = -patternCenterY; + 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 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 diff --git a/src/utils/konvaRenderers.ts b/src/utils/konvaRenderers.ts new file mode 100644 index 0000000..74aa028 --- /dev/null +++ b/src/utils/konvaRenderers.ts @@ -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); +}