respira/src/formats/pen/encoder.ts
Jan-Henrik Bruhn 7a1178166a fix: Combine COLOR_END and CUT flags on same stitch for color changes
The COLOR_END and CUT commands must be on the same stitch, not separate stitches.
This was causing the machine to execute an extra stitch with the new color before
the jump to the new position.

Changes:
- Combine COLOR_END (X flag) and CUT (Y flag) into single stitch at old position
- Machine now correctly pauses after cut, before jumping to new color section
- Update all color change tests to expect combined COLOR_END+CUT stitch

The correct sequence is now:
1. Finishing lock stitches (old color)
2. COLOR_END+CUT stitch (old color) ← Machine pauses here
3. Jump to new position (new color)
4. Starting lock stitches (new color)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 16:44:56 +01:00

373 lines
13 KiB
TypeScript

/**
* PEN Format Encoder
*
* This module contains the logic for encoding embroidery stitches into the Brother PP1 PEN format.
* The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits.
*/
import { MOVE, TRIM, END } from '../import/constants';
// PEN format flags for Brother machines
const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching)
const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command
const PEN_COLOR_END = 0x03; // Last stitch before color change
const PEN_DATA_END = 0x05; // Last stitch of entire pattern
// Constants from PesxToPen.cs
const FEED_LENGTH = 50; // Long jump threshold requiring lock stitches and cut
const TARGET_LENGTH = 8.0; // Target accumulated length for lock stitch direction
const MAX_POINTS = 5; // Maximum points to accumulate for lock stitch direction
export const LOCK_STITCH_JUMP_SIZE = 2.0;
const LOCK_STITCH_SCALE = LOCK_STITCH_JUMP_SIZE / 8.0; // Scale the magnitude-8 vector down to 4
export interface StitchData {
x: number;
y: number;
cmd: number;
colorIndex: number;
}
export interface PenEncodingResult {
penBytes: number[];
bounds: {
minX: number;
maxX: number;
minY: number;
maxY: number;
};
}
/**
* Encode a stitch position to PEN bytes (4 bytes: X_low, X_high, Y_low, Y_high)
* Coordinates are shifted left by 3 bits to make room for flags in low 3 bits
*/
export function encodeStitchPosition(x: number, y: number): number[] {
const xEnc = (Math.round(x) << 3) & 0xffff;
const yEnc = (Math.round(y) << 3) & 0xffff;
return [
xEnc & 0xff,
(xEnc >> 8) & 0xff,
yEnc & 0xff,
(yEnc >> 8) & 0xff
];
}
/**
* Calculate lock stitch direction by accumulating movement vectors
* Matches the C# logic that accumulates coordinates until reaching threshold
*
* Three use cases from C# ConvertEmb function:
* - Loop A (Jump/Entry): lookAhead=true - Hides knot under upcoming stitches
* - Loop B (End/Cut): lookAhead=false - Hides knot inside previous stitches
* - Loop C (Color Change): lookAhead=true - Aligns knot with stop event data
*
* @param stitches Array of stitches to analyze [x, y, cmd, colorIndex]
* @param currentIndex Current stitch index
* @param lookAhead If true, look forward; if false, look backward
* @returns Direction vector components (normalized and scaled to magnitude 8.0)
*/
export function calculateLockDirection(
stitches: number[][],
currentIndex: number,
lookAhead: boolean
): { dirX: number; dirY: number } {
let accumulatedX = 0;
let accumulatedY = 0;
let maxLength = 0;
let bestX = 0;
let bestY = 0;
const step = lookAhead ? 1 : -1;
const maxIterations = lookAhead
? Math.min(MAX_POINTS, stitches.length - currentIndex - 1)
: Math.min(MAX_POINTS, currentIndex);
for (let i = 0; i < maxIterations; i++) {
const idx = currentIndex + (step * (i + 1));
if (idx < 0 || idx >= stitches.length) break;
const stitch = stitches[idx];
const cmd = stitch[2];
// Skip MOVE/JUMP stitches
if ((cmd & MOVE) !== 0) continue;
// Accumulate relative coordinates
const deltaX = Math.round(stitch[0]) - Math.round(stitches[currentIndex][0]);
const deltaY = Math.round(stitch[1]) - Math.round(stitches[currentIndex][1]);
accumulatedX += deltaX;
accumulatedY += deltaY;
const length = Math.sqrt(accumulatedX * accumulatedX + accumulatedY * accumulatedY);
// Track the maximum length vector seen so far
if (length > maxLength) {
maxLength = length;
bestX = accumulatedX;
bestY = accumulatedY;
}
// If we've accumulated enough length, use current vector
if (length >= TARGET_LENGTH) {
return {
dirX: (accumulatedX * 8.0) / length,
dirY: (accumulatedY * 8.0) / length
};
}
}
// If we didn't reach target length, use the best vector we found
if (maxLength > 0.1) {
return {
dirX: (bestX * 8.0) / maxLength,
dirY: (bestY * 8.0) / maxLength
};
}
// Fallback: diagonal direction with magnitude 8.0
const mag = 8.0 / Math.sqrt(2); // ~5.66 for diagonal
return { dirX: mag, dirY: mag };
}
/**
* Generate lock/tack stitches at a position, rotated toward the direction of travel
* Matches Nuihajime_TomeDataPlus from PesxToPen.cs with vector rotation
* @param x X coordinate
* @param y Y coordinate
* @param dirX Direction X component (magnitude ~8.0)
* @param dirY Direction Y component (magnitude ~8.0)
* @returns Array of PEN bytes for lock stitches (32 bytes = 8 stitches * 4 bytes)
*/
export function generateLockStitches(x: number, y: number, dirX: number, dirY: number): number[] {
const lockBytes: number[] = [];
// Generate 8 lock stitches in alternating pattern
// Pattern from C# (from Nuihajime_TomeDataPlus): [+x, +y, -x, -y] repeated
// The direction vector has magnitude ~8.0, so we need to scale it down
// to get reasonable lock stitch size (approximately 0.4 units)
const scaledDirX = dirX * LOCK_STITCH_SCALE;
const scaledDirY = dirY * LOCK_STITCH_SCALE;
// Generate 8 stitches alternating between forward and backward
for (let i = 0; i < 8; i++) {
// Alternate between forward (+) and backward (-) direction
const sign = (i % 2 === 0) ? 1 : -1;
const xAdd = scaledDirX * sign;
const yAdd = scaledDirY * sign;
lockBytes.push(...encodeStitchPosition(x + xAdd, y + yAdd));
}
return lockBytes;
}
/**
* Encode stitches array to PEN format bytes
*
* @param stitches Array of stitches in format [x, y, cmd, colorIndex]
* @returns PEN encoding result with bytes and bounds
*/
export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Track bounds
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
const penStitches: number[] = [];
// Track position for calculating jump distances
let prevX = 0;
let prevY = 0;
for (let i = 0; i < stitches.length; i++) {
const stitch = stitches[i];
const absX = Math.round(stitch[0]);
const absY = Math.round(stitch[1]);
const cmd = stitch[2];
const stitchColor = stitch[3]; // Color index from PyStitch
// Track bounds for non-jump stitches (regular stitches, not MOVE/JUMP)
// A stitch is trackable if it's not a MOVE command
if ((cmd & MOVE) === 0) {
minX = Math.min(minX, absX);
maxX = Math.max(maxX, absX);
minY = Math.min(minY, absY);
maxY = Math.max(maxY, absY);
}
const isFirstStitch = i == 0;
// Check for long jumps that need lock stitches and cuts
if (cmd & MOVE) {
const jumpDist = Math.sqrt((absX - prevX) ** 2 + (absY - prevY) ** 2);
if (!isFirstStitch && jumpDist > FEED_LENGTH) {
// Long jump - add finishing lock stitches at previous position
// Loop B: End/Cut Vector - Look BACKWARD at previous stitches
// This hides the knot inside the embroidery we just finished
const finishDir = calculateLockDirection(stitches, i - 1, false);
penStitches.push(...generateLockStitches(prevX, prevY, finishDir.dirX, finishDir.dirY));
// Encode jump with both FEED and CUT flags
const xEncoded = (absX << 3) & 0xffff;
let yEncoded = (absY << 3) & 0xffff;
yEncoded |= PEN_FEED_DATA; // Jump flag
yEncoded |= PEN_CUT_DATA; // Cut flag for long jumps
penStitches.push(
xEncoded & 0xff,
(xEncoded >> 8) & 0xff,
yEncoded & 0xff,
(yEncoded >> 8) & 0xff
);
// Add starting lock stitches at new position
// Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches
// This hides the knot under the stitches we're about to make
const startDir = calculateLockDirection(stitches, i, true);
penStitches.push(...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY));
// Update position and continue
prevX = absX;
prevY = absY;
continue;
}
}
// Encode absolute coordinates with flags in low 3 bits
// Shift coordinates left by 3 bits to make room for flags
let xEncoded = (absX << 3) & 0xffff;
let yEncoded = (absY << 3) & 0xffff;
// Add command flags to Y-coordinate based on stitch type
if (cmd & MOVE) {
// MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching
yEncoded |= PEN_FEED_DATA;
}
if (cmd & TRIM) {
// TRIM: Set bit 1 (CUT_DATA) - cut thread command
yEncoded |= PEN_CUT_DATA;
}
// Check if this is the last stitch
const isLastStitch = i === stitches.length - 1 || (cmd & END) !== 0;
// Check for color change by comparing stitch color index
const nextStitch = stitches[i + 1];
const nextStitchColor = nextStitch?.[3];
const isColorChange = !isLastStitch && nextStitchColor !== undefined && nextStitchColor !== stitchColor;
// Mark the very last stitch of the pattern with DATA_END
if (isLastStitch) {
xEncoded = (xEncoded & 0xfff8) | PEN_DATA_END;
}
// Add the encoded stitch
penStitches.push(
xEncoded & 0xff,
(xEncoded >> 8) & 0xff,
yEncoded & 0xff,
(yEncoded >> 8) & 0xff
);
// Update position for next iteration
prevX = absX;
prevY = absY;
if (isFirstStitch) {
// Add starting lock stitches at the very beginning of the pattern
// Matches C# behavior: Nuihajime_TomeDataPlus is called when counter <= 2
// Calculate direction for starting locks (look forward into the pattern)
const startDir = calculateLockDirection(stitches, i, true);
penStitches.push(...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY));
}
// Handle color change: finishing lock, COLOR_END+CUT, jump, starting lock
if (isColorChange) {
const nextStitchCmd = nextStitch[2];
const nextStitchX = Math.round(nextStitch[0]);
const nextStitchY = Math.round(nextStitch[1]);
const nextIsJump = (nextStitchCmd & MOVE) !== 0;
// Step 1: Add finishing lock stitches at end of current color
// Loop C: Color Change Vector - Look FORWARD at the stop event data
// This aligns the knot with the stop command's data block for correct tension
const finishDir = calculateLockDirection(stitches, i, true);
penStitches.push(...generateLockStitches(absX, absY, finishDir.dirX, finishDir.dirY));
// Step 2: Add COLOR_END + CUT command at CURRENT position (same stitch!)
// This is where the machine pauses and waits for the user to change thread color
// IMPORTANT: COLOR_END and CUT must be on the SAME stitch, not separate stitches
let colorEndCutXEncoded = (absX << 3) & 0xffff;
let colorEndCutYEncoded = (absY << 3) & 0xffff;
// Add COLOR_END flag to X coordinate and CUT flag to Y coordinate
colorEndCutXEncoded = (colorEndCutXEncoded & 0xfff8) | PEN_COLOR_END;
colorEndCutYEncoded |= PEN_CUT_DATA;
penStitches.push(
colorEndCutXEncoded & 0xff,
(colorEndCutXEncoded >> 8) & 0xff,
colorEndCutYEncoded & 0xff,
(colorEndCutYEncoded >> 8) & 0xff
);
// Machine pauses here for color change
// After user changes color, the following stitches execute with the new color
// Step 3: If next stitch is a JUMP, encode it and skip it in the loop
// Otherwise, add a jump ourselves if positions differ
const jumpToX = nextStitchX;
const jumpToY = nextStitchY;
if (nextIsJump) {
// The PES has a JUMP to the new color position, we'll add it here and skip it later
i++; // Skip the JUMP stitch since we're processing it here
}
// Add jump to new position (if position changed)
if (jumpToX !== absX || jumpToY !== absY) {
const jumpXEncoded = (jumpToX << 3) & 0xffff;
let jumpYEncoded = (jumpToY << 3) & 0xffff;
jumpYEncoded |= PEN_FEED_DATA; // Jump flag
penStitches.push(
jumpXEncoded & 0xff,
(jumpXEncoded >> 8) & 0xff,
jumpYEncoded & 0xff,
(jumpYEncoded >> 8) & 0xff
);
}
// Step 4: Add starting lock stitches at the new position
// Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches in new color
// This hides the knot under the stitches we're about to make
const nextStitchIdx = nextIsJump ? i + 2 : i + 1;
const startDir = calculateLockDirection(stitches, nextStitchIdx < stitches.length ? nextStitchIdx : i, true);
penStitches.push(...generateLockStitches(jumpToX, jumpToY, startDir.dirX, startDir.dirY));
// Update position
prevX = jumpToX;
prevY = jumpToY;
}
// Check for end command
if ((cmd & END) !== 0) {
break;
}
}
return {
penBytes: penStitches,
bounds: {
minX: minX === Infinity ? 0 : minX,
maxX: maxX === -Infinity ? 0 : maxX,
minY: minY === Infinity ? 0 : minY,
maxY: maxY === -Infinity ? 0 : maxY,
},
};
}