mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
GPU accelerated maskeditor rendering (#6767)
## GPU accelerated brush engine for the mask editor - Full GPU acceleration using TypeGPU and type-safe shaders - Catmull-Rom Spline Smoothing - arc-length equidistant resampling - much improved performance, even for huge images - photoshop like opacity clamping for brush strokes - much improved soft brushes - fallback to CPU fully implemented, much improved CPU rendering features as well ### Tested Browsers - Chrome (fully supported) - Safari 26 (fully supported, prev versions CPU fallback) - Firefox (CPU fallback, flags needed for full support) https://github.com/user-attachments/assets/b7b5cb8a-2290-4a95-ae7d-180e11fccdb0 https://github.com/user-attachments/assets/4297aaa5-f249-499a-9b74-869677f1c73b https://github.com/user-attachments/assets/602b4783-3e2b-489e-bcb9-70534bcaac5e ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6767-GPU-accelerated-maskeditor-rendering-2b16d73d3650818cb294e1fca03f6169) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -75,6 +75,7 @@
|
|||||||
"@vitest/coverage-v8": "catalog:",
|
"@vitest/coverage-v8": "catalog:",
|
||||||
"@vitest/ui": "catalog:",
|
"@vitest/ui": "catalog:",
|
||||||
"@vue/test-utils": "catalog:",
|
"@vue/test-utils": "catalog:",
|
||||||
|
"@webgpu/types": "catalog:",
|
||||||
"cross-env": "catalog:",
|
"cross-env": "catalog:",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"eslint-config-prettier": "catalog:",
|
"eslint-config-prettier": "catalog:",
|
||||||
@@ -112,6 +113,7 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"typescript-eslint": "catalog:",
|
"typescript-eslint": "catalog:",
|
||||||
"unplugin-icons": "catalog:",
|
"unplugin-icons": "catalog:",
|
||||||
|
"unplugin-typegpu": "catalog:",
|
||||||
"unplugin-vue-components": "catalog:",
|
"unplugin-vue-components": "catalog:",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
@@ -176,6 +178,7 @@
|
|||||||
"semver": "^7.7.2",
|
"semver": "^7.7.2",
|
||||||
"three": "^0.170.0",
|
"three": "^0.170.0",
|
||||||
"tiptap-markdown": "^0.8.10",
|
"tiptap-markdown": "^0.8.10",
|
||||||
|
"typegpu": "catalog:",
|
||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
"vue-i18n": "catalog:",
|
"vue-i18n": "catalog:",
|
||||||
"vue-router": "catalog:",
|
"vue-router": "catalog:",
|
||||||
|
|||||||
86
pnpm-lock.yaml
generated
86
pnpm-lock.yaml
generated
@@ -126,6 +126,9 @@ catalogs:
|
|||||||
'@vueuse/integrations':
|
'@vueuse/integrations':
|
||||||
specifier: ^13.9.0
|
specifier: ^13.9.0
|
||||||
version: 13.9.0
|
version: 13.9.0
|
||||||
|
'@webgpu/types':
|
||||||
|
specifier: ^0.1.66
|
||||||
|
version: 0.1.66
|
||||||
algoliasearch:
|
algoliasearch:
|
||||||
specifier: ^5.21.0
|
specifier: ^5.21.0
|
||||||
version: 5.21.0
|
version: 5.21.0
|
||||||
@@ -246,6 +249,9 @@ catalogs:
|
|||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.3.8
|
specifier: ^1.3.8
|
||||||
version: 1.3.8
|
version: 1.3.8
|
||||||
|
typegpu:
|
||||||
|
specifier: ^0.8.2
|
||||||
|
version: 0.8.2
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.2
|
specifier: ^5.9.2
|
||||||
version: 5.9.2
|
version: 5.9.2
|
||||||
@@ -255,6 +261,9 @@ catalogs:
|
|||||||
unplugin-icons:
|
unplugin-icons:
|
||||||
specifier: ^0.22.0
|
specifier: ^0.22.0
|
||||||
version: 0.22.0
|
version: 0.22.0
|
||||||
|
unplugin-typegpu:
|
||||||
|
specifier: 0.8.0
|
||||||
|
version: 0.8.0
|
||||||
unplugin-vue-components:
|
unplugin-vue-components:
|
||||||
specifier: ^0.28.0
|
specifier: ^0.28.0
|
||||||
version: 0.28.0
|
version: 0.28.0
|
||||||
@@ -464,6 +473,9 @@ importers:
|
|||||||
tiptap-markdown:
|
tiptap-markdown:
|
||||||
specifier: ^0.8.10
|
specifier: ^0.8.10
|
||||||
version: 0.8.10(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
|
version: 0.8.10(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
|
||||||
|
typegpu:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 0.8.2
|
||||||
vue:
|
vue:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.5.13(typescript@5.9.2)
|
version: 3.5.13(typescript@5.9.2)
|
||||||
@@ -561,6 +573,9 @@ importers:
|
|||||||
'@vue/test-utils':
|
'@vue/test-utils':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 2.4.6
|
version: 2.4.6
|
||||||
|
'@webgpu/types':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 0.1.66
|
||||||
cross-env:
|
cross-env:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
@@ -672,6 +687,9 @@ importers:
|
|||||||
unplugin-icons:
|
unplugin-icons:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 0.22.0(@vue/compiler-sfc@3.5.13)
|
version: 0.22.0(@vue/compiler-sfc@3.5.13)
|
||||||
|
unplugin-typegpu:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 0.8.0(typegpu@0.8.2)
|
||||||
unplugin-vue-components:
|
unplugin-vue-components:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2))
|
version: 0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2))
|
||||||
@@ -1431,6 +1449,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/standalone@7.28.5':
|
||||||
|
resolution: {integrity: sha512-1DViPYJpRU50irpGMfLBQ9B4kyfQuL6X7SS7pwTeWeZX0mNkjzPi0XFqxCjSdddZXUQy4AhnQnnesA/ZHnvAdw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/template@7.27.2':
|
'@babel/template@7.27.2':
|
||||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -3790,8 +3812,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.5.0
|
vue: ^3.5.0
|
||||||
|
|
||||||
'@webgpu/types@0.1.51':
|
'@webgpu/types@0.1.66':
|
||||||
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
|
resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==}
|
||||||
|
|
||||||
'@xstate/fsm@1.6.5':
|
'@xstate/fsm@1.6.5':
|
||||||
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
|
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
|
||||||
@@ -6038,6 +6060,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
magic-string-ast@1.0.3:
|
||||||
|
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
magic-string@0.30.19:
|
magic-string@0.30.19:
|
||||||
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
|
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
|
||||||
|
|
||||||
@@ -7411,6 +7437,14 @@ packages:
|
|||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
|
tinyest-for-wgsl@0.1.3:
|
||||||
|
resolution: {integrity: sha512-Wm5ADG1UyDxykf42S1gLYP4U9e1QP/TdtJeovQi6y68zttpiFLKqQGioHmPs9Mjysh7YMSAr/Lpuk0cD2MVdGA==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
|
tinyest@0.1.2:
|
||||||
|
resolution: {integrity: sha512-aHRmouyowIq1P5jrTF+YK6pGX+WuvFtSCLbqk91yHnU3SWQRIcNIamZLM5XF6lLqB13AWz0PGPXRff2QGDsxIg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
tinyexec@0.3.2:
|
tinyexec@0.3.2:
|
||||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||||
|
|
||||||
@@ -7537,6 +7571,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
|
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
typed-binary@4.3.2:
|
||||||
|
resolution: {integrity: sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ==}
|
||||||
|
|
||||||
|
typegpu@0.8.2:
|
||||||
|
resolution: {integrity: sha512-wkMJWhJE0pSkw2G/FesjqjbtHkREyOKu1Zmyj19xfmaX5+65YFwgfQNKSK8CxqN4kJkP7JFelLDJTSYY536TYg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
typescript-eslint@8.44.0:
|
typescript-eslint@8.44.0:
|
||||||
resolution: {integrity: sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==}
|
resolution: {integrity: sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -7641,6 +7682,11 @@ packages:
|
|||||||
vue-template-es2015-compiler:
|
vue-template-es2015-compiler:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
unplugin-typegpu@0.8.0:
|
||||||
|
resolution: {integrity: sha512-VJHdXSXGOkAx0WhwFczhVUjAI6HyDkrQXk20HnwyuzIE3FdqE5l9sJTCYZzoVGo3z8i/IA5TMHCDzzP0Bc97Cw==}
|
||||||
|
peerDependencies:
|
||||||
|
typegpu: ^0.8.0
|
||||||
|
|
||||||
unplugin-vue-components@0.28.0:
|
unplugin-vue-components@0.28.0:
|
||||||
resolution: {integrity: sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==}
|
resolution: {integrity: sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -8969,6 +9015,8 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/runtime@7.28.4': {}
|
'@babel/runtime@7.28.4': {}
|
||||||
|
|
||||||
|
'@babel/standalone@7.28.5': {}
|
||||||
|
|
||||||
'@babel/template@7.27.2':
|
'@babel/template@7.27.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.27.1
|
'@babel/code-frame': 7.27.1
|
||||||
@@ -11016,7 +11064,7 @@ snapshots:
|
|||||||
'@tweenjs/tween.js': 23.1.3
|
'@tweenjs/tween.js': 23.1.3
|
||||||
'@types/stats.js': 0.17.3
|
'@types/stats.js': 0.17.3
|
||||||
'@types/webxr': 0.5.20
|
'@types/webxr': 0.5.20
|
||||||
'@webgpu/types': 0.1.51
|
'@webgpu/types': 0.1.66
|
||||||
fflate: 0.8.2
|
fflate: 0.8.2
|
||||||
meshoptimizer: 0.18.1
|
meshoptimizer: 0.18.1
|
||||||
|
|
||||||
@@ -11519,7 +11567,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.13(typescript@5.9.2)
|
vue: 3.5.13(typescript@5.9.2)
|
||||||
|
|
||||||
'@webgpu/types@0.1.51': {}
|
'@webgpu/types@0.1.66': {}
|
||||||
|
|
||||||
'@xstate/fsm@1.6.5': {}
|
'@xstate/fsm@1.6.5': {}
|
||||||
|
|
||||||
@@ -14000,6 +14048,10 @@ snapshots:
|
|||||||
|
|
||||||
lz-string@1.5.0: {}
|
lz-string@1.5.0: {}
|
||||||
|
|
||||||
|
magic-string-ast@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
magic-string: 0.30.19
|
||||||
|
|
||||||
magic-string@0.30.19:
|
magic-string@0.30.19:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -15864,6 +15916,12 @@ snapshots:
|
|||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
|
tinyest-for-wgsl@0.1.3:
|
||||||
|
dependencies:
|
||||||
|
tinyest: 0.1.2
|
||||||
|
|
||||||
|
tinyest@0.1.2: {}
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
|
|
||||||
tinyexec@1.0.1: {}
|
tinyexec@1.0.1: {}
|
||||||
@@ -15995,6 +16053,13 @@ snapshots:
|
|||||||
reflect.getprototypeof: 1.0.10
|
reflect.getprototypeof: 1.0.10
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
typed-binary@4.3.2: {}
|
||||||
|
|
||||||
|
typegpu@0.8.2:
|
||||||
|
dependencies:
|
||||||
|
tinyest: 0.1.2
|
||||||
|
typed-binary: 4.3.2
|
||||||
|
|
||||||
typescript-eslint@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2):
|
typescript-eslint@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2)
|
'@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2)
|
||||||
@@ -16090,6 +16155,19 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
unplugin-typegpu@0.8.0(typegpu@0.8.2):
|
||||||
|
dependencies:
|
||||||
|
'@babel/standalone': 7.28.5
|
||||||
|
defu: 6.1.4
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string-ast: 1.0.3
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.3
|
||||||
|
tinyest: 0.1.2
|
||||||
|
tinyest-for-wgsl: 0.1.3
|
||||||
|
typegpu: 0.8.2
|
||||||
|
unplugin: 2.3.5
|
||||||
|
|
||||||
unplugin-vue-components@0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2)):
|
unplugin-vue-components@0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@antfu/utils': 0.7.10
|
'@antfu/utils': 0.7.10
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ catalog:
|
|||||||
'@vue/test-utils': ^2.4.6
|
'@vue/test-utils': ^2.4.6
|
||||||
'@vueuse/core': ^11.0.0
|
'@vueuse/core': ^11.0.0
|
||||||
'@vueuse/integrations': ^13.9.0
|
'@vueuse/integrations': ^13.9.0
|
||||||
|
'@webgpu/types': ^0.1.66
|
||||||
algoliasearch: ^5.21.0
|
algoliasearch: ^5.21.0
|
||||||
axios: ^1.8.2
|
axios: ^1.8.2
|
||||||
cross-env: ^10.1.0
|
cross-env: ^10.1.0
|
||||||
@@ -83,9 +84,11 @@ catalog:
|
|||||||
tailwindcss-primeui: ^0.6.1
|
tailwindcss-primeui: ^0.6.1
|
||||||
tsx: ^4.15.6
|
tsx: ^4.15.6
|
||||||
tw-animate-css: ^1.3.8
|
tw-animate-css: ^1.3.8
|
||||||
|
typegpu: ^0.8.2
|
||||||
typescript: ^5.9.2
|
typescript: ^5.9.2
|
||||||
typescript-eslint: ^8.44.0
|
typescript-eslint: ^8.44.0
|
||||||
unplugin-icons: ^0.22.0
|
unplugin-icons: ^0.22.0
|
||||||
|
unplugin-typegpu: 0.8.0
|
||||||
unplugin-vue-components: ^0.28.0
|
unplugin-vue-components: ^0.28.0
|
||||||
vite: ^5.4.19
|
vite: ^5.4.19
|
||||||
vite-plugin-dts: ^4.5.4
|
vite-plugin-dts: ^4.5.4
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getEffectiveBrushSize,
|
||||||
|
getEffectiveHardness
|
||||||
|
} from '@/composables/maskeditor/brushUtils'
|
||||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||||
|
|
||||||
@@ -36,11 +40,14 @@ const { containerRef } = defineProps<{
|
|||||||
const store = useMaskEditorStore()
|
const store = useMaskEditorStore()
|
||||||
|
|
||||||
const brushOpacity = computed(() => {
|
const brushOpacity = computed(() => {
|
||||||
return store.brushVisible ? '1' : '0'
|
return store.brushVisible ? 1 : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const brushRadius = computed(() => {
|
const brushRadius = computed(() => {
|
||||||
return store.brushSettings.size * store.zoomRatio
|
const size = store.brushSettings.size
|
||||||
|
const hardness = store.brushSettings.hardness
|
||||||
|
const effectiveSize = getEffectiveBrushSize(size, hardness)
|
||||||
|
return effectiveSize * store.zoomRatio
|
||||||
})
|
})
|
||||||
|
|
||||||
const brushSize = computed(() => {
|
const brushSize = computed(() => {
|
||||||
@@ -78,19 +85,26 @@ const gradientVisible = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const gradientBackground = computed(() => {
|
const gradientBackground = computed(() => {
|
||||||
|
const size = store.brushSettings.size
|
||||||
const hardness = store.brushSettings.hardness
|
const hardness = store.brushSettings.hardness
|
||||||
|
const effectiveSize = getEffectiveBrushSize(size, hardness)
|
||||||
|
const effectiveHardness = getEffectiveHardness(size, hardness, effectiveSize)
|
||||||
|
|
||||||
if (hardness === 1) {
|
if (effectiveHardness === 1) {
|
||||||
return 'rgba(255, 0, 0, 0.5)'
|
return 'rgba(255, 0, 0, 0.5)'
|
||||||
}
|
}
|
||||||
|
|
||||||
const midStop = hardness * 100
|
const midStop = effectiveHardness * 100
|
||||||
const outerStop = 100
|
const outerStop = 100
|
||||||
|
// Add an intermediate stop to approximate the squared falloff
|
||||||
|
// At 50% of the fade region, squared falloff is 0.25 (relative to max)
|
||||||
|
const fadeMidStop = midStop + (outerStop - midStop) * 0.5
|
||||||
|
|
||||||
return `radial-gradient(
|
return `radial-gradient(
|
||||||
circle,
|
circle,
|
||||||
rgba(255, 0, 0, 0.5) 0%,
|
rgba(255, 0, 0, 0.5) 0%,
|
||||||
rgba(255, 0, 0, 0.25) ${midStop}%,
|
rgba(255, 0, 0, 0.5) ${midStop}%,
|
||||||
|
rgba(255, 0, 0, 0.125) ${fadeMidStop}%,
|
||||||
rgba(255, 0, 0, 0) ${outerStop}%
|
rgba(255, 0, 0, 0) ${outerStop}%
|
||||||
)`
|
)`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<SliderControl
|
<SliderControl
|
||||||
:label="t('maskEditor.thickness')"
|
:label="t('maskEditor.thickness')"
|
||||||
:min="1"
|
:min="1"
|
||||||
:max="100"
|
:max="500"
|
||||||
:step="1"
|
:step="1"
|
||||||
:model-value="store.brushSettings.size"
|
:model-value="store.brushSettings.size"
|
||||||
@update:model-value="onThicknessChange"
|
@update:model-value="onThicknessChange"
|
||||||
@@ -80,12 +80,12 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SliderControl
|
<SliderControl
|
||||||
:label="t('maskEditor.smoothingPrecision')"
|
label="Stepsize"
|
||||||
:min="1"
|
:min="1"
|
||||||
:max="100"
|
:max="100"
|
||||||
:step="1"
|
:step="1"
|
||||||
:model-value="store.brushSettings.smoothingPrecision"
|
:model-value="store.brushSettings.stepSize"
|
||||||
@update:model-value="onSmoothingPrecisionChange"
|
@update:model-value="onStepSizeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -119,8 +119,8 @@ const onHardnessChange = (value: number) => {
|
|||||||
store.setBrushHardness(value)
|
store.setBrushHardness(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSmoothingPrecisionChange = (value: number) => {
|
const onStepSizeChange = (value: number) => {
|
||||||
store.setBrushSmoothingPrecision(value)
|
store.setBrushStepSize(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetToDefault = () => {
|
const resetToDefault = () => {
|
||||||
|
|||||||
@@ -12,19 +12,28 @@
|
|||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref="imgCanvasRef"
|
ref="imgCanvasRef"
|
||||||
class="absolute top-0 left-0 w-full h-full"
|
class="absolute top-0 left-0 w-full h-full z-0"
|
||||||
@contextmenu.prevent
|
@contextmenu.prevent
|
||||||
/>
|
/>
|
||||||
<canvas
|
<canvas
|
||||||
ref="rgbCanvasRef"
|
ref="rgbCanvasRef"
|
||||||
class="absolute top-0 left-0 w-full h-full"
|
class="absolute top-0 left-0 w-full h-full z-10"
|
||||||
@contextmenu.prevent
|
@contextmenu.prevent
|
||||||
/>
|
/>
|
||||||
<canvas
|
<canvas
|
||||||
ref="maskCanvasRef"
|
ref="maskCanvasRef"
|
||||||
class="absolute top-0 left-0 w-full h-full"
|
class="absolute top-0 left-0 w-full h-full z-30"
|
||||||
@contextmenu.prevent
|
@contextmenu.prevent
|
||||||
/>
|
/>
|
||||||
|
<!-- GPU Preview Canvas -->
|
||||||
|
<canvas
|
||||||
|
ref="gpuCanvasRef"
|
||||||
|
class="absolute top-0 left-0 w-full h-full pointer-events-none"
|
||||||
|
:class="{
|
||||||
|
'z-20': store.activeLayer === 'rgb',
|
||||||
|
'z-40': store.activeLayer === 'mask'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
|
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,6 +96,7 @@ const canvasContainerRef = ref<HTMLDivElement>()
|
|||||||
const imgCanvasRef = ref<HTMLCanvasElement>()
|
const imgCanvasRef = ref<HTMLCanvasElement>()
|
||||||
const maskCanvasRef = ref<HTMLCanvasElement>()
|
const maskCanvasRef = ref<HTMLCanvasElement>()
|
||||||
const rgbCanvasRef = ref<HTMLCanvasElement>()
|
const rgbCanvasRef = ref<HTMLCanvasElement>()
|
||||||
|
const gpuCanvasRef = ref<HTMLCanvasElement>()
|
||||||
const canvasBackgroundRef = ref<HTMLDivElement>()
|
const canvasBackgroundRef = ref<HTMLDivElement>()
|
||||||
|
|
||||||
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
|
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
|
||||||
@@ -97,7 +107,7 @@ const initialized = ref(false)
|
|||||||
const keyboard = useKeyboard()
|
const keyboard = useKeyboard()
|
||||||
const panZoom = usePanAndZoom()
|
const panZoom = usePanAndZoom()
|
||||||
|
|
||||||
let toolManager: ReturnType<typeof useToolManager> | null = null
|
const toolManager = useToolManager(keyboard, panZoom)
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
@@ -135,8 +145,6 @@ const initUI = async () => {
|
|||||||
try {
|
try {
|
||||||
await loader.loadFromNode(node)
|
await loader.loadFromNode(node)
|
||||||
|
|
||||||
toolManager = useToolManager(keyboard, panZoom)
|
|
||||||
|
|
||||||
const imageLoader = useImageLoader()
|
const imageLoader = useImageLoader()
|
||||||
const image = await imageLoader.loadImages()
|
const image = await imageLoader.loadImages()
|
||||||
|
|
||||||
@@ -149,6 +157,18 @@ const initUI = async () => {
|
|||||||
|
|
||||||
store.canvasHistory.saveInitialState()
|
store.canvasHistory.saveInitialState()
|
||||||
|
|
||||||
|
// Initialize GPU resources
|
||||||
|
if (toolManager.brushDrawing) {
|
||||||
|
await toolManager.brushDrawing.initGPUResources()
|
||||||
|
if (gpuCanvasRef.value && toolManager.brushDrawing.initPreviewCanvas) {
|
||||||
|
// Match preview canvas resolution to mask canvas
|
||||||
|
gpuCanvasRef.value.width = maskCanvasRef.value.width
|
||||||
|
gpuCanvasRef.value.height = maskCanvasRef.value.height
|
||||||
|
|
||||||
|
toolManager.brushDrawing.initPreviewCanvas(gpuCanvasRef.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
initialized.value = true
|
initialized.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MaskEditorContent] Initialization failed:', error)
|
console.error('[MaskEditorContent] Initialization failed:', error)
|
||||||
@@ -172,7 +192,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
toolManager?.brushDrawing.saveBrushSettings()
|
toolManager.brushDrawing.saveBrushSettings()
|
||||||
|
|
||||||
keyboard?.removeListeners()
|
keyboard?.removeListeners()
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const onInvert = () => {
|
|||||||
|
|
||||||
const onClear = () => {
|
const onClear = () => {
|
||||||
canvasTools.clearMask()
|
canvasTools.clearMask()
|
||||||
|
store.triggerClear()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|||||||
84
src/composables/maskeditor/ShiftClick.test.ts
Normal file
84
src/composables/maskeditor/ShiftClick.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { resampleSegment } from './splineUtils'
|
||||||
|
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||||
|
|
||||||
|
describe('Shift+Click Drawing Logic', () => {
|
||||||
|
it('should generate equidistant points across connected segments', () => {
|
||||||
|
const spacing = 4
|
||||||
|
let remainder = spacing // Simulate start point already painted
|
||||||
|
const outputPoints: Point[] = []
|
||||||
|
|
||||||
|
// Define points: A -> B -> C
|
||||||
|
// A(0,0) -> B(10,0) -> C(20,0)
|
||||||
|
// Total length 20. Spacing 4.
|
||||||
|
// Expected points at x = 4, 8, 12, 16, 20
|
||||||
|
const pA = { x: 0, y: 0 }
|
||||||
|
const pB = { x: 10, y: 0 }
|
||||||
|
const pC = { x: 20, y: 0 }
|
||||||
|
|
||||||
|
// Segment 1: A -> B
|
||||||
|
const result1 = resampleSegment([pA, pB], spacing, remainder)
|
||||||
|
outputPoints.push(...result1.points)
|
||||||
|
remainder = result1.remainder
|
||||||
|
|
||||||
|
// Verify intermediate state
|
||||||
|
// Length 10. Spacing 4. Start offset 4.
|
||||||
|
// Points at 4, 8. Next at 12.
|
||||||
|
// Remainder = 12 - 10 = 2.
|
||||||
|
expect(result1.points.length).toBe(2)
|
||||||
|
expect(result1.points[0].x).toBeCloseTo(4)
|
||||||
|
expect(result1.points[1].x).toBeCloseTo(8)
|
||||||
|
expect(remainder).toBeCloseTo(2)
|
||||||
|
|
||||||
|
// Segment 2: B -> C
|
||||||
|
const result2 = resampleSegment([pB, pC], spacing, remainder)
|
||||||
|
outputPoints.push(...result2.points)
|
||||||
|
remainder = result2.remainder
|
||||||
|
|
||||||
|
// Verify final state
|
||||||
|
// Start offset 2. Points at 2, 6, 10 (relative to B).
|
||||||
|
// Absolute x: 12, 16, 20.
|
||||||
|
expect(result2.points.length).toBe(3)
|
||||||
|
expect(result2.points[0].x).toBeCloseTo(12)
|
||||||
|
expect(result2.points[1].x).toBeCloseTo(16)
|
||||||
|
expect(result2.points[2].x).toBeCloseTo(20)
|
||||||
|
|
||||||
|
// Verify all distances
|
||||||
|
// Note: The first point is at distance `spacing` from start (0,0)
|
||||||
|
// Subsequent points are `spacing` apart.
|
||||||
|
let prevX = 0
|
||||||
|
for (const p of outputPoints) {
|
||||||
|
const dist = p.x - prevX
|
||||||
|
expect(dist).toBeCloseTo(spacing)
|
||||||
|
prevX = p.x
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle segments shorter than spacing', () => {
|
||||||
|
const spacing = 10
|
||||||
|
let remainder = spacing // Simulate start point already painted
|
||||||
|
|
||||||
|
// A(0,0) -> B(5,0) -> C(15,0)
|
||||||
|
const pA = { x: 0, y: 0 }
|
||||||
|
const pB = { x: 5, y: 0 }
|
||||||
|
const pC = { x: 15, y: 0 }
|
||||||
|
|
||||||
|
// Segment 1: A -> B (Length 5)
|
||||||
|
// Spacing 10. No points should be generated.
|
||||||
|
// Remainder should be 5 (next point needs 5 more units).
|
||||||
|
const result1 = resampleSegment([pA, pB], spacing, remainder)
|
||||||
|
expect(result1.points.length).toBe(0)
|
||||||
|
expect(result1.remainder).toBeCloseTo(5)
|
||||||
|
remainder = result1.remainder
|
||||||
|
|
||||||
|
// Segment 2: B -> C (Length 10)
|
||||||
|
// Start offset 5. First point at 5 (relative to B).
|
||||||
|
// Absolute x = 10.
|
||||||
|
// Next point at 15 (relative to B). Segment ends at 10.
|
||||||
|
// Remainder = 15 - 10 = 5.
|
||||||
|
const result2 = resampleSegment([pB, pC], spacing, remainder)
|
||||||
|
expect(result2.points.length).toBe(1)
|
||||||
|
expect(result2.points[0].x).toBeCloseTo(10)
|
||||||
|
expect(result2.remainder).toBeCloseTo(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
108
src/composables/maskeditor/StrokeProcessor.test.ts
Normal file
108
src/composables/maskeditor/StrokeProcessor.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { StrokeProcessor } from './StrokeProcessor'
|
||||||
|
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||||
|
|
||||||
|
describe('StrokeProcessor', () => {
|
||||||
|
it('should generate equidistant points from irregular input', () => {
|
||||||
|
const spacing = 10
|
||||||
|
const processor = new StrokeProcessor(spacing)
|
||||||
|
const outputPoints: Point[] = []
|
||||||
|
|
||||||
|
// Simulate a horizontal line drawn with irregular speed
|
||||||
|
// Points: (0,0) -> (5,0) -> (25,0) -> (30,0) -> (100,0)
|
||||||
|
const inputPoints: Point[] = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 5, y: 0 }, // dist 5
|
||||||
|
{ x: 25, y: 0 }, // dist 20
|
||||||
|
{ x: 30, y: 0 }, // dist 5
|
||||||
|
{ x: 100, y: 0 } // dist 70
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const p of inputPoints) {
|
||||||
|
outputPoints.push(...processor.addPoint(p))
|
||||||
|
}
|
||||||
|
outputPoints.push(...processor.endStroke())
|
||||||
|
|
||||||
|
// Verify we have points
|
||||||
|
expect(outputPoints.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Verify spacing
|
||||||
|
// Note: The first few points might be affected by the start condition,
|
||||||
|
// but the middle section should be perfectly spaced.
|
||||||
|
// Also, Catmull-Rom splines don't necessarily pass through control points in a straight line
|
||||||
|
// if the points are collinear, they should be straight.
|
||||||
|
|
||||||
|
// Let's check distances between consecutive points
|
||||||
|
const distances: number[] = []
|
||||||
|
for (let i = 1; i < outputPoints.length; i++) {
|
||||||
|
const dx = outputPoints[i].x - outputPoints[i - 1].x
|
||||||
|
const dy = outputPoints[i].y - outputPoints[i - 1].y
|
||||||
|
distances.push(Math.hypot(dx, dy))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that distances are close to spacing
|
||||||
|
// We allow a small epsilon because of floating point and spline approximation
|
||||||
|
// Filter out the very last segment which might be shorter (remainder)
|
||||||
|
// But wait, our logic doesn't output the last point if it's not a full spacing step?
|
||||||
|
// resampleSegment outputs points at [start + spacing, start + 2*spacing, ...]
|
||||||
|
// It does NOT output the end point of the segment.
|
||||||
|
// So all distances between output points should be exactly `spacing`.
|
||||||
|
// EXCEPT possibly if the spline curvature makes the straight-line distance slightly different
|
||||||
|
// from the arc length. But for a straight line input, it should be exact.
|
||||||
|
|
||||||
|
// However, catmull-rom with collinear points IS a straight line.
|
||||||
|
|
||||||
|
// Let's log the distances for debugging if test fails
|
||||||
|
// console.log('Distances:', distances)
|
||||||
|
|
||||||
|
// All distances should be approximately equal to spacing
|
||||||
|
// We might have a gap between segments if the logic isn't perfect,
|
||||||
|
// but within a segment it's guaranteed by resampleSegment.
|
||||||
|
// The critical part is the transition between segments.
|
||||||
|
|
||||||
|
for (let i = 0; i < distances.length; i++) {
|
||||||
|
const d = distances[i]
|
||||||
|
if (Math.abs(d - spacing) > 0.5) {
|
||||||
|
console.log(
|
||||||
|
`Distance mismatch at index ${i}: ${d} (expected ${spacing})`
|
||||||
|
)
|
||||||
|
console.log(`Point ${i}:`, outputPoints[i])
|
||||||
|
console.log(`Point ${i + 1}:`, outputPoints[i + 1])
|
||||||
|
}
|
||||||
|
expect(d).toBeCloseTo(spacing, 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle a simple 3-point stroke', () => {
|
||||||
|
const spacing = 5
|
||||||
|
const processor = new StrokeProcessor(spacing)
|
||||||
|
const points: Point[] = []
|
||||||
|
|
||||||
|
points.push(...processor.addPoint({ x: 0, y: 0 }))
|
||||||
|
points.push(...processor.addPoint({ x: 10, y: 0 }))
|
||||||
|
points.push(...processor.addPoint({ x: 20, y: 0 }))
|
||||||
|
points.push(...processor.endStroke())
|
||||||
|
|
||||||
|
expect(points.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Check distances
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const dx = points[i].x - points[i - 1].x
|
||||||
|
const dy = points[i].y - points[i - 1].y
|
||||||
|
const d = Math.hypot(dx, dy)
|
||||||
|
expect(d).toBeCloseTo(spacing, 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle a single point click', () => {
|
||||||
|
const spacing = 5
|
||||||
|
const processor = new StrokeProcessor(spacing)
|
||||||
|
const points: Point[] = []
|
||||||
|
|
||||||
|
points.push(...processor.addPoint({ x: 100, y: 100 }))
|
||||||
|
points.push(...processor.endStroke())
|
||||||
|
|
||||||
|
expect(points.length).toBe(1)
|
||||||
|
expect(points[0]).toEqual({ x: 100, y: 100 })
|
||||||
|
})
|
||||||
|
})
|
||||||
115
src/composables/maskeditor/StrokeProcessor.ts
Normal file
115
src/composables/maskeditor/StrokeProcessor.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||||
|
import { catmullRomSpline, resampleSegment } from './splineUtils'
|
||||||
|
|
||||||
|
export class StrokeProcessor {
|
||||||
|
private controlPoints: Point[] = []
|
||||||
|
private remainder: number = 0
|
||||||
|
private spacing: number
|
||||||
|
private isFirstPoint: boolean = true
|
||||||
|
private hasProcessedSegment: boolean = false
|
||||||
|
|
||||||
|
constructor(spacing: number) {
|
||||||
|
this.spacing = spacing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a point to the stroke and returns any new equidistant points generated.
|
||||||
|
* Maintain a sliding window of 4 control points for spline generation
|
||||||
|
*/
|
||||||
|
public addPoint(point: Point): Point[] {
|
||||||
|
// Initialize buffer with the first point
|
||||||
|
if (this.isFirstPoint) {
|
||||||
|
this.controlPoints.push(point) // p0: phantom start point
|
||||||
|
this.controlPoints.push(point) // p1: actual start point
|
||||||
|
this.isFirstPoint = false
|
||||||
|
return [] // Wait for more points to form a segment
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controlPoints.push(point)
|
||||||
|
|
||||||
|
// Require 4 points for a spline segment
|
||||||
|
if (this.controlPoints.length < 4) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate segment p1->p2
|
||||||
|
const p0 = this.controlPoints[0]
|
||||||
|
const p1 = this.controlPoints[1]
|
||||||
|
const p2 = this.controlPoints[2]
|
||||||
|
const p3 = this.controlPoints[3]
|
||||||
|
|
||||||
|
const newPoints = this.processSegment(p0, p1, p2, p3)
|
||||||
|
|
||||||
|
// Slide window
|
||||||
|
this.controlPoints.shift()
|
||||||
|
|
||||||
|
return newPoints
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End stroke and flush remaining segments
|
||||||
|
*/
|
||||||
|
public endStroke(): Point[] {
|
||||||
|
if (this.controlPoints.length < 2) {
|
||||||
|
// Insufficient points for a segment
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process remaining segments by duplicating the last point
|
||||||
|
|
||||||
|
const newPoints: Point[] = []
|
||||||
|
|
||||||
|
// Flush the buffer by processing the final segment
|
||||||
|
|
||||||
|
while (this.controlPoints.length >= 3) {
|
||||||
|
const p0 = this.controlPoints[0]
|
||||||
|
const p1 = this.controlPoints[1]
|
||||||
|
const p2 = this.controlPoints[2]
|
||||||
|
const p3 = p2 // Duplicate last point as phantom end
|
||||||
|
|
||||||
|
const points = this.processSegment(p0, p1, p2, p3)
|
||||||
|
newPoints.push(...points)
|
||||||
|
|
||||||
|
this.controlPoints.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single point click
|
||||||
|
if (!this.hasProcessedSegment && this.controlPoints.length >= 2) {
|
||||||
|
// Process zero-length segment for single point
|
||||||
|
const p = this.controlPoints[1]
|
||||||
|
const points = this.processSegment(p, p, p, p)
|
||||||
|
newPoints.push(...points)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPoints
|
||||||
|
}
|
||||||
|
|
||||||
|
private processSegment(p0: Point, p1: Point, p2: Point, p3: Point): Point[] {
|
||||||
|
this.hasProcessedSegment = true
|
||||||
|
// Generate dense points for the segment
|
||||||
|
const densePoints: Point[] = []
|
||||||
|
|
||||||
|
// Adaptive sampling based on segment length
|
||||||
|
const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y)
|
||||||
|
// Use 1 sample per pixel, but at least 5 samples to ensure smoothness for short segments
|
||||||
|
// and cap at a reasonable maximum if needed (though not strictly necessary with density)
|
||||||
|
const samples = Math.max(5, Math.ceil(dist))
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
const t = i / samples
|
||||||
|
densePoints.push(catmullRomSpline(p0, p1, p2, p3, t))
|
||||||
|
}
|
||||||
|
// Add segment end point
|
||||||
|
densePoints.push(p2)
|
||||||
|
|
||||||
|
// Resample points with carried-over remainder
|
||||||
|
const { points, remainder } = resampleSegment(
|
||||||
|
densePoints,
|
||||||
|
this.spacing,
|
||||||
|
this.remainder
|
||||||
|
)
|
||||||
|
|
||||||
|
this.remainder = remainder
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/composables/maskeditor/brushUtils.test.ts
Normal file
47
src/composables/maskeditor/brushUtils.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
|
||||||
|
|
||||||
|
describe('brushUtils', () => {
|
||||||
|
describe('getEffectiveBrushSize', () => {
|
||||||
|
it('should return original size when hardness is 1.0', () => {
|
||||||
|
const size = 100
|
||||||
|
const hardness = 1.0
|
||||||
|
expect(getEffectiveBrushSize(size, hardness)).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 1.5x size when hardness is 0.0', () => {
|
||||||
|
const size = 100
|
||||||
|
const hardness = 0.0
|
||||||
|
expect(getEffectiveBrushSize(size, hardness)).toBe(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should interpolate linearly', () => {
|
||||||
|
const size = 100
|
||||||
|
const hardness = 0.5
|
||||||
|
// Scale should be 1.0 + 0.5 * 0.5 = 1.25
|
||||||
|
expect(getEffectiveBrushSize(size, hardness)).toBe(125)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getEffectiveHardness', () => {
|
||||||
|
it('should return same hardness if effective size matches size', () => {
|
||||||
|
const size = 100
|
||||||
|
const hardness = 0.8
|
||||||
|
const effectiveSize = 100
|
||||||
|
expect(getEffectiveHardness(size, hardness, effectiveSize)).toBe(0.8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should scale hardness down as effective size increases', () => {
|
||||||
|
const size = 100
|
||||||
|
const hardness = 0.5
|
||||||
|
// Effective size at 0.5 hardness is 125
|
||||||
|
const effectiveSize = 125
|
||||||
|
// Hard core radius = 50. New hardness = 50 / 125 = 0.4
|
||||||
|
expect(getEffectiveHardness(size, hardness, effectiveSize)).toBe(0.4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 0 if effective size is 0', () => {
|
||||||
|
expect(getEffectiveHardness(100, 0.5, 0)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
34
src/composables/maskeditor/brushUtils.ts
Normal file
34
src/composables/maskeditor/brushUtils.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Calculates the effective brush size based on the base size and hardness.
|
||||||
|
* As hardness decreases, the effective size increases to allow for a softer falloff.
|
||||||
|
*
|
||||||
|
* @param size - The base radius of the brush
|
||||||
|
* @param hardness - The hardness of the brush (0.0 to 1.0)
|
||||||
|
* @returns The effective radius of the brush
|
||||||
|
*/
|
||||||
|
export function getEffectiveBrushSize(size: number, hardness: number): number {
|
||||||
|
// Scale factor for maximum softness
|
||||||
|
const MAX_SCALE = 1.5
|
||||||
|
const scale = 1.0 + (1.0 - hardness) * (MAX_SCALE - 1.0)
|
||||||
|
return size * scale
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the effective hardness to maintain the visual "hard core" of the brush.
|
||||||
|
* Since the effective size is larger, we need to adjust the hardness value so that
|
||||||
|
* the inner hard circle remains at the same physical radius as the original size * hardness.
|
||||||
|
*
|
||||||
|
* @param size - The base radius of the brush
|
||||||
|
* @param hardness - The base hardness of the brush
|
||||||
|
* @param effectiveSize - The effective radius (calculated by getEffectiveBrushSize)
|
||||||
|
* @returns The adjusted hardness value (0.0 to 1.0)
|
||||||
|
*/
|
||||||
|
export function getEffectiveHardness(
|
||||||
|
size: number,
|
||||||
|
hardness: number,
|
||||||
|
effectiveSize: number
|
||||||
|
): number {
|
||||||
|
if (effectiveSize <= 0) return 0
|
||||||
|
// Adjust hardness to maintain the physical radius of the hard core
|
||||||
|
return (size * hardness) / effectiveSize
|
||||||
|
}
|
||||||
805
src/composables/maskeditor/gpu/GPUBrushRenderer.ts
Normal file
805
src/composables/maskeditor/gpu/GPUBrushRenderer.ts
Normal file
@@ -0,0 +1,805 @@
|
|||||||
|
import * as d from 'typegpu/data'
|
||||||
|
import { StrokePoint } from './gpuSchema'
|
||||||
|
import {
|
||||||
|
brushFragment,
|
||||||
|
brushVertex,
|
||||||
|
blitShader,
|
||||||
|
compositeShader,
|
||||||
|
readbackShader
|
||||||
|
} from './brushShaders'
|
||||||
|
|
||||||
|
// ... (rest of the file)
|
||||||
|
|
||||||
|
const QUAD_VERTS = new Float32Array([-1, -1, 1, -1, 1, 1, -1, 1])
|
||||||
|
const QUAD_INDICES = new Uint16Array([0, 1, 2, 0, 2, 3])
|
||||||
|
|
||||||
|
const UNIFORM_SIZE = 48 // Uniform buffer size aligned to 16 bytes
|
||||||
|
const STROKE_STRIDE = d.sizeOf(StrokePoint) // 16
|
||||||
|
const MAX_STROKES = 10000
|
||||||
|
|
||||||
|
export class GPUBrushRenderer {
|
||||||
|
private device: GPUDevice
|
||||||
|
|
||||||
|
// Buffers
|
||||||
|
private quadVertexBuffer: GPUBuffer
|
||||||
|
private indexBuffer: GPUBuffer
|
||||||
|
private instanceBuffer: GPUBuffer
|
||||||
|
private uniformBuffer: GPUBuffer
|
||||||
|
|
||||||
|
// Pipelines
|
||||||
|
private renderPipeline: GPURenderPipeline // Standard alpha blending pipeline
|
||||||
|
private accumulatePipeline: GPURenderPipeline // SourceOver blending pipeline for stroke accumulation
|
||||||
|
private blitPipeline: GPURenderPipeline
|
||||||
|
private compositePipeline: GPURenderPipeline // Composite pipeline that applies opacity
|
||||||
|
private compositePipelinePreview: GPURenderPipeline // Pipeline for rendering to the preview canvas
|
||||||
|
private erasePipeline: GPURenderPipeline // Pipeline for erasing (Destination Out)
|
||||||
|
private erasePipelinePreview: GPURenderPipeline // Eraser pipeline for the preview canvas
|
||||||
|
readbackPipeline: GPUComputePipeline // Compute pipeline for texture readback
|
||||||
|
|
||||||
|
// Bind Group Layouts
|
||||||
|
private uniformBindGroupLayout: GPUBindGroupLayout
|
||||||
|
private textureBindGroupLayout: GPUBindGroupLayout
|
||||||
|
|
||||||
|
// Shared Bind Groups
|
||||||
|
private mainUniformBindGroup: GPUBindGroup
|
||||||
|
|
||||||
|
// Textures
|
||||||
|
private currentStrokeTexture: GPUTexture | null = null
|
||||||
|
private currentStrokeView: GPUTextureView | null = null
|
||||||
|
|
||||||
|
// Cached Bind Groups
|
||||||
|
private compositeTextureBindGroup: GPUBindGroup | null = null
|
||||||
|
private previewTextureBindGroup: GPUBindGroup | null = null
|
||||||
|
|
||||||
|
// Removed separate uniform bind groups as we will use mainUniformBindGroup
|
||||||
|
|
||||||
|
private lastReadbackTexture: GPUTexture | null = null
|
||||||
|
private lastReadbackBuffer: GPUBuffer | null = null
|
||||||
|
private readbackBindGroup: GPUBindGroup | null = null
|
||||||
|
|
||||||
|
private lastBackgroundTexture: GPUTexture | null = null
|
||||||
|
private backgroundBindGroup: GPUBindGroup | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
device: GPUDevice,
|
||||||
|
presentationFormat: GPUTextureFormat = 'rgba8unorm'
|
||||||
|
) {
|
||||||
|
this.device = device
|
||||||
|
|
||||||
|
// --- 1. Initialize Buffers ---
|
||||||
|
this.quadVertexBuffer = device.createBuffer({
|
||||||
|
size: QUAD_VERTS.byteLength,
|
||||||
|
usage: GPUBufferUsage.VERTEX,
|
||||||
|
mappedAtCreation: true
|
||||||
|
})
|
||||||
|
new Float32Array(this.quadVertexBuffer.getMappedRange()).set(QUAD_VERTS)
|
||||||
|
this.quadVertexBuffer.unmap()
|
||||||
|
|
||||||
|
this.indexBuffer = device.createBuffer({
|
||||||
|
size: QUAD_INDICES.byteLength,
|
||||||
|
usage: GPUBufferUsage.INDEX,
|
||||||
|
mappedAtCreation: true
|
||||||
|
})
|
||||||
|
new Uint16Array(this.indexBuffer.getMappedRange()).set(QUAD_INDICES)
|
||||||
|
this.indexBuffer.unmap()
|
||||||
|
|
||||||
|
this.instanceBuffer = device.createBuffer({
|
||||||
|
size: MAX_STROKES * STROKE_STRIDE,
|
||||||
|
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
|
||||||
|
})
|
||||||
|
|
||||||
|
this.uniformBuffer = device.createBuffer({
|
||||||
|
size: UNIFORM_SIZE,
|
||||||
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 2. Brush Shader (Drawing) ---
|
||||||
|
const brushModuleV = device.createShaderModule({ code: brushVertex })
|
||||||
|
const brushModuleF = device.createShaderModule({ code: brushFragment })
|
||||||
|
|
||||||
|
// Create explicit bind group layouts
|
||||||
|
this.uniformBindGroupLayout = device.createBindGroupLayout({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
binding: 0,
|
||||||
|
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
||||||
|
buffer: { type: 'uniform' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
this.textureBindGroupLayout = device.createBindGroupLayout({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
binding: 0,
|
||||||
|
visibility: GPUShaderStage.FRAGMENT,
|
||||||
|
texture: {} // default is float, 2d
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
this.mainUniformBindGroup = device.createBindGroup({
|
||||||
|
layout: this.uniformBindGroupLayout,
|
||||||
|
entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderPipelineLayout = device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [this.uniformBindGroupLayout]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Standard Render Pipeline (Alpha Blend)
|
||||||
|
this.renderPipeline = device.createRenderPipeline({
|
||||||
|
layout: renderPipelineLayout,
|
||||||
|
vertex: {
|
||||||
|
module: brushModuleV,
|
||||||
|
entryPoint: 'vs',
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
arrayStride: 8,
|
||||||
|
stepMode: 'vertex',
|
||||||
|
attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] // Quad vertex attributes
|
||||||
|
},
|
||||||
|
{
|
||||||
|
arrayStride: 16,
|
||||||
|
stepMode: 'instance',
|
||||||
|
attributes: [
|
||||||
|
{ shaderLocation: 1, offset: 0, format: 'float32x2' }, // Instance attributes: position
|
||||||
|
{ shaderLocation: 2, offset: 8, format: 'float32' }, // size
|
||||||
|
{ shaderLocation: 3, offset: 12, format: 'float32' } // pressure
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: brushModuleF,
|
||||||
|
entryPoint: 'fs',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
format: 'rgba8unorm',
|
||||||
|
blend: {
|
||||||
|
color: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
primitive: { topology: 'triangle-list' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Accumulate strokes using SourceOver blending to ensure smooth intersections.
|
||||||
|
this.accumulatePipeline = device.createRenderPipeline({
|
||||||
|
layout: renderPipelineLayout,
|
||||||
|
vertex: {
|
||||||
|
module: brushModuleV,
|
||||||
|
entryPoint: 'vs',
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
arrayStride: 8,
|
||||||
|
stepMode: 'vertex',
|
||||||
|
attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
arrayStride: 16,
|
||||||
|
stepMode: 'instance',
|
||||||
|
attributes: [
|
||||||
|
{ shaderLocation: 1, offset: 0, format: 'float32x2' },
|
||||||
|
{ shaderLocation: 2, offset: 8, format: 'float32' },
|
||||||
|
{ shaderLocation: 3, offset: 12, format: 'float32' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: brushModuleF,
|
||||||
|
entryPoint: 'fs',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
format: 'rgba8unorm',
|
||||||
|
blend: {
|
||||||
|
// Use SourceOver blending for smooth stroke intersections.
|
||||||
|
color: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
primitive: { topology: 'triangle-list' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 3. Blit Pipeline (For Preview) ---
|
||||||
|
const blitPipelineLayout = device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [this.textureBindGroupLayout]
|
||||||
|
})
|
||||||
|
|
||||||
|
this.blitPipeline = device.createRenderPipeline({
|
||||||
|
layout: blitPipelineLayout,
|
||||||
|
vertex: {
|
||||||
|
module: device.createShaderModule({ code: blitShader }),
|
||||||
|
entryPoint: 'vs'
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: device.createShaderModule({ code: blitShader }),
|
||||||
|
entryPoint: 'fs',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
format: presentationFormat, // Use the presentation format
|
||||||
|
blend: {
|
||||||
|
color: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
primitive: { topology: 'triangle-list' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 4. Composite Pipeline ---
|
||||||
|
|
||||||
|
const compositePipelineLayout = device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [
|
||||||
|
this.textureBindGroupLayout,
|
||||||
|
this.uniformBindGroupLayout
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Standard composite pipeline for offscreen textures
|
||||||
|
this.compositePipeline = device.createRenderPipeline({
|
||||||
|
layout: compositePipelineLayout,
|
||||||
|
vertex: {
|
||||||
|
module: device.createShaderModule({ code: compositeShader }),
|
||||||
|
entryPoint: 'vs'
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: device.createShaderModule({ code: compositeShader }),
|
||||||
|
entryPoint: 'fs',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
format: 'rgba8unorm',
|
||||||
|
blend: {
|
||||||
|
color: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
primitive: { topology: 'triangle-list' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Composite pipeline for the preview canvas
|
||||||
|
this.compositePipelinePreview = device.createRenderPipeline({
|
||||||
|
layout: compositePipelineLayout,
|
||||||
|
vertex: {
|
||||||
|
module: device.createShaderModule({ code: compositeShader }),
|
||||||
|
entryPoint: 'vs'
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: device.createShaderModule({ code: compositeShader }),
|
||||||
|
entryPoint: 'fs',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
format: presentationFormat,
|
||||||
|
blend: {
|
||||||
|
color: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
primitive: { topology: 'triangle-list' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 5. Erase Pipeline (Destination Out) ---
|
||||||
|
// Standard erase pipeline for offscreen textures
|
||||||
|
this.erasePipeline = device.createRenderPipeline({
|
||||||
|
layout: compositePipelineLayout,
|
||||||
|
vertex: {
|
||||||
|
module: device.createShaderModule({ code: compositeShader }),
|
||||||
|
entryPoint: 'vs'
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: device.createShaderModule({ code: compositeShader }),
|
||||||
|
entryPoint: 'fs',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
format: 'rgba8unorm',
|
||||||
|
blend: {
|
||||||
|
color: {
|
||||||
|
srcFactor: 'zero',
|
||||||
|
dstFactor: 'one-minus-src-alpha', // dst * (1 - src_alpha)
|
||||||
|
operation: 'add'
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
srcFactor: 'zero',
|
||||||
|
dstFactor: 'one-minus-src-alpha', // dst_alpha * (1 - src_alpha)
|
||||||
|
operation: 'add'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
primitive: { topology: 'triangle-list' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Erase pipeline for the preview canvas
|
||||||
|
this.erasePipelinePreview = device.createRenderPipeline({
|
||||||
|
layout: compositePipelineLayout,
|
||||||
|
vertex: {
|
||||||
|
module: device.createShaderModule({ code: compositeShader }),
|
||||||
|
entryPoint: 'vs'
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: device.createShaderModule({ code: compositeShader }),
|
||||||
|
entryPoint: 'fs',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
format: presentationFormat,
|
||||||
|
blend: {
|
||||||
|
color: {
|
||||||
|
srcFactor: 'zero',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
srcFactor: 'zero',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
primitive: { topology: 'triangle-list' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 6. Readback Pipeline (Compute) ---
|
||||||
|
this.readbackPipeline = device.createComputePipeline({
|
||||||
|
layout: 'auto',
|
||||||
|
compute: {
|
||||||
|
module: device.createShaderModule({ code: readbackShader }),
|
||||||
|
entryPoint: 'main'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public prepareStroke(width: number, height: number) {
|
||||||
|
// Initialize or resize the accumulation texture
|
||||||
|
if (
|
||||||
|
!this.currentStrokeTexture ||
|
||||||
|
this.currentStrokeTexture.width !== width ||
|
||||||
|
this.currentStrokeTexture.height !== height
|
||||||
|
) {
|
||||||
|
if (this.currentStrokeTexture) this.currentStrokeTexture.destroy()
|
||||||
|
this.currentStrokeTexture = this.device.createTexture({
|
||||||
|
size: [width, height],
|
||||||
|
format: 'rgba8unorm',
|
||||||
|
usage:
|
||||||
|
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||||
|
GPUTextureUsage.TEXTURE_BINDING |
|
||||||
|
GPUTextureUsage.COPY_SRC
|
||||||
|
})
|
||||||
|
this.currentStrokeView = this.currentStrokeTexture.createView()
|
||||||
|
|
||||||
|
// Invalidate texture-dependent bind groups
|
||||||
|
this.compositeTextureBindGroup = null
|
||||||
|
this.previewTextureBindGroup = null
|
||||||
|
// Readback bind group might also be invalid if it was using the old texture
|
||||||
|
if (this.lastReadbackTexture === this.currentStrokeTexture) {
|
||||||
|
this.readbackBindGroup = null
|
||||||
|
this.lastReadbackTexture = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the accumulation texture
|
||||||
|
const encoder = this.device.createCommandEncoder()
|
||||||
|
const pass = encoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: this.currentStrokeView!,
|
||||||
|
loadOp: 'clear',
|
||||||
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
storeOp: 'store'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
pass.end()
|
||||||
|
this.device.queue.submit([encoder.finish()])
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderStrokeToAccumulator(
|
||||||
|
points: { x: number; y: number; pressure: number }[],
|
||||||
|
settings: {
|
||||||
|
size: number
|
||||||
|
opacity: number
|
||||||
|
hardness: number
|
||||||
|
color: [number, number, number]
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
brushShape: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!this.currentStrokeView) return
|
||||||
|
// Render stroke using accumulation pipeline
|
||||||
|
this.renderStrokeInternal(
|
||||||
|
this.currentStrokeView,
|
||||||
|
this.accumulatePipeline,
|
||||||
|
points,
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public compositeStroke(
|
||||||
|
targetView: GPUTextureView,
|
||||||
|
settings: {
|
||||||
|
opacity: number
|
||||||
|
color: [number, number, number]
|
||||||
|
hardness: number // Required for uniform buffer layout
|
||||||
|
screenSize: [number, number]
|
||||||
|
brushShape: number
|
||||||
|
isErasing?: boolean
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!this.currentStrokeTexture) return
|
||||||
|
|
||||||
|
// Update uniforms for the composite pass
|
||||||
|
const buffer = new ArrayBuffer(UNIFORM_SIZE)
|
||||||
|
const f32 = new Float32Array(buffer)
|
||||||
|
const u32 = new Uint32Array(buffer)
|
||||||
|
|
||||||
|
f32[0] = settings.color[0]
|
||||||
|
f32[1] = settings.color[1]
|
||||||
|
f32[2] = settings.color[2]
|
||||||
|
f32[3] = settings.opacity
|
||||||
|
f32[4] = settings.hardness
|
||||||
|
f32[5] = 0 // Padding
|
||||||
|
f32[6] = settings.screenSize[0]
|
||||||
|
f32[7] = settings.screenSize[1]
|
||||||
|
u32[8] = settings.brushShape // Brush shape: 0=Circle, 1=Square
|
||||||
|
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
|
||||||
|
|
||||||
|
const encoder = this.device.createCommandEncoder()
|
||||||
|
|
||||||
|
// Choose pipeline based on operation
|
||||||
|
const pipeline = settings.isErasing
|
||||||
|
? this.erasePipeline
|
||||||
|
: this.compositePipeline
|
||||||
|
|
||||||
|
// 1. Texture Bind Group (Group 0)
|
||||||
|
if (!this.compositeTextureBindGroup) {
|
||||||
|
this.compositeTextureBindGroup = this.device.createBindGroup({
|
||||||
|
layout: this.textureBindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: this.currentStrokeTexture.createView() }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Uniform Bind Group (Group 1) - Use shared mainUniformBindGroup
|
||||||
|
// It is compatible because we used the same layout
|
||||||
|
|
||||||
|
const pass = encoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: targetView,
|
||||||
|
loadOp: 'load',
|
||||||
|
storeOp: 'store'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
pass.setPipeline(pipeline)
|
||||||
|
pass.setBindGroup(0, this.compositeTextureBindGroup)
|
||||||
|
pass.setBindGroup(1, this.mainUniformBindGroup)
|
||||||
|
pass.draw(3)
|
||||||
|
pass.end()
|
||||||
|
|
||||||
|
this.device.queue.submit([encoder.finish()])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct rendering method
|
||||||
|
public renderStroke(
|
||||||
|
targetView: GPUTextureView,
|
||||||
|
points: { x: number; y: number; pressure: number }[],
|
||||||
|
settings: {
|
||||||
|
size: number
|
||||||
|
opacity: number
|
||||||
|
hardness: number
|
||||||
|
color: [number, number, number]
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
brushShape: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.renderStrokeInternal(targetView, this.renderPipeline, points, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStrokeInternal(
|
||||||
|
targetView: GPUTextureView,
|
||||||
|
pipeline: GPURenderPipeline,
|
||||||
|
points: { x: number; y: number; pressure: number }[],
|
||||||
|
settings: {
|
||||||
|
size: number
|
||||||
|
opacity: number
|
||||||
|
hardness: number
|
||||||
|
color: [number, number, number]
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
brushShape: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (points.length === 0) return
|
||||||
|
|
||||||
|
// 1. Update Uniforms
|
||||||
|
const buffer = new ArrayBuffer(UNIFORM_SIZE)
|
||||||
|
const f32 = new Float32Array(buffer)
|
||||||
|
const u32 = new Uint32Array(buffer)
|
||||||
|
|
||||||
|
f32[0] = settings.color[0]
|
||||||
|
f32[1] = settings.color[1]
|
||||||
|
f32[2] = settings.color[2]
|
||||||
|
f32[3] = settings.opacity
|
||||||
|
f32[4] = settings.hardness
|
||||||
|
f32[5] = 0 // Padding
|
||||||
|
f32[6] = settings.width
|
||||||
|
f32[7] = settings.height
|
||||||
|
u32[8] = settings.brushShape
|
||||||
|
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
|
||||||
|
|
||||||
|
// 2. Batch Rendering
|
||||||
|
let processedPoints = 0
|
||||||
|
while (processedPoints < points.length) {
|
||||||
|
const batchSize = Math.min(points.length - processedPoints, MAX_STROKES)
|
||||||
|
const iData = new Float32Array(batchSize * 4)
|
||||||
|
|
||||||
|
for (let i = 0; i < batchSize; i++) {
|
||||||
|
const p = points[processedPoints + i]
|
||||||
|
iData[i * 4 + 0] = p.x
|
||||||
|
iData[i * 4 + 1] = p.y
|
||||||
|
iData[i * 4 + 2] = settings.size
|
||||||
|
iData[i * 4 + 3] = p.pressure
|
||||||
|
}
|
||||||
|
|
||||||
|
this.device.queue.writeBuffer(this.instanceBuffer, 0, iData)
|
||||||
|
|
||||||
|
// 3. Render Pass
|
||||||
|
const encoder = this.device.createCommandEncoder()
|
||||||
|
const pass = encoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: targetView,
|
||||||
|
loadOp: 'load',
|
||||||
|
storeOp: 'store'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
pass.setPipeline(pipeline)
|
||||||
|
pass.setBindGroup(0, this.mainUniformBindGroup)
|
||||||
|
pass.setVertexBuffer(0, this.quadVertexBuffer)
|
||||||
|
pass.setVertexBuffer(1, this.instanceBuffer)
|
||||||
|
pass.setIndexBuffer(this.indexBuffer, 'uint16')
|
||||||
|
pass.drawIndexed(6, batchSize)
|
||||||
|
pass.end()
|
||||||
|
|
||||||
|
this.device.queue.submit([encoder.finish()])
|
||||||
|
|
||||||
|
processedPoints += batchSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blit the accumulated stroke to the preview canvas
|
||||||
|
public blitToCanvas(
|
||||||
|
destinationCtx: GPUCanvasContext,
|
||||||
|
settings: {
|
||||||
|
opacity: number
|
||||||
|
color: [number, number, number]
|
||||||
|
hardness: number
|
||||||
|
screenSize: [number, number]
|
||||||
|
brushShape: number
|
||||||
|
isErasing?: boolean
|
||||||
|
},
|
||||||
|
backgroundTexture?: GPUTexture
|
||||||
|
) {
|
||||||
|
const encoder = this.device.createCommandEncoder()
|
||||||
|
const destView = destinationCtx.getCurrentTexture().createView()
|
||||||
|
|
||||||
|
if (backgroundTexture) {
|
||||||
|
// Draw background texture to allow erasing effect on existing content
|
||||||
|
if (
|
||||||
|
this.lastBackgroundTexture !== backgroundTexture ||
|
||||||
|
!this.backgroundBindGroup
|
||||||
|
) {
|
||||||
|
this.backgroundBindGroup = this.device.createBindGroup({
|
||||||
|
layout: this.textureBindGroupLayout,
|
||||||
|
entries: [{ binding: 0, resource: backgroundTexture.createView() }]
|
||||||
|
})
|
||||||
|
this.lastBackgroundTexture = backgroundTexture
|
||||||
|
}
|
||||||
|
|
||||||
|
const pass = encoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: destView,
|
||||||
|
loadOp: 'clear', // Clear attachment before drawing
|
||||||
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
storeOp: 'store'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
pass.setPipeline(this.blitPipeline)
|
||||||
|
pass.setBindGroup(0, this.backgroundBindGroup)
|
||||||
|
pass.draw(3)
|
||||||
|
pass.end()
|
||||||
|
} else {
|
||||||
|
// Clear the destination texture
|
||||||
|
const clearPass = encoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: destView,
|
||||||
|
loadOp: 'clear',
|
||||||
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
storeOp: 'store'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
clearPass.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the accumulated stroke
|
||||||
|
if (this.currentStrokeTexture) {
|
||||||
|
// Update uniforms for the preview pass
|
||||||
|
const buffer = new ArrayBuffer(UNIFORM_SIZE)
|
||||||
|
const f32 = new Float32Array(buffer)
|
||||||
|
const u32 = new Uint32Array(buffer)
|
||||||
|
|
||||||
|
f32[0] = settings.color[0]
|
||||||
|
f32[1] = settings.color[1]
|
||||||
|
f32[2] = settings.color[2]
|
||||||
|
f32[3] = settings.opacity
|
||||||
|
f32[4] = settings.hardness
|
||||||
|
f32[5] = 0 // Padding
|
||||||
|
f32[6] = settings.screenSize[0]
|
||||||
|
f32[7] = settings.screenSize[1]
|
||||||
|
u32[8] = settings.brushShape
|
||||||
|
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
|
||||||
|
|
||||||
|
// Select preview pipeline based on operation
|
||||||
|
const pipeline = settings.isErasing
|
||||||
|
? this.erasePipelinePreview
|
||||||
|
: this.compositePipelinePreview
|
||||||
|
|
||||||
|
// 1. Texture Bind Group (Group 0)
|
||||||
|
if (!this.previewTextureBindGroup) {
|
||||||
|
this.previewTextureBindGroup = this.device.createBindGroup({
|
||||||
|
layout: this.textureBindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: this.currentStrokeTexture.createView() }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Uniform Bind Group (Group 1) - Use shared mainUniformBindGroup
|
||||||
|
|
||||||
|
const passStroke = encoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: destView,
|
||||||
|
loadOp: 'load', // Load the previous pass result
|
||||||
|
storeOp: 'store'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
passStroke.setPipeline(pipeline)
|
||||||
|
passStroke.setBindGroup(0, this.previewTextureBindGroup)
|
||||||
|
passStroke.setBindGroup(1, this.mainUniformBindGroup)
|
||||||
|
passStroke.draw(3)
|
||||||
|
passStroke.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.device.queue.submit([encoder.finish()])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the preview canvas
|
||||||
|
public clearPreview(destinationCtx: GPUCanvasContext) {
|
||||||
|
const encoder = this.device.createCommandEncoder()
|
||||||
|
const pass = encoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: destinationCtx.getCurrentTexture().createView(),
|
||||||
|
loadOp: 'clear',
|
||||||
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
storeOp: 'store'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
pass.end()
|
||||||
|
this.device.queue.submit([encoder.finish()])
|
||||||
|
}
|
||||||
|
|
||||||
|
public prepareReadback(texture: GPUTexture, outputBuffer: GPUBuffer) {
|
||||||
|
if (
|
||||||
|
this.lastReadbackTexture !== texture ||
|
||||||
|
this.lastReadbackBuffer !== outputBuffer ||
|
||||||
|
!this.readbackBindGroup
|
||||||
|
) {
|
||||||
|
this.readbackBindGroup = this.device.createBindGroup({
|
||||||
|
layout: this.readbackPipeline.getBindGroupLayout(0),
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: texture.createView() },
|
||||||
|
{ binding: 1, resource: { buffer: outputBuffer } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
this.lastReadbackTexture = texture
|
||||||
|
this.lastReadbackBuffer = outputBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = this.device.createCommandEncoder()
|
||||||
|
const pass = encoder.beginComputePass()
|
||||||
|
pass.setPipeline(this.readbackPipeline)
|
||||||
|
pass.setBindGroup(0, this.readbackBindGroup)
|
||||||
|
|
||||||
|
const width = texture.width
|
||||||
|
const height = texture.height
|
||||||
|
// Dispatch workgroups based on texture dimensions (8x8 block size)
|
||||||
|
pass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8))
|
||||||
|
pass.end()
|
||||||
|
|
||||||
|
this.device.queue.submit([encoder.finish()])
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
this.quadVertexBuffer.destroy()
|
||||||
|
this.indexBuffer.destroy()
|
||||||
|
this.instanceBuffer.destroy()
|
||||||
|
this.uniformBuffer.destroy()
|
||||||
|
if (this.currentStrokeTexture) this.currentStrokeTexture.destroy()
|
||||||
|
|
||||||
|
// Clear cached bind groups
|
||||||
|
this.compositeTextureBindGroup = null
|
||||||
|
this.previewTextureBindGroup = null
|
||||||
|
this.readbackBindGroup = null
|
||||||
|
this.backgroundBindGroup = null
|
||||||
|
this.lastReadbackTexture = null
|
||||||
|
this.lastReadbackBuffer = null
|
||||||
|
this.lastBackgroundTexture = null
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/composables/maskeditor/gpu/brushShaders.ts
Normal file
171
src/composables/maskeditor/gpu/brushShaders.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import tgpu from 'typegpu'
|
||||||
|
import * as d from 'typegpu/data'
|
||||||
|
import { BrushUniforms } from './gpuSchema'
|
||||||
|
|
||||||
|
const VertexOutput = d.struct({
|
||||||
|
position: d.builtin.position,
|
||||||
|
localUV: d.location(0, d.vec2f),
|
||||||
|
color: d.location(1, d.vec3f),
|
||||||
|
opacity: d.location(2, d.f32),
|
||||||
|
hardness: d.location(3, d.f32)
|
||||||
|
})
|
||||||
|
|
||||||
|
const brushVertexTemplate = `
|
||||||
|
@group(0) @binding(0) var<uniform> globals: BrushUniforms;
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs(
|
||||||
|
@location(0) quadPos: vec2<f32>,
|
||||||
|
@location(1) pos: vec2<f32>,
|
||||||
|
@location(2) size: f32,
|
||||||
|
@location(3) pressure: f32
|
||||||
|
) -> VertexOutput {
|
||||||
|
// Convert diameter to radius
|
||||||
|
let radius = size * pressure;
|
||||||
|
let pixelPos = pos + (quadPos * radius);
|
||||||
|
|
||||||
|
// Convert pixel coordinates to Normalized Device Coordinates (NDC)
|
||||||
|
let ndcX = (pixelPos.x / globals.screenSize.x) * 2.0 - 1.0;
|
||||||
|
let ndcY = 1.0 - ((pixelPos.y / globals.screenSize.y) * 2.0); // Flip Y axis for WebGPU coordinate system
|
||||||
|
|
||||||
|
return VertexOutput(
|
||||||
|
vec4<f32>(ndcX, ndcY, 0.0, 1.0),
|
||||||
|
quadPos,
|
||||||
|
globals.brushColor,
|
||||||
|
pressure * globals.brushOpacity,
|
||||||
|
globals.hardness
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const brushVertex = tgpu.resolve({
|
||||||
|
template: brushVertexTemplate,
|
||||||
|
externals: {
|
||||||
|
BrushUniforms,
|
||||||
|
VertexOutput
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const brushFragmentTemplate = `
|
||||||
|
@group(0) @binding(0) var<uniform> globals: BrushUniforms;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs(v: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
var dist: f32;
|
||||||
|
if (globals.brushShape == 1u) {
|
||||||
|
// Calculate Chebyshev distance for square shape
|
||||||
|
dist = max(abs(v.localUV.x), abs(v.localUV.y));
|
||||||
|
} else {
|
||||||
|
// Calculate Euclidean distance for circle shape
|
||||||
|
dist = length(v.localUV);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dist > 1.0) { discard; }
|
||||||
|
|
||||||
|
// Calculate alpha with hardness and anti-aliasing
|
||||||
|
let edgeWidth = fwidth(dist);
|
||||||
|
let startFade = min(v.hardness, 1.0 - edgeWidth * 2.0);
|
||||||
|
let linearAlpha = 1.0 - smoothstep(startFade, 1.0, dist);
|
||||||
|
// Apply quadratic falloff for smoother edges
|
||||||
|
let alphaShape = pow(linearAlpha, 2.0);
|
||||||
|
|
||||||
|
// Return premultiplied alpha color
|
||||||
|
let alpha = alphaShape * v.opacity;
|
||||||
|
return vec4<f32>(v.color * alpha, alpha);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const brushFragment = tgpu.resolve({
|
||||||
|
template: brushFragmentTemplate,
|
||||||
|
externals: {
|
||||||
|
VertexOutput,
|
||||||
|
BrushUniforms
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const blitShaderTemplate = `
|
||||||
|
@vertex fn vs(@builtin(vertex_index) vIdx: u32) -> @builtin(position) vec4<f32> {
|
||||||
|
var pos = array<vec2<f32>, 3>(
|
||||||
|
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0)
|
||||||
|
);
|
||||||
|
return vec4<f32>(pos[vIdx], 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var myTexture: texture_2d<f32>;
|
||||||
|
|
||||||
|
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||||
|
let c = textureLoad(myTexture, vec2<i32>(pos.xy), 0);
|
||||||
|
// Treat texture as premultiplied to prevent double-darkening on overlaps
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const blitShader = tgpu.resolve({
|
||||||
|
template: blitShaderTemplate,
|
||||||
|
externals: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const compositeShaderTemplate = `
|
||||||
|
@vertex fn vs(@builtin(vertex_index) vIdx: u32) -> @builtin(position) vec4<f32> {
|
||||||
|
var pos = array<vec2<f32>, 3>(
|
||||||
|
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0)
|
||||||
|
);
|
||||||
|
return vec4<f32>(pos[vIdx], 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var myTexture: texture_2d<f32>;
|
||||||
|
@group(1) @binding(0) var<uniform> globals: BrushUniforms;
|
||||||
|
|
||||||
|
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||||
|
let sampled = textureLoad(myTexture, vec2<i32>(pos.xy), 0);
|
||||||
|
// Apply global brush opacity to accumulated coverage
|
||||||
|
return sampled * globals.brushOpacity;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const compositeShader = tgpu.resolve({
|
||||||
|
template: compositeShaderTemplate,
|
||||||
|
externals: {
|
||||||
|
BrushUniforms
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const readbackShaderTemplate = `
|
||||||
|
@group(0) @binding(0) var inputTex: texture_2d<f32>;
|
||||||
|
@group(0) @binding(1) var<storage, read_write> outputBuf: array<u32>;
|
||||||
|
|
||||||
|
@compute @workgroup_size(8, 8)
|
||||||
|
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||||
|
let dims = textureDimensions(inputTex);
|
||||||
|
if (id.x >= dims.x || id.y >= dims.y) { return; }
|
||||||
|
|
||||||
|
let color = textureLoad(inputTex, vec2<i32>(id.xy), 0);
|
||||||
|
|
||||||
|
var r = color.r;
|
||||||
|
var g = color.g;
|
||||||
|
var b = color.b;
|
||||||
|
let a = color.a;
|
||||||
|
|
||||||
|
if (a > 0.0) {
|
||||||
|
r = r / a;
|
||||||
|
g = g / a;
|
||||||
|
b = b / a;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ir = u32(clamp(r * 255.0, 0.0, 255.0));
|
||||||
|
let ig = u32(clamp(g * 255.0, 0.0, 255.0));
|
||||||
|
let ib = u32(clamp(b * 255.0, 0.0, 255.0));
|
||||||
|
let ia = u32(clamp(a * 255.0, 0.0, 255.0));
|
||||||
|
|
||||||
|
// Pack RGBA channels into a single u32 (Little Endian)
|
||||||
|
let packed = ir | (ig << 8u) | (ib << 16u) | (ia << 24u);
|
||||||
|
|
||||||
|
let index = id.y * dims.x + id.x;
|
||||||
|
outputBuf[index] = packed;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const readbackShader = tgpu.resolve({
|
||||||
|
template: readbackShaderTemplate,
|
||||||
|
externals: {}
|
||||||
|
})
|
||||||
17
src/composables/maskeditor/gpu/gpuSchema.ts
Normal file
17
src/composables/maskeditor/gpu/gpuSchema.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import * as d from 'typegpu/data'
|
||||||
|
|
||||||
|
// Global brush uniforms
|
||||||
|
export const BrushUniforms = d.struct({
|
||||||
|
brushColor: d.vec3f,
|
||||||
|
brushOpacity: d.f32,
|
||||||
|
hardness: d.f32,
|
||||||
|
screenSize: d.vec2f,
|
||||||
|
brushShape: d.u32 // 0: Circle, 1: Square
|
||||||
|
})
|
||||||
|
|
||||||
|
// Per-point instance data
|
||||||
|
export const StrokePoint = d.struct({
|
||||||
|
pos: d.location(0, d.vec2f), // Center position
|
||||||
|
size: d.location(1, d.f32), // Brush radius
|
||||||
|
pressure: d.location(2, d.f32) // Pressure value (0.0 - 1.0)
|
||||||
|
})
|
||||||
126
src/composables/maskeditor/splineUtils.ts
Normal file
126
src/composables/maskeditor/splineUtils.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a Catmull-Rom spline at parameter t between p1 and p2
|
||||||
|
* @param p0 Previous control point
|
||||||
|
* @param p1 Start point of the curve segment
|
||||||
|
* @param p2 End point of the curve segment
|
||||||
|
* @param p3 Next control point
|
||||||
|
* @param t Parameter in range [0, 1]
|
||||||
|
* @returns Interpolated point on the curve
|
||||||
|
*/
|
||||||
|
export function catmullRomSpline(
|
||||||
|
p0: Point,
|
||||||
|
p1: Point,
|
||||||
|
p2: Point,
|
||||||
|
p3: Point,
|
||||||
|
t: number
|
||||||
|
): Point {
|
||||||
|
// Centripetal Catmull-Rom Spline (alpha = 0.5) to prevent loops and overshoots
|
||||||
|
const alpha = 0.5
|
||||||
|
|
||||||
|
const getT = (t: number, p0: Point, p1: Point) => {
|
||||||
|
const d = Math.hypot(p1.x - p0.x, p1.y - p0.y)
|
||||||
|
return t + Math.pow(d, alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
const t0 = 0
|
||||||
|
const t1 = getT(t0, p0, p1)
|
||||||
|
const t2 = getT(t1, p1, p2)
|
||||||
|
const t3 = getT(t2, p2, p3)
|
||||||
|
|
||||||
|
// Map normalized t to parameter range
|
||||||
|
const tInterp = t1 + (t2 - t1) * t
|
||||||
|
|
||||||
|
// Safe interpolation for coincident points
|
||||||
|
const interp = (
|
||||||
|
pA: Point,
|
||||||
|
pB: Point,
|
||||||
|
tA: number,
|
||||||
|
tB: number,
|
||||||
|
t: number
|
||||||
|
): Point => {
|
||||||
|
if (Math.abs(tB - tA) < 0.0001) return pA
|
||||||
|
const k = (t - tA) / (tB - tA)
|
||||||
|
return add(mul(pA, 1 - k), mul(pB, k))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Barry-Goldman pyramidal interpolation
|
||||||
|
const A1 = interp(p0, p1, t0, t1, tInterp)
|
||||||
|
const A2 = interp(p1, p2, t1, t2, tInterp)
|
||||||
|
const A3 = interp(p2, p3, t2, t3, tInterp)
|
||||||
|
|
||||||
|
const B1 = interp(A1, A2, t0, t2, tInterp)
|
||||||
|
const B2 = interp(A2, A3, t1, t3, tInterp)
|
||||||
|
|
||||||
|
const C = interp(B1, B2, t1, t2, tInterp)
|
||||||
|
|
||||||
|
return C
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(p1: Point, p2: Point): Point {
|
||||||
|
return { x: p1.x + p2.x, y: p1.y + p2.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mul(p: Point, s: number): Point {
|
||||||
|
return { x: p.x * s, y: p.y * s }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resamples a curve segment with a starting offset (remainder from previous segment).
|
||||||
|
* Returns the resampled points and the new remainder distance.
|
||||||
|
*
|
||||||
|
* @param points Points defining the curve segment
|
||||||
|
* @param spacing Desired spacing between points
|
||||||
|
* @param startOffset Distance to travel before placing the first point (remainder)
|
||||||
|
* @returns Object containing points and new remainder
|
||||||
|
*/
|
||||||
|
export function resampleSegment(
|
||||||
|
points: Point[],
|
||||||
|
spacing: number,
|
||||||
|
startOffset: number
|
||||||
|
): { points: Point[]; remainder: number } {
|
||||||
|
if (points.length === 0) return { points: [], remainder: startOffset }
|
||||||
|
|
||||||
|
const result: Point[] = []
|
||||||
|
let currentDist = 0
|
||||||
|
let nextSampleDist = startOffset
|
||||||
|
|
||||||
|
// Iterate through segment points
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const p1 = points[i]
|
||||||
|
const p2 = points[i + 1]
|
||||||
|
|
||||||
|
const dx = p2.x - p1.x
|
||||||
|
const dy = p2.y - p1.y
|
||||||
|
const segmentLen = Math.hypot(dx, dy)
|
||||||
|
|
||||||
|
// Handle zero-length segments
|
||||||
|
if (segmentLen < 0.0001) {
|
||||||
|
while (nextSampleDist <= currentDist) {
|
||||||
|
result.push(p1)
|
||||||
|
nextSampleDist += spacing
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate samples within the segment
|
||||||
|
while (nextSampleDist <= currentDist + segmentLen) {
|
||||||
|
const t = (nextSampleDist - currentDist) / segmentLen
|
||||||
|
|
||||||
|
// Interpolate
|
||||||
|
const x = p1.x + t * dx
|
||||||
|
const y = p1.y + t * dy
|
||||||
|
result.push({ x, y })
|
||||||
|
|
||||||
|
nextSampleDist += spacing
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDist += segmentLen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate remainder distance for the next segment
|
||||||
|
const remainder = nextSampleDist - currentDist
|
||||||
|
|
||||||
|
return { points: result, remainder }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
|||||||
export function useCanvasHistory(maxStates = 20) {
|
export function useCanvasHistory(maxStates = 20) {
|
||||||
const store = useMaskEditorStore()
|
const store = useMaskEditorStore()
|
||||||
|
|
||||||
const states = ref<{ mask: ImageData; rgb: ImageData }[]>([])
|
const states = ref<
|
||||||
|
{ mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap }[]
|
||||||
|
>([])
|
||||||
const currentStateIndex = ref(-1)
|
const currentStateIndex = ref(-1)
|
||||||
const initialized = ref(false)
|
const initialized = ref(false)
|
||||||
|
|
||||||
@@ -53,7 +55,10 @@ export function useCanvasHistory(maxStates = 20) {
|
|||||||
initialized.value = true
|
initialized.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveState = () => {
|
const saveState = (
|
||||||
|
providedMaskData?: ImageData | ImageBitmap,
|
||||||
|
providedRgbData?: ImageData | ImageBitmap
|
||||||
|
) => {
|
||||||
const maskCtx = store.maskCtx
|
const maskCtx = store.maskCtx
|
||||||
const rgbCtx = store.rgbCtx
|
const rgbCtx = store.rgbCtx
|
||||||
const maskCanvas = store.maskCanvas
|
const maskCanvas = store.maskCanvas
|
||||||
@@ -68,23 +73,32 @@ export function useCanvasHistory(maxStates = 20) {
|
|||||||
|
|
||||||
states.value = states.value.slice(0, currentStateIndex.value + 1)
|
states.value = states.value.slice(0, currentStateIndex.value + 1)
|
||||||
|
|
||||||
const maskState = maskCtx.getImageData(
|
let maskState: ImageData | ImageBitmap
|
||||||
0,
|
let rgbState: ImageData | ImageBitmap
|
||||||
0,
|
|
||||||
maskCanvas.width,
|
if (providedMaskData && providedRgbData) {
|
||||||
maskCanvas.height
|
maskState = providedMaskData
|
||||||
)
|
rgbState = providedRgbData
|
||||||
const rgbState = rgbCtx.getImageData(
|
} else {
|
||||||
0,
|
maskState = maskCtx.getImageData(
|
||||||
0,
|
0,
|
||||||
rgbCanvas.width,
|
0,
|
||||||
rgbCanvas.height
|
maskCanvas.width,
|
||||||
)
|
maskCanvas.height
|
||||||
|
)
|
||||||
|
rgbState = rgbCtx.getImageData(0, 0, rgbCanvas.width, rgbCanvas.height)
|
||||||
|
}
|
||||||
|
|
||||||
states.value.push({ mask: maskState, rgb: rgbState })
|
states.value.push({ mask: maskState, rgb: rgbState })
|
||||||
currentStateIndex.value++
|
currentStateIndex.value++
|
||||||
|
|
||||||
if (states.value.length > maxStates) {
|
if (states.value.length > maxStates) {
|
||||||
states.value.shift()
|
const removed = states.value.shift()
|
||||||
|
// Cleanup ImageBitmaps to avoid memory leaks
|
||||||
|
if (removed) {
|
||||||
|
if (removed.mask instanceof ImageBitmap) removed.mask.close()
|
||||||
|
if (removed.rgb instanceof ImageBitmap) removed.rgb.close()
|
||||||
|
}
|
||||||
currentStateIndex.value--
|
currentStateIndex.value--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,16 +123,35 @@ export function useCanvasHistory(maxStates = 20) {
|
|||||||
restoreState(states.value[currentStateIndex.value])
|
restoreState(states.value[currentStateIndex.value])
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreState = (state: { mask: ImageData; rgb: ImageData }) => {
|
const restoreState = (state: {
|
||||||
|
mask: ImageData | ImageBitmap
|
||||||
|
rgb: ImageData | ImageBitmap
|
||||||
|
}) => {
|
||||||
const maskCtx = store.maskCtx
|
const maskCtx = store.maskCtx
|
||||||
const rgbCtx = store.rgbCtx
|
const rgbCtx = store.rgbCtx
|
||||||
if (!maskCtx || !rgbCtx) return
|
if (!maskCtx || !rgbCtx) return
|
||||||
|
|
||||||
maskCtx.putImageData(state.mask, 0, 0)
|
if (state.mask instanceof ImageBitmap) {
|
||||||
rgbCtx.putImageData(state.rgb, 0, 0)
|
maskCtx.clearRect(0, 0, state.mask.width, state.mask.height)
|
||||||
|
maskCtx.drawImage(state.mask, 0, 0)
|
||||||
|
} else {
|
||||||
|
maskCtx.putImageData(state.mask, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.rgb instanceof ImageBitmap) {
|
||||||
|
rgbCtx.clearRect(0, 0, state.rgb.width, state.rgb.height)
|
||||||
|
rgbCtx.drawImage(state.rgb, 0, 0)
|
||||||
|
} else {
|
||||||
|
rgbCtx.putImageData(state.rgb, 0, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearStates = () => {
|
const clearStates = () => {
|
||||||
|
// Cleanup bitmaps
|
||||||
|
states.value.forEach((state) => {
|
||||||
|
if (state.mask instanceof ImageBitmap) state.mask.close()
|
||||||
|
if (state.rgb instanceof ImageBitmap) state.rgb.close()
|
||||||
|
})
|
||||||
states.value = []
|
states.value = []
|
||||||
currentStateIndex.value = -1
|
currentStateIndex.value = -1
|
||||||
initialized.value = false
|
initialized.value = false
|
||||||
@@ -127,6 +160,7 @@ export function useCanvasHistory(maxStates = 20) {
|
|||||||
return {
|
return {
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
|
currentStateIndex,
|
||||||
saveInitialState,
|
saveInitialState,
|
||||||
saveState,
|
saveState,
|
||||||
undo,
|
undo,
|
||||||
|
|||||||
@@ -80,21 +80,64 @@ export function useMaskEditorLoader() {
|
|||||||
try {
|
try {
|
||||||
validateNode(node)
|
validateNode(node)
|
||||||
|
|
||||||
const nodeImageUrl = getNodeImageUrl(node)
|
let nodeImageUrl = getNodeImageUrl(node)
|
||||||
|
|
||||||
const nodeImageRef = parseImageRef(nodeImageUrl)
|
let nodeImageRef = parseImageRef(nodeImageUrl)
|
||||||
|
|
||||||
let widgetFilename: string | undefined
|
let widgetFilename: string | undefined
|
||||||
if (node.widgets) {
|
if (node.widgets) {
|
||||||
const imageWidget = node.widgets.find((w) => w.name === 'image')
|
const imageWidget = node.widgets.find((w) => w.name === 'image')
|
||||||
if (
|
if (imageWidget) {
|
||||||
imageWidget &&
|
if (typeof imageWidget.value === 'string') {
|
||||||
typeof imageWidget.value === 'object' &&
|
widgetFilename = imageWidget.value
|
||||||
imageWidget.value &&
|
} else if (
|
||||||
'filename' in imageWidget.value &&
|
typeof imageWidget.value === 'object' &&
|
||||||
typeof imageWidget.value.filename === 'string'
|
imageWidget.value &&
|
||||||
) {
|
'filename' in imageWidget.value &&
|
||||||
widgetFilename = imageWidget.value.filename
|
typeof imageWidget.value.filename === 'string'
|
||||||
|
) {
|
||||||
|
widgetFilename = imageWidget.value.filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a widget filename, we should prioritize it over the node image
|
||||||
|
// because the node image might be stale (e.g. from a previous save)
|
||||||
|
// while the widget value reflects the current selection.
|
||||||
|
if (widgetFilename) {
|
||||||
|
try {
|
||||||
|
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
|
||||||
|
let filename = widgetFilename
|
||||||
|
let subfolder: string | undefined = undefined
|
||||||
|
let type: string | undefined = 'input' // Default to input for widget values
|
||||||
|
|
||||||
|
// Check for type in brackets at the end
|
||||||
|
const typeMatch = filename.match(/ \[([^\]]+)\]$/)
|
||||||
|
if (typeMatch) {
|
||||||
|
type = typeMatch[1]
|
||||||
|
filename = filename.substring(
|
||||||
|
0,
|
||||||
|
filename.length - typeMatch[0].length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for subfolder (forward slash separator)
|
||||||
|
const lastSlashIndex = filename.lastIndexOf('/')
|
||||||
|
if (lastSlashIndex !== -1) {
|
||||||
|
subfolder = filename.substring(0, lastSlashIndex)
|
||||||
|
filename = filename.substring(lastSlashIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeImageRef = {
|
||||||
|
filename,
|
||||||
|
type,
|
||||||
|
subfolder
|
||||||
|
}
|
||||||
|
|
||||||
|
// We also need to update nodeImageUrl to match this new ref so subsequent logic works
|
||||||
|
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse widget filename as ref', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,5 +61,5 @@ export interface Brush {
|
|||||||
size: number
|
size: number
|
||||||
opacity: number
|
opacity: number
|
||||||
hardness: number
|
hardness: number
|
||||||
smoothingPrecision: number
|
stepSize: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
|
|||||||
size: 10,
|
size: 10,
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
hardness: 1,
|
hardness: 1,
|
||||||
smoothingPrecision: 10
|
stepSize: 10
|
||||||
})
|
})
|
||||||
|
|
||||||
const maskBlendMode = ref<MaskBlendMode>(MaskBlendMode.Black)
|
const maskBlendMode = ref<MaskBlendMode>(MaskBlendMode.Black)
|
||||||
@@ -50,6 +50,7 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
|
|||||||
const panOffset = ref<Offset>({ x: 0, y: 0 })
|
const panOffset = ref<Offset>({ x: 0, y: 0 })
|
||||||
const cursorPoint = ref<Point>({ x: 0, y: 0 })
|
const cursorPoint = ref<Point>({ x: 0, y: 0 })
|
||||||
const resetZoomTrigger = ref<number>(0)
|
const resetZoomTrigger = ref<number>(0)
|
||||||
|
const clearTrigger = ref<number>(0)
|
||||||
|
|
||||||
const maskCanvas = ref<HTMLCanvasElement | null>(null)
|
const maskCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const maskCtx = ref<CanvasRenderingContext2D | null>(null)
|
const maskCtx = ref<CanvasRenderingContext2D | null>(null)
|
||||||
@@ -70,6 +71,8 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
|
|||||||
|
|
||||||
const canvasHistory = useCanvasHistory(20)
|
const canvasHistory = useCanvasHistory(20)
|
||||||
|
|
||||||
|
const tgpuRoot = ref<any>(null)
|
||||||
|
|
||||||
watch(maskCanvas, (canvas) => {
|
watch(maskCanvas, (canvas) => {
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
maskCtx.value = canvas.getContext('2d', { willReadFrequently: true })
|
maskCtx.value = canvas.getContext('2d', { willReadFrequently: true })
|
||||||
@@ -110,7 +113,7 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function setBrushSize(size: number): void {
|
function setBrushSize(size: number): void {
|
||||||
brushSettings.value.size = _.clamp(size, 1, 100)
|
brushSettings.value.size = _.clamp(size, 1, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBrushOpacity(opacity: number): void {
|
function setBrushOpacity(opacity: number): void {
|
||||||
@@ -121,8 +124,8 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
|
|||||||
brushSettings.value.hardness = _.clamp(hardness, 0, 1)
|
brushSettings.value.hardness = _.clamp(hardness, 0, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBrushSmoothingPrecision(precision: number): void {
|
function setBrushStepSize(step: number): void {
|
||||||
brushSettings.value.smoothingPrecision = _.clamp(precision, 1, 100)
|
brushSettings.value.stepSize = _.clamp(step, 1, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetBrushToDefault(): void {
|
function resetBrushToDefault(): void {
|
||||||
@@ -130,7 +133,7 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
|
|||||||
brushSettings.value.size = 20
|
brushSettings.value.size = 20
|
||||||
brushSettings.value.opacity = 1
|
brushSettings.value.opacity = 1
|
||||||
brushSettings.value.hardness = 1
|
brushSettings.value.hardness = 1
|
||||||
brushSettings.value.smoothingPrecision = 60
|
brushSettings.value.stepSize = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPaintBucketTolerance(tolerance: number): void {
|
function setPaintBucketTolerance(tolerance: number): void {
|
||||||
@@ -169,6 +172,10 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
|
|||||||
resetZoomTrigger.value++
|
resetZoomTrigger.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerClear(): void {
|
||||||
|
clearTrigger.value++
|
||||||
|
}
|
||||||
|
|
||||||
function setMaskOpacity(opacity: number): void {
|
function setMaskOpacity(opacity: number): void {
|
||||||
maskOpacity.value = _.clamp(opacity, 0, 1)
|
maskOpacity.value = _.clamp(opacity, 0, 1)
|
||||||
}
|
}
|
||||||
@@ -179,7 +186,7 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
|
|||||||
size: 10,
|
size: 10,
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
hardness: 1,
|
hardness: 1,
|
||||||
smoothingPrecision: 10
|
stepSize: 5
|
||||||
}
|
}
|
||||||
maskBlendMode.value = MaskBlendMode.Black
|
maskBlendMode.value = MaskBlendMode.Black
|
||||||
activeLayer.value = 'mask'
|
activeLayer.value = 'mask'
|
||||||
@@ -243,10 +250,12 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
|
|||||||
|
|
||||||
canvasHistory,
|
canvasHistory,
|
||||||
|
|
||||||
|
tgpuRoot,
|
||||||
|
|
||||||
setBrushSize,
|
setBrushSize,
|
||||||
setBrushOpacity,
|
setBrushOpacity,
|
||||||
setBrushHardness,
|
setBrushHardness,
|
||||||
setBrushSmoothingPrecision,
|
setBrushStepSize,
|
||||||
resetBrushToDefault,
|
resetBrushToDefault,
|
||||||
setPaintBucketTolerance,
|
setPaintBucketTolerance,
|
||||||
setFillOpacity,
|
setFillOpacity,
|
||||||
@@ -257,6 +266,8 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
|
|||||||
setPanOffset,
|
setPanOffset,
|
||||||
setCursorPoint,
|
setCursorPoint,
|
||||||
resetZoom,
|
resetZoom,
|
||||||
|
triggerClear,
|
||||||
|
clearTrigger,
|
||||||
setMaskOpacity,
|
setMaskOpacity,
|
||||||
resetState
|
resetState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ vi.mock('@/stores/maskEditorStore', () => ({
|
|||||||
useMaskEditorStore: vi.fn(() => mockStore)
|
useMaskEditorStore: vi.fn(() => mockStore)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Mock ImageBitmap for test environment
|
||||||
|
if (typeof globalThis.ImageBitmap === 'undefined') {
|
||||||
|
globalThis.ImageBitmap = class ImageBitmap {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
constructor(width = 100, height = 100) {
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
|
||||||
describe('useCanvasHistory', () => {
|
describe('useCanvasHistory', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@@ -42,12 +55,16 @@ describe('useCanvasHistory', () => {
|
|||||||
|
|
||||||
mockMaskCtx = {
|
mockMaskCtx = {
|
||||||
getImageData: vi.fn(() => createMockImageData()),
|
getImageData: vi.fn(() => createMockImageData()),
|
||||||
putImageData: vi.fn()
|
putImageData: vi.fn(),
|
||||||
|
clearRect: vi.fn(),
|
||||||
|
drawImage: vi.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
mockRgbCtx = {
|
mockRgbCtx = {
|
||||||
getImageData: vi.fn(() => createMockImageData()),
|
getImageData: vi.fn(() => createMockImageData()),
|
||||||
putImageData: vi.fn()
|
putImageData: vi.fn(),
|
||||||
|
clearRect: vi.fn(),
|
||||||
|
drawImage: vi.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
mockMaskCanvas = {
|
mockMaskCanvas = {
|
||||||
|
|||||||
@@ -45,7 +45,8 @@
|
|||||||
"./node_modules"
|
"./node_modules"
|
||||||
],
|
],
|
||||||
"types": [
|
"types": [
|
||||||
"vitest/globals"
|
"vitest/globals",
|
||||||
|
"@webgpu/types"
|
||||||
],
|
],
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./"
|
"rootDir": "./"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
|||||||
import IconsResolver from 'unplugin-icons/resolver'
|
import IconsResolver from 'unplugin-icons/resolver'
|
||||||
import Icons from 'unplugin-icons/vite'
|
import Icons from 'unplugin-icons/vite'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import typegpuPlugin from 'unplugin-typegpu/vite'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import type { ProxyOptions, UserConfig } from 'vite'
|
import type { ProxyOptions, UserConfig } from 'vite'
|
||||||
import { createHtmlPlugin } from 'vite-plugin-html'
|
import { createHtmlPlugin } from 'vite-plugin-html'
|
||||||
@@ -231,6 +232,7 @@ export default defineConfig({
|
|||||||
? [vueDevTools(), vue(), createHtmlPlugin({})]
|
? [vueDevTools(), vue(), createHtmlPlugin({})]
|
||||||
: [vue()]),
|
: [vue()]),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
typegpuPlugin({}),
|
||||||
comfyAPIPlugin(IS_DEV),
|
comfyAPIPlugin(IS_DEV),
|
||||||
// Twitter/Open Graph meta tags plugin (cloud distribution only)
|
// Twitter/Open Graph meta tags plugin (cloud distribution only)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user