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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
|
|||
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 { 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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
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 (
|
||||
<div className="canvas-panel">
|
||||
<h2>Pattern Preview</h2>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={800}
|
||||
height={600}
|
||||
className="pattern-canvas"
|
||||
/>
|
||||
{!pesData && (
|
||||
<div className="canvas-placeholder">
|
||||
Load a PES file to preview the pattern
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
|
|
|||
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