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 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/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/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 91% rename from src/utils/patternConverterClient.ts rename to src/formats/import/client.ts index 2ac46ec..ef4bd53 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 { 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/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/formats/import/worker.ts b/src/formats/import/worker.ts new file mode 100644 index 0000000..6b0cc42 --- /dev/null +++ b/src/formats/import/worker.ts @@ -0,0 +1,423 @@ +import { loadPyodide, type PyodideInterface } from 'pyodide'; +import { + STITCH, + MOVE, + TRIM, + END, +} from './constants'; +import { encodeStitchesToPen } from '../pen/encoder'; + +// Message types from main thread +export type WorkerMessage = + | { type: 'INITIALIZE'; pyodideIndexURL?: string; pystitchWheelURL?: string } + | { type: 'CONVERT_PES'; fileData: ArrayBuffer; fileName: string }; + +// Response types to main thread +export type WorkerResponse = + | { type: 'INIT_PROGRESS'; progress: number; step: string } + | { type: 'INIT_COMPLETE' } + | { type: 'INIT_ERROR'; error: string } + | { + type: 'CONVERT_COMPLETE'; + data: { + stitches: number[][]; + threads: Array<{ + color: number; + hex: string; + brand: string | null; + catalogNumber: string | null; + description: string | null; + chart: string | null; + }>; + uniqueColors: Array<{ + color: number; + hex: string; + brand: string | null; + catalogNumber: string | null; + description: string | null; + chart: string | null; + threadIndices: number[]; + }>; + penData: number[]; // Serialized as array + colorCount: number; + stitchCount: number; + bounds: { + minX: number; + maxX: number; + minY: number; + maxY: number; + }; + }; + } + | { type: 'CONVERT_ERROR'; error: string }; + +console.log('[PatternConverterWorker] Worker script loaded'); + +let pyodide: PyodideInterface | null = null; +let isInitializing = false; + +// JavaScript constants module to expose to Python +const jsEmbConstants = { + STITCH, + MOVE, + TRIM, + END, +}; + +/** + * Initialize Pyodide with progress tracking + */ +async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: string) { + if (pyodide) { + return; // Already initialized + } + + if (isInitializing) { + throw new Error('Initialization already in progress'); + } + + isInitializing = true; + + try { + self.postMessage({ + type: 'INIT_PROGRESS', + progress: 0, + step: 'Starting initialization...', + } as WorkerResponse); + + console.log('[PyodideWorker] Loading Pyodide runtime...'); + + self.postMessage({ + type: 'INIT_PROGRESS', + progress: 10, + step: 'Loading Python runtime...', + } as WorkerResponse); + + // Load Pyodide runtime + // Use provided URL or default to /assets/ + const indexURL = pyodideIndexURL || '/assets/'; + console.log('[PyodideWorker] Pyodide index URL:', indexURL); + + pyodide = await loadPyodide({ + indexURL: indexURL, + }); + + console.log('[PyodideWorker] Pyodide runtime loaded'); + + self.postMessage({ + type: 'INIT_PROGRESS', + progress: 70, + step: 'Python runtime loaded', + } as WorkerResponse); + + self.postMessage({ + type: 'INIT_PROGRESS', + progress: 75, + step: 'Loading pystitch library...', + } as WorkerResponse); + + // Load pystitch wheel + // Use provided URL or default + const wheelURL = pystitchWheelURL || '/pystitch-1.0.0-py3-none-any.whl'; + console.log('[PyodideWorker] Pystitch wheel URL:', wheelURL); + + await pyodide.loadPackage(wheelURL); + + console.log('[PyodideWorker] pystitch library loaded'); + + self.postMessage({ + type: 'INIT_PROGRESS', + progress: 100, + step: 'Ready!', + } as WorkerResponse); + + self.postMessage({ + type: 'INIT_COMPLETE', + } as WorkerResponse); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Unknown error'; + console.error('[PyodideWorker] Initialization error:', err); + + self.postMessage({ + type: 'INIT_ERROR', + error: errorMsg, + } as WorkerResponse); + + throw err; + } finally { + isInitializing = false; + } +} + +/** + * Convert PES file to PEN format + */ +async function convertPesToPen(fileData: ArrayBuffer) { + if (!pyodide) { + throw new Error('Pyodide not initialized'); + } + + try { + // Register our JavaScript constants module for Python to import + pyodide.registerJsModule('js_emb_constants', jsEmbConstants); + + // Convert to Uint8Array + const uint8Array = new Uint8Array(fileData); + + // Write file to Pyodide virtual filesystem + const tempFileName = '/tmp/pattern.pes'; + pyodide.FS.writeFile(tempFileName, uint8Array); + + // Read the pattern using PyStitch (same logic as original converter) + const result = await pyodide.runPythonAsync(` +import pystitch +from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE +from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END + +# Read the PES file +pattern = pystitch.read('${tempFileName}') + +def map_cmd(pystitch_cmd): + """Map PyStitch command to our JavaScript constant values + + This ensures we have known, consistent values regardless of PyStitch's internal values. + Our JS constants use pyembroidery-style bitmask values: + STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100 + """ + if pystitch_cmd == STITCH: + return JS_STITCH + elif pystitch_cmd == JUMP: + return JS_MOVE # PyStitch JUMP maps to our MOVE constant + elif pystitch_cmd == TRIM: + return JS_TRIM + elif pystitch_cmd == END: + return JS_END + else: + # For any other commands, preserve as bitmask + result = JS_STITCH + if pystitch_cmd & JUMP: + result |= JS_MOVE + if pystitch_cmd & TRIM: + result |= JS_TRIM + if pystitch_cmd & END: + result |= JS_END + return result + +# Use the raw stitches list which preserves command flags +# Each stitch in pattern.stitches is [x, y, cmd] +# We need to assign color indices based on COLOR_CHANGE commands +# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches) +# +# IMPORTANT: In PES files, COLOR_CHANGE commands can appear before finishing +# stitches (tack/lock stitches) that semantically belong to the PREVIOUS color. +# We need to detect this pattern and assign colors correctly. + +stitches_with_colors = [] +current_color = 0 + +for i, stitch in enumerate(pattern.stitches): + x, y, cmd = stitch + + # Check for color change command + if cmd == COLOR_CHANGE: + current_color += 1 + continue + + # Check for stop command - skip it + if cmd == STOP: + continue + + # Check for standalone END command (no stitch data) + if cmd == END: + continue + + # PyStitch inserts duplicate stitches at the same coordinates during color changes + # Skip any stitch that has the exact same position as the previous one + if len(stitches_with_colors) > 0: + last_stitch = stitches_with_colors[-1] + last_x, last_y = last_stitch[0], last_stitch[1] + + if x == last_x and y == last_y: + # Duplicate position - skip it + continue + + # Add actual stitch with current color index and mapped command + mapped_cmd = map_cmd(cmd) + stitches_with_colors.append([x, y, mapped_cmd, current_color]) + +# Convert to JSON-serializable format +{ + 'stitches': stitches_with_colors, + 'threads': [ + { + 'color': thread.color if hasattr(thread, 'color') else 0, + 'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000', + 'catalog_number': thread.catalog_number if hasattr(thread, 'catalog_number') else -1, + 'brand': thread.brand if hasattr(thread, 'brand') else "", + 'description': thread.description if hasattr(thread, 'description') else "", + 'chart': thread.chart if hasattr(thread, 'chart') else "" + } + for thread in pattern.threadlist + ], + 'thread_count': len(pattern.threadlist), + 'stitch_count': len(stitches_with_colors), + 'color_changes': current_color +} + `); + + // Convert Python result to JavaScript + const data = result.toJs({ dict_converter: Object.fromEntries }); + + // Clean up virtual file + try { + pyodide.FS.unlink(tempFileName); + } catch { + // Ignore errors + } + + // Extract stitches and validate + const stitches: number[][] = Array.from( + data.stitches as ArrayLike> + ).map((stitch) => Array.from(stitch)); + + if (!stitches || stitches.length === 0) { + throw new Error('Invalid PES file or no stitches found'); + } + + // Extract thread data - preserve null values for unavailable metadata + const threads = ( + data.threads as Array<{ + color?: number; + hex?: string; + catalog_number?: number | string; + brand?: string; + description?: string; + chart?: string; + }> + ).map((thread) => { + // Normalize catalog_number - can be string or number from PyStitch + const catalogNum = thread.catalog_number; + const normalizedCatalog = + catalogNum !== undefined && + catalogNum !== null && + catalogNum !== -1 && + catalogNum !== '-1' && + catalogNum !== '' + ? String(catalogNum) + : null; + + return { + color: thread.color ?? 0, + hex: thread.hex || '#000000', + catalogNumber: normalizedCatalog, + brand: thread.brand && thread.brand !== '' ? thread.brand : null, + description: + thread.description && thread.description !== '' + ? thread.description + : null, + chart: thread.chart && thread.chart !== '' ? thread.chart : null, + }; + }); + + // 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( + (acc, thread, idx) => { + const existing = acc.find((c) => c.hex === thread.hex); + if (existing) { + existing.threadIndices.push(idx); + } else { + acc.push({ + color: thread.color, + hex: thread.hex, + brand: thread.brand, + catalogNumber: thread.catalogNumber, + description: thread.description, + chart: thread.chart, + threadIndices: [idx], + }); + } + return acc; + }, + [] as Array<{ + color: number; + hex: string; + brand: string | null; + catalogNumber: string | null; + description: string | null; + chart: string | null; + threadIndices: number[]; + }> + ); + + // Calculate PEN stitch count (should match what machine will count) + const penStitchCount = penStitches.length / 4; + + console.log('[patternConverter] PEN encoding complete:'); + console.log(` - PyStitch stitches: ${stitches.length}`); + console.log(` - PEN bytes: ${penStitches.length}`); + console.log(` - PEN stitches (bytes/4): ${penStitchCount}`); + console.log(` - Bounds: (${minX}, ${minY}) to (${maxX}, ${maxY})`); + + // Post result back to main thread + self.postMessage({ + type: 'CONVERT_COMPLETE', + data: { + stitches, + threads, + uniqueColors, + penData: penStitches, // Send as array (will be converted to Uint8Array in main thread) + colorCount: data.thread_count, + stitchCount: data.stitch_count, + bounds: { + minX: minX === Infinity ? 0 : minX, + maxX: maxX === -Infinity ? 0 : maxX, + minY: minY === Infinity ? 0 : minY, + maxY: maxY === -Infinity ? 0 : maxY, + }, + }, + } as WorkerResponse); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Unknown error'; + console.error('[PyodideWorker] Conversion error:', err); + + self.postMessage({ + type: 'CONVERT_ERROR', + error: errorMsg, + } as WorkerResponse); + + throw err; + } +} + +// Handle messages from main thread +self.onmessage = async (event: MessageEvent) => { + const message = event.data; + console.log('[PatternConverterWorker] Received message:', message.type); + + try { + switch (message.type) { + case 'INITIALIZE': + console.log('[PatternConverterWorker] Starting initialization...'); + await initializePyodide(message.pyodideIndexURL, message.pystitchWheelURL); + break; + + case 'CONVERT_PES': + console.log('[PatternConverterWorker] Starting PES conversion...'); + await convertPesToPen(message.fileData); + break; + + default: + console.error('[PatternConverterWorker] Unknown message type:', message); + } + } catch (err) { + console.error('[PatternConverterWorker] Error handling message:', err); + } +}; + +console.log('[PatternConverterWorker] Message handler registered'); 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 new file mode 100644 index 0000000..0d7f6cb --- /dev/null +++ b/src/formats/pen/encoder.test.ts @@ -0,0 +1,643 @@ +import { describe, it, expect } from 'vitest'; +import { + encodeStitchPosition, + calculateLockDirection, + 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; + +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 encode color change sequence in correct order', () => { + // Test the exact sequence of operations for a color change + const stitches = [ + [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); + + // 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) + + let idx = 0; + + // 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', () => { + 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 correctly', () => { + const stitches = [ + [0, 0, STITCH, 0], + [10, 0, TRIM, 0], + [20, 0, STITCH | END, 0], + ]; + + const result = encodeStitchesToPen(stitches); + const decoded = decodeAllPenStitches(result.penBytes); + + // 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', () => { + 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) + }); + + 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); + }); +}); diff --git a/src/formats/pen/encoder.ts b/src/formats/pen/encoder.ts new file mode 100644 index 0000000..c2e962a --- /dev/null +++ b/src/formats/pen/encoder.ts @@ -0,0 +1,364 @@ +/** + * PEN Format Encoder + * + * This module contains the logic for encoding embroidery stitches into the Brother PP1 PEN format. + * The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits. + */ + +import { MOVE, TRIM, END } from '../import/constants'; + +// PEN format flags for Brother machines +const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching) +const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command +const PEN_COLOR_END = 0x03; // Last stitch before color change +const PEN_DATA_END = 0x05; // Last stitch of entire pattern + +// Constants from PesxToPen.cs +const FEED_LENGTH = 50; // Long jump threshold requiring lock stitches and cut +const TARGET_LENGTH = 8.0; // Target accumulated length for lock stitch direction +const MAX_POINTS = 5; // Maximum points to accumulate for lock stitch direction +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/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; + }; +} 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 diff --git a/src/utils/penParser.ts b/src/utils/penParser.ts deleted file mode 100644 index edee9b6..0000000 --- a/src/utils/penParser.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/workers/patternConverter.worker.ts b/src/workers/patternConverter.worker.ts deleted file mode 100644 index 88d56a1..0000000 --- a/src/workers/patternConverter.worker.ts +++ /dev/null @@ -1,754 +0,0 @@ -import { loadPyodide, type PyodideInterface } from 'pyodide'; -import { - STITCH, - MOVE, - TRIM, - END, - PEN_FEED_DATA, - PEN_CUT_DATA, - PEN_COLOR_END, - PEN_DATA_END, -} from '../utils/embroideryConstants'; - -// Message types from main thread -export type WorkerMessage = - | { type: 'INITIALIZE'; pyodideIndexURL?: string; pystitchWheelURL?: string } - | { type: 'CONVERT_PES'; fileData: ArrayBuffer; fileName: string }; - -// Response types to main thread -export type WorkerResponse = - | { type: 'INIT_PROGRESS'; progress: number; step: string } - | { type: 'INIT_COMPLETE' } - | { type: 'INIT_ERROR'; error: string } - | { - type: 'CONVERT_COMPLETE'; - data: { - stitches: number[][]; - threads: Array<{ - color: number; - hex: string; - brand: string | null; - catalogNumber: string | null; - description: string | null; - chart: string | null; - }>; - uniqueColors: Array<{ - color: number; - hex: string; - brand: string | null; - catalogNumber: string | null; - description: string | null; - chart: string | null; - threadIndices: number[]; - }>; - penData: number[]; // Serialized as array - colorCount: number; - stitchCount: number; - bounds: { - minX: number; - maxX: number; - minY: number; - maxY: number; - }; - }; - } - | { type: 'CONVERT_ERROR'; error: string }; - -console.log('[PatternConverterWorker] Worker script loaded'); - -let pyodide: PyodideInterface | null = null; -let isInitializing = false; - -// JavaScript constants module to expose to Python -const jsEmbConstants = { - STITCH, - MOVE, - TRIM, - END, -}; - -/** - * Initialize Pyodide with progress tracking - */ -async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: string) { - if (pyodide) { - return; // Already initialized - } - - if (isInitializing) { - throw new Error('Initialization already in progress'); - } - - isInitializing = true; - - try { - self.postMessage({ - type: 'INIT_PROGRESS', - progress: 0, - step: 'Starting initialization...', - } as WorkerResponse); - - console.log('[PyodideWorker] Loading Pyodide runtime...'); - - self.postMessage({ - type: 'INIT_PROGRESS', - progress: 10, - step: 'Loading Python runtime...', - } as WorkerResponse); - - // Load Pyodide runtime - // Use provided URL or default to /assets/ - const indexURL = pyodideIndexURL || '/assets/'; - console.log('[PyodideWorker] Pyodide index URL:', indexURL); - - pyodide = await loadPyodide({ - indexURL: indexURL, - }); - - console.log('[PyodideWorker] Pyodide runtime loaded'); - - self.postMessage({ - type: 'INIT_PROGRESS', - progress: 70, - step: 'Python runtime loaded', - } as WorkerResponse); - - self.postMessage({ - type: 'INIT_PROGRESS', - progress: 75, - step: 'Loading pystitch library...', - } as WorkerResponse); - - // Load pystitch wheel - // Use provided URL or default - const wheelURL = pystitchWheelURL || '/pystitch-1.0.0-py3-none-any.whl'; - console.log('[PyodideWorker] Pystitch wheel URL:', wheelURL); - - await pyodide.loadPackage(wheelURL); - - console.log('[PyodideWorker] pystitch library loaded'); - - self.postMessage({ - type: 'INIT_PROGRESS', - progress: 100, - step: 'Ready!', - } as WorkerResponse); - - self.postMessage({ - type: 'INIT_COMPLETE', - } as WorkerResponse); - } catch (err) { - const errorMsg = err instanceof Error ? err.message : 'Unknown error'; - console.error('[PyodideWorker] Initialization error:', err); - - self.postMessage({ - type: 'INIT_ERROR', - error: errorMsg, - } as WorkerResponse); - - throw err; - } finally { - isInitializing = false; - } -} - -/** - * 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 - */ -async function convertPesToPen(fileData: ArrayBuffer) { - if (!pyodide) { - throw new Error('Pyodide not initialized'); - } - - try { - // Register our JavaScript constants module for Python to import - pyodide.registerJsModule('js_emb_constants', jsEmbConstants); - - // Convert to Uint8Array - const uint8Array = new Uint8Array(fileData); - - // Write file to Pyodide virtual filesystem - const tempFileName = '/tmp/pattern.pes'; - pyodide.FS.writeFile(tempFileName, uint8Array); - - // Read the pattern using PyStitch (same logic as original converter) - const result = await pyodide.runPythonAsync(` -import pystitch -from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE -from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END - -# Read the PES file -pattern = pystitch.read('${tempFileName}') - -def map_cmd(pystitch_cmd): - """Map PyStitch command to our JavaScript constant values - - This ensures we have known, consistent values regardless of PyStitch's internal values. - Our JS constants use pyembroidery-style bitmask values: - STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100 - """ - if pystitch_cmd == STITCH: - return JS_STITCH - elif pystitch_cmd == JUMP: - return JS_MOVE # PyStitch JUMP maps to our MOVE constant - elif pystitch_cmd == TRIM: - return JS_TRIM - elif pystitch_cmd == END: - return JS_END - else: - # For any other commands, preserve as bitmask - result = JS_STITCH - if pystitch_cmd & JUMP: - result |= JS_MOVE - if pystitch_cmd & TRIM: - result |= JS_TRIM - if pystitch_cmd & END: - result |= JS_END - return result - -# Use the raw stitches list which preserves command flags -# Each stitch in pattern.stitches is [x, y, cmd] -# We need to assign color indices based on COLOR_CHANGE commands -# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches) -# -# IMPORTANT: In PES files, COLOR_CHANGE commands can appear before finishing -# stitches (tack/lock stitches) that semantically belong to the PREVIOUS color. -# We need to detect this pattern and assign colors correctly. - -stitches_with_colors = [] -current_color = 0 - -for i, stitch in enumerate(pattern.stitches): - x, y, cmd = stitch - - # Check for color change command - if cmd == COLOR_CHANGE: - current_color += 1 - continue - - # Check for stop command - skip it - if cmd == STOP: - continue - - # Check for standalone END command (no stitch data) - if cmd == END: - continue - - # PyStitch inserts duplicate stitches at the same coordinates during color changes - # Skip any stitch that has the exact same position as the previous one - if len(stitches_with_colors) > 0: - last_stitch = stitches_with_colors[-1] - last_x, last_y = last_stitch[0], last_stitch[1] - - if x == last_x and y == last_y: - # Duplicate position - skip it - continue - - # Add actual stitch with current color index and mapped command - mapped_cmd = map_cmd(cmd) - stitches_with_colors.append([x, y, mapped_cmd, current_color]) - -# Convert to JSON-serializable format -{ - 'stitches': stitches_with_colors, - 'threads': [ - { - 'color': thread.color if hasattr(thread, 'color') else 0, - 'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000', - 'catalog_number': thread.catalog_number if hasattr(thread, 'catalog_number') else -1, - 'brand': thread.brand if hasattr(thread, 'brand') else "", - 'description': thread.description if hasattr(thread, 'description') else "", - 'chart': thread.chart if hasattr(thread, 'chart') else "" - } - for thread in pattern.threadlist - ], - 'thread_count': len(pattern.threadlist), - 'stitch_count': len(stitches_with_colors), - 'color_changes': current_color -} - `); - - // Convert Python result to JavaScript - const data = result.toJs({ dict_converter: Object.fromEntries }); - - // Clean up virtual file - try { - pyodide.FS.unlink(tempFileName); - } catch { - // Ignore errors - } - - // Extract stitches and validate - const stitches: number[][] = Array.from( - data.stitches as ArrayLike> - ).map((stitch) => Array.from(stitch)); - - if (!stitches || stitches.length === 0) { - throw new Error('Invalid PES file or no stitches found'); - } - - // Extract thread data - preserve null values for unavailable metadata - const threads = ( - data.threads as Array<{ - color?: number; - hex?: string; - catalog_number?: number | string; - brand?: string; - description?: string; - chart?: string; - }> - ).map((thread) => { - // Normalize catalog_number - can be string or number from PyStitch - const catalogNum = thread.catalog_number; - const normalizedCatalog = - catalogNum !== undefined && - catalogNum !== null && - catalogNum !== -1 && - catalogNum !== '-1' && - catalogNum !== '' - ? String(catalogNum) - : null; - - return { - color: thread.color ?? 0, - hex: thread.hex || '#000000', - catalogNumber: normalizedCatalog, - brand: thread.brand && thread.brand !== '' ? thread.brand : null, - description: - thread.description && thread.description !== '' - ? thread.description - : null, - chart: thread.chart && thread.chart !== '' ? thread.chart : null, - }; - }); - - // 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; - } - } - - // Calculate unique colors from threads (threads represent color blocks, not unique colors) - const uniqueColors = threads.reduce( - (acc, thread, idx) => { - const existing = acc.find((c) => c.hex === thread.hex); - if (existing) { - existing.threadIndices.push(idx); - } else { - acc.push({ - color: thread.color, - hex: thread.hex, - brand: thread.brand, - catalogNumber: thread.catalogNumber, - description: thread.description, - chart: thread.chart, - threadIndices: [idx], - }); - } - return acc; - }, - [] as Array<{ - color: number; - hex: string; - brand: string | null; - catalogNumber: string | null; - description: string | null; - chart: string | null; - threadIndices: number[]; - }> - ); - - // Calculate PEN stitch count (should match what machine will count) - const penStitchCount = penStitches.length / 4; - - console.log('[patternConverter] PEN encoding complete:'); - console.log(` - PyStitch stitches: ${stitches.length}`); - console.log(` - PEN bytes: ${penStitches.length}`); - console.log(` - PEN stitches (bytes/4): ${penStitchCount}`); - console.log(` - Bounds: (${minX}, ${minY}) to (${maxX}, ${maxY})`); - - // Post result back to main thread - self.postMessage({ - type: 'CONVERT_COMPLETE', - data: { - stitches, - threads, - uniqueColors, - penData: penStitches, // Send as array (will be converted to Uint8Array in main thread) - colorCount: data.thread_count, - stitchCount: data.stitch_count, - bounds: { - minX: minX === Infinity ? 0 : minX, - maxX: maxX === -Infinity ? 0 : maxX, - minY: minY === Infinity ? 0 : minY, - maxY: maxY === -Infinity ? 0 : maxY, - }, - }, - } as WorkerResponse); - } catch (err) { - const errorMsg = err instanceof Error ? err.message : 'Unknown error'; - console.error('[PyodideWorker] Conversion error:', err); - - self.postMessage({ - type: 'CONVERT_ERROR', - error: errorMsg, - } as WorkerResponse); - - throw err; - } -} - -// Handle messages from main thread -self.onmessage = async (event: MessageEvent) => { - const message = event.data; - console.log('[PatternConverterWorker] Received message:', message.type); - - try { - switch (message.type) { - case 'INITIALIZE': - console.log('[PatternConverterWorker] Starting initialization...'); - await initializePyodide(message.pyodideIndexURL, message.pystitchWheelURL); - break; - - case 'CONVERT_PES': - console.log('[PatternConverterWorker] Starting PES conversion...'); - await convertPesToPen(message.fileData); - break; - - default: - console.error('[PatternConverterWorker] Unknown message type:', message); - } - } catch (err) { - console.error('[PatternConverterWorker] Error handling message:', err); - } -}; - -console.log('[PatternConverterWorker] Message handler registered'); 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}'], + }, +});