From 584f795330ce4b9b811b5819a4314c1322203b6a Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sun, 14 Dec 2025 11:44:48 +0100 Subject: [PATCH 1/6] feature: Add comprehensive tests for PEN encoding logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract PEN encoding logic into separate testable module (penEncoder.ts) - Implement 24 comprehensive tests covering: - Position encoding and coordinate shifting - Lock stitch direction calculation (forward/backward) - Lock stitch generation with rotation - Full PEN encoding with color changes, jumps, and bounds - Edge cases (empty arrays, single stitches, TRIM flags) - Setup vitest for testing - Refactor pattern converter worker to use extracted penEncoder module - Fix bounds calculation to include non-MOVE stitches (not just STITCH) - Remove duplicate function definitions from worker - Add test scripts: npm run test, npm run test:ui, npm run test:run All tests passing (24/24) and build successful. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- package-lock.json | 412 ++++++++++++++++++++++++- package.json | 7 +- src/utils/penEncoder.test.ts | 343 ++++++++++++++++++++ src/utils/penEncoder.ts | 366 ++++++++++++++++++++++ src/workers/patternConverter.worker.ts | 343 +------------------- vitest.config.ts | 9 + 6 files changed, 1141 insertions(+), 339 deletions(-) create mode 100644 src/utils/penEncoder.test.ts create mode 100644 src/utils/penEncoder.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 43c7c84..d3eed8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/ui": "^4.0.15", "electron": "^39.2.6", "electron-icon-builder": "^2.0.1", "eslint": "^9.39.1", @@ -48,7 +49,8 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4", - "vite-plugin-static-copy": "^3.1.4" + "vite-plugin-static-copy": "^3.1.4", + "vitest": "^4.0.15" } }, "node_modules/@babel/code-frame": { @@ -4179,6 +4181,13 @@ "node": ">=10" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@reforged/maker-appimage": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@reforged/maker-appimage/-/maker-appimage-5.1.1.tgz", @@ -4532,6 +4541,13 @@ "integrity": "sha512-aywhxHNb6l7COooF3m439eT/6QN8E/RSl5IVboSKthMHcp0GlZYMSoS7546rqDLmFRxTD8f1tu/NIS9vtDwYAg==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -4888,6 +4904,24 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/electron-squirrel-startup": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/electron-squirrel-startup/-/electron-squirrel-startup-1.0.2.tgz", @@ -5331,6 +5365,139 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", + "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", + "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.15", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", + "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", + "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", + "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", + "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.15.tgz", + "integrity": "sha512-sxSyJMaKp45zI0u+lHrPuZM1ZJQ8FaVD35k+UxVrha1yyvQ+TZuUYllUixwvQXlB7ixoDc7skf3lQPopZIvaQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.15" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", + "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vscode/sudo-prompt": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz", @@ -5844,6 +6011,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", @@ -6334,6 +6511,16 @@ "follow-redirects": "^1.15.6" } }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -8394,6 +8581,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -8542,6 +8739,16 @@ "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==", "dev": true }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -8705,6 +8912,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -11423,6 +11637,16 @@ "node": ">=4" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11702,6 +11926,17 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/omggif": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", @@ -12042,6 +12277,13 @@ "node": ">=4" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pe-library": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz", @@ -13375,6 +13617,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -13382,6 +13631,21 @@ "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -13576,6 +13840,20 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -14208,6 +14486,13 @@ "license": "MIT", "optional": true }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -14215,6 +14500,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -14231,6 +14526,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -14319,6 +14624,16 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -14732,6 +15047,84 @@ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/vitest": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", + "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.15", + "@vitest/mocker": "4.0.15", + "@vitest/pretty-format": "4.0.15", + "@vitest/runner": "4.0.15", + "@vitest/snapshot": "4.0.15", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.15", + "@vitest/browser-preview": "4.0.15", + "@vitest/browser-webdriverio": "4.0.15", + "@vitest/ui": "4.0.15", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -14886,6 +15279,23 @@ "dev": true, "license": "ISC" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 676f159..3a89bc5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", "start": "electron-forge start", "package": "electron-forge package", "make": "electron-forge make", @@ -49,6 +52,7 @@ "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/ui": "^4.0.15", "electron": "^39.2.6", "electron-icon-builder": "^2.0.1", "eslint": "^9.39.1", @@ -58,6 +62,7 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4", - "vite-plugin-static-copy": "^3.1.4" + "vite-plugin-static-copy": "^3.1.4", + "vitest": "^4.0.15" } } diff --git a/src/utils/penEncoder.test.ts b/src/utils/penEncoder.test.ts new file mode 100644 index 0000000..32c7814 --- /dev/null +++ b/src/utils/penEncoder.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect } from 'vitest'; +import { + encodeStitchPosition, + calculateLockDirection, + generateLockStitches, + encodeStitchesToPen, +} from './penEncoder'; +import { STITCH, MOVE, TRIM, END } from './embroideryConstants'; + +describe('encodeStitchPosition', () => { + it('should encode position (0, 0) correctly', () => { + const result = encodeStitchPosition(0, 0); + expect(result).toEqual([0x00, 0x00, 0x00, 0x00]); + }); + + it('should shift coordinates left by 3 bits', () => { + // Position (1, 1) should become (8, 8) after shifting + const result = encodeStitchPosition(1, 1); + expect(result).toEqual([0x08, 0x00, 0x08, 0x00]); + }); + + it('should handle negative coordinates', () => { + // -1 in 16-bit signed = 0xFFFF, shifted left 3 = 0xFFF8 + const result = encodeStitchPosition(-1, -1); + expect(result).toEqual([0xF8, 0xFF, 0xF8, 0xFF]); + }); + + it('should encode multi-byte coordinates correctly', () => { + // Position (128, 0) -> shifted = 1024 = 0x0400 + const result = encodeStitchPosition(128, 0); + expect(result).toEqual([0x00, 0x04, 0x00, 0x00]); + }); + + it('should round fractional coordinates', () => { + const result = encodeStitchPosition(1.5, 2.4); + // 2 << 3 = 16, 2 << 3 = 16 + expect(result).toEqual([0x10, 0x00, 0x10, 0x00]); + }); +}); + +describe('calculateLockDirection', () => { + it('should look ahead for forward direction', () => { + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, STITCH, 0], + [20, 0, STITCH, 0], + ]; + + const result = calculateLockDirection(stitches, 0, true); + + // Should accumulate forward stitches + expect(result.dirX).toBeGreaterThan(0); + expect(result.dirY).toBe(0); + // Result should have magnitude ~8.0 + const magnitude = Math.sqrt(result.dirX ** 2 + result.dirY ** 2); + expect(magnitude).toBeCloseTo(8.0, 1); + }); + + it('should look backward for backward direction', () => { + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, STITCH, 0], + [20, 0, STITCH, 0], + ]; + + const result = calculateLockDirection(stitches, 2, false); + + // Should accumulate backward stitches + expect(result.dirX).toBeLessThan(0); + expect(result.dirY).toBe(0); + // Result should have magnitude ~8.0 + const magnitude = Math.sqrt(result.dirX ** 2 + result.dirY ** 2); + expect(magnitude).toBeCloseTo(8.0, 1); + }); + + it('should skip MOVE stitches when accumulating', () => { + const stitches = [ + [0, 0, STITCH, 0], + [5, 0, MOVE, 0], // Should be skipped + [10, 0, STITCH, 0], + [15, 0, STITCH, 0], + ]; + + const result = calculateLockDirection(stitches, 0, true); + + // Should skip the MOVE stitch and only count actual stitches + expect(result.dirX).toBeGreaterThan(0); + }); + + it('should return fallback diagonal for empty or short stitch sequences', () => { + const stitches = [ + [0, 0, STITCH, 0], + ]; + + const result = calculateLockDirection(stitches, 0, true); + + // Should return diagonal fallback + const expectedMag = 8.0 / Math.sqrt(2); + expect(result.dirX).toBeCloseTo(expectedMag, 1); + expect(result.dirY).toBeCloseTo(expectedMag, 1); + }); + + it('should normalize accumulated vector to magnitude 8.0', () => { + const stitches = [ + [0, 0, STITCH, 0], + [3, 4, STITCH, 0], // Distance = 5 + [6, 8, STITCH, 0], // Accumulated: (6, 8), length = 10 + ]; + + const result = calculateLockDirection(stitches, 0, true); + + // Should normalize (6, 8) to magnitude 8.0 + // Expected: (6 * 8 / 10, 8 * 8 / 10) = (4.8, 6.4) + expect(result.dirX).toBeCloseTo(4.8, 1); + expect(result.dirY).toBeCloseTo(6.4, 1); + + const magnitude = Math.sqrt(result.dirX ** 2 + result.dirY ** 2); + expect(magnitude).toBeCloseTo(8.0, 1); + }); + + it('should stop accumulating after reaching target length', () => { + // Create a long chain of stitches + const stitches = [ + [0, 0, STITCH, 0], + [2, 0, STITCH, 0], + [4, 0, STITCH, 0], + [6, 0, STITCH, 0], + [8, 0, STITCH, 0], + [10, 0, STITCH, 0], + [100, 0, STITCH, 0], // This should not be reached + ]; + + const result = calculateLockDirection(stitches, 0, true); + + // Should stop once accumulated length >= 8.0 + const magnitude = Math.sqrt(result.dirX ** 2 + result.dirY ** 2); + expect(magnitude).toBeCloseTo(8.0, 1); + }); +}); + +describe('generateLockStitches', () => { + it('should generate 8 lock stitches (32 bytes)', () => { + const result = generateLockStitches(0, 0, 8.0, 0); + expect(result.length).toBe(32); // 8 stitches * 4 bytes each + }); + + it('should alternate between +dir and -dir', () => { + const result = generateLockStitches(0, 0, 8.0, 0); + expect(result.length).toBe(32); // 8 stitches * 4 bytes + + // With a larger base position, verify the pattern still generates correctly + const result2 = generateLockStitches(100, 100, 8.0, 0); + expect(result2.length).toBe(32); + }); + + it('should rotate stitches in the given direction', () => { + // Direction pointing right (8, 0) + const result = generateLockStitches(0, 0, 8.0, 0); + + // Scale: 0.4 / 8.0 = 0.05 + // Scaled direction: (0.4, 0) + // Positions should alternate between (+0.4, 0) and (-0.4, 0) + + expect(result.length).toBe(32); + + // With diagonal direction (8/√2, 8/√2) + const diag = 8.0 / Math.sqrt(2); + const result2 = generateLockStitches(0, 0, diag, diag); + expect(result2.length).toBe(32); + }); +}); + +describe('encodeStitchesToPen', () => { + it('should encode a simple stitch sequence', () => { + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, STITCH, 0], + [20, 0, STITCH | END, 0], // Last stitch with both STITCH and END flags + ]; + + const result = encodeStitchesToPen(stitches); + + expect(result.penBytes.length).toBeGreaterThan(0); + expect(result.penBytes.length % 4).toBe(0); // Should be multiple of 4 (4 bytes per stitch) + expect(result.bounds.minX).toBe(0); + expect(result.bounds.maxX).toBe(20); + }); + + it('should track bounds correctly', () => { + const stitches = [ + [10, 20, STITCH, 0], + [-5, 30, STITCH, 0], + [15, -10, STITCH, 0], + [0, 0, END, 0], + ]; + + const result = encodeStitchesToPen(stitches); + + expect(result.bounds.minX).toBe(-5); + expect(result.bounds.maxX).toBe(15); + expect(result.bounds.minY).toBe(-10); + expect(result.bounds.maxY).toBe(30); + }); + + it('should mark the last stitch with DATA_END flag', () => { + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, END, 0], + ]; + + const result = encodeStitchesToPen(stitches); + + // Last stitch should have DATA_END (0x05) in low 3 bits of X coordinate + const lastStitchStart = result.penBytes.length - 4; + const xLow = result.penBytes[lastStitchStart]; + expect(xLow & 0x07).toBe(0x05); // DATA_END flag + }); + + it('should handle color changes with lock stitches', () => { + const stitches = [ + [0, 0, STITCH, 0], // Color 0 + [10, 0, STITCH, 0], // Color 0 + [20, 0, STITCH, 0], // Color 0 - last stitch before color change + [20, 0, STITCH, 1], // Color 1 - first stitch of new color + [30, 0, STITCH, 1], // Color 1 + [40, 0, END, 1], // Color 1 - last stitch + ]; + + const result = encodeStitchesToPen(stitches); + + // Should include: + // - Regular stitches for color 0 (3 stitches = 12 bytes) + // - Finishing lock stitches (32 bytes) + // - Cut command (4 bytes) + // - COLOR_END marker (4 bytes) + // - Starting lock stitches (32 bytes) + // - Regular stitches for color 1 (3 stitches = 12 bytes) + // Total: 96+ bytes + + expect(result.penBytes.length).toBeGreaterThan(90); // Should have many bytes from lock stitches + }); + + it('should handle long jumps with lock stitches and cut', () => { + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, STITCH, 0], + [100, 0, MOVE, 0], // Long jump (distance > 50) + [110, 0, STITCH, 0], + [120, 0, END, 0], + ]; + + const result = encodeStitchesToPen(stitches); + + // Should include: + // - Initial stitches + // - Finishing lock stitches before jump (32 bytes) + // - Jump with FEED and CUT flags (4 bytes) + // - Starting lock stitches after jump (32 bytes) + // - Final stitches + + expect(result.penBytes.length).toBeGreaterThan(80); + + // Jump stitch should have both FEED (0x01) and CUT (0x02) flags + // We need to find the jump in the output + // The jump will have Y coordinate with flags 0x03 (FEED | CUT) + }); + + it('should encode MOVE flag for jump stitches', () => { + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, MOVE, 0], // Short jump (no lock stitches) + [20, 0, END, 0], + ]; + + const result = encodeStitchesToPen(stitches); + + // Second stitch (jump) should have FEED_DATA flag (0x01) in Y low byte + // Stitch format: [xLow, xHigh, yLow, yHigh] + // We need to find the jump stitch - it's the second one encoded + const jumpStitchStart = 4; // Skip first stitch + const yLow = result.penBytes[jumpStitchStart + 2]; + expect(yLow & 0x01).toBe(0x01); // FEED_DATA flag + }); + + it('should not include MOVE stitches in bounds calculation', () => { + const stitches = [ + [0, 0, STITCH, 0], + [100, 100, MOVE, 0], // Jump - should not affect bounds + [10, 10, STITCH, 0], + [20, 20, STITCH | END, 0], // Last stitch with both STITCH and END flags + ]; + + const result = encodeStitchesToPen(stitches); + + // Bounds should only include STITCH positions, not MOVE + expect(result.bounds.minX).toBe(0); + expect(result.bounds.maxX).toBe(20); + expect(result.bounds.minY).toBe(0); + expect(result.bounds.maxY).toBe(20); + }); + + it('should handle TRIM flag', () => { + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, TRIM, 0], + [20, 0, END, 0], + ]; + + const result = encodeStitchesToPen(stitches); + + // TRIM stitch should have CUT_DATA flag (0x02) in Y low byte + const trimStitchStart = 4; + const yLow = result.penBytes[trimStitchStart + 2]; + expect(yLow & 0x02).toBe(0x02); // CUT_DATA flag + }); + + it('should handle empty stitch array', () => { + const stitches: number[][] = []; + + const result = encodeStitchesToPen(stitches); + + expect(result.penBytes.length).toBe(0); + expect(result.bounds.minX).toBe(0); + expect(result.bounds.maxX).toBe(0); + expect(result.bounds.minY).toBe(0); + expect(result.bounds.maxY).toBe(0); + }); + + it('should handle single stitch', () => { + const stitches = [ + [5, 10, END, 0], + ]; + + const result = encodeStitchesToPen(stitches); + + expect(result.penBytes.length).toBe(4); + expect(result.bounds.minX).toBe(5); + expect(result.bounds.maxX).toBe(5); + expect(result.bounds.minY).toBe(10); + expect(result.bounds.maxY).toBe(10); + // END stitches update bounds (they're not MOVE stitches) + }); +}); diff --git a/src/utils/penEncoder.ts b/src/utils/penEncoder.ts new file mode 100644 index 0000000..eb81e3a --- /dev/null +++ b/src/utils/penEncoder.ts @@ -0,0 +1,366 @@ +/** + * 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, + PEN_FEED_DATA, + PEN_CUT_DATA, + PEN_COLOR_END, + PEN_DATA_END, +} from './embroideryConstants'; + +// 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 +const LOCK_STITCH_SCALE = 0.4 / 8.0; // Scale the magnitude-8 vector down to 0.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; + lockBytes.push(...encodeStitchPosition(x + scaledDirX * sign, y + scaledDirY * sign)); + } + + 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); + } + + // Check for long jumps that need lock stitches and cuts + if (cmd & MOVE) { + const jumpDist = Math.sqrt((absX - prevX) ** 2 + (absY - prevY) ** 2); + + if (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; + + // Handle color change: finishing lock, cut, jump, COLOR_END, 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 cut command at current position + const cutXEncoded = (absX << 3) & 0xffff; + const cutYEncoded = ((absY << 3) & 0xffff) | PEN_CUT_DATA; + + penStitches.push( + cutXEncoded & 0xff, + (cutXEncoded >> 8) & 0xff, + cutYEncoded & 0xff, + (cutYEncoded >> 8) & 0xff + ); + + // 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 COLOR_END marker at NEW position + // This is where the machine pauses and waits for the user to change thread color + let colorEndXEncoded = (jumpToX << 3) & 0xffff; + const colorEndYEncoded = (jumpToY << 3) & 0xffff; + + // Add COLOR_END flag to X coordinate + colorEndXEncoded = (colorEndXEncoded & 0xfff8) | PEN_COLOR_END; + + penStitches.push( + colorEndXEncoded & 0xff, + (colorEndXEncoded >> 8) & 0xff, + colorEndYEncoded & 0xff, + (colorEndYEncoded >> 8) & 0xff + ); + + // Step 5: 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, + }, + }; +} diff --git a/src/workers/patternConverter.worker.ts b/src/workers/patternConverter.worker.ts index 88d56a1..2e6287d 100644 --- a/src/workers/patternConverter.worker.ts +++ b/src/workers/patternConverter.worker.ts @@ -4,11 +4,8 @@ import { MOVE, TRIM, END, - PEN_FEED_DATA, - PEN_CUT_DATA, - PEN_COLOR_END, - PEN_DATA_END, } from '../utils/embroideryConstants'; +import { encodeStitchesToPen } from '../utils/penEncoder'; // Message types from main thread export type WorkerMessage = @@ -152,133 +149,6 @@ async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: st } } -/** - * 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 - * @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) - */ -function calculateLockDirection( - stitches: number[][], - currentIndex: number, - lookAhead: boolean -): { dirX: number; dirY: number } { - const TARGET_LENGTH = 8.0; // Target accumulated length (from C# code) - const MAX_POINTS = 5; // Maximum points to accumulate (from C# code) - - 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 (scaled) - * @param dirY Direction Y component (scaled) - * @returns Array of PEN bytes for lock stitches - */ -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 scale = 0.4 / 8.0; // Scale the magnitude-8 vector down to 0.4 - const scaledDirX = dirX * scale; - const scaledDirY = dirY * 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; - lockBytes.push(...encodeStitchPosition(x + scaledDirX * sign, y + scaledDirY * sign)); - } - - return lockBytes; -} - -/** - * 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 - */ -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 - ]; -} - /** * Convert PES file to PEN format */ @@ -449,212 +319,11 @@ for i, stitch in enumerate(pattern.stitches): }; }); - // Track bounds - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - - // PyStitch returns ABSOLUTE coordinates - // PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780) - const penStitches: number[] = []; - - // Track position for calculating jump distances - let prevX = 0; - let prevY = 0; - - // Constants from PesxToPen.cs - const FEED_LENGTH = 50; // Long jump threshold requiring lock stitches and cut - console.log(stitches); - 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 - if (cmd === STITCH) { - minX = Math.min(minX, absX); - maxX = Math.max(maxX, absX); - minY = Math.min(minY, absY); - maxY = Math.max(maxY, absY); - } - - // Check for long jumps that need lock stitches and cuts - if (cmd & MOVE) { - const jumpDist = Math.sqrt((absX - prevX) ** 2 + (absY - prevY) ** 2); - - if (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 - // As per official app line 780: buffer[index64] = (byte) ((int) numArray4[index64 / 4, 0] << 3 & (int) byte.MaxValue); - 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 stitch as 4 bytes: [X_low, X_high, Y_low, Y_high] - penStitches.push( - xEncoded & 0xff, - (xEncoded >> 8) & 0xff, - yEncoded & 0xff, - (yEncoded >> 8) & 0xff - ); - - // Update position for next iteration - prevX = absX; - prevY = absY; - - // Handle color change: finishing lock, cut, jump, COLOR_END, 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; - - console.log(`[PEN] Color change detected at stitch ${i}: color ${stitchColor} -> ${nextStitchColor}`); - console.log(`[PEN] Current position: (${absX}, ${absY})`); - console.log(`[PEN] Next stitch: cmd=${nextStitchCmd}, isJump=${nextIsJump}, pos=(${nextStitchX}, ${nextStitchY})`); - - // 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)); - console.log(`[PEN] Added 8 finishing lock stitches at (${absX}, ${absY}) dir=(${finishDir.dirX.toFixed(2)}, ${finishDir.dirY.toFixed(2)})`); - - // Step 2: Add cut command at current position - const cutXEncoded = (absX << 3) & 0xffff; - const cutYEncoded = ((absY << 3) & 0xffff) | PEN_CUT_DATA; - - penStitches.push( - cutXEncoded & 0xff, - (cutXEncoded >> 8) & 0xff, - cutYEncoded & 0xff, - (cutYEncoded >> 8) & 0xff - ); - console.log(`[PEN] Added cut command at (${absX}, ${absY})`); - - // 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 - console.log(`[PEN] Next stitch is JUMP, using it to move to new color`); - i++; // Skip the JUMP stitch since we're processing it here - } else if (nextStitchX === absX && nextStitchY === absY) { - // Next color starts at same position, no jump needed - console.log(`[PEN] Next color starts at same position, no jump needed`); - } else { - // Need to add a jump ourselves - console.log(`[PEN] Adding jump to next color position`); - } - - // 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 - ); - console.log(`[PEN] Added jump to (${jumpToX}, ${jumpToY})`); - } - - // Step 4: Add COLOR_END marker at NEW position - // This is where the machine pauses and waits for the user to change thread color - let colorEndXEncoded = (jumpToX << 3) & 0xffff; - const colorEndYEncoded = (jumpToY << 3) & 0xffff; - - // Add COLOR_END flag to X coordinate - colorEndXEncoded = (colorEndXEncoded & 0xfff8) | PEN_COLOR_END; - - penStitches.push( - colorEndXEncoded & 0xff, - (colorEndXEncoded >> 8) & 0xff, - colorEndYEncoded & 0xff, - (colorEndYEncoded >> 8) & 0xff - ); - console.log(`[PEN] Added COLOR_END marker at (${jumpToX}, ${jumpToY})`); - - // Step 5: 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)); - console.log(`[PEN] Added 8 starting lock stitches at (${jumpToX}, ${jumpToY}) dir=(${startDir.dirX.toFixed(2)}, ${startDir.dirY.toFixed(2)})`); - - // Update position - prevX = jumpToX; - prevY = jumpToY; - } - - // Check for end command - if ((cmd & END) !== 0) { - break; - } - } + // Encode stitches to PEN format using the extracted encoder + console.log('[patternConverter] Encoding stitches to PEN format...'); + console.log(' - Input stitches:', stitches); + const { penBytes: penStitches, bounds } = encodeStitchesToPen(stitches); + const { minX, maxX, minY, maxY } = bounds; // Calculate unique colors from threads (threads represent color blocks, not unique colors) const uniqueColors = threads.reduce( diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a6de6dc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + }, +}); From 6699fcf8e7b58402954e193dbe575f529367dfaa Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sun, 14 Dec 2025 11:59:46 +0100 Subject: [PATCH 2/6] feature: Add detailed sequence verification tests for PEN encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhanced penEncoder test suite with byte-level sequence verification: - Added helper functions to decode PEN bytes for detailed assertions - Replaced length-only tests with precise operation order verification - Added tests for color change sequences (same position and with jump) - Added test for color change followed by explicit JUMP command - Added test for long jump sequences with lock stitches - Verified exact placement of lock stitches, cuts, jumps, and color markers All 27 tests passing with comprehensive coverage of PEN format encoding. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/utils/penEncoder.test.ts | 367 ++++++++++++++++++++++++++++++++--- 1 file changed, 345 insertions(+), 22 deletions(-) diff --git a/src/utils/penEncoder.test.ts b/src/utils/penEncoder.test.ts index 32c7814..f6945c5 100644 --- a/src/utils/penEncoder.test.ts +++ b/src/utils/penEncoder.test.ts @@ -7,6 +7,73 @@ import { } from './penEncoder'; import { STITCH, MOVE, TRIM, END } from './embroideryConstants'; +// PEN format flag constants for testing +const PEN_FEED_DATA = 0x01; +const PEN_CUT_DATA = 0x02; +const PEN_COLOR_END = 0x03; +const PEN_DATA_END = 0x05; + +/** + * Helper function to decode a single PEN stitch (4 bytes) into coordinates and flags + */ +function decodePenStitch(bytes: number[], offset: number): { + x: number; + y: number; + xFlags: number; + yFlags: number; + isFeed: boolean; + isCut: boolean; + isColorEnd: boolean; + isDataEnd: boolean; +} { + const xLow = bytes[offset]; + const xHigh = bytes[offset + 1]; + const yLow = bytes[offset + 2]; + const yHigh = bytes[offset + 3]; + + const xRaw = xLow | (xHigh << 8); + const yRaw = yLow | (yHigh << 8); + + // Extract flags from low 3 bits + const xFlags = xRaw & 0x07; + const yFlags = yRaw & 0x07; + + // Clear flags and shift right to get actual coordinates + const xClean = xRaw & 0xFFF8; + const yClean = yRaw & 0xFFF8; + + // Convert to signed + let xSigned = xClean; + let ySigned = yClean; + if (xSigned > 0x7FFF) xSigned = xSigned - 0x10000; + if (ySigned > 0x7FFF) ySigned = ySigned - 0x10000; + + const x = xSigned >> 3; + const y = ySigned >> 3; + + return { + x, + y, + xFlags, + yFlags, + isFeed: (yFlags & PEN_FEED_DATA) !== 0, + isCut: (yFlags & PEN_CUT_DATA) !== 0, + isColorEnd: xFlags === PEN_COLOR_END, + isDataEnd: xFlags === PEN_DATA_END, + }; +} + +/** + * Helper to parse all stitches from PEN bytes + */ +function decodeAllPenStitches(bytes: number[]) { + const stitches = []; + for (let i = 0; i < bytes.length; i += 4) { + stitches.push(decodePenStitch(bytes, i)); + } + return stitches; +} + describe('encodeStitchPosition', () => { it('should encode position (0, 0) correctly', () => { const result = encodeStitchPosition(0, 0); @@ -240,29 +307,273 @@ describe('encodeStitchesToPen', () => { expect(result.penBytes.length).toBeGreaterThan(90); // Should have many bytes from lock stitches }); - it('should handle long jumps with lock stitches and cut', () => { + it('should encode color change sequence in correct order', () => { + // Test the exact sequence of operations for a color change const stitches = [ - [0, 0, STITCH, 0], - [10, 0, STITCH, 0], - [100, 0, MOVE, 0], // Long jump (distance > 50) - [110, 0, STITCH, 0], - [120, 0, END, 0], + [0, 0, STITCH, 0], // Color 0 + [10, 0, STITCH, 0], // Color 0 - last stitch before color change + [10, 0, STITCH, 1], // Color 1 - first stitch (same position) + [20, 0, STITCH | END, 1], // Color 1 - last stitch ]; const result = encodeStitchesToPen(stitches); + const decoded = decodeAllPenStitches(result.penBytes); - // Should include: - // - Initial stitches - // - Finishing lock stitches before jump (32 bytes) - // - Jump with FEED and CUT flags (4 bytes) - // - Starting lock stitches after jump (32 bytes) - // - Final stitches + // Expected sequence: + // 1. Stitch at (0, 0) - color 0 + // 2. Stitch at (10, 0) - color 0 (last before change) + // 3. 8 finishing lock stitches around (10, 0) + // 4. Cut command at (10, 0) + // 5. COLOR_END marker at (10, 0) - no jump needed since next color is at same position + // 6. 8 starting lock stitches around (10, 0) + // 7. Stitch at (10, 0) - color 1 + // 8. Stitch at (20, 0) - color 1 (last, with END flag) - expect(result.penBytes.length).toBeGreaterThan(80); + let idx = 0; - // Jump stitch should have both FEED (0x01) and CUT (0x02) flags - // We need to find the jump in the output - // The jump will have Y coordinate with flags 0x03 (FEED | CUT) + // 1. First stitch (0, 0) + expect(decoded[idx].x).toBe(0); + expect(decoded[idx].y).toBe(0); + expect(decoded[idx].isFeed).toBe(false); + expect(decoded[idx].isCut).toBe(false); + idx++; + + // 2. Second stitch (10, 0) - last before color change + expect(decoded[idx].x).toBe(10); + expect(decoded[idx].y).toBe(0); + idx++; + + // 3. 8 finishing lock stitches (should be around position 10, 0) + for (let i = 0; i < 8; i++) { + const lockStitch = decoded[idx]; + expect(lockStitch.x).toBeCloseTo(10, 1); // Allow some deviation due to rotation + expect(lockStitch.y).toBeCloseTo(0, 1); + expect(lockStitch.isFeed).toBe(false); + expect(lockStitch.isCut).toBe(false); + idx++; + } + + // 4. Cut command + const cutStitch = decoded[idx]; + expect(cutStitch.x).toBe(10); + expect(cutStitch.y).toBe(0); + expect(cutStitch.isCut).toBe(true); + idx++; + + // 5. COLOR_END marker (no jump needed since same position) + const colorEndStitch = decoded[idx]; + expect(colorEndStitch.x).toBe(10); + expect(colorEndStitch.y).toBe(0); + expect(colorEndStitch.isColorEnd).toBe(true); + idx++; + + // 6. 8 starting lock stitches for new color + for (let i = 0; i < 8; i++) { + const lockStitch = decoded[idx]; + expect(lockStitch.x).toBeCloseTo(10, 1); + expect(lockStitch.y).toBeCloseTo(0, 1); + idx++; + } + + // 7. First stitch of new color + expect(decoded[idx].x).toBe(10); + expect(decoded[idx].y).toBe(0); + idx++; + + // 8. Last stitch with DATA_END flag + expect(decoded[idx].x).toBe(20); + expect(decoded[idx].y).toBe(0); + expect(decoded[idx].isDataEnd).toBe(true); + }); + + it('should encode color change with jump in correct order', () => { + // Test color change when next color is at a different position + const stitches = [ + [0, 0, STITCH, 0], // Color 0 + [10, 0, STITCH, 0], // Color 0 - last before change + [30, 10, STITCH, 1], // Color 1 - different position, requires jump + [40, 10, STITCH | END, 1], + ]; + + const result = encodeStitchesToPen(stitches); + const decoded = decodeAllPenStitches(result.penBytes); + + let idx = 2; // Skip first two regular stitches + + // After second stitch, should have: + // 1. 8 finishing lock stitches at (10, 0) + for (let i = 0; i < 8; i++) { + expect(decoded[idx].x).toBeCloseTo(10, 1); + expect(decoded[idx].y).toBeCloseTo(0, 1); + idx++; + } + + // 2. Cut command at (10, 0) + expect(decoded[idx].isCut).toBe(true); + expect(decoded[idx].x).toBe(10); + idx++; + + // 3. Jump to new position (30, 10) + expect(decoded[idx].x).toBe(30); + expect(decoded[idx].y).toBe(10); + expect(decoded[idx].isFeed).toBe(true); + idx++; + + // 4. COLOR_END marker at (30, 10) + expect(decoded[idx].x).toBe(30); + expect(decoded[idx].y).toBe(10); + expect(decoded[idx].isColorEnd).toBe(true); + idx++; + + // 5. 8 starting lock stitches at (30, 10) + for (let i = 0; i < 8; i++) { + expect(decoded[idx].x).toBeCloseTo(30, 1); + expect(decoded[idx].y).toBeCloseTo(10, 1); + idx++; + } + + // 6. Continue with new color stitches + expect(decoded[idx].x).toBe(30); + expect(decoded[idx].y).toBe(10); + }); + + it('should encode color change followed by explicit JUMP in correct order', () => { + // Test when PES data has a JUMP stitch immediately after color change + // This is a common pattern: color change, then jump to new location + const stitches = [ + [0, 0, STITCH, 0], // Color 0 + [10, 0, STITCH, 0], // Color 0 - last before change + [50, 20, MOVE, 1], // Color 1 - JUMP to new location (50, 20) + [50, 20, STITCH, 1], // Color 1 - first actual stitch at new location + [60, 20, STITCH | END, 1], + ]; + + const result = encodeStitchesToPen(stitches); + const decoded = decodeAllPenStitches(result.penBytes); + + // Expected sequence: + // 1. Stitch at (0, 0) - color 0 + // 2. Stitch at (10, 0) - color 0 (last before change) + // 3. 8 finishing lock stitches at (10, 0) + // 4. Cut command at (10, 0) + // 5. Jump to (50, 20) - from MOVE stitch (the encoder skips this MOVE in the loop with i++) + // 6. COLOR_END marker at (50, 20) + // 7. 8 starting lock stitches at (50, 20) + // 8. First stitch of new color at (50, 20) + // 9. Last stitch at (60, 20) with END flag + + let idx = 0; + + // 1-2. First two stitches + expect(decoded[idx++].x).toBe(0); + expect(decoded[idx++].x).toBe(10); + + // 3. 8 finishing lock stitches at (10, 0) + for (let i = 0; i < 8; i++) { + expect(decoded[idx].x).toBeCloseTo(10, 1); + expect(decoded[idx].y).toBeCloseTo(0, 1); + idx++; + } + + // 4. Cut command at (10, 0) + expect(decoded[idx].x).toBe(10); + expect(decoded[idx].y).toBe(0); + expect(decoded[idx].isCut).toBe(true); + idx++; + + // 5. Jump to new location (50, 20) - extracted from the MOVE stitch + expect(decoded[idx].x).toBe(50); + expect(decoded[idx].y).toBe(20); + expect(decoded[idx].isFeed).toBe(true); + idx++; + + // 6. COLOR_END marker at (50, 20) + expect(decoded[idx].x).toBe(50); + expect(decoded[idx].y).toBe(20); + expect(decoded[idx].isColorEnd).toBe(true); + idx++; + + // 7. 8 starting lock stitches at (50, 20) + for (let i = 0; i < 8; i++) { + expect(decoded[idx].x).toBeCloseTo(50, 1); + expect(decoded[idx].y).toBeCloseTo(20, 1); + idx++; + } + + // 8. First actual stitch of new color at (50, 20) + expect(decoded[idx].x).toBe(50); + expect(decoded[idx].y).toBe(20); + expect(decoded[idx].isFeed).toBe(false); + idx++; + + // 9. Last stitch with DATA_END + expect(decoded[idx].x).toBe(60); + expect(decoded[idx].y).toBe(20); + expect(decoded[idx].isDataEnd).toBe(true); + }); + + it('should handle long jumps with lock stitches and cut in correct order', () => { + // Test the exact sequence for a long jump (distance > 50) + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, STITCH, 0], + [100, 0, MOVE, 0], // Long jump (distance = 90 > 50) + [110, 0, STITCH, 0], + [120, 0, STITCH | END, 0], + ]; + + const result = encodeStitchesToPen(stitches); + const decoded = decodeAllPenStitches(result.penBytes); + + // Expected sequence: + // 1. Stitch at (0, 0) + // 2. Stitch at (10, 0) + // 3. 8 finishing lock stitches at (10, 0) + // 4. Jump to (100, 0) with FEED and CUT flags + // 5. 8 starting lock stitches at (100, 0) + // 6. Stitch at (110, 0) + // 7. Stitch at (120, 0) with END flag + + let idx = 0; + + // 1-2. First two stitches + expect(decoded[idx++].x).toBe(0); + expect(decoded[idx++].x).toBe(10); + + // 3. 8 finishing lock stitches at (10, 0) + for (let i = 0; i < 8; i++) { + const lockStitch = decoded[idx]; + expect(lockStitch.x).toBeCloseTo(10, 1); + expect(lockStitch.y).toBeCloseTo(0, 1); + expect(lockStitch.isFeed).toBe(false); + expect(lockStitch.isCut).toBe(false); + idx++; + } + + // 4. Jump to (100, 0) with BOTH FEED and CUT flags + const jumpStitch = decoded[idx]; + expect(jumpStitch.x).toBe(100); + expect(jumpStitch.y).toBe(0); + expect(jumpStitch.isFeed).toBe(true); + expect(jumpStitch.isCut).toBe(true); + expect(jumpStitch.yFlags).toBe(PEN_FEED_DATA | PEN_CUT_DATA); // 0x03 + idx++; + + // 5. 8 starting lock stitches at (100, 0) + for (let i = 0; i < 8; i++) { + const lockStitch = decoded[idx]; + expect(lockStitch.x).toBeCloseTo(100, 1); + expect(lockStitch.y).toBeCloseTo(0, 1); + idx++; + } + + // 6-7. Final two stitches + expect(decoded[idx].x).toBe(110); + expect(decoded[idx].y).toBe(0); + idx++; + + expect(decoded[idx].x).toBe(120); + expect(decoded[idx].isDataEnd).toBe(true); }); it('should encode MOVE flag for jump stitches', () => { @@ -299,19 +610,31 @@ describe('encodeStitchesToPen', () => { expect(result.bounds.maxY).toBe(20); }); - it('should handle TRIM flag', () => { + it('should handle TRIM flag correctly', () => { const stitches = [ [0, 0, STITCH, 0], [10, 0, TRIM, 0], - [20, 0, END, 0], + [20, 0, STITCH | END, 0], ]; const result = encodeStitchesToPen(stitches); + const decoded = decodeAllPenStitches(result.penBytes); - // TRIM stitch should have CUT_DATA flag (0x02) in Y low byte - const trimStitchStart = 4; - const yLow = result.penBytes[trimStitchStart + 2]; - expect(yLow & 0x02).toBe(0x02); // CUT_DATA flag + // Verify sequence: + // 1. Regular stitch at (0, 0) + expect(decoded[0].x).toBe(0); + expect(decoded[0].isCut).toBe(false); + + // 2. TRIM command at (10, 0) - should have CUT flag + expect(decoded[1].x).toBe(10); + expect(decoded[1].y).toBe(0); + expect(decoded[1].isCut).toBe(true); + expect(decoded[1].isFeed).toBe(false); // TRIM doesn't include FEED + expect(decoded[1].yFlags).toBe(PEN_CUT_DATA); // Only CUT flag + + // 3. Final stitch with DATA_END + expect(decoded[2].x).toBe(20); + expect(decoded[2].isDataEnd).toBe(true); }); it('should handle empty stitch array', () => { From b09f97afef10c6c38a83b5ce12b92f3405df1555 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sun, 14 Dec 2025 12:02:32 +0100 Subject: [PATCH 3/6] feature: Add test step to CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added npm run test:run to GitHub Actions build workflow - Updated workflow name to reflect testing is included - Tests now run between lint and build steps 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/build.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3977b61..1f0da28 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build and Lint +name: Build, Test, and Lint on: push: @@ -8,8 +8,8 @@ on: workflow_dispatch: jobs: - build-and-lint: - name: Build and Lint + build-test-lint: + name: Build, Test, and Lint runs-on: ubuntu-latest steps: @@ -28,5 +28,8 @@ jobs: - name: Run linter run: npm run lint + - name: Run tests + run: npm run test:run + - name: Build application run: npm run build From 11b710eb1708f72bc9c4354886b9fd2da461334d Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sun, 14 Dec 2025 12:19:21 +0100 Subject: [PATCH 4/6] feature: Reorganize code into formats folder structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved embroidery format-related code from utils to new formats folder: Structure: - src/formats/pen/ - PEN format encoding and parsing - encoder.ts (was utils/penEncoder.ts) - encoder.test.ts (was utils/penEncoder.test.ts) - parser.ts (was utils/penParser.ts) - PEN constants moved inline to encoder.ts - src/formats/import/ - Pattern import/conversion (currently PES) - worker.ts (was workers/patternConverter.worker.ts) - client.ts (was utils/patternConverterClient.ts) - pesImporter.ts (was utils/pystitchConverter.ts) - pyodideLoader.ts (was utils/pyodideLoader.ts) - constants.ts (PyStitch/pyembroidery constants) Benefits: - Better separation of concerns - PEN encoder is co-located with PEN parser - Import logic is in one place and extensible for other formats - Removed utils/embroideryConstants.ts - split into appropriate locations - Updated all 18 import references across the codebase All tests passing, build successful. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/components/FileUpload.tsx | 2 +- src/components/KonvaComponents.tsx | 6 +++--- src/components/PatternCanvas.tsx | 2 +- .../import/client.ts} | 8 ++++---- .../import/constants.ts} | 6 ------ .../import/pesImporter.ts} | 2 +- src/{utils => formats/import}/pyodideLoader.ts | 0 .../import/worker.ts} | 4 ++-- .../pen/encoder.test.ts} | 4 ++-- .../penEncoder.ts => formats/pen/encoder.ts} | 16 +++++++--------- .../penParser.ts => formats/pen/parser.ts} | 2 +- src/hooks/useBrotherMachine.ts | 2 +- src/platform/browser/BrowserStorageService.ts | 2 +- src/platform/electron/ElectronStorageService.ts | 2 +- src/platform/interfaces/IStorageService.ts | 2 +- src/services/PatternCacheService.ts | 2 +- src/stores/useMachineStore.ts | 2 +- src/stores/usePatternStore.ts | 2 +- src/stores/useUIStore.ts | 2 +- src/utils/konvaRenderers.ts | 6 +++--- 20 files changed, 33 insertions(+), 41 deletions(-) rename src/{utils/patternConverterClient.ts => formats/import/client.ts} (96%) rename src/{utils/embroideryConstants.ts => formats/import/constants.ts} (71%) rename src/{utils/pystitchConverter.ts => formats/import/pesImporter.ts} (91%) rename src/{utils => formats/import}/pyodideLoader.ts (100%) rename src/{workers/patternConverter.worker.ts => formats/import/worker.ts} (99%) rename src/{utils/penEncoder.test.ts => formats/pen/encoder.test.ts} (99%) rename src/{utils/penEncoder.ts => formats/pen/encoder.ts} (97%) rename src/{utils/penParser.ts => formats/pen/parser.ts} (94%) diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index 98db67c..3ed4a91 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow'; import { useMachineStore } from '../stores/useMachineStore'; import { usePatternStore } from '../stores/usePatternStore'; import { useUIStore } from '../stores/useUIStore'; -import { convertPesToPen, type PesPatternData } from '../utils/pystitchConverter'; +import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter'; import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers'; import { PatternInfoSkeleton } from './SkeletonLoader'; import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid'; diff --git a/src/components/KonvaComponents.tsx b/src/components/KonvaComponents.tsx index 5c0e4ad..d9bce9d 100644 --- a/src/components/KonvaComponents.tsx +++ b/src/components/KonvaComponents.tsx @@ -1,9 +1,9 @@ import { memo, useMemo } from 'react'; import { Group, Line, Rect, Text, Circle } from 'react-konva'; -import type { PesPatternData } from '../utils/pystitchConverter'; -import { getThreadColor } from '../utils/pystitchConverter'; +import type { PesPatternData } from '../formats/import/pesImporter'; +import { getThreadColor } from '../formats/import/pesImporter'; import type { MachineInfo } from '../types/machine'; -import { MOVE } from '../utils/embroideryConstants'; +import { MOVE } from '../formats/import/constants'; interface GridProps { gridSize: number; diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx index c7cdbdb..208a6fd 100644 --- a/src/components/PatternCanvas.tsx +++ b/src/components/PatternCanvas.tsx @@ -5,7 +5,7 @@ import { usePatternStore } from '../stores/usePatternStore'; import { Stage, Layer, Group } from 'react-konva'; import Konva from 'konva'; import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon, PhotoIcon, ArrowsPointingInIcon } from '@heroicons/react/24/solid'; -import type { PesPatternData } from '../utils/pystitchConverter'; +import type { PesPatternData } from '../formats/import/pesImporter'; import { calculateInitialScale } from '../utils/konvaRenderers'; import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents'; diff --git a/src/utils/patternConverterClient.ts b/src/formats/import/client.ts similarity index 96% rename from src/utils/patternConverterClient.ts rename to src/formats/import/client.ts index 2ac46ec..785f394 100644 --- a/src/utils/patternConverterClient.ts +++ b/src/formats/import/client.ts @@ -1,7 +1,7 @@ -import type { WorkerMessage, WorkerResponse } from '../workers/patternConverter.worker'; -import PatternConverterWorker from '../workers/patternConverter.worker?worker'; -import { parsePenData } from './penParser'; -import type { PenData } from '../types/machine'; +import type { WorkerMessage, WorkerResponse } from './worker'; +import PatternConverterWorker from './worker?worker'; +import { parsePenData } from '../pen/parser'; +import type { PenData } from '../../types/machine'; export type PyodideState = 'not_loaded' | 'loading' | 'ready' | 'error'; diff --git a/src/utils/embroideryConstants.ts b/src/formats/import/constants.ts similarity index 71% rename from src/utils/embroideryConstants.ts rename to src/formats/import/constants.ts index 4f1f006..01e13ba 100644 --- a/src/utils/embroideryConstants.ts +++ b/src/formats/import/constants.ts @@ -15,9 +15,3 @@ export const TRIM = 0x20; // Trim thread command export const COLOR_CHANGE = 0x40; // Color change command export const STOP = 0x80; // Stop command export const END = 0x100; // End of pattern - -// PEN format flags for Brother machines -export const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching) -export const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command -export const PEN_COLOR_END = 0x03; // Last stitch before color change -export const PEN_DATA_END = 0x05; // Last stitch of entire pattern diff --git a/src/utils/pystitchConverter.ts b/src/formats/import/pesImporter.ts similarity index 91% rename from src/utils/pystitchConverter.ts rename to src/formats/import/pesImporter.ts index fc46668..ae1b6b3 100644 --- a/src/utils/pystitchConverter.ts +++ b/src/formats/import/pesImporter.ts @@ -1,4 +1,4 @@ -import { patternConverterClient, type PesPatternData } from "./patternConverterClient"; +import { patternConverterClient, type PesPatternData } from "./client"; // Re-export the type for backwards compatibility export type { PesPatternData }; diff --git a/src/utils/pyodideLoader.ts b/src/formats/import/pyodideLoader.ts similarity index 100% rename from src/utils/pyodideLoader.ts rename to src/formats/import/pyodideLoader.ts diff --git a/src/workers/patternConverter.worker.ts b/src/formats/import/worker.ts similarity index 99% rename from src/workers/patternConverter.worker.ts rename to src/formats/import/worker.ts index 2e6287d..6b0cc42 100644 --- a/src/workers/patternConverter.worker.ts +++ b/src/formats/import/worker.ts @@ -4,8 +4,8 @@ import { MOVE, TRIM, END, -} from '../utils/embroideryConstants'; -import { encodeStitchesToPen } from '../utils/penEncoder'; +} from './constants'; +import { encodeStitchesToPen } from '../pen/encoder'; // Message types from main thread export type WorkerMessage = diff --git a/src/utils/penEncoder.test.ts b/src/formats/pen/encoder.test.ts similarity index 99% rename from src/utils/penEncoder.test.ts rename to src/formats/pen/encoder.test.ts index f6945c5..e77f930 100644 --- a/src/utils/penEncoder.test.ts +++ b/src/formats/pen/encoder.test.ts @@ -4,8 +4,8 @@ import { calculateLockDirection, generateLockStitches, encodeStitchesToPen, -} from './penEncoder'; -import { STITCH, MOVE, TRIM, END } from './embroideryConstants'; +} from './encoder'; +import { STITCH, MOVE, TRIM, END } from '../import/constants'; // PEN format flag constants for testing const PEN_FEED_DATA = 0x01; diff --git a/src/utils/penEncoder.ts b/src/formats/pen/encoder.ts similarity index 97% rename from src/utils/penEncoder.ts rename to src/formats/pen/encoder.ts index eb81e3a..c2e962a 100644 --- a/src/utils/penEncoder.ts +++ b/src/formats/pen/encoder.ts @@ -5,15 +5,13 @@ * The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits. */ -import { - MOVE, - TRIM, - END, - PEN_FEED_DATA, - PEN_CUT_DATA, - PEN_COLOR_END, - PEN_DATA_END, -} from './embroideryConstants'; +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 diff --git a/src/utils/penParser.ts b/src/formats/pen/parser.ts similarity index 94% rename from src/utils/penParser.ts rename to src/formats/pen/parser.ts index edee9b6..80e68e7 100644 --- a/src/utils/penParser.ts +++ b/src/formats/pen/parser.ts @@ -1,4 +1,4 @@ -import type { PenData, PenStitch, PenColorBlock } from '../types/machine'; +import type { PenData, PenStitch, PenColorBlock } from '../../types/machine'; // PEN format flags const PEN_FEED_DATA = 0x01; // Y-coordinate low byte, bit 0 diff --git a/src/hooks/useBrotherMachine.ts b/src/hooks/useBrotherMachine.ts index e775f31..e0a0648 100644 --- a/src/hooks/useBrotherMachine.ts +++ b/src/hooks/useBrotherMachine.ts @@ -11,7 +11,7 @@ import { } from "../services/PatternCacheService"; import type { IStorageService } from "../platform/interfaces/IStorageService"; import { createStorageService } from "../platform"; -import type { PesPatternData } from "../utils/pystitchConverter"; +import type { PesPatternData } from "../formats/import/pesImporter"; import { SewingMachineError } from "../utils/errorCodeHelpers"; export function useBrotherMachine() { diff --git a/src/platform/browser/BrowserStorageService.ts b/src/platform/browser/BrowserStorageService.ts index c786bc8..d79e539 100644 --- a/src/platform/browser/BrowserStorageService.ts +++ b/src/platform/browser/BrowserStorageService.ts @@ -1,6 +1,6 @@ import { PatternCacheService } from '../../services/PatternCacheService'; import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService'; -import type { PesPatternData } from '../../utils/pystitchConverter'; +import type { PesPatternData } from '../../formats/import/pesImporter'; /** * Browser implementation of storage service using localStorage diff --git a/src/platform/electron/ElectronStorageService.ts b/src/platform/electron/ElectronStorageService.ts index 5ccf44c..629c4a8 100644 --- a/src/platform/electron/ElectronStorageService.ts +++ b/src/platform/electron/ElectronStorageService.ts @@ -1,5 +1,5 @@ import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService'; -import type { PesPatternData } from '../../utils/pystitchConverter'; +import type { PesPatternData } from '../../formats/import/pesImporter'; /** * Electron implementation of storage service using electron-store via IPC diff --git a/src/platform/interfaces/IStorageService.ts b/src/platform/interfaces/IStorageService.ts index 9847221..75c7aa6 100644 --- a/src/platform/interfaces/IStorageService.ts +++ b/src/platform/interfaces/IStorageService.ts @@ -1,4 +1,4 @@ -import type { PesPatternData } from '../../utils/pystitchConverter'; +import type { PesPatternData } from '../../formats/import/pesImporter'; export interface ICachedPattern { uuid: string; diff --git a/src/services/PatternCacheService.ts b/src/services/PatternCacheService.ts index 456c58b..3f5d64d 100644 --- a/src/services/PatternCacheService.ts +++ b/src/services/PatternCacheService.ts @@ -1,4 +1,4 @@ -import type { PesPatternData } from '../utils/pystitchConverter'; +import type { PesPatternData } from '../formats/import/pesImporter'; interface CachedPattern { uuid: string; diff --git a/src/stores/useMachineStore.ts b/src/stores/useMachineStore.ts index e58cce4..85ffd83 100644 --- a/src/stores/useMachineStore.ts +++ b/src/stores/useMachineStore.ts @@ -10,7 +10,7 @@ import { SewingMachineError } from '../utils/errorCodeHelpers'; import { uuidToString } from '../services/PatternCacheService'; import { createStorageService } from '../platform'; import type { IStorageService } from '../platform/interfaces/IStorageService'; -import type { PesPatternData } from '../utils/pystitchConverter'; +import type { PesPatternData } from '../formats/import/pesImporter'; interface MachineState { // Service instances diff --git a/src/stores/usePatternStore.ts b/src/stores/usePatternStore.ts index 9135d5c..21ae53e 100644 --- a/src/stores/usePatternStore.ts +++ b/src/stores/usePatternStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { PesPatternData } from '../utils/pystitchConverter'; +import type { PesPatternData } from '../formats/import/pesImporter'; interface PatternState { // Pattern data diff --git a/src/stores/useUIStore.ts b/src/stores/useUIStore.ts index 70690f2..42b7cb9 100644 --- a/src/stores/useUIStore.ts +++ b/src/stores/useUIStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { patternConverterClient } from '../utils/patternConverterClient'; +import { patternConverterClient } from '../formats/import/client'; interface UIState { // Pyodide state diff --git a/src/utils/konvaRenderers.ts b/src/utils/konvaRenderers.ts index 21ca8bc..09fd935 100644 --- a/src/utils/konvaRenderers.ts +++ b/src/utils/konvaRenderers.ts @@ -1,8 +1,8 @@ import Konva from 'konva'; -import type { PesPatternData } from './pystitchConverter'; -import { getThreadColor } from './pystitchConverter'; +import type { PesPatternData } from '../formats/import/pesImporter'; +import { getThreadColor } from '../formats/import/pesImporter'; import type { MachineInfo } from '../types/machine'; -import { MOVE } from './embroideryConstants'; +import { MOVE } from '../formats/import/constants'; /** * Renders a grid with specified spacing From ba380723c0ebc6e481cea82f78821bcbd0e48bfb Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sun, 14 Dec 2025 12:27:24 +0100 Subject: [PATCH 5/6] feature: Refactor PEN parser to decoder with coherent types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed and restructured PEN parsing to match encoder pattern: Changes: - Renamed pen/parser.ts → pen/decoder.ts (consistent with encoder.ts) - Created pen/types.ts for PEN-specific type definitions - Moved types out of machine.ts (they're format-specific, not machine-specific) - Unified decoder with encoder test helpers (encoder.test.ts now uses decoder) New structure: - decodePenStitch() - decode single 4-byte stitch - decodeAllPenStitches() - decode all stitches from bytes - decodePenData() - full decode with color blocks and bounds - getStitchColor() - helper to get color for a stitch index Type definitions: - DecodedPenStitch - individual stitch with coordinates and flags - PenColorBlock - color block (stitch range for one thread) - DecodedPenData - complete decoded pattern data Backward compatibility: - Added compatibility aliases (isJump, flags, startStitch, endStitch) - Maintains API compatibility with existing UI code Also added dist-electron to eslint ignore list. All tests passing (27/27), build successful, lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- eslint.config.mjs | 2 +- src/formats/import/client.ts | 14 +-- src/formats/pen/decoder.ts | 179 ++++++++++++++++++++++++++++++++ src/formats/pen/encoder.test.ts | 64 +----------- src/formats/pen/parser.ts | 135 ------------------------ src/formats/pen/types.ts | 51 +++++++++ 6 files changed, 239 insertions(+), 206 deletions(-) create mode 100644 src/formats/pen/decoder.ts delete mode 100644 src/formats/pen/parser.ts create mode 100644 src/formats/pen/types.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index ff46326..a9c0bff 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist', '.vite']), + globalIgnores(['dist', 'dist-electron', '.vite']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/src/formats/import/client.ts b/src/formats/import/client.ts index 785f394..ef4bd53 100644 --- a/src/formats/import/client.ts +++ b/src/formats/import/client.ts @@ -1,7 +1,7 @@ import type { WorkerMessage, WorkerResponse } from './worker'; import PatternConverterWorker from './worker?worker'; -import { parsePenData } from '../pen/parser'; -import type { PenData } from '../../types/machine'; +import { decodePenData } from '../pen/decoder'; +import type { DecodedPenData } from '../pen/types'; export type PyodideState = 'not_loaded' | 'loading' | 'ready' | 'error'; @@ -24,8 +24,8 @@ export interface PesPatternData { chart: string | null; threadIndices: number[]; }>; - penData: Uint8Array; // Raw PEN bytes sent to machine - penStitches: PenData; // Parsed PEN stitches (for rendering) + penData: Uint8Array; // Raw PEN bytes sent to machine + penStitches: DecodedPenData; // Decoded PEN stitches (for rendering) colorCount: number; stitchCount: number; bounds: { @@ -180,9 +180,9 @@ class PatternConverterClient { // Convert penData array back to Uint8Array const penData = new Uint8Array(message.data.penData); - // Parse the PEN data to get stitches for rendering - const penStitches = parsePenData(penData); - console.log('[PatternConverter] Parsed PEN data:', penStitches.totalStitches, 'stitches,', penStitches.colorCount, 'colors'); + // Decode the PEN data to get stitches for rendering + const penStitches = decodePenData(penData); + console.log('[PatternConverter] Decoded PEN data:', penStitches.stitches.length, 'stitches,', penStitches.colorBlocks.length, 'colors'); const result: PesPatternData = { ...message.data, diff --git a/src/formats/pen/decoder.ts b/src/formats/pen/decoder.ts new file mode 100644 index 0000000..50e910f --- /dev/null +++ b/src/formats/pen/decoder.ts @@ -0,0 +1,179 @@ +/** + * PEN Format Decoder + * + * This module contains the logic for decoding Brother PP1 PEN format embroidery files. + * The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits. + */ + +import type { DecodedPenStitch, DecodedPenData, PenColorBlock } from './types'; + +// PEN format flags +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 + +/** + * Decode a single PEN stitch (4 bytes) into coordinates and flags + * + * @param bytes The byte array containing PEN data + * @param offset The offset in bytes to start reading from + * @returns Decoded stitch with coordinates and flag information + */ +export function decodePenStitch( + bytes: Uint8Array | number[], + offset: number +): DecodedPenStitch { + const xLow = bytes[offset]; + const xHigh = bytes[offset + 1]; + const yLow = bytes[offset + 2]; + const yHigh = bytes[offset + 3]; + + const xRaw = xLow | (xHigh << 8); + const yRaw = yLow | (yHigh << 8); + + // Extract flags from low 3 bits + const xFlags = xRaw & 0x07; + const yFlags = yRaw & 0x07; + + // Clear flags and shift right to get actual coordinates + const xClean = xRaw & 0xFFF8; + const yClean = yRaw & 0xFFF8; + + // Convert to signed 16-bit + let xSigned = xClean; + let ySigned = yClean; + if (xSigned > 0x7FFF) xSigned = xSigned - 0x10000; + if (ySigned > 0x7FFF) ySigned = ySigned - 0x10000; + + // Shift right by 3 to get actual coordinates + const x = xSigned >> 3; + const y = ySigned >> 3; + + const isFeed = (yFlags & PEN_FEED_DATA) !== 0; + const isCut = (yFlags & PEN_CUT_DATA) !== 0; + const isColorEnd = xFlags === PEN_COLOR_END; + const isDataEnd = xFlags === PEN_DATA_END; + + return { + x, + y, + xFlags, + yFlags, + isFeed, + isCut, + isColorEnd, + isDataEnd, + // Compatibility aliases + isJump: isFeed, + flags: (xFlags & 0x07) | (yFlags & 0x07), + }; +} + +/** + * Decode all stitches from PEN format bytes + * + * @param bytes PEN format byte array + * @returns Array of decoded stitches + */ +export function decodeAllPenStitches(bytes: Uint8Array | number[]): DecodedPenStitch[] { + if (bytes.length < 4 || bytes.length % 4 !== 0) { + throw new Error(`Invalid PEN data size: ${bytes.length} bytes (must be multiple of 4)`); + } + + const stitches: DecodedPenStitch[] = []; + for (let i = 0; i < bytes.length; i += 4) { + stitches.push(decodePenStitch(bytes, i)); + } + return stitches; +} + +/** + * Decode PEN format data into a complete pattern structure + * + * This function parses PEN bytes and extracts: + * - Individual stitches with coordinates and flags + * - Color blocks (groups of stitches using the same thread color) + * - Pattern bounds + * + * @param data PEN format byte array + * @returns Complete decoded pattern data + */ +export function decodePenData(data: Uint8Array): DecodedPenData { + const stitches = decodeAllPenStitches(data); + const colorBlocks: PenColorBlock[] = []; + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + let currentColorStart = 0; + let currentColor = 0; + + for (let i = 0; i < stitches.length; i++) { + const stitch = stitches[i]; + + // Track bounds (exclude jump stitches) + if (!stitch.isFeed) { + minX = Math.min(minX, stitch.x); + maxX = Math.max(maxX, stitch.x); + minY = Math.min(minY, stitch.y); + maxY = Math.max(maxY, stitch.y); + } + + // Check for color change or data end + if (stitch.isColorEnd) { + colorBlocks.push({ + startStitchIndex: currentColorStart, + endStitchIndex: i, + colorIndex: currentColor, + // Compatibility aliases + startStitch: currentColorStart, + endStitch: i, + }); + currentColor++; + currentColorStart = i + 1; + } else if (stitch.isDataEnd) { + // Final color block + if (currentColorStart <= i) { + colorBlocks.push({ + startStitchIndex: currentColorStart, + endStitchIndex: i, + colorIndex: currentColor, + // Compatibility aliases + startStitch: currentColorStart, + endStitch: i, + }); + } + break; + } + } + + return { + stitches, + colorBlocks, + bounds: { + minX: minX === Infinity ? 0 : minX, + maxX: maxX === -Infinity ? 0 : maxX, + minY: minY === Infinity ? 0 : minY, + maxY: maxY === -Infinity ? 0 : maxY, + }, + }; +} + +/** + * Get the color index for a stitch at the given index + * + * @param penData Decoded PEN pattern data + * @param stitchIndex Index of the stitch + * @returns Color index, or -1 if not found + */ +export function getStitchColor(penData: DecodedPenData, stitchIndex: number): number { + for (const block of penData.colorBlocks) { + if (stitchIndex >= block.startStitchIndex && stitchIndex <= block.endStitchIndex) { + return block.colorIndex; + } + } + return -1; +} diff --git a/src/formats/pen/encoder.test.ts b/src/formats/pen/encoder.test.ts index e77f930..5ecb553 100644 --- a/src/formats/pen/encoder.test.ts +++ b/src/formats/pen/encoder.test.ts @@ -5,74 +5,12 @@ import { generateLockStitches, encodeStitchesToPen, } from './encoder'; +import { decodeAllPenStitches } from './decoder'; import { STITCH, MOVE, TRIM, END } from '../import/constants'; // PEN format flag constants for testing const PEN_FEED_DATA = 0x01; const PEN_CUT_DATA = 0x02; -const PEN_COLOR_END = 0x03; -const PEN_DATA_END = 0x05; - -/** - * Helper function to decode a single PEN stitch (4 bytes) into coordinates and flags - */ -function decodePenStitch(bytes: number[], offset: number): { - x: number; - y: number; - xFlags: number; - yFlags: number; - isFeed: boolean; - isCut: boolean; - isColorEnd: boolean; - isDataEnd: boolean; -} { - const xLow = bytes[offset]; - const xHigh = bytes[offset + 1]; - const yLow = bytes[offset + 2]; - const yHigh = bytes[offset + 3]; - - const xRaw = xLow | (xHigh << 8); - const yRaw = yLow | (yHigh << 8); - - // Extract flags from low 3 bits - const xFlags = xRaw & 0x07; - const yFlags = yRaw & 0x07; - - // Clear flags and shift right to get actual coordinates - const xClean = xRaw & 0xFFF8; - const yClean = yRaw & 0xFFF8; - - // Convert to signed - let xSigned = xClean; - let ySigned = yClean; - if (xSigned > 0x7FFF) xSigned = xSigned - 0x10000; - if (ySigned > 0x7FFF) ySigned = ySigned - 0x10000; - - const x = xSigned >> 3; - const y = ySigned >> 3; - - return { - x, - y, - xFlags, - yFlags, - isFeed: (yFlags & PEN_FEED_DATA) !== 0, - isCut: (yFlags & PEN_CUT_DATA) !== 0, - isColorEnd: xFlags === PEN_COLOR_END, - isDataEnd: xFlags === PEN_DATA_END, - }; -} - -/** - * Helper to parse all stitches from PEN bytes - */ -function decodeAllPenStitches(bytes: number[]) { - const stitches = []; - for (let i = 0; i < bytes.length; i += 4) { - stitches.push(decodePenStitch(bytes, i)); - } - return stitches; -} describe('encodeStitchPosition', () => { it('should encode position (0, 0) correctly', () => { diff --git a/src/formats/pen/parser.ts b/src/formats/pen/parser.ts deleted file mode 100644 index 80e68e7..0000000 --- a/src/formats/pen/parser.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { PenData, PenStitch, PenColorBlock } from '../../types/machine'; - -// PEN format flags -const PEN_FEED_DATA = 0x01; // Y-coordinate low byte, bit 0 -const PEN_COLOR_END = 0x03; // X-coordinate low byte, bits 0-2 -const PEN_DATA_END = 0x05; // X-coordinate low byte, bits 0-2 - -export function parsePenData(data: Uint8Array): PenData { - if (data.length < 4 || data.length % 4 !== 0) { - throw new Error(`Invalid PEN data size: ${data.length} bytes`); - } - - const stitches: PenStitch[] = []; - const colorBlocks: PenColorBlock[] = []; - const stitchCount = data.length / 4; - - let currentColorStart = 0; - let currentColor = 0; - let minX = Infinity, maxX = -Infinity; - let minY = Infinity, maxY = -Infinity; - - console.log(`Parsing PEN data: ${data.length} bytes, ${stitchCount} stitches`); - - for (let i = 0; i < stitchCount; i++) { - const offset = i * 4; - - // Extract coordinates (shifted left by 3 bits in PEN format) - const xRaw = data[offset] | (data[offset + 1] << 8); - const yRaw = data[offset + 2] | (data[offset + 3] << 8); - - // Extract flags from low 3 bits - const xFlags = data[offset] & 0x07; - const yFlags = data[offset + 2] & 0x07; - - // Decode coordinates (shift right by 3 to get actual position) - // The coordinates are stored as signed 16-bit values, left-shifted by 3 - // Step 1: Clear the flag bits (low 3 bits) from the raw values - const xRawClean = xRaw & 0xFFF8; - const yRawClean = yRaw & 0xFFF8; - - // Step 2: Convert from unsigned 16-bit to signed 16-bit - let xSigned = xRawClean; - let ySigned = yRawClean; - if (xSigned > 0x7FFF) xSigned = xSigned - 0x10000; - if (ySigned > 0x7FFF) ySigned = ySigned - 0x10000; - - // Step 3: Shift right by 3 (arithmetic shift, preserves sign) - const x = xSigned >> 3; - const y = ySigned >> 3; - - const stitch: PenStitch = { - x, - y, - flags: (xFlags & 0x07) | (yFlags & 0x07), - isJump: (yFlags & PEN_FEED_DATA) !== 0, - }; - - stitches.push(stitch); - - // Track bounds - if (!stitch.isJump) { - minX = Math.min(minX, x); - maxX = Math.max(maxX, x); - minY = Math.min(minY, y); - maxY = Math.max(maxY, y); - } - - // Check for color change or data end - if (xFlags === PEN_COLOR_END) { - const block: PenColorBlock = { - startStitch: currentColorStart, - endStitch: i, - colorIndex: currentColor, - }; - colorBlocks.push(block); - - console.log( - `Color ${currentColor}: stitches ${currentColorStart}-${i} (${ - i - currentColorStart + 1 - } stitches)` - ); - - currentColor++; - currentColorStart = i + 1; - } else if (xFlags === PEN_DATA_END) { - if (currentColorStart < i) { - const block: PenColorBlock = { - startStitch: currentColorStart, - endStitch: i, - colorIndex: currentColor, - }; - colorBlocks.push(block); - - console.log( - `Color ${currentColor} (final): stitches ${currentColorStart}-${i} (${ - i - currentColorStart + 1 - } stitches)` - ); - - currentColor++; - } - console.log(`Data end marker at stitch ${i}`); - break; - } - } - - const result: PenData = { - stitches, - colorBlocks, - totalStitches: stitches.length, - colorCount: colorBlocks.length, - bounds: { - minX: minX === Infinity ? 0 : minX, - maxX: maxX === -Infinity ? 0 : maxX, - minY: minY === Infinity ? 0 : minY, - maxY: maxY === -Infinity ? 0 : maxY, - }, - }; - - console.log( - `Parsed: ${result.totalStitches} stitches, ${result.colorCount} colors` - ); - console.log(`Bounds: (${result.bounds.minX}, ${result.bounds.minY}) to (${result.bounds.maxX}, ${result.bounds.maxY})`); - - return result; -} - -export function getStitchColor(penData: PenData, stitchIndex: number): number { - for (const block of penData.colorBlocks) { - if (stitchIndex >= block.startStitch && stitchIndex <= block.endStitch) { - return block.colorIndex; - } - } - return -1; -} diff --git a/src/formats/pen/types.ts b/src/formats/pen/types.ts new file mode 100644 index 0000000..e27f7b9 --- /dev/null +++ b/src/formats/pen/types.ts @@ -0,0 +1,51 @@ +/** + * PEN Format Types + * + * Type definitions for decoded PEN format data. + * These types represent the parsed structure of Brother PP1 PEN embroidery files. + */ + +/** + * A single decoded PEN stitch with coordinates and flags + */ +export interface DecodedPenStitch { + x: number; // X coordinate (already shifted right by 3) + y: number; // Y coordinate (already shifted right by 3) + xFlags: number; // Flags from X coordinate low 3 bits + yFlags: number; // Flags from Y coordinate low 3 bits + isFeed: boolean; // Jump/move without stitching (Y-bit 0) + isCut: boolean; // Trim/cut thread (Y-bit 1) + isColorEnd: boolean; // Color change marker (X-bits 0-2 = 0x03) + isDataEnd: boolean; // Pattern end marker (X-bits 0-2 = 0x05) + + // Compatibility aliases + isJump: boolean; // Alias for isFeed (backward compatibility) + flags: number; // Combined flags (backward compatibility) +} + +/** + * A color block representing stitches of the same thread color + */ +export interface PenColorBlock { + startStitchIndex: number; // Index of first stitch in this color + endStitchIndex: number; // Index of last stitch in this color + colorIndex: number; // Color number (0-based) + + // Compatibility aliases + startStitch: number; // Alias for startStitchIndex (backward compatibility) + endStitch: number; // Alias for endStitchIndex (backward compatibility) +} + +/** + * Complete decoded PEN pattern data + */ +export interface DecodedPenData { + stitches: DecodedPenStitch[]; + colorBlocks: PenColorBlock[]; + bounds: { + minX: number; + maxX: number; + minY: number; + maxY: number; + }; +} From d35228e40be7766c4221fec8d351344485de20a6 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sun, 14 Dec 2025 12:32:05 +0100 Subject: [PATCH 6/6] feature: Add tests for automatic DATA_END flag insertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added tests to verify the encoder correctly handles pattern termination: - Test that DATA_END flag is added to last stitch even without END flag - Test that DATA_END flag is added when input has explicit END flag This ensures patterns are always properly terminated in PEN format, regardless of whether the input stitches have an END marker. All 29 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/formats/pen/encoder.test.ts | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/formats/pen/encoder.test.ts b/src/formats/pen/encoder.test.ts index 5ecb553..0d7f6cb 100644 --- a/src/formats/pen/encoder.test.ts +++ b/src/formats/pen/encoder.test.ts @@ -601,4 +601,43 @@ describe('encodeStitchesToPen', () => { expect(result.bounds.maxY).toBe(10); // END stitches update bounds (they're not MOVE stitches) }); + + it('should add DATA_END flag to last stitch even without END flag in input', () => { + // Test that the encoder automatically marks the last stitch with DATA_END + // even if the input stitches don't have an END flag + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, STITCH, 0], + [20, 0, STITCH, 0], // Last stitch - NO END flag + ]; + + const result = encodeStitchesToPen(stitches); + const decoded = decodeAllPenStitches(result.penBytes); + + // First two stitches should NOT have DATA_END flag + expect(decoded[0].isDataEnd).toBe(false); + expect(decoded[1].isDataEnd).toBe(false); + + // Last stitch SHOULD have DATA_END flag automatically added + expect(decoded[2].isDataEnd).toBe(true); + expect(decoded[2].x).toBe(20); + expect(decoded[2].y).toBe(0); + }); + + it('should add DATA_END flag when input has explicit END flag', () => { + // Verify that END flag in input also results in DATA_END flag in output + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, STITCH, 0], + [20, 0, STITCH | END, 0], // Explicit END flag + ]; + + const result = encodeStitchesToPen(stitches); + const decoded = decodeAllPenStitches(result.penBytes); + + // Last stitch should have DATA_END flag + expect(decoded[2].isDataEnd).toBe(true); + expect(decoded[2].x).toBe(20); + expect(decoded[2].y).toBe(0); + }); });