Compare commits
15 Commits
5f1c217853
...
waic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a9fea0f0a | ||
|
|
da77f2f194 | ||
|
|
1ed729f8da | ||
|
|
444c20f233 | ||
|
|
d4f160af75 | ||
|
|
f30ebcf20b | ||
|
|
eab24fc2bf | ||
|
|
f74d8e5013 | ||
|
|
b3fa3c8341 | ||
|
|
0b658d11c5 | ||
|
|
156df50d33 | ||
|
|
ec46e53c75 | ||
| 2497cb93ff | |||
| 174adb5366 | |||
| 2f16c4762f |
90
.VSCodeCounter/2026-06-28_22-27-28/details.md
Normal file
90
.VSCodeCounter/2026-06-28_22-27-28/details.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Details
|
||||||
|
|
||||||
|
Date : 2026-06-28 22:27:28
|
||||||
|
|
||||||
|
Directory d:\\rust-ws\\eskin-player
|
||||||
|
|
||||||
|
Total : 75 files, 8653 codes, 180 comments, 1313 blanks, all 10146 lines
|
||||||
|
|
||||||
|
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
| filename | language | code | comment | blank | total |
|
||||||
|
| :--- | :--- | ---: | ---: | ---: | ---: |
|
||||||
|
| [.openclaw/settings.json](/.openclaw/settings.json) | JSON with Comments | 35 | 0 | 0 | 35 |
|
||||||
|
| [.scale/assets.json](/.scale/assets.json) | JSON | 4 | 0 | 1 | 5 |
|
||||||
|
| [.scale/engineering-standards-baseline.json](/.scale/engineering-standards-baseline.json) | JSON | 6 | 0 | 1 | 7 |
|
||||||
|
| [.scale/engineering-standards.json](/.scale/engineering-standards.json) | JSON | 67 | 0 | 1 | 68 |
|
||||||
|
| [.scale/frameworks.json](/.scale/frameworks.json) | JSON | 23 | 0 | 1 | 24 |
|
||||||
|
| [.scale/governance.lock.json](/.scale/governance.lock.json) | JSON | 200 | 0 | 1 | 201 |
|
||||||
|
| [.scale/output-policy.json](/.scale/output-policy.json) | JSON | 72 | 0 | 1 | 73 |
|
||||||
|
| [.scale/product-smoke.json](/.scale/product-smoke.json) | JSON | 33 | 0 | 1 | 34 |
|
||||||
|
| [.scale/quality-contract.json](/.scale/quality-contract.json) | JSON | 70 | 0 | 1 | 71 |
|
||||||
|
| [.scale/resource-policy.json](/.scale/resource-policy.json) | JSON | 39 | 0 | 1 | 40 |
|
||||||
|
| [.scale/skills-registry.json](/.scale/skills-registry.json) | JSON | 82 | 0 | 1 | 83 |
|
||||||
|
| [.scale/skills.json](/.scale/skills.json) | JSON | 630 | 0 | 1 | 631 |
|
||||||
|
| [.scale/tools.json](/.scale/tools.json) | JSON | 118 | 0 | 1 | 119 |
|
||||||
|
| [.scale/verification.json](/.scale/verification.json) | JSON | 35 | 0 | 1 | 36 |
|
||||||
|
| [.scale/workflow.json](/.scale/workflow.json) | JSON | 51 | 0 | 1 | 52 |
|
||||||
|
| [AGENTS.md](/AGENTS.md) | Markdown | 21 | 0 | 6 | 27 |
|
||||||
|
| [README.md](/README.md) | Markdown | 33 | 0 | 10 | 43 |
|
||||||
|
| [TOOLS.md](/TOOLS.md) | Markdown | 56 | 0 | 6 | 62 |
|
||||||
|
| [build.rs](/build.rs) | Rust | 24 | 0 | 10 | 34 |
|
||||||
|
| [docs/workflow/QUALITY\_CONTRACT.md](/docs/workflow/QUALITY_CONTRACT.md) | Markdown | 28 | 0 | 8 | 36 |
|
||||||
|
| [docs/workflow/README.md](/docs/workflow/README.md) | Markdown | 143 | 0 | 45 | 188 |
|
||||||
|
| [docs/workflow/templates/api-contract.md](/docs/workflow/templates/api-contract.md) | Markdown | 15 | 0 | 15 | 30 |
|
||||||
|
| [docs/workflow/templates/architecture-review.md](/docs/workflow/templates/architecture-review.md) | Markdown | 15 | 0 | 9 | 24 |
|
||||||
|
| [docs/workflow/templates/db-change-plan.md](/docs/workflow/templates/db-change-plan.md) | Markdown | 11 | 0 | 10 | 21 |
|
||||||
|
| [docs/workflow/templates/docs-impact.md](/docs/workflow/templates/docs-impact.md) | Markdown | 9 | 0 | 9 | 18 |
|
||||||
|
| [docs/workflow/templates/e2e-plan.md](/docs/workflow/templates/e2e-plan.md) | Markdown | 11 | 0 | 10 | 21 |
|
||||||
|
| [docs/workflow/templates/explore.md](/docs/workflow/templates/explore.md) | Markdown | 11 | 0 | 10 | 21 |
|
||||||
|
| [docs/workflow/templates/github-actions-scale-preflight.yml](/docs/workflow/templates/github-actions-scale-preflight.yml) | YAML | 27 | 0 | 6 | 33 |
|
||||||
|
| [docs/workflow/templates/mini-prd.md](/docs/workflow/templates/mini-prd.md) | Markdown | 23 | 0 | 20 | 43 |
|
||||||
|
| [docs/workflow/templates/plan.md](/docs/workflow/templates/plan.md) | Markdown | 17 | 0 | 12 | 29 |
|
||||||
|
| [docs/workflow/templates/pre-push-scale-preflight.sh](/docs/workflow/templates/pre-push-scale-preflight.sh) | Shell Script | 6 | 1 | 2 | 9 |
|
||||||
|
| [docs/workflow/templates/product-smoke.md](/docs/workflow/templates/product-smoke.md) | Markdown | 44 | 0 | 18 | 62 |
|
||||||
|
| [docs/workflow/templates/reality-check.md](/docs/workflow/templates/reality-check.md) | Markdown | 13 | 0 | 13 | 26 |
|
||||||
|
| [docs/workflow/templates/resource-cleanup.md](/docs/workflow/templates/resource-cleanup.md) | Markdown | 10 | 0 | 5 | 15 |
|
||||||
|
| [docs/workflow/templates/resource-impact.md](/docs/workflow/templates/resource-impact.md) | Markdown | 15 | 0 | 11 | 26 |
|
||||||
|
| [docs/workflow/templates/review.md](/docs/workflow/templates/review.md) | Markdown | 9 | 0 | 8 | 17 |
|
||||||
|
| [docs/workflow/templates/runtime.md](/docs/workflow/templates/runtime.md) | Markdown | 15 | 0 | 7 | 22 |
|
||||||
|
| [docs/workflow/templates/security-review.md](/docs/workflow/templates/security-review.md) | Markdown | 15 | 0 | 12 | 27 |
|
||||||
|
| [docs/workflow/templates/skill-evidence.md](/docs/workflow/templates/skill-evidence.md) | Markdown | 21 | 0 | 13 | 34 |
|
||||||
|
| [docs/workflow/templates/skill-plan.md](/docs/workflow/templates/skill-plan.md) | Markdown | 25 | 0 | 15 | 40 |
|
||||||
|
| [docs/workflow/templates/standards-impact.md](/docs/workflow/templates/standards-impact.md) | Markdown | 20 | 0 | 9 | 29 |
|
||||||
|
| [docs/workflow/templates/summary.md](/docs/workflow/templates/summary.md) | Markdown | 11 | 0 | 9 | 20 |
|
||||||
|
| [docs/workflow/templates/ui-spec.md](/docs/workflow/templates/ui-spec.md) | Markdown | 17 | 0 | 13 | 30 |
|
||||||
|
| [docs/workflow/templates/verification.md](/docs/workflow/templates/verification.md) | Markdown | 11 | 0 | 8 | 19 |
|
||||||
|
| [docs/workflow/templates/visual-review.md](/docs/workflow/templates/visual-review.md) | Markdown | 11 | 0 | 10 | 21 |
|
||||||
|
| [docs/worklog/metrics.md](/docs/worklog/metrics.md) | Markdown | 15 | 2 | 12 | 29 |
|
||||||
|
| [render.md](/render.md) | Markdown | 631 | 0 | 236 | 867 |
|
||||||
|
| [scripts/gates/all.sh](/scripts/gates/all.sh) | Shell Script | 43 | 2 | 10 | 55 |
|
||||||
|
| [scripts/qa/product-smoke.ps1](/scripts/qa/product-smoke.ps1) | PowerShell | 94 | 1 | 13 | 108 |
|
||||||
|
| [scripts/qa/product-smoke.sh](/scripts/qa/product-smoke.sh) | Shell Script | 86 | 1 | 12 | 99 |
|
||||||
|
| [scripts/tests/run.sh](/scripts/tests/run.sh) | Shell Script | 6 | 2 | 1 | 9 |
|
||||||
|
| [scripts/validate-config.sh](/scripts/validate-config.sh) | Shell Script | 53 | 2 | 11 | 66 |
|
||||||
|
| [scripts/workflow/verify.sh](/scripts/workflow/verify.sh) | Shell Script | 17 | 6 | 7 | 30 |
|
||||||
|
| [src/app.rs](/src/app.rs) | Rust | 330 | 12 | 39 | 381 |
|
||||||
|
| [src/connection.rs](/src/connection.rs) | Rust | 153 | 11 | 26 | 190 |
|
||||||
|
| [src/main.rs](/src/main.rs) | Rust | 30 | 0 | 4 | 34 |
|
||||||
|
| [src/matrix.rs](/src/matrix.rs) | Rust | 101 | 0 | 10 | 111 |
|
||||||
|
| [src/model.rs](/src/model.rs) | Rust | 170 | 7 | 19 | 196 |
|
||||||
|
| [src/recording.rs](/src/recording.rs) | Rust | 324 | 52 | 69 | 445 |
|
||||||
|
| [src/render.rs](/src/render.rs) | Rust | 855 | 8 | 61 | 924 |
|
||||||
|
| [src/resources.rs](/src/resources.rs) | Rust | 937 | 0 | 71 | 1,008 |
|
||||||
|
| [src/serial\_core/codec.rs](/src/serial_core/codec.rs) | Rust | 6 | 0 | 2 | 8 |
|
||||||
|
| [src/serial\_core/codecs/mod.rs](/src/serial_core/codecs/mod.rs) | Rust | 1 | 0 | 1 | 2 |
|
||||||
|
| [src/serial\_core/codecs/tactile\_a.rs](/src/serial_core/codecs/tactile_a.rs) | Rust | 163 | 1 | 21 | 185 |
|
||||||
|
| [src/serial\_core/error.rs](/src/serial_core/error.rs) | Rust | 45 | 0 | 7 | 52 |
|
||||||
|
| [src/serial\_core/frame.rs](/src/serial_core/frame.rs) | Rust | 32 | 0 | 5 | 37 |
|
||||||
|
| [src/serial\_core/mod.rs](/src/serial_core/mod.rs) | Rust | 6 | 0 | 1 | 7 |
|
||||||
|
| [src/serial\_core/serial.rs](/src/serial_core/serial.rs) | Rust | 78 | 7 | 12 | 97 |
|
||||||
|
| [src/serial\_core/utils.rs](/src/serial_core/utils.rs) | Rust | 8 | 0 | 3 | 11 |
|
||||||
|
| [src/style.rs](/src/style.rs) | Rust | 283 | 0 | 37 | 320 |
|
||||||
|
| [src/texture.rs](/src/texture.rs) | Rust | 231 | 0 | 21 | 252 |
|
||||||
|
| [src/theme.rs](/src/theme.rs) | Rust | 149 | 1 | 19 | 169 |
|
||||||
|
| [src/ui.rs](/src/ui.rs) | Rust | 1,088 | 57 | 123 | 1,268 |
|
||||||
|
| [src/utils.rs](/src/utils.rs) | Rust | 10 | 0 | 2 | 12 |
|
||||||
|
| [static/wgsl/shader.wgsl](/static/wgsl/shader.wgsl) | WGSL | 556 | 7 | 105 | 668 |
|
||||||
|
|
||||||
|
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
|
||||||
15
.VSCodeCounter/2026-06-28_22-27-28/diff-details.md
Normal file
15
.VSCodeCounter/2026-06-28_22-27-28/diff-details.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Diff Details
|
||||||
|
|
||||||
|
Date : 2026-06-28 22:27:28
|
||||||
|
|
||||||
|
Directory d:\\rust-ws\\eskin-player
|
||||||
|
|
||||||
|
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
|
||||||
|
|
||||||
|
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details
|
||||||
|
|
||||||
|
## Files
|
||||||
|
| filename | language | code | comment | blank | total |
|
||||||
|
| :--- | :--- | ---: | ---: | ---: | ---: |
|
||||||
|
|
||||||
|
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details
|
||||||
2
.VSCodeCounter/2026-06-28_22-27-28/diff.csv
Normal file
2
.VSCodeCounter/2026-06-28_22-27-28/diff.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"filename", "language", "", "comment", "blank", "total"
|
||||||
|
"Total", "-", , 0, 0, 0
|
||||||
|
19
.VSCodeCounter/2026-06-28_22-27-28/diff.md
Normal file
19
.VSCodeCounter/2026-06-28_22-27-28/diff.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Diff Summary
|
||||||
|
|
||||||
|
Date : 2026-06-28 22:27:28
|
||||||
|
|
||||||
|
Directory d:\\rust-ws\\eskin-player
|
||||||
|
|
||||||
|
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
|
||||||
|
|
||||||
|
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
| language | files | code | comment | blank | total |
|
||||||
|
| :--- | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
|
||||||
|
## Directories
|
||||||
|
| path | files | code | comment | blank | total |
|
||||||
|
| :--- | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
|
||||||
|
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
|
||||||
22
.VSCodeCounter/2026-06-28_22-27-28/diff.txt
Normal file
22
.VSCodeCounter/2026-06-28_22-27-28/diff.txt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
Date : 2026-06-28 22:27:28
|
||||||
|
Directory : d:\rust-ws\eskin-player
|
||||||
|
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
|
||||||
|
|
||||||
|
Languages
|
||||||
|
+----------+------------+------------+------------+------------+------------+
|
||||||
|
| language | files | code | comment | blank | total |
|
||||||
|
+----------+------------+------------+------------+------------+------------+
|
||||||
|
+----------+------------+------------+------------+------------+------------+
|
||||||
|
|
||||||
|
Directories
|
||||||
|
+------+------------+------------+------------+------------+------------+
|
||||||
|
| path | files | code | comment | blank | total |
|
||||||
|
+------+------------+------------+------------+------------+------------+
|
||||||
|
+------+------------+------------+------------+------------+------------+
|
||||||
|
|
||||||
|
Files
|
||||||
|
+----------+----------+------------+------------+------------+------------+
|
||||||
|
| filename | language | code | comment | blank | total |
|
||||||
|
+----------+----------+------------+------------+------------+------------+
|
||||||
|
| Total | | 0 | 0 | 0 | 0 |
|
||||||
|
+----------+----------+------------+------------+------------+------------+
|
||||||
77
.VSCodeCounter/2026-06-28_22-27-28/results.csv
Normal file
77
.VSCodeCounter/2026-06-28_22-27-28/results.csv
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"filename", "language", "Rust", "Markdown", "JSON", "JSON with Comments", "Shell Script", "WGSL", "PowerShell", "YAML", "comment", "blank", "total"
|
||||||
|
"d:\rust-ws\eskin-player\.openclaw\settings.json", "JSON with Comments", 0, 0, 0, 35, 0, 0, 0, 0, 0, 0, 35
|
||||||
|
"d:\rust-ws\eskin-player\.scale\assets.json", "JSON", 0, 0, 4, 0, 0, 0, 0, 0, 0, 1, 5
|
||||||
|
"d:\rust-ws\eskin-player\.scale\engineering-standards-baseline.json", "JSON", 0, 0, 6, 0, 0, 0, 0, 0, 0, 1, 7
|
||||||
|
"d:\rust-ws\eskin-player\.scale\engineering-standards.json", "JSON", 0, 0, 67, 0, 0, 0, 0, 0, 0, 1, 68
|
||||||
|
"d:\rust-ws\eskin-player\.scale\frameworks.json", "JSON", 0, 0, 23, 0, 0, 0, 0, 0, 0, 1, 24
|
||||||
|
"d:\rust-ws\eskin-player\.scale\governance.lock.json", "JSON", 0, 0, 200, 0, 0, 0, 0, 0, 0, 1, 201
|
||||||
|
"d:\rust-ws\eskin-player\.scale\output-policy.json", "JSON", 0, 0, 72, 0, 0, 0, 0, 0, 0, 1, 73
|
||||||
|
"d:\rust-ws\eskin-player\.scale\product-smoke.json", "JSON", 0, 0, 33, 0, 0, 0, 0, 0, 0, 1, 34
|
||||||
|
"d:\rust-ws\eskin-player\.scale\quality-contract.json", "JSON", 0, 0, 70, 0, 0, 0, 0, 0, 0, 1, 71
|
||||||
|
"d:\rust-ws\eskin-player\.scale\resource-policy.json", "JSON", 0, 0, 39, 0, 0, 0, 0, 0, 0, 1, 40
|
||||||
|
"d:\rust-ws\eskin-player\.scale\skills-registry.json", "JSON", 0, 0, 82, 0, 0, 0, 0, 0, 0, 1, 83
|
||||||
|
"d:\rust-ws\eskin-player\.scale\skills.json", "JSON", 0, 0, 630, 0, 0, 0, 0, 0, 0, 1, 631
|
||||||
|
"d:\rust-ws\eskin-player\.scale\tools.json", "JSON", 0, 0, 118, 0, 0, 0, 0, 0, 0, 1, 119
|
||||||
|
"d:\rust-ws\eskin-player\.scale\verification.json", "JSON", 0, 0, 35, 0, 0, 0, 0, 0, 0, 1, 36
|
||||||
|
"d:\rust-ws\eskin-player\.scale\workflow.json", "JSON", 0, 0, 51, 0, 0, 0, 0, 0, 0, 1, 52
|
||||||
|
"d:\rust-ws\eskin-player\AGENTS.md", "Markdown", 0, 21, 0, 0, 0, 0, 0, 0, 0, 6, 27
|
||||||
|
"d:\rust-ws\eskin-player\README.md", "Markdown", 0, 33, 0, 0, 0, 0, 0, 0, 0, 10, 43
|
||||||
|
"d:\rust-ws\eskin-player\TOOLS.md", "Markdown", 0, 56, 0, 0, 0, 0, 0, 0, 0, 6, 62
|
||||||
|
"d:\rust-ws\eskin-player\build.rs", "Rust", 24, 0, 0, 0, 0, 0, 0, 0, 0, 10, 34
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\QUALITY_CONTRACT.md", "Markdown", 0, 28, 0, 0, 0, 0, 0, 0, 0, 8, 36
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\README.md", "Markdown", 0, 143, 0, 0, 0, 0, 0, 0, 0, 45, 188
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\api-contract.md", "Markdown", 0, 15, 0, 0, 0, 0, 0, 0, 0, 15, 30
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\architecture-review.md", "Markdown", 0, 15, 0, 0, 0, 0, 0, 0, 0, 9, 24
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\db-change-plan.md", "Markdown", 0, 11, 0, 0, 0, 0, 0, 0, 0, 10, 21
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\docs-impact.md", "Markdown", 0, 9, 0, 0, 0, 0, 0, 0, 0, 9, 18
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\e2e-plan.md", "Markdown", 0, 11, 0, 0, 0, 0, 0, 0, 0, 10, 21
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\explore.md", "Markdown", 0, 11, 0, 0, 0, 0, 0, 0, 0, 10, 21
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\github-actions-scale-preflight.yml", "YAML", 0, 0, 0, 0, 0, 0, 0, 27, 0, 6, 33
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\mini-prd.md", "Markdown", 0, 23, 0, 0, 0, 0, 0, 0, 0, 20, 43
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\plan.md", "Markdown", 0, 17, 0, 0, 0, 0, 0, 0, 0, 12, 29
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\pre-push-scale-preflight.sh", "Shell Script", 0, 0, 0, 0, 6, 0, 0, 0, 1, 2, 9
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\product-smoke.md", "Markdown", 0, 44, 0, 0, 0, 0, 0, 0, 0, 18, 62
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\reality-check.md", "Markdown", 0, 13, 0, 0, 0, 0, 0, 0, 0, 13, 26
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\resource-cleanup.md", "Markdown", 0, 10, 0, 0, 0, 0, 0, 0, 0, 5, 15
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\resource-impact.md", "Markdown", 0, 15, 0, 0, 0, 0, 0, 0, 0, 11, 26
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\review.md", "Markdown", 0, 9, 0, 0, 0, 0, 0, 0, 0, 8, 17
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\runtime.md", "Markdown", 0, 15, 0, 0, 0, 0, 0, 0, 0, 7, 22
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\security-review.md", "Markdown", 0, 15, 0, 0, 0, 0, 0, 0, 0, 12, 27
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\skill-evidence.md", "Markdown", 0, 21, 0, 0, 0, 0, 0, 0, 0, 13, 34
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\skill-plan.md", "Markdown", 0, 25, 0, 0, 0, 0, 0, 0, 0, 15, 40
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\standards-impact.md", "Markdown", 0, 20, 0, 0, 0, 0, 0, 0, 0, 9, 29
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\summary.md", "Markdown", 0, 11, 0, 0, 0, 0, 0, 0, 0, 9, 20
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\ui-spec.md", "Markdown", 0, 17, 0, 0, 0, 0, 0, 0, 0, 13, 30
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\verification.md", "Markdown", 0, 11, 0, 0, 0, 0, 0, 0, 0, 8, 19
|
||||||
|
"d:\rust-ws\eskin-player\docs\workflow\templates\visual-review.md", "Markdown", 0, 11, 0, 0, 0, 0, 0, 0, 0, 10, 21
|
||||||
|
"d:\rust-ws\eskin-player\docs\worklog\metrics.md", "Markdown", 0, 15, 0, 0, 0, 0, 0, 0, 2, 12, 29
|
||||||
|
"d:\rust-ws\eskin-player\render.md", "Markdown", 0, 631, 0, 0, 0, 0, 0, 0, 0, 236, 867
|
||||||
|
"d:\rust-ws\eskin-player\scripts\gates\all.sh", "Shell Script", 0, 0, 0, 0, 43, 0, 0, 0, 2, 10, 55
|
||||||
|
"d:\rust-ws\eskin-player\scripts\qa\product-smoke.ps1", "PowerShell", 0, 0, 0, 0, 0, 0, 94, 0, 1, 13, 108
|
||||||
|
"d:\rust-ws\eskin-player\scripts\qa\product-smoke.sh", "Shell Script", 0, 0, 0, 0, 86, 0, 0, 0, 1, 12, 99
|
||||||
|
"d:\rust-ws\eskin-player\scripts\tests\run.sh", "Shell Script", 0, 0, 0, 0, 6, 0, 0, 0, 2, 1, 9
|
||||||
|
"d:\rust-ws\eskin-player\scripts\validate-config.sh", "Shell Script", 0, 0, 0, 0, 53, 0, 0, 0, 2, 11, 66
|
||||||
|
"d:\rust-ws\eskin-player\scripts\workflow\verify.sh", "Shell Script", 0, 0, 0, 0, 17, 0, 0, 0, 6, 7, 30
|
||||||
|
"d:\rust-ws\eskin-player\src\app.rs", "Rust", 330, 0, 0, 0, 0, 0, 0, 0, 12, 39, 381
|
||||||
|
"d:\rust-ws\eskin-player\src\connection.rs", "Rust", 153, 0, 0, 0, 0, 0, 0, 0, 11, 26, 190
|
||||||
|
"d:\rust-ws\eskin-player\src\main.rs", "Rust", 30, 0, 0, 0, 0, 0, 0, 0, 0, 4, 34
|
||||||
|
"d:\rust-ws\eskin-player\src\matrix.rs", "Rust", 101, 0, 0, 0, 0, 0, 0, 0, 0, 10, 111
|
||||||
|
"d:\rust-ws\eskin-player\src\model.rs", "Rust", 170, 0, 0, 0, 0, 0, 0, 0, 7, 19, 196
|
||||||
|
"d:\rust-ws\eskin-player\src\recording.rs", "Rust", 324, 0, 0, 0, 0, 0, 0, 0, 52, 69, 445
|
||||||
|
"d:\rust-ws\eskin-player\src\render.rs", "Rust", 855, 0, 0, 0, 0, 0, 0, 0, 8, 61, 924
|
||||||
|
"d:\rust-ws\eskin-player\src\resources.rs", "Rust", 937, 0, 0, 0, 0, 0, 0, 0, 0, 71, 1008
|
||||||
|
"d:\rust-ws\eskin-player\src\serial_core\codec.rs", "Rust", 6, 0, 0, 0, 0, 0, 0, 0, 0, 2, 8
|
||||||
|
"d:\rust-ws\eskin-player\src\serial_core\codecs\mod.rs", "Rust", 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
|
||||||
|
"d:\rust-ws\eskin-player\src\serial_core\codecs\tactile_a.rs", "Rust", 163, 0, 0, 0, 0, 0, 0, 0, 1, 21, 185
|
||||||
|
"d:\rust-ws\eskin-player\src\serial_core\error.rs", "Rust", 45, 0, 0, 0, 0, 0, 0, 0, 0, 7, 52
|
||||||
|
"d:\rust-ws\eskin-player\src\serial_core\frame.rs", "Rust", 32, 0, 0, 0, 0, 0, 0, 0, 0, 5, 37
|
||||||
|
"d:\rust-ws\eskin-player\src\serial_core\mod.rs", "Rust", 6, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7
|
||||||
|
"d:\rust-ws\eskin-player\src\serial_core\serial.rs", "Rust", 78, 0, 0, 0, 0, 0, 0, 0, 7, 12, 97
|
||||||
|
"d:\rust-ws\eskin-player\src\serial_core\utils.rs", "Rust", 8, 0, 0, 0, 0, 0, 0, 0, 0, 3, 11
|
||||||
|
"d:\rust-ws\eskin-player\src\style.rs", "Rust", 283, 0, 0, 0, 0, 0, 0, 0, 0, 37, 320
|
||||||
|
"d:\rust-ws\eskin-player\src\texture.rs", "Rust", 231, 0, 0, 0, 0, 0, 0, 0, 0, 21, 252
|
||||||
|
"d:\rust-ws\eskin-player\src\theme.rs", "Rust", 149, 0, 0, 0, 0, 0, 0, 0, 1, 19, 169
|
||||||
|
"d:\rust-ws\eskin-player\src\ui.rs", "Rust", 1088, 0, 0, 0, 0, 0, 0, 0, 57, 123, 1268
|
||||||
|
"d:\rust-ws\eskin-player\src\utils.rs", "Rust", 10, 0, 0, 0, 0, 0, 0, 0, 0, 2, 12
|
||||||
|
"d:\rust-ws\eskin-player\static\wgsl\shader.wgsl", "WGSL", 0, 0, 0, 0, 0, 556, 0, 0, 7, 105, 668
|
||||||
|
"Total", "-", 5024, 1276, 1430, 35, 211, 556, 94, 27, 180, 1313, 10146
|
||||||
|
1
.VSCodeCounter/2026-06-28_22-27-28/results.json
Normal file
1
.VSCodeCounter/2026-06-28_22-27-28/results.json
Normal file
File diff suppressed because one or more lines are too long
49
.VSCodeCounter/2026-06-28_22-27-28/results.md
Normal file
49
.VSCodeCounter/2026-06-28_22-27-28/results.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Summary
|
||||||
|
|
||||||
|
Date : 2026-06-28 22:27:28
|
||||||
|
|
||||||
|
Directory d:\\rust-ws\\eskin-player
|
||||||
|
|
||||||
|
Total : 75 files, 8653 codes, 180 comments, 1313 blanks, all 10146 lines
|
||||||
|
|
||||||
|
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
| language | files | code | comment | blank | total |
|
||||||
|
| :--- | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| Rust | 22 | 5,024 | 156 | 563 | 5,743 |
|
||||||
|
| JSON | 14 | 1,430 | 0 | 14 | 1,444 |
|
||||||
|
| Markdown | 29 | 1,276 | 2 | 569 | 1,847 |
|
||||||
|
| WGSL | 1 | 556 | 7 | 105 | 668 |
|
||||||
|
| Shell Script | 6 | 211 | 14 | 43 | 268 |
|
||||||
|
| PowerShell | 1 | 94 | 1 | 13 | 108 |
|
||||||
|
| JSON with Comments | 1 | 35 | 0 | 0 | 35 |
|
||||||
|
| YAML | 1 | 27 | 0 | 6 | 33 |
|
||||||
|
|
||||||
|
## Directories
|
||||||
|
| path | files | code | comment | blank | total |
|
||||||
|
| :--- | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| . | 75 | 8,653 | 180 | 1,313 | 10,146 |
|
||||||
|
| . (Files) | 5 | 765 | 0 | 268 | 1,033 |
|
||||||
|
| .openclaw | 1 | 35 | 0 | 0 | 35 |
|
||||||
|
| .scale | 14 | 1,430 | 0 | 14 | 1,444 |
|
||||||
|
| docs | 27 | 568 | 3 | 319 | 890 |
|
||||||
|
| docs\\workflow | 26 | 553 | 1 | 307 | 861 |
|
||||||
|
| docs\\workflow (Files) | 2 | 171 | 0 | 53 | 224 |
|
||||||
|
| docs\\workflow\\templates | 24 | 382 | 1 | 254 | 637 |
|
||||||
|
| docs\\worklog | 1 | 15 | 2 | 12 | 29 |
|
||||||
|
| scripts | 6 | 299 | 14 | 54 | 367 |
|
||||||
|
| scripts (Files) | 1 | 53 | 2 | 11 | 66 |
|
||||||
|
| scripts\\gates | 1 | 43 | 2 | 10 | 55 |
|
||||||
|
| scripts\\qa | 2 | 180 | 2 | 25 | 207 |
|
||||||
|
| scripts\\tests | 1 | 6 | 2 | 1 | 9 |
|
||||||
|
| scripts\\workflow | 1 | 17 | 6 | 7 | 30 |
|
||||||
|
| src | 21 | 5,000 | 156 | 553 | 5,709 |
|
||||||
|
| src (Files) | 13 | 4,661 | 148 | 501 | 5,310 |
|
||||||
|
| src\\serial_core | 8 | 339 | 8 | 52 | 399 |
|
||||||
|
| src\\serial_core (Files) | 6 | 175 | 7 | 30 | 212 |
|
||||||
|
| src\\serial_core\\codecs | 2 | 164 | 1 | 22 | 187 |
|
||||||
|
| static | 1 | 556 | 7 | 105 | 668 |
|
||||||
|
| static\\wgsl | 1 | 556 | 7 | 105 | 668 |
|
||||||
|
|
||||||
|
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
|
||||||
127
.VSCodeCounter/2026-06-28_22-27-28/results.txt
Normal file
127
.VSCodeCounter/2026-06-28_22-27-28/results.txt
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
Date : 2026-06-28 22:27:28
|
||||||
|
Directory : d:\rust-ws\eskin-player
|
||||||
|
Total : 75 files, 8653 codes, 180 comments, 1313 blanks, all 10146 lines
|
||||||
|
|
||||||
|
Languages
|
||||||
|
+--------------------+------------+------------+------------+------------+------------+
|
||||||
|
| language | files | code | comment | blank | total |
|
||||||
|
+--------------------+------------+------------+------------+------------+------------+
|
||||||
|
| Rust | 22 | 5,024 | 156 | 563 | 5,743 |
|
||||||
|
| JSON | 14 | 1,430 | 0 | 14 | 1,444 |
|
||||||
|
| Markdown | 29 | 1,276 | 2 | 569 | 1,847 |
|
||||||
|
| WGSL | 1 | 556 | 7 | 105 | 668 |
|
||||||
|
| Shell Script | 6 | 211 | 14 | 43 | 268 |
|
||||||
|
| PowerShell | 1 | 94 | 1 | 13 | 108 |
|
||||||
|
| JSON with Comments | 1 | 35 | 0 | 0 | 35 |
|
||||||
|
| YAML | 1 | 27 | 0 | 6 | 33 |
|
||||||
|
+--------------------+------------+------------+------------+------------+------------+
|
||||||
|
|
||||||
|
Directories
|
||||||
|
+------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
|
||||||
|
| path | files | code | comment | blank | total |
|
||||||
|
+------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
|
||||||
|
| . | 75 | 8,653 | 180 | 1,313 | 10,146 |
|
||||||
|
| . (Files) | 5 | 765 | 0 | 268 | 1,033 |
|
||||||
|
| .openclaw | 1 | 35 | 0 | 0 | 35 |
|
||||||
|
| .scale | 14 | 1,430 | 0 | 14 | 1,444 |
|
||||||
|
| docs | 27 | 568 | 3 | 319 | 890 |
|
||||||
|
| docs\workflow | 26 | 553 | 1 | 307 | 861 |
|
||||||
|
| docs\workflow (Files) | 2 | 171 | 0 | 53 | 224 |
|
||||||
|
| docs\workflow\templates | 24 | 382 | 1 | 254 | 637 |
|
||||||
|
| docs\worklog | 1 | 15 | 2 | 12 | 29 |
|
||||||
|
| scripts | 6 | 299 | 14 | 54 | 367 |
|
||||||
|
| scripts (Files) | 1 | 53 | 2 | 11 | 66 |
|
||||||
|
| scripts\gates | 1 | 43 | 2 | 10 | 55 |
|
||||||
|
| scripts\qa | 2 | 180 | 2 | 25 | 207 |
|
||||||
|
| scripts\tests | 1 | 6 | 2 | 1 | 9 |
|
||||||
|
| scripts\workflow | 1 | 17 | 6 | 7 | 30 |
|
||||||
|
| src | 21 | 5,000 | 156 | 553 | 5,709 |
|
||||||
|
| src (Files) | 13 | 4,661 | 148 | 501 | 5,310 |
|
||||||
|
| src\serial_core | 8 | 339 | 8 | 52 | 399 |
|
||||||
|
| src\serial_core (Files) | 6 | 175 | 7 | 30 | 212 |
|
||||||
|
| src\serial_core\codecs | 2 | 164 | 1 | 22 | 187 |
|
||||||
|
| static | 1 | 556 | 7 | 105 | 668 |
|
||||||
|
| static\wgsl | 1 | 556 | 7 | 105 | 668 |
|
||||||
|
+------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
|
||||||
|
|
||||||
|
Files
|
||||||
|
+------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+
|
||||||
|
| filename | language | code | comment | blank | total |
|
||||||
|
+------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+
|
||||||
|
| d:\rust-ws\eskin-player\.openclaw\settings.json | JSON with Comments | 35 | 0 | 0 | 35 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\assets.json | JSON | 4 | 0 | 1 | 5 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\engineering-standards-baseline.json | JSON | 6 | 0 | 1 | 7 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\engineering-standards.json | JSON | 67 | 0 | 1 | 68 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\frameworks.json | JSON | 23 | 0 | 1 | 24 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\governance.lock.json | JSON | 200 | 0 | 1 | 201 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\output-policy.json | JSON | 72 | 0 | 1 | 73 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\product-smoke.json | JSON | 33 | 0 | 1 | 34 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\quality-contract.json | JSON | 70 | 0 | 1 | 71 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\resource-policy.json | JSON | 39 | 0 | 1 | 40 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\skills-registry.json | JSON | 82 | 0 | 1 | 83 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\skills.json | JSON | 630 | 0 | 1 | 631 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\tools.json | JSON | 118 | 0 | 1 | 119 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\verification.json | JSON | 35 | 0 | 1 | 36 |
|
||||||
|
| d:\rust-ws\eskin-player\.scale\workflow.json | JSON | 51 | 0 | 1 | 52 |
|
||||||
|
| d:\rust-ws\eskin-player\AGENTS.md | Markdown | 21 | 0 | 6 | 27 |
|
||||||
|
| d:\rust-ws\eskin-player\README.md | Markdown | 33 | 0 | 10 | 43 |
|
||||||
|
| d:\rust-ws\eskin-player\TOOLS.md | Markdown | 56 | 0 | 6 | 62 |
|
||||||
|
| d:\rust-ws\eskin-player\build.rs | Rust | 24 | 0 | 10 | 34 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\QUALITY_CONTRACT.md | Markdown | 28 | 0 | 8 | 36 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\README.md | Markdown | 143 | 0 | 45 | 188 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\api-contract.md | Markdown | 15 | 0 | 15 | 30 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\architecture-review.md | Markdown | 15 | 0 | 9 | 24 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\db-change-plan.md | Markdown | 11 | 0 | 10 | 21 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\docs-impact.md | Markdown | 9 | 0 | 9 | 18 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\e2e-plan.md | Markdown | 11 | 0 | 10 | 21 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\explore.md | Markdown | 11 | 0 | 10 | 21 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\github-actions-scale-preflight.yml | YAML | 27 | 0 | 6 | 33 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\mini-prd.md | Markdown | 23 | 0 | 20 | 43 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\plan.md | Markdown | 17 | 0 | 12 | 29 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\pre-push-scale-preflight.sh | Shell Script | 6 | 1 | 2 | 9 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\product-smoke.md | Markdown | 44 | 0 | 18 | 62 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\reality-check.md | Markdown | 13 | 0 | 13 | 26 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\resource-cleanup.md | Markdown | 10 | 0 | 5 | 15 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\resource-impact.md | Markdown | 15 | 0 | 11 | 26 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\review.md | Markdown | 9 | 0 | 8 | 17 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\runtime.md | Markdown | 15 | 0 | 7 | 22 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\security-review.md | Markdown | 15 | 0 | 12 | 27 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\skill-evidence.md | Markdown | 21 | 0 | 13 | 34 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\skill-plan.md | Markdown | 25 | 0 | 15 | 40 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\standards-impact.md | Markdown | 20 | 0 | 9 | 29 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\summary.md | Markdown | 11 | 0 | 9 | 20 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\ui-spec.md | Markdown | 17 | 0 | 13 | 30 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\verification.md | Markdown | 11 | 0 | 8 | 19 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\workflow\templates\visual-review.md | Markdown | 11 | 0 | 10 | 21 |
|
||||||
|
| d:\rust-ws\eskin-player\docs\worklog\metrics.md | Markdown | 15 | 2 | 12 | 29 |
|
||||||
|
| d:\rust-ws\eskin-player\render.md | Markdown | 631 | 0 | 236 | 867 |
|
||||||
|
| d:\rust-ws\eskin-player\scripts\gates\all.sh | Shell Script | 43 | 2 | 10 | 55 |
|
||||||
|
| d:\rust-ws\eskin-player\scripts\qa\product-smoke.ps1 | PowerShell | 94 | 1 | 13 | 108 |
|
||||||
|
| d:\rust-ws\eskin-player\scripts\qa\product-smoke.sh | Shell Script | 86 | 1 | 12 | 99 |
|
||||||
|
| d:\rust-ws\eskin-player\scripts\tests\run.sh | Shell Script | 6 | 2 | 1 | 9 |
|
||||||
|
| d:\rust-ws\eskin-player\scripts\validate-config.sh | Shell Script | 53 | 2 | 11 | 66 |
|
||||||
|
| d:\rust-ws\eskin-player\scripts\workflow\verify.sh | Shell Script | 17 | 6 | 7 | 30 |
|
||||||
|
| d:\rust-ws\eskin-player\src\app.rs | Rust | 330 | 12 | 39 | 381 |
|
||||||
|
| d:\rust-ws\eskin-player\src\connection.rs | Rust | 153 | 11 | 26 | 190 |
|
||||||
|
| d:\rust-ws\eskin-player\src\main.rs | Rust | 30 | 0 | 4 | 34 |
|
||||||
|
| d:\rust-ws\eskin-player\src\matrix.rs | Rust | 101 | 0 | 10 | 111 |
|
||||||
|
| d:\rust-ws\eskin-player\src\model.rs | Rust | 170 | 7 | 19 | 196 |
|
||||||
|
| d:\rust-ws\eskin-player\src\recording.rs | Rust | 324 | 52 | 69 | 445 |
|
||||||
|
| d:\rust-ws\eskin-player\src\render.rs | Rust | 855 | 8 | 61 | 924 |
|
||||||
|
| d:\rust-ws\eskin-player\src\resources.rs | Rust | 937 | 0 | 71 | 1,008 |
|
||||||
|
| d:\rust-ws\eskin-player\src\serial_core\codec.rs | Rust | 6 | 0 | 2 | 8 |
|
||||||
|
| d:\rust-ws\eskin-player\src\serial_core\codecs\mod.rs | Rust | 1 | 0 | 1 | 2 |
|
||||||
|
| d:\rust-ws\eskin-player\src\serial_core\codecs\tactile_a.rs | Rust | 163 | 1 | 21 | 185 |
|
||||||
|
| d:\rust-ws\eskin-player\src\serial_core\error.rs | Rust | 45 | 0 | 7 | 52 |
|
||||||
|
| d:\rust-ws\eskin-player\src\serial_core\frame.rs | Rust | 32 | 0 | 5 | 37 |
|
||||||
|
| d:\rust-ws\eskin-player\src\serial_core\mod.rs | Rust | 6 | 0 | 1 | 7 |
|
||||||
|
| d:\rust-ws\eskin-player\src\serial_core\serial.rs | Rust | 78 | 7 | 12 | 97 |
|
||||||
|
| d:\rust-ws\eskin-player\src\serial_core\utils.rs | Rust | 8 | 0 | 3 | 11 |
|
||||||
|
| d:\rust-ws\eskin-player\src\style.rs | Rust | 283 | 0 | 37 | 320 |
|
||||||
|
| d:\rust-ws\eskin-player\src\texture.rs | Rust | 231 | 0 | 21 | 252 |
|
||||||
|
| d:\rust-ws\eskin-player\src\theme.rs | Rust | 149 | 1 | 19 | 169 |
|
||||||
|
| d:\rust-ws\eskin-player\src\ui.rs | Rust | 1,088 | 57 | 123 | 1,268 |
|
||||||
|
| d:\rust-ws\eskin-player\src\utils.rs | Rust | 10 | 0 | 2 | 12 |
|
||||||
|
| d:\rust-ws\eskin-player\static\wgsl\shader.wgsl | WGSL | 556 | 7 | 105 | 668 |
|
||||||
|
| Total | | 8,653 | 180 | 1,313 | 10,146 |
|
||||||
|
+------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+
|
||||||
7
.scale/.gitignore
vendored
Normal file
7
.scale/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
events/
|
||||||
|
checkpoints/
|
||||||
|
evidence/
|
||||||
|
state/
|
||||||
|
hooks/*.sh
|
||||||
4
.scale/assets.json
Normal file
4
.scale/assets.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"assets": []
|
||||||
|
}
|
||||||
6
.scale/engineering-standards-baseline.json
Normal file
6
.scale/engineering-standards-baseline.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"generatedAt": "",
|
||||||
|
"scope": "Legacy findings tracked separately. New findings and changed-file findings must still be fixed before completion.",
|
||||||
|
"findings": []
|
||||||
|
}
|
||||||
67
.scale/engineering-standards.json
Normal file
67
.scale/engineering-standards.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"mode": "warn",
|
||||||
|
"sourceDirectories": [
|
||||||
|
"src",
|
||||||
|
"app",
|
||||||
|
"packages",
|
||||||
|
"services",
|
||||||
|
"cmd",
|
||||||
|
"internal",
|
||||||
|
"pkg"
|
||||||
|
],
|
||||||
|
"ignoredDirectories": [
|
||||||
|
".git",
|
||||||
|
".scale",
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"coverage",
|
||||||
|
"test-results",
|
||||||
|
"playwright-report",
|
||||||
|
"tmp",
|
||||||
|
"temp",
|
||||||
|
"docs",
|
||||||
|
"tests",
|
||||||
|
"__tests__",
|
||||||
|
"e2e"
|
||||||
|
],
|
||||||
|
"allowedConsoleDirectories": [
|
||||||
|
"src/api",
|
||||||
|
"src/cli",
|
||||||
|
"scripts"
|
||||||
|
],
|
||||||
|
"allowedConsoleFiles": [
|
||||||
|
"src/dashboard/DashboardServer.ts"
|
||||||
|
],
|
||||||
|
"maxFileLines": 500,
|
||||||
|
"logging": {
|
||||||
|
"approvedLoggers": [
|
||||||
|
"pino",
|
||||||
|
"winston",
|
||||||
|
"zap",
|
||||||
|
"zerolog",
|
||||||
|
"logrus",
|
||||||
|
"slog"
|
||||||
|
],
|
||||||
|
"sensitiveFields": [
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"token",
|
||||||
|
"accessToken",
|
||||||
|
"refreshToken",
|
||||||
|
"secret",
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"apiKey",
|
||||||
|
"credential",
|
||||||
|
"privateKey"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"architecture": {
|
||||||
|
"enforceLayering": true
|
||||||
|
},
|
||||||
|
"blockingRules": [],
|
||||||
|
"allowedFindingPatterns": [],
|
||||||
|
"baselineFindings": []
|
||||||
|
}
|
||||||
23
.scale/frameworks.json
Normal file
23
.scale/frameworks.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"lastReviewedAt": "",
|
||||||
|
"reviewIntervalDays": 90,
|
||||||
|
"frameworks": [],
|
||||||
|
"orm": [],
|
||||||
|
"ui": {
|
||||||
|
"designSystem": "",
|
||||||
|
"componentLibrary": "",
|
||||||
|
"visualReviewRequired": true
|
||||||
|
},
|
||||||
|
"architecture": {
|
||||||
|
"layers": [
|
||||||
|
"api",
|
||||||
|
"service",
|
||||||
|
"domain",
|
||||||
|
"repository",
|
||||||
|
"infrastructure"
|
||||||
|
],
|
||||||
|
"dependencyRule": "outer layers depend inward through explicit interfaces"
|
||||||
|
},
|
||||||
|
"bannedImports": []
|
||||||
|
}
|
||||||
200
.scale/governance.lock.json
Normal file
200
.scale/governance.lock.json
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"scalePackage": "@hongmaple0820/scale-engine",
|
||||||
|
"scaleVersion": "0.0.0-dev",
|
||||||
|
"pack": "standard",
|
||||||
|
"packVersion": 1,
|
||||||
|
"generatedAt": "2026-05-20T06:59:28.689Z",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/README.md",
|
||||||
|
"sha256": "95568223be7593850dd117af82c7a983bb361a19651884b7dd8a1240a4a37956",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/explore.md",
|
||||||
|
"sha256": "e3845d0ecc4d85d11cbd2ac49de2a6fc3fad4debf071c31a4567c76fc44bceea",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/mini-prd.md",
|
||||||
|
"sha256": "276bec99769152ed90c4ab56e789c4fd8756de4fb1794db260a70a6a5ef032b8",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/skill-plan.md",
|
||||||
|
"sha256": "60f48a9d780bf0029bb7281a92d3ff9ca702e6cae1c49d9ca17fda790f04439e",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/skill-evidence.md",
|
||||||
|
"sha256": "fa697a67437bae8b3372d620cfab4e64daff7a693a0603327389da362cf805d7",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/runtime.md",
|
||||||
|
"sha256": "e1f4c575dec24c95e9b9f72d52d47badbd1bd0e8fb207193b671803a0c59fa9c",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/reality-check.md",
|
||||||
|
"sha256": "11a9102b87b6f08b118a21edc5fcdda49f27741078e463a44a6e4dd904513d0b",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/resource-cleanup.md",
|
||||||
|
"sha256": "ba2b343cbee04d3037638f3751f64cb14ad63c528529be364967bb49619cfa25",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/ui-spec.md",
|
||||||
|
"sha256": "328022424df35d7a221ff18fb5c04840c74f327deabf1d49f7a7e739fe06290c",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/visual-review.md",
|
||||||
|
"sha256": "7ebf7765969a7cf36b46aeb4d9f1c04f5513cefdd2e57256c1badc83722b00cc",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/api-contract.md",
|
||||||
|
"sha256": "a9091cc5f6d7b8178589dcff2253afa366d188903b175dd5c69dbc6c955bace0",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/docs-impact.md",
|
||||||
|
"sha256": "a4ff6c98539b71ff7f6df2cce8253b9073c6c134ee2ee9d1e1fc34b819b961a0",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/resource-impact.md",
|
||||||
|
"sha256": "af37cb2d4d97cfe46243269225732dfc10180dd6eb1e58b3085abb6ee0e3cc5f",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/standards-impact.md",
|
||||||
|
"sha256": "eed804359ca45b0b7d4436b305ea44f9872bccaa3c6e4fcf3dfbf0b1f422545f",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/architecture-review.md",
|
||||||
|
"sha256": "819390550452c3bb65e5eff2fee44d3d5767a3f4e164fd64cdb10420fd3f40aa",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/security-review.md",
|
||||||
|
"sha256": "542bb035a0e45dc954056d95652952717ec34765debe0de3649246fb6e1290a8",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/db-change-plan.md",
|
||||||
|
"sha256": "9e76ec7d3efae5374373bc1ad11c94da74ff59927b3778a1fed557dba0c51458",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/e2e-plan.md",
|
||||||
|
"sha256": "e77779e7f0f61610c9d1ef594a53565599d0777f2c6c12a9c6507f82b6fbfb26",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/product-smoke.md",
|
||||||
|
"sha256": "5b4be8ecddc47d1b4f890bd83532ed5652a1444a0ab2d30c0c91015a46f648d4",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/plan.md",
|
||||||
|
"sha256": "08100fe1bb88e801e11c13cd1ad6d26dbebe6117027b93b26c44ad3ef907ff81",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/verification.md",
|
||||||
|
"sha256": "b9cbe1f287115a15b18b201e8e25e2c40a2dc3aa4b347098c21414c66ea0fb49",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/review.md",
|
||||||
|
"sha256": "62e3bee211f9e4381bfb3fb7463edb78e74da708bc804c35f4acab9c5f65d010",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/summary.md",
|
||||||
|
"sha256": "00c99d6236ca749e330eb3769c1c0a55082f64db58362b73944419e7ec7753cf",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/github-actions-scale-preflight.yml",
|
||||||
|
"sha256": "a218c4a23251e6da42b807ebc490d363d17490abd5e8ec7bcb11377444571f33",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/workflow/templates/pre-push-scale-preflight.sh",
|
||||||
|
"sha256": "3b3a8e45ab3ac8774e54fbcbd5b44cddbf1df06a74aa671f535289b9629a3f7c",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/worklog/metrics.md",
|
||||||
|
"sha256": "66ced9a1d893b142e01d2e4aaf0e3d6509a8166d6274fbacfc4abee52874359b",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/qa/product-smoke.ps1",
|
||||||
|
"sha256": "e5b0694cbb83dd3e722435b6133c45ea43eab576a3f013dc30e6bc5492f8d6fb",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/qa/product-smoke.sh",
|
||||||
|
"sha256": "633ec087cc54081ca9aa52f4915274b427b0e2148cf1510a8dac56ce47a71c7e",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".scale/verification.json",
|
||||||
|
"sha256": "483e73923114628149c7f31a984526cb07bf60a4c237273beb68697078dc1941",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".scale/skills.json",
|
||||||
|
"sha256": "56228729c0fbb89ee31fee286b47a877002fc0972ea45db7f3153b7ec213d24e",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".scale/tools.json",
|
||||||
|
"sha256": "f204f9ef206a7b0f25b2064661bd0d5ff25980b36c0f9bda0bb973af206074ad",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".scale/resource-policy.json",
|
||||||
|
"sha256": "67d1e469d6cef5d68508ac3cc8bb5c9fd1eea33826f9c3f2fdf8e19cff10f6b9",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".scale/assets.json",
|
||||||
|
"sha256": "a8174f5941573d1acd5d5df5dee4bca235701e21c29685694d143512d3247acf",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".scale/output-policy.json",
|
||||||
|
"sha256": "c51b6f0b4ecc570f0b8a7738a1a3b0013cc76fb99dfb121c8ee9f51e7fa7c3bb",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".scale/product-smoke.json",
|
||||||
|
"sha256": "9b07dd0d9dd0d3a1689c497cd1f79b1f72b8324d4059b5172c1b503a9d82585f",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".scale/engineering-standards.json",
|
||||||
|
"sha256": "dd8a8bc0f0594f31ba9fd71120e73d4c78db84b7603aab50bdaf875401d78ad9",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".scale/engineering-standards-baseline.json",
|
||||||
|
"sha256": "14b1ebdb505038b5eba18297b4e76ae1185cd60c98a6f131001633498bed2cf7",
|
||||||
|
"owned": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".scale/frameworks.json",
|
||||||
|
"sha256": "78158a49e9c1331e0222e6efa1bb3744c5fe2bdb043da66a5238855f433756cc",
|
||||||
|
"owned": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
72
.scale/output-policy.json
Normal file
72
.scale/output-policy.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"sourceFormat": "markdown",
|
||||||
|
"artifactDirectory": "artifacts",
|
||||||
|
"manifestFile": "artifact-manifest.json",
|
||||||
|
"defaultTheme": "auto",
|
||||||
|
"defaultGitPolicy": "review",
|
||||||
|
"safety": {
|
||||||
|
"allowRemoteScripts": false,
|
||||||
|
"allowRemoteStyles": false,
|
||||||
|
"detectSecrets": true
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"plan-comparison": {
|
||||||
|
"label": "Plan Comparison",
|
||||||
|
"sources": [
|
||||||
|
"mini-prd.md",
|
||||||
|
"explore.md",
|
||||||
|
"plan.md"
|
||||||
|
],
|
||||||
|
"description": "Compare candidate approaches, tradeoffs, open questions, and decision criteria."
|
||||||
|
},
|
||||||
|
"implementation-plan": {
|
||||||
|
"label": "Implementation Plan",
|
||||||
|
"sources": [
|
||||||
|
"plan.md",
|
||||||
|
"verification.md"
|
||||||
|
],
|
||||||
|
"description": "Convert the implementation plan and verification strategy into a scannable delivery surface."
|
||||||
|
},
|
||||||
|
"code-review": {
|
||||||
|
"label": "Code Review",
|
||||||
|
"sources": [
|
||||||
|
"review.md",
|
||||||
|
"security-review.md",
|
||||||
|
"standards-impact.md"
|
||||||
|
],
|
||||||
|
"description": "Summarize review findings, severity, evidence, and residual risks."
|
||||||
|
},
|
||||||
|
"status-report": {
|
||||||
|
"label": "Status Report",
|
||||||
|
"sources": [
|
||||||
|
"summary.md",
|
||||||
|
"verification.md",
|
||||||
|
"resource-impact.md",
|
||||||
|
"standards-impact.md"
|
||||||
|
],
|
||||||
|
"description": "Show current task status, proof, blockers, resource state, and follow-ups."
|
||||||
|
},
|
||||||
|
"incident-report": {
|
||||||
|
"label": "Incident Report",
|
||||||
|
"sources": [
|
||||||
|
"explore.md",
|
||||||
|
"plan.md",
|
||||||
|
"verification.md",
|
||||||
|
"review.md"
|
||||||
|
],
|
||||||
|
"description": "Explain incident context, diagnosis, fix, validation, and prevention work."
|
||||||
|
},
|
||||||
|
"release-report": {
|
||||||
|
"label": "Release Report",
|
||||||
|
"sources": [
|
||||||
|
"summary.md",
|
||||||
|
"verification.md",
|
||||||
|
"review.md",
|
||||||
|
"resource-impact.md",
|
||||||
|
"standards-impact.md"
|
||||||
|
],
|
||||||
|
"description": "Package final release evidence, risk state, unverified items, and sign-off readiness."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
.scale/product-smoke.json
Normal file
33
.scale/product-smoke.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"gate": "warn",
|
||||||
|
"requiredForLevels": [
|
||||||
|
"M",
|
||||||
|
"L",
|
||||||
|
"CRITICAL"
|
||||||
|
],
|
||||||
|
"emptyProbeBehavior": "block",
|
||||||
|
"setupGuide": [
|
||||||
|
"Set probes[].enabled=true only after replacing the example command with a real product path.",
|
||||||
|
"Use a command that crosses the real boundary: client/UI -> gateway/router -> service -> persistence or observable result.",
|
||||||
|
"Run: scale preflight --profile productSmoke --json",
|
||||||
|
"Run: scale runtime final-check --level M --json"
|
||||||
|
],
|
||||||
|
"runtimeEvidence": {
|
||||||
|
"requiredKind": "command",
|
||||||
|
"requiredStatus": "passed",
|
||||||
|
"requireArtifacts": true
|
||||||
|
},
|
||||||
|
"probes": [
|
||||||
|
{
|
||||||
|
"id": "example-business-flow",
|
||||||
|
"enabled": false,
|
||||||
|
"description": "Replace with a real user/product path such as UI -> gateway -> service -> database/storage.",
|
||||||
|
"command": "curl -fsS http://127.0.0.1:3000/health",
|
||||||
|
"expected": {
|
||||||
|
"exitCode": 0,
|
||||||
|
"evidenceArtifact": ".agent/logs/product-smoke.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
70
.scale/quality-contract.json
Normal file
70
.scale/quality-contract.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"project": "eskin-model-player",
|
||||||
|
"description": "E-Skin 传感器数据可视化工具质量契约",
|
||||||
|
"gates": {
|
||||||
|
"pre_commit": {
|
||||||
|
"enabled": true,
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "type_check",
|
||||||
|
"command": "cargo check",
|
||||||
|
"required": true,
|
||||||
|
"description": "类型检查"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lint",
|
||||||
|
"command": "cargo clippy -- -D warnings",
|
||||||
|
"required": true,
|
||||||
|
"description": "Clippy lint 检查"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "test",
|
||||||
|
"command": "cargo test",
|
||||||
|
"required": true,
|
||||||
|
"description": "单元测试"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pre_push": {
|
||||||
|
"enabled": true,
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "build_release",
|
||||||
|
"command": "cargo build --release",
|
||||||
|
"required": true,
|
||||||
|
"description": "Release 构建验证"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "full_test",
|
||||||
|
"command": "cargo test --all",
|
||||||
|
"required": true,
|
||||||
|
"description": "完整测试套件"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"code_review": {
|
||||||
|
"enabled": true,
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "no_unwrap",
|
||||||
|
"pattern": "\\.unwrap\\(\\)",
|
||||||
|
"severity": "warning",
|
||||||
|
"description": "避免裸 unwrap(),使用 expect() 或 ? 运算符"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_todo",
|
||||||
|
"pattern": "todo!\\(\\)|unimplemented!\\(\\)",
|
||||||
|
"severity": "warning",
|
||||||
|
"description": "生产代码不应包含 todo!/unimplemented!"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality_metrics": {
|
||||||
|
"max_complexity": 15,
|
||||||
|
"min_test_coverage": 0,
|
||||||
|
"max_file_lines": 500,
|
||||||
|
"max_function_lines": 80
|
||||||
|
}
|
||||||
|
}
|
||||||
39
.scale/resource-policy.json
Normal file
39
.scale/resource-policy.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"maxGitFileSizeBytes": 5242880,
|
||||||
|
"ignoredDirectories": [
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"vendor",
|
||||||
|
".next",
|
||||||
|
".turbo"
|
||||||
|
],
|
||||||
|
"retainedRuntimeDirectories": [
|
||||||
|
".scale/tmp",
|
||||||
|
".scale/evidence",
|
||||||
|
".scale/reports",
|
||||||
|
".scale/resource-reports",
|
||||||
|
"tmp",
|
||||||
|
"temp",
|
||||||
|
"test-results",
|
||||||
|
"playwright-report",
|
||||||
|
"coverage"
|
||||||
|
],
|
||||||
|
"owners": {
|
||||||
|
"docs": "engineering",
|
||||||
|
"docs/standards": "engineering",
|
||||||
|
"docs/workflow": "engineering",
|
||||||
|
"docs/decisions": "architecture",
|
||||||
|
"docs/modules": "module-owner",
|
||||||
|
".planning": "engineering",
|
||||||
|
".scale": "engineering"
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"example": {
|
||||||
|
"path": "src/example",
|
||||||
|
"owner": "team-or-owner"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
.scale/skills-registry.json
Normal file
82
.scale/skills-registry.json
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"project": "eskin-model-player",
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"name": "graphify",
|
||||||
|
"category": "knowledge",
|
||||||
|
"description": "代码知识图谱",
|
||||||
|
"install": "pip install graphifyy && graphify install",
|
||||||
|
"riskLevel": "LOW",
|
||||||
|
"status": "optional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "systematic-debugging",
|
||||||
|
"category": "debugging",
|
||||||
|
"description": "系统化调试方法论",
|
||||||
|
"riskLevel": "LOW",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "freeze-guard",
|
||||||
|
"category": "safety",
|
||||||
|
"description": "防止代码冻结/死锁",
|
||||||
|
"riskLevel": "LOW",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tdd",
|
||||||
|
"category": "testing",
|
||||||
|
"description": "测试驱动开发",
|
||||||
|
"riskLevel": "LOW",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "verification",
|
||||||
|
"category": "quality",
|
||||||
|
"description": "验证检查",
|
||||||
|
"riskLevel": "LOW",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review",
|
||||||
|
"category": "quality",
|
||||||
|
"description": "代码审查",
|
||||||
|
"riskLevel": "LOW",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ce-compound",
|
||||||
|
"category": "workflow",
|
||||||
|
"description": "复合编辑",
|
||||||
|
"riskLevel": "LOW",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ship",
|
||||||
|
"category": "workflow",
|
||||||
|
"description": "发布交付",
|
||||||
|
"riskLevel": "LOW",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scale-methodology",
|
||||||
|
"category": "methodology",
|
||||||
|
"description": "SCALE 求是方法论",
|
||||||
|
"riskLevel": "LOW",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "codegraph",
|
||||||
|
"category": "knowledge",
|
||||||
|
"description": "代码图谱",
|
||||||
|
"riskLevel": "LOW",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"riskFlags": {
|
||||||
|
"HIGH": "需要人工审查",
|
||||||
|
"MEDIUM": "自动审查",
|
||||||
|
"LOW": "自动信任"
|
||||||
|
}
|
||||||
|
}
|
||||||
630
.scale/skills.json
Normal file
630
.scale/skills.json
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"policy": {
|
||||||
|
"mode": "warn",
|
||||||
|
"enforceLevels": [
|
||||||
|
"M",
|
||||||
|
"L",
|
||||||
|
"CRITICAL"
|
||||||
|
],
|
||||||
|
"requireSkillPlan": true
|
||||||
|
},
|
||||||
|
"domains": {
|
||||||
|
"ui": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"app/**/*.tsx",
|
||||||
|
"pages/**/*.tsx",
|
||||||
|
"components/**/*.tsx",
|
||||||
|
"**/*.css",
|
||||||
|
"**/*.scss"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"ui",
|
||||||
|
"ux",
|
||||||
|
"frontend",
|
||||||
|
"component",
|
||||||
|
"page",
|
||||||
|
"layout",
|
||||||
|
"responsive",
|
||||||
|
"visual",
|
||||||
|
"界面",
|
||||||
|
"页面",
|
||||||
|
"交互",
|
||||||
|
"视觉",
|
||||||
|
"前端"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"requiredSkills": [
|
||||||
|
"frontend-design",
|
||||||
|
"ui-ux-pro-max"
|
||||||
|
],
|
||||||
|
"recommendedSkills": [
|
||||||
|
"awesome-design-md",
|
||||||
|
"webapp-testing",
|
||||||
|
"agent-browser",
|
||||||
|
"mcp-chrome-devtools",
|
||||||
|
"browser-testing-with-devtools",
|
||||||
|
"design-review"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"mini-prd.md",
|
||||||
|
"ui-spec.md",
|
||||||
|
"visual-review.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"design-system",
|
||||||
|
"screenshot",
|
||||||
|
"responsive-check",
|
||||||
|
"browser-run",
|
||||||
|
"visual-review"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"webResearch": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"docs/research/**",
|
||||||
|
"docs/**/research.md",
|
||||||
|
"**/research.md"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"web research",
|
||||||
|
"search online",
|
||||||
|
"online",
|
||||||
|
"latest",
|
||||||
|
"source citation",
|
||||||
|
"source citations",
|
||||||
|
"logged-in",
|
||||||
|
"login",
|
||||||
|
"dynamic web page",
|
||||||
|
"authenticated page",
|
||||||
|
"inspect page",
|
||||||
|
"web-access",
|
||||||
|
"network",
|
||||||
|
"web fetch"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"requiredSkills": [
|
||||||
|
"web-access"
|
||||||
|
],
|
||||||
|
"recommendedSkills": [
|
||||||
|
"agent-browser",
|
||||||
|
"mcp-chrome-devtools",
|
||||||
|
"source-driven-development",
|
||||||
|
"browser-use"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"verification.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"source-citation",
|
||||||
|
"browser-evidence",
|
||||||
|
"network-console-check"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserAutomation": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"tests/e2e/**",
|
||||||
|
"e2e/**",
|
||||||
|
"playwright.config.*",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/*.e2e.ts"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"browser automation",
|
||||||
|
"browser interaction",
|
||||||
|
"browser behavior",
|
||||||
|
"browser",
|
||||||
|
"playwright",
|
||||||
|
"agent-browser",
|
||||||
|
"chrome devtools",
|
||||||
|
"cdp",
|
||||||
|
"screenshot",
|
||||||
|
"console log",
|
||||||
|
"network request",
|
||||||
|
"e2e",
|
||||||
|
"end-to-end"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommendedSkills": [
|
||||||
|
"webapp-testing",
|
||||||
|
"agent-browser",
|
||||||
|
"web-access",
|
||||||
|
"mcp-chrome-devtools",
|
||||||
|
"playwright",
|
||||||
|
"playwright-interactive"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"e2e-plan.md",
|
||||||
|
"verification.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"browser-run",
|
||||||
|
"screenshot",
|
||||||
|
"console-log",
|
||||||
|
"network-console-check"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"e2e": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"tests/e2e/**",
|
||||||
|
"e2e/**",
|
||||||
|
"playwright.config.*"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"e2e",
|
||||||
|
"browser",
|
||||||
|
"playwright",
|
||||||
|
"end-to-end",
|
||||||
|
"端到端",
|
||||||
|
"浏览器"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommendedSkills": [
|
||||||
|
"webapp-testing",
|
||||||
|
"agent-browser",
|
||||||
|
"web-access",
|
||||||
|
"mcp-chrome-devtools",
|
||||||
|
"playwright",
|
||||||
|
"playwright-interactive"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"e2e-plan.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"browser-run",
|
||||||
|
"screenshot",
|
||||||
|
"console-log"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"desktopAutomation": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"tests/desktop/**",
|
||||||
|
"desktop/**",
|
||||||
|
"e2e/desktop/**"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"desktop automation",
|
||||||
|
"desktop app",
|
||||||
|
"computer use",
|
||||||
|
"cua",
|
||||||
|
"gui automation",
|
||||||
|
"operate desktop",
|
||||||
|
"windows desktop",
|
||||||
|
"wps",
|
||||||
|
"wechat",
|
||||||
|
"weixin",
|
||||||
|
"office app"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"requiredSkills": [
|
||||||
|
"turix-cua"
|
||||||
|
],
|
||||||
|
"recommendedSkills": [
|
||||||
|
"agent-browser",
|
||||||
|
"web-access",
|
||||||
|
"computer-use",
|
||||||
|
"opencli"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"verification.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"desktop-screenshot",
|
||||||
|
"operator-safety",
|
||||||
|
"side-effect-boundary"
|
||||||
|
],
|
||||||
|
"blockLevels": [
|
||||||
|
"CRITICAL"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"externalCli": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"scripts/**",
|
||||||
|
".github/workflows/**"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"external cli",
|
||||||
|
"agent cli",
|
||||||
|
"codex",
|
||||||
|
"codex cli",
|
||||||
|
"claude code",
|
||||||
|
"gemini cli",
|
||||||
|
"opencode",
|
||||||
|
"aider",
|
||||||
|
"cross-agent",
|
||||||
|
"subagent",
|
||||||
|
"wps cli",
|
||||||
|
"wechat automation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommendedSkills": [
|
||||||
|
"codex-cli",
|
||||||
|
"gemini-cli",
|
||||||
|
"opencode-cli",
|
||||||
|
"git-workflow-and-versioning",
|
||||||
|
"code-reviewer"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"verification.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"cli-version-check",
|
||||||
|
"command-output",
|
||||||
|
"dry-run-or-safe-mode",
|
||||||
|
"side-effect-boundary"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"**/api/**",
|
||||||
|
"**/routes/**",
|
||||||
|
"**/controller/**",
|
||||||
|
"**/*.api",
|
||||||
|
"**/*.proto"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"api",
|
||||||
|
"endpoint",
|
||||||
|
"route",
|
||||||
|
"handler",
|
||||||
|
"接口",
|
||||||
|
"路由"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommendedSkills": [
|
||||||
|
"tdd-guide",
|
||||||
|
"code-review"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"mini-prd.md",
|
||||||
|
"api-contract.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"contract-check"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"db": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"**/migration/**",
|
||||||
|
"**/migrations/**",
|
||||||
|
"**/*.sql",
|
||||||
|
"**/schema.*",
|
||||||
|
"**/model/**"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"database",
|
||||||
|
"db",
|
||||||
|
"migration",
|
||||||
|
"schema",
|
||||||
|
"sql",
|
||||||
|
"数据表",
|
||||||
|
"数据库",
|
||||||
|
"迁移"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"requiredSkills": [
|
||||||
|
"security-review"
|
||||||
|
],
|
||||||
|
"recommendedSkills": [
|
||||||
|
"systematic-debugging"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"db-change-plan.md",
|
||||||
|
"security-review.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"rollback-plan",
|
||||||
|
"migration-test"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"**/auth/**",
|
||||||
|
"**/permission/**",
|
||||||
|
"**/security/**",
|
||||||
|
"**/middleware/**"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"auth",
|
||||||
|
"permission",
|
||||||
|
"tenant",
|
||||||
|
"token",
|
||||||
|
"credential",
|
||||||
|
"secret",
|
||||||
|
"rbac",
|
||||||
|
"鉴权",
|
||||||
|
"权限",
|
||||||
|
"租户",
|
||||||
|
"密钥"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"requiredSkills": [
|
||||||
|
"security-review"
|
||||||
|
],
|
||||||
|
"recommendedSkills": [
|
||||||
|
"code-review"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"security-review.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"threat-model",
|
||||||
|
"rollback-plan"
|
||||||
|
],
|
||||||
|
"blockLevels": [
|
||||||
|
"CRITICAL"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"docs/**",
|
||||||
|
"**/*.md"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"docs",
|
||||||
|
"documentation",
|
||||||
|
"document",
|
||||||
|
"readme",
|
||||||
|
"文档"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommendedSkills": [
|
||||||
|
"update-docs",
|
||||||
|
"workflow-guide"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"docs-impact.md"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"resourceGovernance": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
".scale/resource-policy.json",
|
||||||
|
".scale/assets.json",
|
||||||
|
"docs/modules/**",
|
||||||
|
"docs/decisions/**",
|
||||||
|
"docs/worklog/tasks/**",
|
||||||
|
"test-results/**",
|
||||||
|
"playwright-report/**",
|
||||||
|
"coverage/**",
|
||||||
|
"tmp/**",
|
||||||
|
"scripts/tmp/**",
|
||||||
|
"**/*.png",
|
||||||
|
"**/*.jpg",
|
||||||
|
"**/*.jpeg",
|
||||||
|
"**/*.webp",
|
||||||
|
"**/*.gif",
|
||||||
|
"**/*.mp4",
|
||||||
|
"**/*.webm",
|
||||||
|
"**/*.mov",
|
||||||
|
"**/*.wav",
|
||||||
|
"**/*.mp3"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"asset",
|
||||||
|
"resource",
|
||||||
|
"artifact retention",
|
||||||
|
"lifecycle",
|
||||||
|
"temporary file",
|
||||||
|
"e2e report",
|
||||||
|
"screenshot",
|
||||||
|
"video",
|
||||||
|
"documentation drift",
|
||||||
|
"resource governance"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommendedSkills": [
|
||||||
|
"documentation-and-adrs",
|
||||||
|
"git-workflow-and-versioning",
|
||||||
|
"ai-slop-cleaner"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"docs-impact.md",
|
||||||
|
"resource-impact.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"asset-scan",
|
||||||
|
"asset-doctor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engineeringStandards": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
".scale/engineering-standards.json",
|
||||||
|
".scale/frameworks.json",
|
||||||
|
"docs/standards/**",
|
||||||
|
"src/**",
|
||||||
|
"app/**",
|
||||||
|
"packages/**",
|
||||||
|
"services/**",
|
||||||
|
"internal/**",
|
||||||
|
"pkg/**"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"coding standard",
|
||||||
|
"engineering standard",
|
||||||
|
"logging",
|
||||||
|
"redaction",
|
||||||
|
"desensitization",
|
||||||
|
"orm",
|
||||||
|
"framework convention",
|
||||||
|
"architecture boundary",
|
||||||
|
"design pattern",
|
||||||
|
"test rigor",
|
||||||
|
"sql injection",
|
||||||
|
"xss",
|
||||||
|
"脱敏",
|
||||||
|
"日志",
|
||||||
|
"架构规范",
|
||||||
|
"编码规范",
|
||||||
|
"框架规范",
|
||||||
|
"联调",
|
||||||
|
"发版"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommendedSkills": [
|
||||||
|
"code-review-and-quality",
|
||||||
|
"security-and-hardening",
|
||||||
|
"documentation-and-adrs"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"standards-impact.md",
|
||||||
|
"architecture-review.md",
|
||||||
|
"security-review.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"standards-scan",
|
||||||
|
"standards-doctor"
|
||||||
|
],
|
||||||
|
"blockLevels": [
|
||||||
|
"L",
|
||||||
|
"CRITICAL"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
".github/PULL_REQUEST_TEMPLATE.md",
|
||||||
|
".github/pull_request_template.md"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"review",
|
||||||
|
"code review",
|
||||||
|
"pull request",
|
||||||
|
"pr",
|
||||||
|
"merge request",
|
||||||
|
"changes reviewed",
|
||||||
|
"审查",
|
||||||
|
"评审"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"requiredSkills": [
|
||||||
|
"code-reviewer"
|
||||||
|
],
|
||||||
|
"recommendedSkills": [
|
||||||
|
"pr-creator"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"review.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"review-evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"detect": {
|
||||||
|
"files": [
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"package.json",
|
||||||
|
".github/workflows/**"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"release",
|
||||||
|
"ship",
|
||||||
|
"publish",
|
||||||
|
"deploy",
|
||||||
|
"pull request",
|
||||||
|
"pr",
|
||||||
|
"发版",
|
||||||
|
"发布",
|
||||||
|
"部署"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"requiredSkills": [
|
||||||
|
"code-reviewer"
|
||||||
|
],
|
||||||
|
"recommendedSkills": [
|
||||||
|
"pr-creator",
|
||||||
|
"fix",
|
||||||
|
"verification",
|
||||||
|
"code-review"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"review.md",
|
||||||
|
"summary.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"preflight"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"skillDiscovery": {
|
||||||
|
"detect": {
|
||||||
|
"keywords": [
|
||||||
|
"skill",
|
||||||
|
"capability",
|
||||||
|
"missing capability",
|
||||||
|
"install skill",
|
||||||
|
"find skill"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommendedSkills": [
|
||||||
|
"find-skills"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fullstackPrototype": {
|
||||||
|
"detect": {
|
||||||
|
"keywords": [
|
||||||
|
"fullstack",
|
||||||
|
"full-stack",
|
||||||
|
"mvp",
|
||||||
|
"prototype",
|
||||||
|
"next.js",
|
||||||
|
"react api",
|
||||||
|
"node api"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommendedSkills": [
|
||||||
|
"fullstack-developer"
|
||||||
|
],
|
||||||
|
"requiredArtifacts": [
|
||||||
|
"skill-plan.md",
|
||||||
|
"skill-evidence.md",
|
||||||
|
"mini-prd.md",
|
||||||
|
"api-contract.md"
|
||||||
|
],
|
||||||
|
"requiredVerification": [
|
||||||
|
"preflight"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
.scale/tools.json
Normal file
118
.scale/tools.json
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"mode": "evidence-required",
|
||||||
|
"tools": {
|
||||||
|
"web-access": {
|
||||||
|
"enabled": true,
|
||||||
|
"requiredFor": [
|
||||||
|
"webResearch"
|
||||||
|
],
|
||||||
|
"recommendedFor": [
|
||||||
|
"browserAutomation"
|
||||||
|
],
|
||||||
|
"destructiveActions": "block",
|
||||||
|
"evidenceRequired": true
|
||||||
|
},
|
||||||
|
"frontend-design": {
|
||||||
|
"enabled": true,
|
||||||
|
"requiredFor": [
|
||||||
|
"ui"
|
||||||
|
],
|
||||||
|
"destructiveActions": "block",
|
||||||
|
"evidenceRequired": true
|
||||||
|
},
|
||||||
|
"ui-ux-pro-max": {
|
||||||
|
"enabled": true,
|
||||||
|
"requiredFor": [
|
||||||
|
"ui"
|
||||||
|
],
|
||||||
|
"destructiveActions": "block",
|
||||||
|
"evidenceRequired": true
|
||||||
|
},
|
||||||
|
"agent-browser": {
|
||||||
|
"enabled": true,
|
||||||
|
"requiredFor": [
|
||||||
|
"browserAutomation"
|
||||||
|
],
|
||||||
|
"recommendedFor": [
|
||||||
|
"ui",
|
||||||
|
"e2e"
|
||||||
|
],
|
||||||
|
"allowedDomains": [
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1"
|
||||||
|
],
|
||||||
|
"destructiveActions": "confirm",
|
||||||
|
"command": "agent-browser",
|
||||||
|
"evidenceRequired": true
|
||||||
|
},
|
||||||
|
"playwright": {
|
||||||
|
"enabled": true,
|
||||||
|
"requiredFor": [
|
||||||
|
"e2e"
|
||||||
|
],
|
||||||
|
"recommendedFor": [
|
||||||
|
"browserAutomation",
|
||||||
|
"ui"
|
||||||
|
],
|
||||||
|
"destructiveActions": "confirm",
|
||||||
|
"command": "npx playwright",
|
||||||
|
"evidenceRequired": true
|
||||||
|
},
|
||||||
|
"mcp-chrome-devtools": {
|
||||||
|
"enabled": true,
|
||||||
|
"requiredFor": [
|
||||||
|
"browserAutomation"
|
||||||
|
],
|
||||||
|
"recommendedFor": [
|
||||||
|
"ui",
|
||||||
|
"e2e"
|
||||||
|
],
|
||||||
|
"destructiveActions": "confirm",
|
||||||
|
"mcpToolName": "chrome-devtools",
|
||||||
|
"evidenceRequired": true
|
||||||
|
},
|
||||||
|
"desktop-cua": {
|
||||||
|
"enabled": false,
|
||||||
|
"requiredFor": [
|
||||||
|
"desktopAutomation"
|
||||||
|
],
|
||||||
|
"destructiveActions": "block",
|
||||||
|
"command": "cua",
|
||||||
|
"evidenceRequired": true
|
||||||
|
},
|
||||||
|
"codex-cli": {
|
||||||
|
"enabled": false,
|
||||||
|
"requiredFor": [],
|
||||||
|
"recommendedFor": [
|
||||||
|
"externalCli",
|
||||||
|
"review"
|
||||||
|
],
|
||||||
|
"destructiveActions": "block",
|
||||||
|
"command": "codex",
|
||||||
|
"evidenceRequired": true
|
||||||
|
},
|
||||||
|
"gemini-cli": {
|
||||||
|
"enabled": false,
|
||||||
|
"requiredFor": [],
|
||||||
|
"recommendedFor": [
|
||||||
|
"externalCli",
|
||||||
|
"review"
|
||||||
|
],
|
||||||
|
"destructiveActions": "block",
|
||||||
|
"command": "gemini",
|
||||||
|
"evidenceRequired": true
|
||||||
|
},
|
||||||
|
"opencode-cli": {
|
||||||
|
"enabled": false,
|
||||||
|
"requiredFor": [],
|
||||||
|
"recommendedFor": [
|
||||||
|
"externalCli",
|
||||||
|
"review"
|
||||||
|
],
|
||||||
|
"destructiveActions": "block",
|
||||||
|
"command": "opencode",
|
||||||
|
"evidenceRequired": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
.scale/verification.json
Normal file
35
.scale/verification.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"defaultProfile": "default",
|
||||||
|
"profiles": {
|
||||||
|
"default": {
|
||||||
|
"commands": {},
|
||||||
|
"services": []
|
||||||
|
},
|
||||||
|
"productSmoke": {
|
||||||
|
"commands": {
|
||||||
|
"smoke": "powershell -ExecutionPolicy Bypass -File scripts/qa/product-smoke.ps1"
|
||||||
|
},
|
||||||
|
"services": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": [],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"tmp",
|
||||||
|
"vendor"
|
||||||
|
],
|
||||||
|
"policy": {
|
||||||
|
"mode": "standard",
|
||||||
|
"optionalToolsWarnOnly": true,
|
||||||
|
"artifactGate": "warn",
|
||||||
|
"artifactGateLevels": [
|
||||||
|
"M",
|
||||||
|
"L",
|
||||||
|
"CRITICAL"
|
||||||
|
],
|
||||||
|
"engineeringStandardsGate": "block",
|
||||||
|
"productSmokeGate": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
51
.scale/workflow.json
Normal file
51
.scale/workflow.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"project": "eskin-model-player",
|
||||||
|
"workflow": {
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"name": "explore",
|
||||||
|
"description": "理解需求和现有代码",
|
||||||
|
"required": true,
|
||||||
|
"artifacts": ["docs/workflow/templates/explore.md"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "plan",
|
||||||
|
"description": "制定实现方案",
|
||||||
|
"required": true,
|
||||||
|
"artifacts": ["docs/workflow/templates/plan.md"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "implement",
|
||||||
|
"description": "编码实现",
|
||||||
|
"required": true,
|
||||||
|
"gates": ["type_check", "lint"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "test",
|
||||||
|
"description": "测试验证",
|
||||||
|
"required": true,
|
||||||
|
"gates": ["test"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review",
|
||||||
|
"description": "代码审查",
|
||||||
|
"required": true,
|
||||||
|
"artifacts": ["docs/workflow/templates/review.md"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ship",
|
||||||
|
"description": "提交发布",
|
||||||
|
"required": true,
|
||||||
|
"gates": ["build_release", "full_test"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"skills_routing": {
|
||||||
|
"debug": "systematic-debugging",
|
||||||
|
"test": "tdd",
|
||||||
|
"review": "review",
|
||||||
|
"ship": "ship",
|
||||||
|
"graph": "graphify"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
AGENTS.md
Normal file
26
AGENTS.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# .
|
||||||
|
|
||||||
|
## SCALE Engine Integration (OpenClaw)
|
||||||
|
|
||||||
|
This project uses SCALE Engine for AI engineering governance via OpenClaw.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
- `scale create <type> <title>` — Create artifact
|
||||||
|
- `scale transition <id> <action>` — Transition artifact state
|
||||||
|
- `scale list --type Spec` — List artifacts
|
||||||
|
- `scale role activate <role>` — Switch role
|
||||||
|
- `scale doctor` — Health check
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
1. **Explore** → Role: explorer (Read/Grep only)
|
||||||
|
2. **Plan** → Create Spec → refine → approve (guard: ambiguity ≤ 0.2)
|
||||||
|
3. **Implement** → Role: implementer (Edit/Write/Bash unlocked)
|
||||||
|
4. **Verify** → Must run tests before claiming done
|
||||||
|
5. **Learn** → Defects → Lessons → Rules → Hooks
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
- 🔴 Dangerous commands are physically blocked
|
||||||
|
- 🔴 Hardcoded secrets are blocked on Edit/Write
|
||||||
|
- 🟡 3 identical retries triggers brute-retry detection
|
||||||
|
- 🟡 Claiming done without running tests is blocked
|
||||||
|
- 🟢 All tool calls are tracked in .scale/events/
|
||||||
89
Cargo.lock
generated
89
Cargo.lock
generated
@@ -559,6 +559,12 @@ dependencies = [
|
|||||||
"arrayvec",
|
"arrayvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -664,6 +670,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder-lite"
|
name = "byteorder-lite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1292,7 +1304,7 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "eskin-model-player"
|
name = "eskin-model-player"
|
||||||
version = "0.5.0"
|
version = "5.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
@@ -1301,10 +1313,13 @@ dependencies = [
|
|||||||
"eframe",
|
"eframe",
|
||||||
"egui_extras",
|
"egui_extras",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"fs_extra",
|
||||||
"glam",
|
"glam",
|
||||||
|
"gltf",
|
||||||
"image",
|
"image",
|
||||||
"log",
|
"log",
|
||||||
"serialport",
|
"serialport",
|
||||||
|
"tobj",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1455,6 +1470,12 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1584,6 +1605,45 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gltf"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"byteorder",
|
||||||
|
"gltf-json",
|
||||||
|
"image",
|
||||||
|
"lazy_static",
|
||||||
|
"serde_json",
|
||||||
|
"urlencoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gltf-derive"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
|
||||||
|
dependencies = [
|
||||||
|
"inflections",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gltf-json"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
|
||||||
|
dependencies = [
|
||||||
|
"gltf-derive",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glutin"
|
name = "glutin"
|
||||||
version = "0.32.3"
|
version = "0.32.3"
|
||||||
@@ -1907,6 +1967,12 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inflections"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "interpolate_name"
|
name = "interpolate_name"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@@ -2082,6 +2148,12 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leb128fmt"
|
name = "leb128fmt"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3908,6 +3980,15 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tobj"
|
||||||
|
version = "4.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2da83817ac7401436633efc35c19edae9f326fa741fae775a1caa406de9db07c"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -4047,6 +4128,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
19
Cargo.toml
19
Cargo.toml
@@ -1,7 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "eskin-model-player"
|
name = "eskin-model-player"
|
||||||
version = "0.5.0"
|
version = "5.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
authors = ["JOYSONQUIN"]
|
||||||
|
description = "Desktop pressure sensor visualization and playback application."
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[package.metadata.wix]
|
||||||
|
eula = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
eframe = { version = "0.34.2", features = ["default", "wgpu", "__screenshot"] }
|
eframe = { version = "0.34.2", features = ["default", "wgpu", "__screenshot"] }
|
||||||
@@ -15,3 +21,14 @@ egui_extras = { version = "0.34.2", features = ["image"] }
|
|||||||
crossbeam-channel = "0.5.15"
|
crossbeam-channel = "0.5.15"
|
||||||
crc = "3.4.0"
|
crc = "3.4.0"
|
||||||
log = "0.4.29"
|
log = "0.4.29"
|
||||||
|
tobj = "4.0.4"
|
||||||
|
gltf = "1.4.1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
fs_extra = "1.3.0"
|
||||||
|
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ESkinPlayer"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|||||||
61
TOOLS.md
Normal file
61
TOOLS.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# TOOLS.md — eskin-player 工具链文档
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
**eskin-model-player** 是一个 Rust GUI 应用,用于 E-Skin 传感器数据的实时可视化。
|
||||||
|
- 框架:eframe/egui + wgpu(WebGPU)
|
||||||
|
- 串口通信:serialport crate
|
||||||
|
- 数据处理:glam(数学)、bytemuck(内存布局)、crc(校验)
|
||||||
|
|
||||||
|
## 构建工具
|
||||||
|
| 工具 | 版本 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| Rust | 1.95.0+ | 主语言(edition 2024) |
|
||||||
|
| Cargo | 1.95.0+ | 构建系统 |
|
||||||
|
| SCALE Engine | 0.21.2 | 工程约束与质量门控 |
|
||||||
|
|
||||||
|
## 项目命令
|
||||||
|
```bash
|
||||||
|
cargo build --release # 构建
|
||||||
|
cargo clippy # Lint
|
||||||
|
cargo test # 测试
|
||||||
|
cargo check # 类型检查
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
```
|
||||||
|
eskin-player/
|
||||||
|
├── Cargo.toml # 依赖配置
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # 入口
|
||||||
|
│ ├── app.rs # 应用逻辑
|
||||||
|
│ ├── ui.rs # UI 组件
|
||||||
|
│ ├── render.rs # 渲染
|
||||||
|
│ ├── connection.rs # 连接管理
|
||||||
|
│ ├── matrix.rs # 矩阵数据
|
||||||
|
│ ├── texture.rs # 纹理
|
||||||
|
│ ├── theme.rs # 主题
|
||||||
|
│ ├── utils.rs # 工具函数
|
||||||
|
│ ├── shader.wgsl # WebGPU shader
|
||||||
|
│ └── serial_core/ # 串口核心模块
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── serial.rs
|
||||||
|
│ ├── codec.rs
|
||||||
|
│ ├── codecs/
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ └── tactile_a.rs
|
||||||
|
│ ├── frame.rs
|
||||||
|
│ ├── error.rs
|
||||||
|
│ └── utils.rs
|
||||||
|
├── static/ # 静态资源
|
||||||
|
├── .scale/ # SCALE Engine 配置
|
||||||
|
├── .openclaw/ # Agent 配置
|
||||||
|
├── AGENTS.md # Agent 知识文档
|
||||||
|
└── docs/workflow/ # 工作流模板
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键依赖
|
||||||
|
- **eframe 0.34.2** — 跨平台 GUI 框架(wgpu 后端)
|
||||||
|
- **serialport 4.9.0** — 串口通信
|
||||||
|
- **glam 0.32.1** — 线性代数
|
||||||
|
- **crc 3.4.0** — CRC 校验
|
||||||
|
- **crossbeam-channel 0.5.15** — 无锁消息通道
|
||||||
33
build.rs
Normal file
33
build.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use std::{env, fs, path::PathBuf};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use fs_extra::{copy_items, dir::CopyOptions};
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
|
||||||
|
let source_dir = manifest_dir.join("res");
|
||||||
|
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
|
||||||
|
let resource_dir = out_dir.join("res");
|
||||||
|
|
||||||
|
let mut copy_options = CopyOptions::new();
|
||||||
|
copy_options.overwrite = true;
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed={}", source_dir.display());
|
||||||
|
|
||||||
|
if resource_dir.exists() {
|
||||||
|
fs::remove_dir_all(&resource_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if source_dir.exists() {
|
||||||
|
let items = fs::read_dir(&source_dir)?
|
||||||
|
.map(|entry| entry.map(|entry| entry.path()))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
fs::create_dir_all(&resource_dir)?;
|
||||||
|
copy_items(&items, &resource_dir, ©_options)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=RESOURCE_DIR={}", resource_dir.display());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
1060
docs/cargo-wix-guide.md
Normal file
1060
docs/cargo-wix-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
35
docs/workflow/QUALITY_CONTRACT.md
Normal file
35
docs/workflow/QUALITY_CONTRACT.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 质量契约 (Quality Contract)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
本文档定义 eskin-model-player 项目的质量标准和交付要求。
|
||||||
|
|
||||||
|
## 门控检查
|
||||||
|
|
||||||
|
### Pre-commit(提交前)
|
||||||
|
| 检查项 | 命令 | 必须通过 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| 类型检查 | `cargo check` | ✅ |
|
||||||
|
| Lint | `cargo clippy -- -D warnings` | ✅ |
|
||||||
|
| 单元测试 | `cargo test` | ✅ |
|
||||||
|
|
||||||
|
### Pre-push(推送前)
|
||||||
|
| 检查项 | 命令 | 必须通过 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| Release 构建 | `cargo build --release` | ✅ |
|
||||||
|
| 完整测试 | `cargo test --all` | ✅ |
|
||||||
|
|
||||||
|
### 代码审查规则
|
||||||
|
- ❌ 禁止裸 `unwrap()` — 使用 `expect("原因")` 或 `?` 运算符
|
||||||
|
- ❌ 禁止 `todo!()` / `unimplemented!()` — 生产代码必须完整实现
|
||||||
|
|
||||||
|
## 质量指标
|
||||||
|
| 指标 | 标准 |
|
||||||
|
|------|------|
|
||||||
|
| 圈复杂度 | ≤ 15 |
|
||||||
|
| 文件行数 | ≤ 500 行 |
|
||||||
|
| 函数行数 | ≤ 80 行 |
|
||||||
|
|
||||||
|
## 交付承诺
|
||||||
|
1. **诚实交付**:所有验证必须真实运行,未验证项必须明确列出
|
||||||
|
2. **逐步验证**:每完成一步,运行相关验证命令
|
||||||
|
3. **知识沉淀**:将经验写入 AGENTS.md 和 TOOLS.md
|
||||||
187
docs/workflow/README.md
Normal file
187
docs/workflow/README.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# . Workflow
|
||||||
|
|
||||||
|
Governance mode: standard
|
||||||
|
Governance pack: standard
|
||||||
|
|
||||||
|
## Task Levels
|
||||||
|
|
||||||
|
| Level | Use for | Required artifacts |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| S | typo, comments, small local edits | relevant validation only |
|
||||||
|
| M | bug fixes, new APIs, 2-5 files | explore, skill plan, plan, verification, review, summary |
|
||||||
|
| L | cross-module or architecture changes | full artifacts plus human confirmation |
|
||||||
|
| CRITICAL | auth, permissions, migrations, production config | rollback plan, security review, full verification |
|
||||||
|
|
||||||
|
## Standard Task Directory
|
||||||
|
|
||||||
|
```text
|
||||||
|
.planning/tasks/<yyyy-mm-dd>-<task-slug>/
|
||||||
|
├── explore.md
|
||||||
|
├── mini-prd.md
|
||||||
|
├── plan.md
|
||||||
|
├── runtime.md
|
||||||
|
├── reality-check.md
|
||||||
|
├── resource-cleanup.md
|
||||||
|
├── verification.md
|
||||||
|
├── review.md
|
||||||
|
├── summary.md
|
||||||
|
├── artifact-manifest.json
|
||||||
|
└── artifacts/
|
||||||
|
├── index.html
|
||||||
|
└── release-report.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Use service-aware verification when configured:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scale preflight --service all
|
||||||
|
scale preflight --service all --preflight-profile full
|
||||||
|
scale verify <task-id> --profile default
|
||||||
|
scale verify <task-id> --service <service-name>
|
||||||
|
scale verify <task-id> --artifact-gate warn
|
||||||
|
scale verify <task-id> --artifact-gate block
|
||||||
|
scale verify <task-id> --require-installed-skills
|
||||||
|
scale verify <task-id> --profile productSmoke
|
||||||
|
scale task-artifacts check --dir .planning/tasks/<task-dir> --level L
|
||||||
|
scale artifact render --task-id <task-dir> --type release-report
|
||||||
|
scale artifact doctor --task-id <task-dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `.scale/verification.json` as the source of truth for profiles and service commands.
|
||||||
|
Keep `.scale/skills.json` as the source of truth for active skill routing policy.
|
||||||
|
Keep `.scale/output-policy.json` as the source of truth for derived HTML artifact types, source Markdown mapping, security policy, and Git retention behavior.
|
||||||
|
Keep `.scale/resource-policy.json` and `.scale/assets.json` as the source of truth for generated reports, temporary files, module documentation, media, reusable scripts, and Git retention policy.
|
||||||
|
Keep `.scale/engineering-standards.json` and `.scale/frameworks.json` as the source of truth for logging, security, ORM, architecture, framework, UI/UX, testing, and coding standard checks.
|
||||||
|
Keep `.scale/engineering-standards-baseline.json` as the temporary exception list for known legacy standards findings; it must not be used to hide new or changed-file problems.
|
||||||
|
Use `artifactGate: "warn"` while introducing the workflow, then move M/L/CRITICAL work to `"block"` once templates and local gates are stable.
|
||||||
|
|
||||||
|
## Workflow Upgrade
|
||||||
|
|
||||||
|
Do not rerun `scale init` as a blind upgrade command. Generated governance files may contain local project adaptations.
|
||||||
|
|
||||||
|
Use the guarded upgrade flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scale upgrade check --dir .
|
||||||
|
scale upgrade plan --dir . --html
|
||||||
|
scale upgrade apply --dir . --confirm
|
||||||
|
scale upgrade rollback --dir .
|
||||||
|
scale tools outdated --dir .
|
||||||
|
scale skill outdated --dir .
|
||||||
|
scale preflight --preflight-profile quick
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `.scale/governance.lock.json` records generated file hashes and pack versions.
|
||||||
|
- Clean or missing generated files can be planned safely.
|
||||||
|
- Locally changed generated files require manual review before replacement or merge.
|
||||||
|
- `scale upgrade apply --confirm` only restores missing generated files and refreshes the lock after writing `.scale/backups/upgrade-*/manifest.json`.
|
||||||
|
- `scale upgrade rollback` only rolls back the latest SCALE-managed safe apply.
|
||||||
|
- Third-party skills, MCP servers, browser tools, desktop automation, and external CLI tools are never auto-installed by the upgrade flow.
|
||||||
|
- Community sources require source, install script, permission, and changelog review. Desktop automation is treated as high risk.
|
||||||
|
|
||||||
|
## HTML Artifacts
|
||||||
|
|
||||||
|
Markdown remains the editable source of truth for task artifacts. HTML artifacts are derived human-review surfaces for plan comparison, implementation plans, code reviews, status reports, incident reports, and release reports.
|
||||||
|
|
||||||
|
Use HTML when a human needs to compare, review, or sign off. Keep source Markdown, manifest metadata, and safety checks in place so the derived HTML stays traceable and does not leak secrets or remote scripts.
|
||||||
|
|
||||||
|
## Active Skill Routing
|
||||||
|
|
||||||
|
SCALE plans required skills from task description, service selection, and changed files. UI/API work requires a Mini-PRD plus domain evidence such as `ui-spec.md`, `visual-review.md`, or `api-contract.md`. Security and database work require explicit review or rollback artifacts.
|
||||||
|
|
||||||
|
Tool orchestration is part of the workflow contract:
|
||||||
|
|
||||||
|
- UI/UX work requires `frontend-design` and `ui-ux-pro-max`, and should consider `awesome-design-md`, browser screenshots, responsive checks, and visual review evidence.
|
||||||
|
- Web research, logged-in pages, and dynamic browser work require `web-access` evidence, source citations, and browser/network/console evidence when available.
|
||||||
|
- Browser E2E work should combine `webapp-testing`, Playwright, Agent Browser, web-access, or Chrome DevTools MCP according to the target and record screenshots plus console/network findings.
|
||||||
|
- Desktop or client-side GUI automation uses CUA/computer-use only with explicit operator-safety notes, desktop screenshots, and a side-effect boundary.
|
||||||
|
- External agent or CLI orchestration such as Codex, Gemini CLI, OpenCode, WPS, or WeChat automation must record version checks, exact commands, output summaries, and dry-run or safe-mode evidence.
|
||||||
|
|
||||||
|
When a task records `servicesTouched`, `scale verify <task-id>` uses those services automatically. You can still override selection with `--service all`, `--service api`, or `--service api,gateway`.
|
||||||
|
|
||||||
|
Before M/L work, check whether required workflow skills are physically installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scale skill doctor --json
|
||||||
|
scale skill check --require-installed --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workspace Lifecycle
|
||||||
|
|
||||||
|
Before finishing an agent-created branch or deleting a temporary worktree, inspect root and child repository state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scale workspace status --json
|
||||||
|
scale workspace finish --summary
|
||||||
|
scale workspace finish --json
|
||||||
|
scale workspace cleanup --dir <temporary-worktree> --dry-run --json
|
||||||
|
scale workspace cleanup --dir <temporary-worktree> --apply --confirm <branch-or-head> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not remove a temporary worktree while any submodule or nested repository has uncommitted or unpushed work. Child repositories must be committed and reviewed in their own remotes, then the root repository can record any required pointer or governance updates. Cleanup defaults to dry-run. Applying cleanup requires the reported confirmation token, normally the temporary branch name.
|
||||||
|
|
||||||
|
Use `scale ship <task-id>` for governed commits. It checks MOE/submodule child repository state before staging reviewed root files, so dirty or unpushed child work cannot be hidden inside a root commit. It also enforces the GitLab Flow branch lifecycle: work happens on short branches, merges target `dev`, production lands on `master`, and release publishing is triggered by user-created `vX.Y.Z` tags. Direct governed commits on `dev`, `master`, `main`, or detached HEAD are blocked. Raw `git add .` is outside the governed path and must not be used for MOE releases.
|
||||||
|
|
||||||
|
## Resource Governance
|
||||||
|
|
||||||
|
Use asset scanning before committing generated reports, media, temporary scripts, or long-lived documentation changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scale assets scan --json
|
||||||
|
scale assets doctor --json
|
||||||
|
scale assets settle --task-id <task-id> --artifact-dir .planning/tasks/<task-dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
Default policy:
|
||||||
|
|
||||||
|
- maintained module docs, standards, contracts, ADRs, reusable scripts: commit and keep current.
|
||||||
|
- task planning, verification, runtime-contract, reality-check, and cleanup artifacts: keep in `.planning/tasks`; promote final truth to maintained docs when useful.
|
||||||
|
- screenshots, videos, E2E reports, coverage, temporary scripts, and runtime logs: keep out of Git unless explicitly promoted.
|
||||||
|
- large media: use Git LFS or external artifact storage instead of normal Git history.
|
||||||
|
|
||||||
|
## Engineering Standards
|
||||||
|
|
||||||
|
Use standards scanning before reviewing or shipping M/L/CRITICAL work:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scale standards scan --json
|
||||||
|
scale standards doctor --json
|
||||||
|
scale standards doctor --changed --json
|
||||||
|
scale standards doctor --changed-files src/example.ts,src/example.test.ts --json
|
||||||
|
scale standards baseline --write --artifact-dir .planning/tasks/<task-dir> --task-id <task-id> --json
|
||||||
|
scale standards settle --task-id <task-id> --artifact-dir .planning/tasks/<task-dir>
|
||||||
|
scale preflight --preflight-profile full --json
|
||||||
|
scale verify <task-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Default policy:
|
||||||
|
|
||||||
|
- ad-hoc console/output logging is allowed only for CLI/script paths.
|
||||||
|
- sensitive fields such as token, password, secret, authorization, cookie, and credentials must not be logged.
|
||||||
|
- hardcoded secret-like assignments are blocked before review or release.
|
||||||
|
- SQL must use parameterized queries, ORM bind parameters, or safe query builders.
|
||||||
|
- unsafe HTML sinks, dynamic code execution, empty catch blocks, and type suppressions require remediation before release.
|
||||||
|
- framework and architecture rules live in `.scale/frameworks.json` and module standards docs.
|
||||||
|
- `.scale/frameworks.json > bannedImports` blocks direct use of deprecated ORMs, unsafe SDKs, or off-system UI components.
|
||||||
|
- `.scale/frameworks.json > lastReviewedAt/reviewIntervalDays` warns when module framework decisions need review.
|
||||||
|
- `.scale/engineering-standards.json > blockingRules` promotes selected warning rule IDs to release-blocking findings.
|
||||||
|
- `.scale/engineering-standards.json > allowedFindingPatterns` allows narrow rule/path/evidence exceptions without hiding unrelated findings in the same file.
|
||||||
|
- `.scale/engineering-standards-baseline.json` may hold known legacy findings during rollout, but normal task gates should prefer `--changed` or `--changed-files` so new work is blocked without forcing a whole-repo cleanup.
|
||||||
|
- `.scale/verification.json > policy.engineeringStandardsGate` controls whether preflight and task verification treat standards as `off`, `warn`, or `block`.
|
||||||
|
- `.scale/product-smoke.json` defines real product-path probes. Use it to prove a routed user/business flow, not only build, unit tests, or `/health`.
|
||||||
|
- `.scale/verification.json > policy.productSmokeGate` controls whether missing or failed product smoke evidence warns or blocks M/L/CRITICAL delivery.
|
||||||
|
- Full standards scans are for release readiness, scheduled remediation, and architecture cleanup. Changed-file scans are the default for day-to-day feature and bug branches.
|
||||||
|
- Use `scale standards baseline --write` only during an explicit rollout or remediation planning task. It writes the machine-readable baseline and a `standards-legacy-debt.md` classification report for staged cleanup.
|
||||||
|
|
||||||
|
## Automation Templates
|
||||||
|
|
||||||
|
Optional automation templates are generated under `docs/workflow/templates/`:
|
||||||
|
|
||||||
|
- `github-actions-scale-preflight.yml`: CI workflow that runs `scale preflight --service all --preflight-profile ci`.
|
||||||
|
- `pre-push-scale-preflight.sh`: local pre-push hook template that runs the default quick preflight.
|
||||||
|
|
||||||
|
Keep these templates advisory until `scale preflight --service all --preflight-profile full` is reliable locally for the project.
|
||||||
29
docs/workflow/templates/api-contract.md
Normal file
29
docs/workflow/templates/api-contract.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# API Contract
|
||||||
|
|
||||||
|
## Endpoint Or Interface
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Permission Rules
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Compatibility Notes
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] TBD
|
||||||
23
docs/workflow/templates/architecture-review.md
Normal file
23
docs/workflow/templates/architecture-review.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Architecture Review
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Modules touched:
|
||||||
|
- Public contracts touched:
|
||||||
|
- Data flow touched:
|
||||||
|
|
||||||
|
## Boundary Checks
|
||||||
|
|
||||||
|
- [ ] API/controller layer does not bypass service/usecase layer
|
||||||
|
- [ ] Domain layer is not coupled to infrastructure details
|
||||||
|
- [ ] Repository/ORM usage follows project conventions
|
||||||
|
- [ ] Shared framework components are reused instead of duplicated
|
||||||
|
- [ ] New abstractions remove real complexity
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Approved/changes required:
|
||||||
20
docs/workflow/templates/db-change-plan.md
Normal file
20
docs/workflow/templates/db-change-plan.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# DB Change Plan
|
||||||
|
|
||||||
|
## Schema Or Data Change
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Migration Steps
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
TBD
|
||||||
17
docs/workflow/templates/docs-impact.md
Normal file
17
docs/workflow/templates/docs-impact.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Docs Impact
|
||||||
|
|
||||||
|
## Code Changes Requiring Docs
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Documentation Updated
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## No-Docs-Needed Rationale
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Links Checked
|
||||||
|
|
||||||
|
- TBD
|
||||||
20
docs/workflow/templates/e2e-plan.md
Normal file
20
docs/workflow/templates/e2e-plan.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# E2E Plan
|
||||||
|
|
||||||
|
## User Paths
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Browser Coverage
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Test Data
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Assertions
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
TBD
|
||||||
20
docs/workflow/templates/explore.md
Normal file
20
docs/workflow/templates/explore.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Explore
|
||||||
|
|
||||||
|
## Files Read
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Main Conflict
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Affected Modules
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
TBD
|
||||||
32
docs/workflow/templates/github-actions-scale-preflight.yml
Normal file
32
docs/workflow/templates/github-actions-scale-preflight.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: SCALE Preflight
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
preflight:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install project dependencies when present
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -f package-lock.json ]; then
|
||||||
|
npm ci
|
||||||
|
elif [ -f package.json ]; then
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run SCALE preflight
|
||||||
|
run: npx @hongmaple0820/scale-engine@latest preflight --service all --preflight-profile ci
|
||||||
42
docs/workflow/templates/mini-prd.md
Normal file
42
docs/workflow/templates/mini-prd.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Mini-PRD
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Target Users
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Core Scenario
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## User Path
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Permission Rules
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Data Impact
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Exception Scenarios
|
||||||
|
|
||||||
|
1. TBD
|
||||||
|
2. TBD
|
||||||
|
3. TBD
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] TBD
|
||||||
|
|
||||||
|
## Rollback Or Disable Strategy
|
||||||
|
TBD
|
||||||
28
docs/workflow/templates/plan.md
Normal file
28
docs/workflow/templates/plan.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Plan
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Exception Contract
|
||||||
|
|
||||||
|
1. TBD
|
||||||
|
2. TBD
|
||||||
|
3. TBD
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Human Confirmation
|
||||||
|
|
||||||
|
- Required for L/CRITICAL tasks:
|
||||||
|
- Confirmation source:
|
||||||
|
- Execution boundary approved:
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
TBD
|
||||||
8
docs/workflow/templates/pre-push-scale-preflight.sh
Normal file
8
docs/workflow/templates/pre-push-scale-preflight.sh
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if command -v scale >/dev/null 2>&1; then
|
||||||
|
scale preflight --service all
|
||||||
|
else
|
||||||
|
npx @hongmaple0820/scale-engine@latest preflight --service all
|
||||||
|
fi
|
||||||
61
docs/workflow/templates/product-smoke.md
Normal file
61
docs/workflow/templates/product-smoke.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Product Smoke
|
||||||
|
|
||||||
|
## Real Product Path
|
||||||
|
|
||||||
|
Describe the smallest end-to-end path that proves the change works through the real product boundary.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UI or client -> gateway/router -> service -> database/storage/queue -> observable result
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use a green health endpoint as the only proof when the user-facing path depends on routing, authentication, storage, async tasks, browser behavior, or third-party integration.
|
||||||
|
|
||||||
|
## Quick Setup
|
||||||
|
|
||||||
|
1. Open `.scale/product-smoke.json`.
|
||||||
|
2. Replace the example command with one real product path command.
|
||||||
|
3. Set that probe's `enabled` field to `true`.
|
||||||
|
4. Run `scale preflight --profile productSmoke --json`.
|
||||||
|
5. Run `scale runtime final-check --level M --json`.
|
||||||
|
|
||||||
|
`status: "skipped"` means no real product path was exercised. It does not count as completion evidence.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- Base URL:
|
||||||
|
- Test user or tenant:
|
||||||
|
- Required fixtures:
|
||||||
|
- Services that must be running:
|
||||||
|
|
||||||
|
## Smoke Commands
|
||||||
|
|
||||||
|
| Command | Expected Result | Evidence Artifact |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TBD | TBD | TBD |
|
||||||
|
|
||||||
|
## Runtime Evidence
|
||||||
|
|
||||||
|
Record at least one runtime evidence item:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scale runtime record \
|
||||||
|
--kind command \
|
||||||
|
--title "Product smoke: <flow>" \
|
||||||
|
--status passed \
|
||||||
|
--command "<exact smoke command>" \
|
||||||
|
--exit-code 0 \
|
||||||
|
--summary "<business result, task id, status, or observable output>" \
|
||||||
|
--artifacts ".agent/logs/<service>/<smoke>.json" \
|
||||||
|
--metadata-json '{"productSmoke":true,"realProductPath":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Assertions
|
||||||
|
|
||||||
|
- [ ] Request crossed the real product boundary, not only an isolated unit.
|
||||||
|
- [ ] Authentication or user identity path was exercised when relevant.
|
||||||
|
- [ ] Persistence/storage/queue side effect was verified when relevant.
|
||||||
|
- [ ] Async task or eventual state was polled to terminal status when relevant.
|
||||||
|
- [ ] Failure output is specific enough to diagnose the failing layer.
|
||||||
|
- [ ] Runtime artifacts are ignored or deliberately promoted according to resource governance.
|
||||||
25
docs/workflow/templates/reality-check.md
Normal file
25
docs/workflow/templates/reality-check.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Reality Check
|
||||||
|
|
||||||
|
## Confirmed
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Not Verified
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Stub / Fake / Partial
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Credential-Gated
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Environment-Gated
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## User-Visible Risk
|
||||||
|
|
||||||
|
- TBD
|
||||||
14
docs/workflow/templates/resource-cleanup.md
Normal file
14
docs/workflow/templates/resource-cleanup.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Resource Cleanup
|
||||||
|
|
||||||
|
## New Resources
|
||||||
|
|
||||||
|
| Resource | Location | Keep / Move / Delete | Reason |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| TBD | TBD | TBD | TBD |
|
||||||
|
|
||||||
|
## Docs Promotion
|
||||||
|
|
||||||
|
- Promote to docs:
|
||||||
|
- Keep in planning:
|
||||||
|
- Keep local/runtime only:
|
||||||
|
- Delete before handoff:
|
||||||
25
docs/workflow/templates/resource-impact.md
Normal file
25
docs/workflow/templates/resource-impact.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Resource Impact
|
||||||
|
|
||||||
|
## Resources Created
|
||||||
|
|
||||||
|
| Path | Type | Git Policy | Retention |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| TBD | canonical-doc/task-artifact/evidence-report/temporary/reusable-script/generated-media/contract/decision-record | commit/ignore/lfs/external/review | TBD |
|
||||||
|
|
||||||
|
## Resources Updated
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Resources Promoted To Maintained Docs
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Resources To Delete Or Archive Before Finish
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Source Of Truth Updates
|
||||||
|
|
||||||
|
- [ ] .scale/resource-policy.json
|
||||||
|
- [ ] .scale/assets.json
|
||||||
|
- [ ] docs/modules/<module>/README.md
|
||||||
16
docs/workflow/templates/review.md
Normal file
16
docs/workflow/templates/review.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Review
|
||||||
|
|
||||||
|
## Code Review
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Security Review
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Same-Pattern Scan
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Residual Risks
|
||||||
|
TBD
|
||||||
21
docs/workflow/templates/runtime.md
Normal file
21
docs/workflow/templates/runtime.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Runtime Contract
|
||||||
|
|
||||||
|
## Configuration Source
|
||||||
|
|
||||||
|
- Source: TBD
|
||||||
|
- Environment/profile: TBD
|
||||||
|
- Runtime overrides: TBD
|
||||||
|
- Secrets boundary: TBD
|
||||||
|
|
||||||
|
## Service Topology
|
||||||
|
|
||||||
|
| Service | URL Or Command | Config Source | Auth Mode | Status |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| TBD | TBD | TBD | TBD | Not checked |
|
||||||
|
|
||||||
|
## Verification Boundary
|
||||||
|
|
||||||
|
- Confirmed:
|
||||||
|
- Not covered:
|
||||||
|
- Credential-gated:
|
||||||
|
- Environment-gated:
|
||||||
26
docs/workflow/templates/security-review.md
Normal file
26
docs/workflow/templates/security-review.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Security Review
|
||||||
|
|
||||||
|
## Assets And Trust Boundaries
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Authorization Rules
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Abuse Cases
|
||||||
|
|
||||||
|
1. TBD
|
||||||
|
2. TBD
|
||||||
|
3. TBD
|
||||||
|
|
||||||
|
## Sensitive Data Impact
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Rollback Or Disable Strategy
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Final Verdict
|
||||||
|
TBD
|
||||||
33
docs/workflow/templates/skill-evidence.md
Normal file
33
docs/workflow/templates/skill-evidence.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Skill Evidence
|
||||||
|
|
||||||
|
## Planned Skills
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Tool Selection Rationale
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Used Skills
|
||||||
|
|
||||||
|
| Skill | Phase | Trigger | Evidence | Status |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| skill-id | plan/build/verify/review | why it was selected | command, screenshot, report, or artifact path | executed/skipped/fallback |
|
||||||
|
|
||||||
|
## Browser Or Web Evidence
|
||||||
|
|
||||||
|
| Tool | Target | Evidence | Result |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| web-access/agent-browser/Chrome DevTools MCP | URL or local target | screenshot, console log, network finding, source URL | passed/failed/skipped |
|
||||||
|
|
||||||
|
## Desktop Or External CLI Evidence
|
||||||
|
|
||||||
|
| Tool | Scope | Safety Boundary | Evidence | Result |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| cua/codex/gemini/opencode/wps/wechat | command or app target | read-only/dry-run/test account/manual approval | output summary, screenshot, or report path | passed/failed/skipped |
|
||||||
|
|
||||||
|
## Skipped Skills
|
||||||
|
|
||||||
|
| Skill | Reason | Fallback Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| skill-id | why it could not run | manual review, alternate command, or explicit risk |
|
||||||
39
docs/workflow/templates/skill-plan.md
Normal file
39
docs/workflow/templates/skill-plan.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Skill Plan
|
||||||
|
|
||||||
|
## Detected Intents
|
||||||
|
|
||||||
|
| Domain | Score | Evidence |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| | | |
|
||||||
|
|
||||||
|
## Required Skills
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Recommended Skills
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Required Artifacts
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Required Verification Evidence
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Tool Orchestration
|
||||||
|
|
||||||
|
| Capability | Primary Tool Or Skill | Fallback | Required Evidence |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| UI/UX design | frontend-design, ui-ux-pro-max | awesome-design-md | design-system, ui-spec.md, visual-review.md |
|
||||||
|
| Web research or logged-in pages | web-access | agent-browser, Chrome DevTools MCP | source citations, browser evidence |
|
||||||
|
| Browser E2E | webapp-testing, Playwright | agent-browser, web-access | screenshot, console, network evidence |
|
||||||
|
| Desktop GUI automation | CUA/computer-use | manual verification | desktop screenshot, operator-safety notes |
|
||||||
|
| External agent CLI | codex/gemini/opencode CLI | manual review | version check, exact command output |
|
||||||
|
|
||||||
|
## Skipped Skills
|
||||||
|
|
||||||
|
| Skill | Reason | Fallback Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| | | |
|
||||||
28
docs/workflow/templates/standards-impact.md
Normal file
28
docs/workflow/templates/standards-impact.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Standards Impact
|
||||||
|
|
||||||
|
## Standards Checked
|
||||||
|
|
||||||
|
- [ ] Logging and redaction
|
||||||
|
- [ ] Architecture boundaries
|
||||||
|
- [ ] ORM/database access
|
||||||
|
- [ ] Framework/component conventions
|
||||||
|
- [ ] UI/UX acceptance where user-facing
|
||||||
|
- [ ] Test and verification rigor
|
||||||
|
- [ ] Security-sensitive inputs and outputs
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| Severity | Rule | Path | Decision |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| TBD | TBD | TBD | fix/accept/escalate |
|
||||||
|
|
||||||
|
## Policy Updates
|
||||||
|
|
||||||
|
- [ ] .scale/engineering-standards.json
|
||||||
|
- [ ] .scale/frameworks.json
|
||||||
|
- [ ] docs/standards/
|
||||||
|
|
||||||
|
## Settlement
|
||||||
|
|
||||||
|
- Standards scan:
|
||||||
|
- Standards doctor:
|
||||||
19
docs/workflow/templates/summary.md
Normal file
19
docs/workflow/templates/summary.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Summary
|
||||||
|
|
||||||
|
## Delivered Changes
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Remaining Risks
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Follow-Ups
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Metric Row
|
||||||
|
|
||||||
|
| Date | Task | Level | Services | Files Changed | First Verification Pass | Fix Iterations | Artifact Complete | Residual Risk | Final Gate |
|
||||||
|
| --- | --- | --- | --- | ---: | --- | ---: | --- | --- | --- |
|
||||||
|
| | | | | | | | | | |
|
||||||
29
docs/workflow/templates/ui-spec.md
Normal file
29
docs/workflow/templates/ui-spec.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# UI Spec
|
||||||
|
|
||||||
|
## User Goal
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Primary Flow
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Interaction States
|
||||||
|
|
||||||
|
- Default:
|
||||||
|
- Loading:
|
||||||
|
- Empty:
|
||||||
|
- Error:
|
||||||
|
- Success:
|
||||||
|
|
||||||
|
## Responsive Behavior
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Accessibility Requirements
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] TBD
|
||||||
18
docs/workflow/templates/verification.md
Normal file
18
docs/workflow/templates/verification.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Verification
|
||||||
|
|
||||||
|
## Commands Run
|
||||||
|
|
||||||
|
| Command | Result | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| | | |
|
||||||
|
|
||||||
|
## Output Summary
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Failures And Fixes
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Final Status
|
||||||
|
TBD
|
||||||
20
docs/workflow/templates/visual-review.md
Normal file
20
docs/workflow/templates/visual-review.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Visual Review
|
||||||
|
|
||||||
|
## Screenshots Or Evidence
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Layout And Responsiveness
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Text Fit And Overlap
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Accessibility Notes
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Final Verdict
|
||||||
|
TBD
|
||||||
28
docs/worklog/metrics.md
Normal file
28
docs/worklog/metrics.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Workflow Metrics
|
||||||
|
|
||||||
|
<!-- SCALE_METRICS:START -->
|
||||||
|
| Date | Task | Level | Services | Files Changed | First Verification Pass | Fix Iterations | Rework Needed | Artifact Complete | Residual Risk | Final Gate |
|
||||||
|
| --- | --- | --- | --- | ---: | --- | ---: | --- | --- | --- | --- |
|
||||||
|
| | | | | | | | | | | |
|
||||||
|
<!-- SCALE_METRICS:END -->
|
||||||
|
|
||||||
|
## Monthly Review
|
||||||
|
|
||||||
|
### Repeated Failure Patterns
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
### Slowest Gates
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
### Documentation Gaps
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
### Product Design Misses
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
### Proposed Workflow Changes
|
||||||
|
TBD
|
||||||
866
render.md
Normal file
866
render.md
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
# 渲染流程梳理
|
||||||
|
|
||||||
|
这份文档拆解当前项目里的渲染链路,包括:
|
||||||
|
|
||||||
|
- 压力数据如何从 CPU 进入 GPU
|
||||||
|
- `egui_wgpu::CallbackTrait` 的 `prepare / paint` 两阶段
|
||||||
|
- `BackgroundRenderResources` 负责管理哪些 GPU 资源
|
||||||
|
- `shader.wgsl` 里的各个 shader entry point 分别做什么
|
||||||
|
- `Finger / Hand` 两种模式如何切换渲染路径
|
||||||
|
|
||||||
|
主要相关文件:
|
||||||
|
|
||||||
|
- `src/app.rs`
|
||||||
|
- `src/render.rs`
|
||||||
|
- `src/matrix.rs`
|
||||||
|
- `src/model.rs`
|
||||||
|
- `static/wgsl/shader.wgsl`
|
||||||
|
|
||||||
|
## 总览
|
||||||
|
|
||||||
|
渲染入口在 `EskinDesktopApp::ui`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
self.draw_wgpu_background(ui);
|
||||||
|
self.draw_panel_context_menu(ui);
|
||||||
|
self.draw_title_bar(ui, frame);
|
||||||
|
self.draw_floating_panels(&ctx);
|
||||||
|
```
|
||||||
|
|
||||||
|
顺序很重要:
|
||||||
|
|
||||||
|
1. `draw_wgpu_background` 先绘制整块 WGPU 背景和 3D 内容。
|
||||||
|
2. 右键菜单、标题栏、floating panel 后画,所以会盖在 WGPU 内容上。
|
||||||
|
3. `ctx.request_repaint()` 保证背景持续刷新。
|
||||||
|
|
||||||
|
真正的 WGPU 绘制不是直接在 `app.rs` 里执行,而是通过 egui 的 paint callback:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||||||
|
rect,
|
||||||
|
WgpuBackgroundCallback {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pressure: self.pressure_matrix,
|
||||||
|
active_mode: self.active_mode.clone(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
可以理解成:`app.rs` 每帧把“当前要画什么”打包成 `WgpuBackgroundCallback`,然后交给 `egui_wgpu` 在合适的 GPU 阶段执行。
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
### 1. 串口数据进入 App
|
||||||
|
|
||||||
|
`update_pressure_matrix` 从 `ConnectionManager` 取最新数据:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if let Some(sample) = self.connection.take_latest_sample() {
|
||||||
|
normalize_pressure_sample(
|
||||||
|
&sample.matrix,
|
||||||
|
sample.rows,
|
||||||
|
sample.cols,
|
||||||
|
&mut self.pressure_matrix,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
串口原始数据是一个扁平的一维矩阵,元素是整数压力值。
|
||||||
|
|
||||||
|
`normalize_pressure_sample` 会把它转成:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT];
|
||||||
|
```
|
||||||
|
|
||||||
|
每个 cell 有两个 `f32`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[normalized_pressure, display_value]
|
||||||
|
```
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
| 值 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `normalized_pressure` | `0.0..=1.0`,用于颜色、大小、亮度。 |
|
||||||
|
| `display_value` | 原始压力值的显示版本,用于数字模式。 |
|
||||||
|
|
||||||
|
当前归一化范围写死在 `app.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const RANGE_MIN: f32 = 0.0;
|
||||||
|
const RANGE_MAX: f32 = 7000.0;
|
||||||
|
```
|
||||||
|
|
||||||
|
这意味着虽然 `FingerMode / HandGatewayMode` 里已经有 `range` 字段,但实际颜色归一化还没有用到它。
|
||||||
|
|
||||||
|
### 2. App 创建 WGPU Callback
|
||||||
|
|
||||||
|
`draw_wgpu_background` 根据当前 viewport 创建 callback:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
WgpuBackgroundCallback {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pressure: self.pressure_matrix,
|
||||||
|
active_mode: self.active_mode.clone(),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`WgpuBackgroundCallback` 保存的是“每帧变化的数据”:
|
||||||
|
|
||||||
|
- 当前视口宽度
|
||||||
|
- 当前视口高度
|
||||||
|
- 当前压力帧
|
||||||
|
- 当前渲染模式
|
||||||
|
|
||||||
|
它不保存长期 GPU 资源。长期资源在 `BackgroundRenderResources` 里。
|
||||||
|
|
||||||
|
### 3. Callback Prepare 阶段
|
||||||
|
|
||||||
|
`WgpuBackgroundCallback` 实现了:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback
|
||||||
|
```
|
||||||
|
|
||||||
|
`prepare` 阶段代码:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap();
|
||||||
|
resources.prepare(queue, self.width, self.height, &self.pressure);
|
||||||
|
```
|
||||||
|
|
||||||
|
这个阶段主要做 CPU -> GPU 的数据更新:
|
||||||
|
|
||||||
|
1. 根据 viewport aspect ratio 重新计算相机矩阵。
|
||||||
|
2. 把 `MatrixUniform` 写入 `uniform_buffer`。
|
||||||
|
3. 根据当前 `PressureFrame` 更新所有 marker instance。
|
||||||
|
4. 把 `glyph_instances` 写入 `glyph_instance_buffer`。
|
||||||
|
|
||||||
|
这里不会真正 draw,只是准备 GPU buffer。
|
||||||
|
|
||||||
|
### 4. Callback Paint 阶段
|
||||||
|
|
||||||
|
`paint` 阶段代码:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let resources: &BackgroundRenderResources = resources.get().unwrap();
|
||||||
|
resources.paint(render_pass, &self.active_mode);
|
||||||
|
```
|
||||||
|
|
||||||
|
这个阶段才是真正向 `render_pass` 发 draw call。
|
||||||
|
|
||||||
|
`active_mode` 在这里传进去,决定后面画点阵、数字,还是画模型。
|
||||||
|
|
||||||
|
## GPU 资源管理
|
||||||
|
|
||||||
|
`BackgroundRenderResources` 在 `EskinDesktopApp::new` 里创建一次:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
renderer
|
||||||
|
.callback_resources
|
||||||
|
.insert(BackgroundRenderResources::new(
|
||||||
|
&wgpu_state.device,
|
||||||
|
&wgpu_state.queue,
|
||||||
|
&wgpu_state.target_format,
|
||||||
|
MATRIX_ROWS,
|
||||||
|
MATRIX_COLS,
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
它负责保存长期存在的 GPU 资源:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct BackgroundRenderResources {
|
||||||
|
layout: MatrixLayout,
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
|
uniform: MatrixUniform,
|
||||||
|
uniform_buffer: wgpu::Buffer,
|
||||||
|
uniform_bind_group: wgpu::BindGroup,
|
||||||
|
background_pipeline: wgpu::RenderPipeline,
|
||||||
|
glyph_pipeline: wgpu::RenderPipeline,
|
||||||
|
dot_pipeline: wgpu::RenderPipeline,
|
||||||
|
model_pipeline: wgpu::RenderPipeline,
|
||||||
|
model: Option<Model>,
|
||||||
|
model_instance_buffer: wgpu::Buffer,
|
||||||
|
glyph_vertex_buffer: wgpu::Buffer,
|
||||||
|
glyph_instance_buffer: wgpu::Buffer,
|
||||||
|
glyph_instances: Vec<GlyphInstance>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段含义:
|
||||||
|
|
||||||
|
| 字段 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `layout` | 压力矩阵的世界空间布局。 |
|
||||||
|
| `rows`, `cols` | 当前 GPU 资源按多少行列创建。 |
|
||||||
|
| `uniform` | CPU 侧 uniform 缓存。 |
|
||||||
|
| `uniform_buffer` | GPU uniform buffer。 |
|
||||||
|
| `uniform_bind_group` | WGSL 里的 `@group(0)`。 |
|
||||||
|
| `background_pipeline` | 全屏背景、底部色条。 |
|
||||||
|
| `glyph_pipeline` | 数字/数值 marker。 |
|
||||||
|
| `dot_pipeline` | 圆点 marker。 |
|
||||||
|
| `model_pipeline` | OBJ 模型渲染。 |
|
||||||
|
| `model` | 加载好的模型和材质。 |
|
||||||
|
| `model_instance_buffer` | 模型实例矩阵。 |
|
||||||
|
| `glyph_vertex_buffer` | 一个 marker quad 的 6 个顶点。 |
|
||||||
|
| `glyph_instance_buffer` | 每个压力点一个 instance。 |
|
||||||
|
| `glyph_instances` | CPU 侧 instance 数组。 |
|
||||||
|
|
||||||
|
## 模式切换
|
||||||
|
|
||||||
|
当前运行时渲染模式:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum ActiveMode {
|
||||||
|
Finger(FingerMode),
|
||||||
|
Hand(HandGatewayMode),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`app.rs` 持有:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
active_mode: ActiveMode,
|
||||||
|
```
|
||||||
|
|
||||||
|
UI 层的 `SerialMode` 是用户选择,真正渲染用的是 `ActiveMode`。
|
||||||
|
|
||||||
|
切换逻辑在 `switch_mode`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn switch_mode(&mut self, next: SerialMode) {
|
||||||
|
self.connect_state.mode = next;
|
||||||
|
self.config_state.mode = next;
|
||||||
|
self.connection.disconnect();
|
||||||
|
self.pressure_matrix.fill([0.0, 0.0]);
|
||||||
|
self.data_log_frame = 0;
|
||||||
|
|
||||||
|
self.active_mode = match next {
|
||||||
|
SerialMode::Finger => ActiveMode::Finger(FingerMode {
|
||||||
|
rows: self.matrix_config.rows,
|
||||||
|
cols: self.matrix_config.cols,
|
||||||
|
range: 0..7000,
|
||||||
|
dot: true,
|
||||||
|
}),
|
||||||
|
SerialMode::Hand => ActiveMode::Hand(HandGatewayMode { range: 0..7000 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这里做了几件事:
|
||||||
|
|
||||||
|
1. 同步 UI 状态。
|
||||||
|
2. 断开当前连接。
|
||||||
|
3. 清空压力帧。
|
||||||
|
4. 重置日志计数。
|
||||||
|
5. 设置新的 `active_mode`。
|
||||||
|
|
||||||
|
这比只改一个 enum 更像真正的互斥生命周期切换。
|
||||||
|
|
||||||
|
## 绘制分发
|
||||||
|
|
||||||
|
`BackgroundRenderResources::paint` 先画背景,再按模式分支:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
render_pass.set_pipeline(&self.background_pipeline);
|
||||||
|
render_pass.draw(0..3, 0..1);
|
||||||
|
|
||||||
|
match active_mode {
|
||||||
|
ActiveMode::Finger(mode) => self.paint_finger(render_pass, mode),
|
||||||
|
ActiveMode::Hand(mode) => self.paint_hand(render_pass, mode),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前模式行为:
|
||||||
|
|
||||||
|
| 模式 | 绘制内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `Finger` | 背景 + 压力 marker。 |
|
||||||
|
| `Hand` | 背景 + 模型。 |
|
||||||
|
|
||||||
|
## Finger 模式
|
||||||
|
|
||||||
|
Finger 模式根据 `dot` 字段选择点模式或数字模式:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let marker_pipeline = if mode.dot {
|
||||||
|
&self.dot_pipeline
|
||||||
|
} else {
|
||||||
|
&self.glyph_pipeline
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
| `mode.dot` | pipeline | 效果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `true` | `dot_pipeline` | 画圆点。 |
|
||||||
|
| `false` | `glyph_pipeline` | 画数字。 |
|
||||||
|
|
||||||
|
两个 pipeline 共用同一套 vertex buffer:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
|
||||||
|
render_pass.set_vertex_buffer(1, self.glyph_instance_buffer.slice(..));
|
||||||
|
render_pass.draw(0..6, 0..draw_count);
|
||||||
|
```
|
||||||
|
|
||||||
|
解释:
|
||||||
|
|
||||||
|
- vertex buffer 0:一个 quad 的 6 个顶点。
|
||||||
|
- vertex buffer 1:每个压力点一个 instance。
|
||||||
|
- `draw(0..6, 0..draw_count)`:每个 instance 都复用这 6 个顶点。
|
||||||
|
|
||||||
|
## Hand 模式
|
||||||
|
|
||||||
|
Hand 模式当前只画模型:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
render_pass.set_pipeline(&self.model_pipeline);
|
||||||
|
render_pass.set_vertex_buffer(1, self.model_instance_buffer.slice(..));
|
||||||
|
```
|
||||||
|
|
||||||
|
然后遍历 mesh:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
for mesh in &model.meshes {
|
||||||
|
if let Some(material) = model.materials.get(mesh.material) {
|
||||||
|
render_pass.set_bind_group(1, &material.bind_group, &[]);
|
||||||
|
}
|
||||||
|
render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
|
||||||
|
render_pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||||
|
render_pass.draw_indexed(0..mesh.num_elements, 0, 0..1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
绑定关系:
|
||||||
|
|
||||||
|
| Slot / Group | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| bind group 0 | `MatrixUniform` |
|
||||||
|
| bind group 1 | 模型纹理和 sampler |
|
||||||
|
| vertex buffer 0 | 模型顶点 |
|
||||||
|
| vertex buffer 1 | 模型实例矩阵 |
|
||||||
|
|
||||||
|
## Matrix 布局
|
||||||
|
|
||||||
|
`src/matrix.rs` 定义压力矩阵如何放到 3D 世界里。
|
||||||
|
|
||||||
|
固定默认尺寸:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub const MATRIX_ROWS: u32 = 12;
|
||||||
|
pub const MATRIX_COLS: u32 = 7;
|
||||||
|
```
|
||||||
|
|
||||||
|
`MatrixLayout::new(rows, cols)` 计算:
|
||||||
|
|
||||||
|
- cell 间距
|
||||||
|
- board 宽度
|
||||||
|
- board 深度
|
||||||
|
- board padding
|
||||||
|
- marker 垂直浮起高度
|
||||||
|
|
||||||
|
`glyph_world_position` 把 `(row, col)` 映射到世界坐标:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let x = (col as f32 - cols as f32 / 2.0 + 0.5) * layout.cell_spacing;
|
||||||
|
let z = (row as f32 - rows as f32 / 2.0 + 0.5) * layout.cell_spacing;
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
[
|
||||||
|
x,
|
||||||
|
MATRIX_OFFSET_Y + layout.label_float_offset,
|
||||||
|
MATRIX_OFFSET_Z + z,
|
||||||
|
1.0,
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
所以 marker 是铺在 X/Z 平面上,Y 方向稍微浮起来。
|
||||||
|
|
||||||
|
`build_view_projection` 根据矩阵尺寸创建相机:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let view = glam::Mat4::look_at_rh(eye, target, glam::Vec3::Y);
|
||||||
|
let projection = glam::Mat4::perspective_rh(...);
|
||||||
|
let open_gl_to_wgpu = ...
|
||||||
|
```
|
||||||
|
|
||||||
|
最后的 `open_gl_to_wgpu` 是为了把 OpenGL 风格深度范围转换成 WGPU 需要的 clip space。
|
||||||
|
|
||||||
|
## Uniform
|
||||||
|
|
||||||
|
Rust 侧:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
struct MatrixUniform {
|
||||||
|
view_proj: [[f32; 4]; 4],
|
||||||
|
viewport: [f32; 4],
|
||||||
|
glyph: [f32; 4],
|
||||||
|
color: [f32; 4],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
WGSL 侧:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
struct MatrixUniform {
|
||||||
|
view_proj: mat4x4f,
|
||||||
|
viewport: vec4f,
|
||||||
|
glyph: vec4f,
|
||||||
|
color: vec4f,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0)
|
||||||
|
var<uniform> u: MatrixUniform;
|
||||||
|
```
|
||||||
|
|
||||||
|
字段含义:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `view_proj` | 世界坐标到 clip space 的矩阵。 |
|
||||||
|
| `viewport.xy` | 当前 viewport 像素尺寸。 |
|
||||||
|
| `glyph.x` | marker 基础像素大小。 |
|
||||||
|
| `color` | 目前基本没用上,预留字段。 |
|
||||||
|
|
||||||
|
每帧在 `prepare` 里写入 GPU:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
queue.write_buffer(
|
||||||
|
&self.uniform_buffer,
|
||||||
|
0,
|
||||||
|
bytemuck::cast_slice(&[self.uniform]),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Marker / Glyph 几何
|
||||||
|
|
||||||
|
代码里叫 `glyph`,但它不是字体系统里的 glyph。它更像一个“压力 marker”。
|
||||||
|
|
||||||
|
基础几何是一个 quad:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let glyph_vertices = [
|
||||||
|
[-1.0, -1.0],
|
||||||
|
[ 1.0, -1.0],
|
||||||
|
[-1.0, 1.0],
|
||||||
|
[-1.0, 1.0],
|
||||||
|
[ 1.0, -1.0],
|
||||||
|
[ 1.0, 1.0],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
这 6 个点组成两个三角形。
|
||||||
|
|
||||||
|
每个压力点对应一个 `GlyphInstance`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct GlyphInstance {
|
||||||
|
world_position: [f32; 4],
|
||||||
|
style: [f32; 4],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
WGSL 接收:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
struct GlyphInstanceInput {
|
||||||
|
@location(1) world_position: vec4f,
|
||||||
|
@location(2) style: vec4f,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`style` 的当前含义:
|
||||||
|
|
||||||
|
| 分量 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `style.x` | 归一化压力值。 |
|
||||||
|
| `style.y` | 显示用压力值。 |
|
||||||
|
| `style.z` | 预留。 |
|
||||||
|
| `style.w` | 预留。 |
|
||||||
|
|
||||||
|
## Shader 总览
|
||||||
|
|
||||||
|
所有 pipeline 当前共用一个 WGSL 文件:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
include_str!("../static/wgsl/shader.wgsl")
|
||||||
|
```
|
||||||
|
|
||||||
|
不同 pipeline 选择不同入口函数。
|
||||||
|
|
||||||
|
## Background Shader
|
||||||
|
|
||||||
|
Rust 侧创建:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
create_background_pipeline(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
WGSL entry point:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
@vertex
|
||||||
|
fn vs_background(...)
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_background(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`vs_background` 画一个全屏三角形:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
let positions = array<vec2f, 3>(
|
||||||
|
vec2f(-1.0, -3.0),
|
||||||
|
vec2f(3.0, 1.0),
|
||||||
|
vec2f(-1.0, 1.0),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
一个大三角形覆盖整屏,避免两个三角形拼接时可能出现的边界问题。
|
||||||
|
|
||||||
|
`fs_background` 负责画:
|
||||||
|
|
||||||
|
- 深色垂直渐变
|
||||||
|
- vignette 暗角
|
||||||
|
- 中央氛围带
|
||||||
|
- 底部压力色条
|
||||||
|
- 色条刻度线
|
||||||
|
|
||||||
|
颜色映射来自:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
fn sample_range_color(value: f32) -> vec3f
|
||||||
|
```
|
||||||
|
|
||||||
|
色阶大概是:
|
||||||
|
|
||||||
|
1. 蓝青
|
||||||
|
2. 绿色
|
||||||
|
3. 橙色
|
||||||
|
4. 红色
|
||||||
|
|
||||||
|
## Glyph / 数字 Shader
|
||||||
|
|
||||||
|
Rust 侧创建:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
create_glyph_pipeline(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
WGSL entry point:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
@vertex
|
||||||
|
fn vs_glyph(...)
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_glyph(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
这是数字模式。
|
||||||
|
|
||||||
|
`vs_glyph` 做几件事:
|
||||||
|
|
||||||
|
1. 把 instance 的 `world_position` 乘 `u.view_proj`,得到 clip space 中心点。
|
||||||
|
2. 从 `instance.style.x` 读取压力强度。
|
||||||
|
3. 根据强度计算 marker 的像素大小。
|
||||||
|
4. 用 `viewport` 把像素大小转换成 NDC 偏移。
|
||||||
|
|
||||||
|
关键代码:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
let shaped = pow(saturate(instance.style.x), 0.9);
|
||||||
|
let pixel_size = u.glyph.x * mix(1.08, 2.20, shaped);
|
||||||
|
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
|
||||||
|
```
|
||||||
|
|
||||||
|
这意味着数字不是固定世界尺寸,而是接近固定屏幕像素尺寸,更适合读数。
|
||||||
|
|
||||||
|
`fs_glyph` 做数字绘制:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
let alpha = number_alpha(in.local, in.display_value);
|
||||||
|
let color = sample_range_color(in.intensity) * mix(0.82, 1.16, saturate(in.intensity));
|
||||||
|
return vec4f(color, alpha);
|
||||||
|
```
|
||||||
|
|
||||||
|
数字不是字体贴图,而是在 shader 里用七段数码管方式画出来。
|
||||||
|
|
||||||
|
相关函数:
|
||||||
|
|
||||||
|
- `digit_segment_on`
|
||||||
|
- `seven_segment_digit_alpha`
|
||||||
|
- `digit_count`
|
||||||
|
- `digit_at`
|
||||||
|
- `number_alpha`
|
||||||
|
|
||||||
|
## Dot / 点 Shader
|
||||||
|
|
||||||
|
Rust 侧创建:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
create_dot_pipeline(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
WGSL entry point:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
@vertex
|
||||||
|
fn vs_dot(...)
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_dot(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
这是圆点模式。
|
||||||
|
|
||||||
|
`vs_dot` 和 `vs_glyph` 类似,也是把世界坐标点展开成屏幕像素大小的 quad。
|
||||||
|
|
||||||
|
点模式的尺寸曲线更小:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
let pixel_size = u.glyph.x * mix(0.72, 1.85, shaped);
|
||||||
|
```
|
||||||
|
|
||||||
|
`fs_dot` 画:
|
||||||
|
|
||||||
|
- 圆形核心
|
||||||
|
- 外发光
|
||||||
|
- 小高光
|
||||||
|
- 压力映射颜色
|
||||||
|
|
||||||
|
圆形 alpha 来自:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
fn circle_alpha(local: vec2f, radius: f32, softness: f32) -> f32
|
||||||
|
```
|
||||||
|
|
||||||
|
核心片段:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
let core = circle_alpha(in.local, 0.42, 0.055);
|
||||||
|
let glow = circle_alpha(in.local, 0.78, 0.18) * intensity * 0.45;
|
||||||
|
let alpha = max(core, glow);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model Shader
|
||||||
|
|
||||||
|
Rust 侧创建:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
create_model_pipeline(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
WGSL entry point:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
@vertex
|
||||||
|
fn vs_model(...)
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_model(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
输入 layout:
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
@location(0) position: vec3f,
|
||||||
|
@location(1) tex_coords: vec2f,
|
||||||
|
@location(2) normal: vec3f,
|
||||||
|
@location(5) model_0: vec4f,
|
||||||
|
@location(6) model_1: vec4f,
|
||||||
|
@location(7) model_2: vec4f,
|
||||||
|
@location(8) model_3: vec4f,
|
||||||
|
```
|
||||||
|
|
||||||
|
`position / tex_coords / normal` 来自模型 vertex buffer。
|
||||||
|
|
||||||
|
`model_0..model_3` 来自 instance buffer,也就是每个模型实例的 transform matrix。
|
||||||
|
|
||||||
|
`vs_model`:
|
||||||
|
|
||||||
|
1. 重建 model matrix。
|
||||||
|
2. 计算 world position。
|
||||||
|
3. 用 `u.view_proj` 转到 clip space。
|
||||||
|
4. 变换 normal。
|
||||||
|
5. 翻转纹理 Y 坐标。
|
||||||
|
|
||||||
|
`fs_model`:
|
||||||
|
|
||||||
|
1. 采样纹理。
|
||||||
|
2. 计算简单漫反射光照。
|
||||||
|
3. 加一点 rim light。
|
||||||
|
4. 输出不透明颜色。
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
let light_dir = normalize(vec3f(-0.35, 0.85, 0.45));
|
||||||
|
let diffuse = max(dot(normal, light_dir), 0.0);
|
||||||
|
let rim = pow(1.0 - saturate(abs(normal.y)), 2.0) * 0.16;
|
||||||
|
let base = textureSample(model_texture, model_sampler, in.tex_coords).rgb;
|
||||||
|
let color = base * (0.28 + diffuse * 0.72) + vec3f(0.14, 0.24, 0.32) * rim;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 模型加载
|
||||||
|
|
||||||
|
模型在 `BackgroundRenderResources::new` 里加载一次:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let model =
|
||||||
|
match resources::load_model("cube.obj", device, _queue, &texture_bind_group_layout) {
|
||||||
|
Ok(model) => Some(model),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("failed to load cube.obj: {err:#}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
当前模型实例 transform 是写死的:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let model_instance = Instance::new(
|
||||||
|
glam::Vec3::new(0.0, -2.0, 12.0),
|
||||||
|
glam::Quat::from_rotation_y(0.65) * glam::Quat::from_rotation_x(-0.35),
|
||||||
|
)
|
||||||
|
.to_raw();
|
||||||
|
```
|
||||||
|
|
||||||
|
它把模型放到和压力矩阵相同的相机空间里。
|
||||||
|
|
||||||
|
## 当前限制
|
||||||
|
|
||||||
|
### 1. `rows / cols` 还没有真正重建布局
|
||||||
|
|
||||||
|
`FingerMode` 里有:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
|
```
|
||||||
|
|
||||||
|
但 `BackgroundRenderResources` 的 `layout` 和 instance buffer 是创建时确定的。
|
||||||
|
|
||||||
|
现在 `paint_finger` 只用 rows/cols 限制 draw count:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let draw_count = self.visible_instance_count(mode.rows, mode.cols);
|
||||||
|
```
|
||||||
|
|
||||||
|
所以它能少画一些点,但不会重新计算新尺寸下的空间布局。
|
||||||
|
|
||||||
|
如果后面要支持动态矩阵尺寸,需要:
|
||||||
|
|
||||||
|
1. 尺寸变化时重建 `MatrixLayout` 和 instance buffer。
|
||||||
|
2. 或者一开始分配最大尺寸 buffer,然后每帧按 active rows/cols 更新坐标。
|
||||||
|
|
||||||
|
### 2. `range` 字段还没有真正用起来
|
||||||
|
|
||||||
|
`FingerMode` 和 `HandGatewayMode` 都有:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
range: Range<u32>
|
||||||
|
```
|
||||||
|
|
||||||
|
但现在归一化仍在 `normalize_pressure_sample` 里硬编码为 `0..7000`。
|
||||||
|
|
||||||
|
所以如果要让不同模式有不同色域范围,需要把 range 传入归一化过程,或者把归一化移动到 render/update instance 阶段。
|
||||||
|
|
||||||
|
### 3. `glyph` 命名有点误导
|
||||||
|
|
||||||
|
现在的 `glyph` 实际更像 marker。
|
||||||
|
|
||||||
|
推荐未来改名:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GlyphVertex -> MarkerVertex
|
||||||
|
GlyphInstance -> MarkerInstance
|
||||||
|
glyph_pipeline -> number_marker_pipeline
|
||||||
|
dot_pipeline -> dot_marker_pipeline
|
||||||
|
glyph_vertex_buffer -> marker_vertex_buffer
|
||||||
|
glyph_instance_buffer -> marker_instance_buffer
|
||||||
|
glyph_instances -> marker_instances
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Hand 模式还没有压力叠加
|
||||||
|
|
||||||
|
现在:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ActiveMode::Hand(mode) => self.paint_hand(render_pass, mode),
|
||||||
|
```
|
||||||
|
|
||||||
|
只画模型,不画压力点。
|
||||||
|
|
||||||
|
如果手掌模式要显示压力映射,需要额外设计“压力矩阵 -> 手模型表面/区域”的映射。
|
||||||
|
|
||||||
|
可能方向:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ActiveMode::Hand(mode) => {
|
||||||
|
self.paint_hand(render_pass, mode);
|
||||||
|
self.paint_hand_pressure_overlay(render_pass, mode);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
但 overlay 的坐标不应该复用现在的矩阵 X/Z 网格,需要新的手部映射数据。
|
||||||
|
|
||||||
|
## 建议下一步
|
||||||
|
|
||||||
|
把:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct FingerMode {
|
||||||
|
pub rows: u32,
|
||||||
|
pub cols: u32,
|
||||||
|
pub range: Range<u32>,
|
||||||
|
pub dot: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
改成:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum MarkerMode {
|
||||||
|
Dot,
|
||||||
|
Number,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FingerMode {
|
||||||
|
pub rows: u32,
|
||||||
|
pub cols: u32,
|
||||||
|
pub range: Range<u32>,
|
||||||
|
pub marker_mode: MarkerMode,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这样比 `dot: bool` 更清楚。
|
||||||
|
|
||||||
|
现在的:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
dot: true
|
||||||
|
```
|
||||||
|
|
||||||
|
需要脑内翻译成“点模式”。
|
||||||
|
|
||||||
|
改成:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
marker_mode: MarkerMode::Dot
|
||||||
|
```
|
||||||
|
|
||||||
|
代码语义会直接很多。
|
||||||
|
|
||||||
BIN
res/hand.glb
Normal file
BIN
res/hand.glb
Normal file
Binary file not shown.
BIN
res/hand.png
Normal file
BIN
res/hand.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
54
scripts/gates/all.sh
Executable file
54
scripts/gates/all.sh
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# scripts/gates/all.sh — 运行所有门控检查
|
||||||
|
set -e
|
||||||
|
source ~/.cargo/env 2>/dev/null || true
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
DRY_RUN=false
|
||||||
|
if [ "$1" = "--dry-run" ]; then
|
||||||
|
DRY_RUN=true
|
||||||
|
echo "🔒 门控检查 (dry-run 模式)"
|
||||||
|
else
|
||||||
|
echo "🔒 运行门控检查..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
run_gate() {
|
||||||
|
local name="$1"
|
||||||
|
local cmd="$2"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = "true" ]; then
|
||||||
|
echo " ⏭️ $name (跳过, dry-run)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " 🔍 $name..."
|
||||||
|
if eval "$cmd" > /dev/null 2>&1; then
|
||||||
|
echo " ✅ $name 通过"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo " ❌ $name 失败"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
run_gate "类型检查 (cargo check)" "cargo check" || FAILED=$((FAILED + 1))
|
||||||
|
run_gate "Lint (cargo clippy)" "cargo clippy -- -D warnings" || FAILED=$((FAILED + 1))
|
||||||
|
run_gate "测试 (cargo test)" "cargo test" || FAILED=$((FAILED + 1))
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = "false" ]; then
|
||||||
|
run_gate "Release 构建" "cargo build --release" || FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────"
|
||||||
|
if [ $FAILED -gt 0 ]; then
|
||||||
|
echo "❌ $FAILED 个门控失败"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ 所有门控通过"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
107
scripts/qa/product-smoke.ps1
Normal file
107
scripts/qa/product-smoke.ps1
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Product smoke probe runner generated by scale-engine.
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||||
|
$ConfigPath = Join-Path $Root ".scale\product-smoke.json"
|
||||||
|
$LogDir = Join-Path $Root ".agent\logs"
|
||||||
|
$LogPath = Join-Path $LogDir "product-smoke.json"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||||||
|
|
||||||
|
$NodeProgram = @'
|
||||||
|
const fs = require('fs');
|
||||||
|
const cp = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const configPath = process.argv[2];
|
||||||
|
const logPath = process.argv[3];
|
||||||
|
|
||||||
|
function writeReport(report) {
|
||||||
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||||
|
fs.writeFileSync(logPath, JSON.stringify(report, null, 2) + '\n', 'utf8');
|
||||||
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
writeReport({
|
||||||
|
version: 1,
|
||||||
|
status: 'failed',
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
message: 'Missing .scale/product-smoke.json',
|
||||||
|
results: []
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
|
||||||
|
const probes = Array.isArray(config.probes) ? config.probes.filter(probe => probe && probe.enabled === true) : [];
|
||||||
|
|
||||||
|
if (probes.length === 0) {
|
||||||
|
const status = config.emptyProbeBehavior === 'block' ? 'failed' : 'skipped';
|
||||||
|
writeReport({
|
||||||
|
version: 1,
|
||||||
|
status,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
message: 'No enabled product smoke probes. Enable probes in .scale/product-smoke.json after defining the real product path.',
|
||||||
|
results: []
|
||||||
|
});
|
||||||
|
process.exit(status === 'failed' ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = probes.map((probe) => {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const expectedExitCode = Number.isInteger(probe.expected && probe.expected.exitCode) ? probe.expected.exitCode : 0;
|
||||||
|
const command = String(probe.command || '');
|
||||||
|
if (!command.trim()) {
|
||||||
|
return {
|
||||||
|
id: String(probe.id || 'unnamed-probe'),
|
||||||
|
description: String(probe.description || ''),
|
||||||
|
command,
|
||||||
|
expectedExitCode,
|
||||||
|
exitCode: 1,
|
||||||
|
status: 'failed',
|
||||||
|
startedAt,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
outputTail: 'Probe command is empty'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const result = cp.spawnSync(command, {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
shell: true,
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: Number(config.timeoutMs || 180000)
|
||||||
|
});
|
||||||
|
const output = String(result.stdout || '') + String(result.stderr || '') + String(result.error ? result.error.message : '');
|
||||||
|
const exitCode = typeof result.status === 'number' ? result.status : 1;
|
||||||
|
return {
|
||||||
|
id: String(probe.id || 'unnamed-probe'),
|
||||||
|
description: String(probe.description || ''),
|
||||||
|
command,
|
||||||
|
expectedExitCode,
|
||||||
|
exitCode,
|
||||||
|
status: exitCode === expectedExitCode ? 'passed' : 'failed',
|
||||||
|
startedAt,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
outputTail: output.length > 2000 ? output.slice(-2000) : output
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const failed = results.filter(result => result.status !== 'passed');
|
||||||
|
writeReport({
|
||||||
|
version: 1,
|
||||||
|
status: failed.length === 0 ? 'passed' : 'failed',
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
results
|
||||||
|
});
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
|
||||||
|
'@
|
||||||
|
|
||||||
|
$TempFile = [System.IO.Path]::GetTempFileName() + ".js"
|
||||||
|
Set-Content -Path $TempFile -Value $NodeProgram -Encoding UTF8
|
||||||
|
try {
|
||||||
|
node $TempFile $ConfigPath $LogPath
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
Remove-Item -Force $TempFile -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
98
scripts/qa/product-smoke.sh
Executable file
98
scripts/qa/product-smoke.sh
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
CONFIG_PATH="$ROOT/.scale/product-smoke.json"
|
||||||
|
LOG_DIR="$ROOT/.agent/logs"
|
||||||
|
LOG_PATH="$LOG_DIR/product-smoke.json"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
node - "$CONFIG_PATH" "$LOG_PATH" <<'NODE'
|
||||||
|
const fs = require('fs');
|
||||||
|
const cp = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const configPath = process.argv[2];
|
||||||
|
const logPath = process.argv[3];
|
||||||
|
|
||||||
|
function writeReport(report) {
|
||||||
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||||
|
fs.writeFileSync(logPath, JSON.stringify(report, null, 2) + '\n', 'utf8');
|
||||||
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
writeReport({
|
||||||
|
version: 1,
|
||||||
|
status: 'failed',
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
message: 'Missing .scale/product-smoke.json',
|
||||||
|
results: []
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
|
||||||
|
const probes = Array.isArray(config.probes) ? config.probes.filter(probe => probe && probe.enabled === true) : [];
|
||||||
|
|
||||||
|
if (probes.length === 0) {
|
||||||
|
const status = config.emptyProbeBehavior === 'block' ? 'failed' : 'skipped';
|
||||||
|
writeReport({
|
||||||
|
version: 1,
|
||||||
|
status,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
message: 'No enabled product smoke probes. Enable probes in .scale/product-smoke.json after defining the real product path.',
|
||||||
|
results: []
|
||||||
|
});
|
||||||
|
process.exit(status === 'failed' ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = probes.map((probe) => {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const expectedExitCode = Number.isInteger(probe.expected && probe.expected.exitCode) ? probe.expected.exitCode : 0;
|
||||||
|
const command = String(probe.command || '');
|
||||||
|
if (!command.trim()) {
|
||||||
|
return {
|
||||||
|
id: String(probe.id || 'unnamed-probe'),
|
||||||
|
description: String(probe.description || ''),
|
||||||
|
command,
|
||||||
|
expectedExitCode,
|
||||||
|
exitCode: 1,
|
||||||
|
status: 'failed',
|
||||||
|
startedAt,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
outputTail: 'Probe command is empty'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const result = cp.spawnSync(command, {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
shell: true,
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: Number(config.timeoutMs || 180000)
|
||||||
|
});
|
||||||
|
const output = String(result.stdout || '') + String(result.stderr || '') + String(result.error ? result.error.message : '');
|
||||||
|
const exitCode = typeof result.status === 'number' ? result.status : 1;
|
||||||
|
return {
|
||||||
|
id: String(probe.id || 'unnamed-probe'),
|
||||||
|
description: String(probe.description || ''),
|
||||||
|
command,
|
||||||
|
expectedExitCode,
|
||||||
|
exitCode,
|
||||||
|
status: exitCode === expectedExitCode ? 'passed' : 'failed',
|
||||||
|
startedAt,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
outputTail: output.length > 2000 ? output.slice(-2000) : output
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const failed = results.filter(result => result.status !== 'passed');
|
||||||
|
writeReport({
|
||||||
|
version: 1,
|
||||||
|
status: failed.length === 0 ? 'passed' : 'failed',
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
results
|
||||||
|
});
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
|
||||||
|
NODE
|
||||||
8
scripts/tests/run.sh
Executable file
8
scripts/tests/run.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# scripts/tests/run.sh — 运行测试
|
||||||
|
set -e
|
||||||
|
echo "🧪 运行测试..."
|
||||||
|
source ~/.cargo/env 2>/dev/null || true
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
cargo test 2>&1
|
||||||
|
echo "✅ 测试完成"
|
||||||
65
scripts/validate-config.sh
Executable file
65
scripts/validate-config.sh
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# validate-config.sh — 验证 SCALE OS 配置完整性
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔍 验证 SCALE OS 配置..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
WARN=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local name="$1"
|
||||||
|
local path="$2"
|
||||||
|
local required="$3"
|
||||||
|
|
||||||
|
if [ -e "$path" ]; then
|
||||||
|
echo " ✅ $name"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
elif [ "$required" = "true" ]; then
|
||||||
|
echo " ❌ $name (缺失: $path)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
else
|
||||||
|
echo " ⚠️ $name (可选, 缺失: $path)"
|
||||||
|
WARN=$((WARN + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "📁 项目级文件:"
|
||||||
|
check "AGENTS.md" "AGENTS.md" "true"
|
||||||
|
check "TOOLS.md" "TOOLS.md" "true"
|
||||||
|
check ".openclaw/settings.json" ".openclaw/settings.json" "true"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📁 SCALE 配置:"
|
||||||
|
check ".scale/ 目录" ".scale" "true"
|
||||||
|
check "workflow.json" ".scale/workflow.json" "true"
|
||||||
|
check "quality-contract.json" ".scale/quality-contract.json" "true"
|
||||||
|
check "skills-registry.json" ".scale/skills-registry.json" "true"
|
||||||
|
check "verification.json" ".scale/verification.json" "true"
|
||||||
|
check "skills.json" ".scale/skills.json" "true"
|
||||||
|
check "tools.json" ".scale/tools.json" "true"
|
||||||
|
check ".gitignore" ".scale/.gitignore" "true"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📁 工作流模板:"
|
||||||
|
check "docs/workflow/ 目录" "docs/workflow" "true"
|
||||||
|
check "QUALITY_CONTRACT.md" "docs/workflow/QUALITY_CONTRACT.md" "true"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📁 验证脚本:"
|
||||||
|
check "scripts/validate-config.sh" "scripts/validate-config.sh" "true"
|
||||||
|
check "scripts/qa/product-smoke.sh" "scripts/qa/product-smoke.sh" "true"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "────────────────────────────────────"
|
||||||
|
echo "结果: ✅ $PASS 通过 | ❌ $FAIL 失败 | ⚠️ $WARN 警告"
|
||||||
|
|
||||||
|
if [ $FAIL -gt 0 ]; then
|
||||||
|
echo "❌ 配置验证失败"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ 配置验证通过"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
29
scripts/workflow/verify.sh
Executable file
29
scripts/workflow/verify.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# scripts/workflow/verify.sh — 验证工作流配置
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
PROFILE="${1:-default}"
|
||||||
|
echo "📋 验证工作流 (profile: $PROFILE)..."
|
||||||
|
|
||||||
|
# 检查 workflow.json
|
||||||
|
if [ ! -f ".scale/workflow.json" ]; then
|
||||||
|
echo "❌ 缺少 .scale/workflow.json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 quality-contract.json
|
||||||
|
if [ ! -f ".scale/quality-contract.json" ]; then
|
||||||
|
echo "❌ 缺少 .scale/quality-contract.json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查模板文件
|
||||||
|
TEMPLATE_COUNT=$(ls docs/workflow/templates/*.md 2>/dev/null | wc -l)
|
||||||
|
echo " 📄 工作流模板: $TEMPLATE_COUNT 个"
|
||||||
|
|
||||||
|
# 验证 JSON 格式
|
||||||
|
python3 -c "import json; json.load(open('.scale/workflow.json')); print(' ✅ workflow.json 格式正确')"
|
||||||
|
python3 -c "import json; json.load(open('.scale/quality-contract.json')); print(' ✅ quality-contract.json 格式正确')"
|
||||||
|
|
||||||
|
echo "✅ 工作流验证通过"
|
||||||
511
src/app.rs
511
src/app.rs
@@ -1,38 +1,64 @@
|
|||||||
use eframe::{egui, egui_wgpu};
|
use crate::breakout::{BreakoutGame, control_from_matrix};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::connection::ConnectionManager;
|
use crate::connection::ConnectionManager;
|
||||||
use crate::theme::{ENGINEERING_DARK, apply_fonts, apply_theme};
|
use crate::force::{ForceEstimatorState, HudSpatialForce};
|
||||||
|
use crate::recording::Recorder;
|
||||||
|
use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
|
||||||
|
use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout};
|
||||||
|
use crate::ui::SerialMode;
|
||||||
use crate::{
|
use crate::{
|
||||||
matrix::{MATRIX_COLS, MATRIX_ROWS},
|
matrix::{MATRIX_COLS, MATRIX_ROWS},
|
||||||
render::{
|
render::{
|
||||||
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback,
|
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, PressureSamples,
|
||||||
|
WgpuBackgroundCallback,
|
||||||
},
|
},
|
||||||
ui::{
|
ui::{
|
||||||
ConfigPanelState, ConnectPanelState, FloatingPanelState, draw_config_panel,
|
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
|
||||||
draw_connect_panel, draw_scene_panel, draw_stats_panel,
|
draw_config_panel, draw_export_panel, draw_hand_force_panels, draw_matrix_config_panel,
|
||||||
|
draw_stats_panel, panel_restore_item,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use eframe::{egui, egui_wgpu};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
const DATA_LOG_EVERY_FRAMES: u64 = 30;
|
const SUMMARY_POINTS_PER_SERIES: usize = 42;
|
||||||
|
const HAND_FORCE_PANEL_COUNT: usize = 7;
|
||||||
|
const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [84, 84, 84, 84, 84, 70, 44];
|
||||||
|
|
||||||
pub struct EskinDesktopApp {
|
pub struct EskinDesktopApp {
|
||||||
connect_panel: FloatingPanelState,
|
connect_panel: FloatingPanelState,
|
||||||
connect_state: ConnectPanelState,
|
connect_state: ConnectPanelState,
|
||||||
connection: Arc<ConnectionManager>,
|
connection: Arc<ConnectionManager>,
|
||||||
pressure_matrix: PressureFrame,
|
pressure_matrix: PressureFrame,
|
||||||
data_log_frame: u64,
|
hand_pressure: PressureSamples,
|
||||||
scene_panel: FloatingPanelState,
|
scene_panel: FloatingPanelState,
|
||||||
config_panel: FloatingPanelState,
|
config_panel: FloatingPanelState,
|
||||||
config_state: ConfigPanelState,
|
config_state: ConfigPanelState,
|
||||||
stats_panel: FloatingPanelState,
|
stats_panel: FloatingPanelState,
|
||||||
|
// New: recording, export, matrix config, signal charts
|
||||||
|
recorder: Recorder,
|
||||||
|
export_panel: FloatingPanelState,
|
||||||
|
export_path: String,
|
||||||
|
matrix_config_panel: FloatingPanelState,
|
||||||
|
matrix_config: MatrixConfigState,
|
||||||
|
signal_history: Vec<f32>,
|
||||||
|
hand_signal_histories: [Vec<f32>; HAND_FORCE_PANEL_COUNT],
|
||||||
|
force_estimator: ForceEstimatorState,
|
||||||
|
latest_spatial_force: Option<HudSpatialForce>,
|
||||||
|
latest_raw_matrix: Vec<u32>,
|
||||||
|
latest_matrix_rows: u32,
|
||||||
|
latest_matrix_cols: u32,
|
||||||
|
breakout_visible: bool,
|
||||||
|
breakout_game: BreakoutGame,
|
||||||
|
context_menu_open: bool,
|
||||||
|
context_menu_pos: egui::Pos2,
|
||||||
|
active_mode: ActiveMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EskinDesktopApp {
|
impl EskinDesktopApp {
|
||||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||||||
apply_fonts(&cc.egui_ctx);
|
apply_fonts(&cc.egui_ctx);
|
||||||
apply_theme(&cc.egui_ctx, &ENGINEERING_DARK);
|
apply_theme(&cc.egui_ctx, &ONE_DARK_PRO);
|
||||||
|
|
||||||
let wgpu_state = cc
|
let wgpu_state = cc
|
||||||
.wgpu_render_state
|
.wgpu_render_state
|
||||||
@@ -44,28 +70,63 @@ impl EskinDesktopApp {
|
|||||||
.callback_resources
|
.callback_resources
|
||||||
.insert(BackgroundRenderResources::new(
|
.insert(BackgroundRenderResources::new(
|
||||||
&wgpu_state.device,
|
&wgpu_state.device,
|
||||||
|
&wgpu_state.queue,
|
||||||
&wgpu_state.target_format,
|
&wgpu_state.target_format,
|
||||||
MATRIX_ROWS,
|
MATRIX_ROWS,
|
||||||
MATRIX_COLS,
|
MATRIX_COLS,
|
||||||
));
|
));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
connect_panel: FloatingPanelState::new([0.0, 0.0], [0.0, 0.0]),
|
connect_panel: FloatingPanelState::new([0.0, 0.0], [layout::RIGHT_TAG_X, 48.0]),
|
||||||
connect_state: ConnectPanelState::default(),
|
connect_state: ConnectPanelState::default(),
|
||||||
connection: Arc::new(ConnectionManager::new()),
|
connection: Arc::new(ConnectionManager::new()),
|
||||||
pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT],
|
pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT],
|
||||||
data_log_frame: 0,
|
hand_pressure: Vec::new(),
|
||||||
scene_panel: FloatingPanelState::new([16.0, 48.0], [16.0, 48.0]),
|
scene_panel: FloatingPanelState::new(
|
||||||
config_panel: FloatingPanelState::new([840.0, 48.0], [128.0, 48.0]),
|
[layout::LEFT_X, layout::TOP_Y],
|
||||||
|
[layout::LEFT_TAG_X, layout::TOP_Y],
|
||||||
|
),
|
||||||
|
config_panel: FloatingPanelState::new(
|
||||||
|
[layout::RIGHT_X, layout::TOP_Y],
|
||||||
|
[layout::RIGHT_TAG_X, layout::TOP_Y],
|
||||||
|
),
|
||||||
config_state: ConfigPanelState::default(),
|
config_state: ConfigPanelState::default(),
|
||||||
stats_panel: FloatingPanelState::new([16.0, 520.0], [240.0, 48.0]),
|
stats_panel: FloatingPanelState::new(
|
||||||
|
[layout::LEFT_X, layout::LEFT_SECOND_Y],
|
||||||
|
[layout::LEFT_TAG_X, layout::LEFT_SECOND_Y],
|
||||||
|
),
|
||||||
|
recorder: Recorder::full(),
|
||||||
|
export_panel: FloatingPanelState::new(
|
||||||
|
[layout::LEFT_X, layout::TOP_Y],
|
||||||
|
[layout::LEFT_TAG_X, layout::TOP_Y],
|
||||||
|
),
|
||||||
|
export_path: String::new(),
|
||||||
|
matrix_config_panel: FloatingPanelState::new(
|
||||||
|
[layout::RIGHT_X, layout::RIGHT_SECOND_Y],
|
||||||
|
[layout::RIGHT_TAG_X, layout::RIGHT_SECOND_Y],
|
||||||
|
),
|
||||||
|
matrix_config: MatrixConfigState::default(),
|
||||||
|
signal_history: Vec::with_capacity(128),
|
||||||
|
hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)),
|
||||||
|
force_estimator: ForceEstimatorState::new(),
|
||||||
|
latest_spatial_force: None,
|
||||||
|
latest_raw_matrix: Vec::new(),
|
||||||
|
latest_matrix_rows: MATRIX_ROWS,
|
||||||
|
latest_matrix_cols: MATRIX_COLS,
|
||||||
|
breakout_visible: false,
|
||||||
|
breakout_game: BreakoutGame::default(),
|
||||||
|
context_menu_open: false,
|
||||||
|
context_menu_pos: egui::pos2(24.0, 48.0),
|
||||||
|
active_mode: ActiveMode::Finger(FingerMode {
|
||||||
|
rows: 12,
|
||||||
|
cols: 7,
|
||||||
|
range: 0..7000,
|
||||||
|
dot: true,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) {
|
fn draw_wgpu_background_rect(&self, ui: &mut egui::Ui, rect: egui::Rect) {
|
||||||
self.update_pressure_matrix();
|
|
||||||
|
|
||||||
let rect = ui.max_rect();
|
|
||||||
let width = rect.width().max(1.0);
|
let width = rect.width().max(1.0);
|
||||||
let height = rect.height().max(1.0);
|
let height = rect.height().max(1.0);
|
||||||
|
|
||||||
@@ -75,8 +136,84 @@ impl EskinDesktopApp {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
pressure: self.pressure_matrix,
|
pressure: self.pressure_matrix,
|
||||||
|
hand_pressure: self.hand_pressure.clone(),
|
||||||
|
active_mode: self.active_mode.clone(),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
self.paint_spatial_force_overlay(ui, rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) {
|
||||||
|
let Some(force) = self.latest_spatial_force else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let painter = ui.painter();
|
||||||
|
let center = rect.center();
|
||||||
|
let magnitude = force.magnitude.clamp(0.0, 1.8);
|
||||||
|
let length = 34.0 + magnitude * 54.0;
|
||||||
|
let angle = force.angle_deg.to_radians();
|
||||||
|
let direction = egui::vec2(angle.cos(), angle.sin());
|
||||||
|
let end = center + direction * length;
|
||||||
|
let side = egui::vec2(-direction.y, direction.x);
|
||||||
|
let color = egui::Color32::from_rgb(255, 196, 54);
|
||||||
|
let glow = egui::Color32::from_rgba_unmultiplied(255, 196, 54, 58);
|
||||||
|
|
||||||
|
painter.line_segment([center, end], egui::Stroke::new(9.0, glow));
|
||||||
|
painter.line_segment([center, end], egui::Stroke::new(2.4, color));
|
||||||
|
painter.line_segment(
|
||||||
|
[end, end - direction * 14.0 + side * 7.0],
|
||||||
|
egui::Stroke::new(2.4, color),
|
||||||
|
);
|
||||||
|
painter.line_segment(
|
||||||
|
[end, end - direction * 14.0 - side * 7.0],
|
||||||
|
egui::Stroke::new(2.4, color),
|
||||||
|
);
|
||||||
|
painter.circle_filled(center, 4.2, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_workspace(&mut self, ui: &mut egui::Ui) {
|
||||||
|
if self.breakout_visible {
|
||||||
|
self.draw_split_workspace(ui);
|
||||||
|
} else {
|
||||||
|
self.draw_wgpu_background_rect(ui, ui.max_rect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_split_workspace(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let screen = ui.max_rect();
|
||||||
|
let content = egui::Rect::from_min_max(
|
||||||
|
screen.left_top() + egui::vec2(0.0, layout::TITLE_BAR_HEIGHT),
|
||||||
|
screen.right_bottom(),
|
||||||
|
);
|
||||||
|
let gutter = 10.0;
|
||||||
|
let half_width = (content.width() - gutter).max(1.0) * 0.5;
|
||||||
|
let left = egui::Rect::from_min_size(content.min, egui::vec2(half_width, content.height()));
|
||||||
|
let right = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(left.right() + gutter, content.top()),
|
||||||
|
content.right_bottom(),
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(screen, egui::CornerRadius::ZERO, ONE_DARK_PRO.bg);
|
||||||
|
paint_split_viewport_shell(ui, left, "DOTMATRIX", "压力矩阵 / WGPU");
|
||||||
|
paint_split_viewport_shell(ui, right, "BREAKOUT", "压力控制打砖块");
|
||||||
|
|
||||||
|
let left_body = split_viewport_body(left);
|
||||||
|
let right_body = split_viewport_body(right);
|
||||||
|
self.draw_wgpu_background_rect(ui, left_body);
|
||||||
|
|
||||||
|
let breakout_control = control_from_matrix(
|
||||||
|
&self.latest_raw_matrix,
|
||||||
|
self.latest_matrix_rows,
|
||||||
|
self.latest_matrix_cols,
|
||||||
|
);
|
||||||
|
self.breakout_game.draw_viewport(
|
||||||
|
ui.ctx(),
|
||||||
|
right_body,
|
||||||
|
&mut self.breakout_visible,
|
||||||
|
breakout_control,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_pressure_matrix(&mut self) {
|
fn update_pressure_matrix(&mut self) {
|
||||||
@@ -87,15 +224,35 @@ impl EskinDesktopApp {
|
|||||||
sample.cols,
|
sample.cols,
|
||||||
&mut self.pressure_matrix,
|
&mut self.pressure_matrix,
|
||||||
);
|
);
|
||||||
self.data_log_frame += 1;
|
self.hand_pressure = normalize_pressure_samples(&sample.matrix);
|
||||||
if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 {
|
self.latest_raw_matrix.clear();
|
||||||
log_pressure_sample(&sample.matrix, sample.rows, sample.cols);
|
self.latest_raw_matrix.extend_from_slice(&sample.matrix);
|
||||||
|
self.latest_matrix_rows = sample.rows;
|
||||||
|
self.latest_matrix_cols = sample.cols;
|
||||||
|
|
||||||
|
self.latest_spatial_force = self.force_estimator.analyze(&sample.matrix);
|
||||||
|
|
||||||
|
// Keep JE-Skin's summary path separate from the optional spatial-force vector.
|
||||||
|
let raw_total = sample
|
||||||
|
.matrix
|
||||||
|
.iter()
|
||||||
|
.fold(0_u64, |sum, value| sum + *value as u64)
|
||||||
|
.min(u32::MAX as u64) as u32;
|
||||||
|
let force = raw_to_g1(raw_total).min(25.6);
|
||||||
|
let force = if force <= 0.1 { 0.0 } else { force };
|
||||||
|
self.signal_history.push(force);
|
||||||
|
if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES {
|
||||||
|
self.signal_history.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config_state.mode == SerialMode::Hand {
|
||||||
|
update_hand_signal_histories(&mut self.hand_signal_histories, &sample.matrix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_title_bar(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
fn draw_title_bar(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
let title_bar_height = 36.0;
|
let title_bar_height = layout::TITLE_BAR_HEIGHT;
|
||||||
let title_bar_rect = ui
|
let title_bar_rect = ui
|
||||||
.allocate_space(egui::vec2(ui.available_width(), title_bar_height))
|
.allocate_space(egui::vec2(ui.available_width(), title_bar_height))
|
||||||
.1;
|
.1;
|
||||||
@@ -104,11 +261,11 @@ impl EskinDesktopApp {
|
|||||||
ui.painter().rect_filled(
|
ui.painter().rect_filled(
|
||||||
title_bar_rect,
|
title_bar_rect,
|
||||||
egui::CornerRadius::ZERO,
|
egui::CornerRadius::ZERO,
|
||||||
ENGINEERING_DARK.panel_deep,
|
ONE_DARK_PRO.panel_deep,
|
||||||
);
|
);
|
||||||
ui.painter().line_segment(
|
ui.painter().line_segment(
|
||||||
[title_bar_rect.left_bottom(), title_bar_rect.right_bottom()],
|
[title_bar_rect.left_bottom(), title_bar_rect.right_bottom()],
|
||||||
egui::Stroke::new(1.0, ENGINEERING_DARK.border),
|
egui::Stroke::new(1.0, ONE_DARK_PRO.border),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drag-to-move: double-click to maximize, drag to move
|
// Drag-to-move: double-click to maximize, drag to move
|
||||||
@@ -206,39 +363,267 @@ impl EskinDesktopApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
|
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
|
||||||
draw_connect_panel(
|
// draw_scene_panel(ctx, &mut self.scene_panel);
|
||||||
|
// if self.config_state.mode == SerialMode::Hand && self.stats_panel.visible {
|
||||||
|
// self.config_panel.visible = false;
|
||||||
|
// self.export_panel.visible = false;
|
||||||
|
// self.matrix_config_panel.visible = false;
|
||||||
|
// }
|
||||||
|
|
||||||
|
if let Some(next_mode) = draw_config_panel(
|
||||||
ctx,
|
ctx,
|
||||||
&mut self.connect_panel,
|
&mut self.config_panel,
|
||||||
&mut self.connect_state,
|
&mut self.config_state,
|
||||||
&self.connection,
|
&self.connection,
|
||||||
|
&self.recorder,
|
||||||
|
) {
|
||||||
|
self.switch_mode(next_mode);
|
||||||
|
}
|
||||||
|
match self.config_state.mode {
|
||||||
|
SerialMode::Finger => {
|
||||||
|
draw_stats_panel(
|
||||||
|
ctx,
|
||||||
|
&mut self.stats_panel,
|
||||||
|
&self.signal_history,
|
||||||
|
self.latest_spatial_force,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SerialMode::Hand => {
|
||||||
|
draw_hand_force_panels(ctx, self.stats_panel.visible, &self.hand_signal_histories);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw_export_panel(
|
||||||
|
ctx,
|
||||||
|
&mut self.export_panel,
|
||||||
|
&self.recorder,
|
||||||
|
&mut self.export_path,
|
||||||
);
|
);
|
||||||
draw_scene_panel(ctx, &mut self.scene_panel);
|
draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config);
|
||||||
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state);
|
}
|
||||||
draw_stats_panel(ctx, &mut self.stats_panel);
|
|
||||||
|
fn draw_panel_context_menu(&mut self, ctx: &egui::Context) {
|
||||||
|
let (secondary_clicked, pointer_pos, escape_pressed) = ctx.input(|input| {
|
||||||
|
(
|
||||||
|
input.pointer.secondary_clicked(),
|
||||||
|
input.pointer.interact_pos(),
|
||||||
|
input.key_pressed(egui::Key::Escape),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if secondary_clicked {
|
||||||
|
self.context_menu_open = true;
|
||||||
|
self.context_menu_pos = pointer_pos.unwrap_or(self.context_menu_pos);
|
||||||
|
}
|
||||||
|
if escape_pressed {
|
||||||
|
self.context_menu_open = false;
|
||||||
|
}
|
||||||
|
if !self.context_menu_open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut close_menu = false;
|
||||||
|
let response = egui::Area::new(egui::Id::new("workspace_context_menu_area"))
|
||||||
|
.fixed_pos(self.context_menu_pos)
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||||
|
ui.set_min_width(132.0);
|
||||||
|
ui.label("显示面板");
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
panel_restore_item(ui, "配置", &mut self.config_panel);
|
||||||
|
panel_restore_item(ui, "录制", &mut self.export_panel);
|
||||||
|
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
|
||||||
|
if self.stats_panel.visible {
|
||||||
|
if ui
|
||||||
|
.add_sized(
|
||||||
|
egui::vec2(ui.available_width(), 0.0),
|
||||||
|
egui::Button::new("隐藏 统计"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.stats_panel.visible = false;
|
||||||
|
close_menu = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panel_restore_item(ui, "统计", &mut self.stats_panel);
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add_sized(
|
||||||
|
egui::vec2(ui.available_width(), 0.0),
|
||||||
|
egui::Button::new("打砖块"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.breakout_visible = true;
|
||||||
|
close_menu = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.add_sized(
|
||||||
|
egui::vec2(ui.available_width(), 0.0),
|
||||||
|
egui::Button::new("全部显示"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.config_panel.visible = true;
|
||||||
|
self.export_panel.visible = true;
|
||||||
|
self.matrix_config_panel.visible = true;
|
||||||
|
self.stats_panel.visible = true;
|
||||||
|
self.breakout_visible = true;
|
||||||
|
close_menu = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let clicked_outside = ctx.input(|input| {
|
||||||
|
input.pointer.primary_clicked()
|
||||||
|
&& input
|
||||||
|
.pointer
|
||||||
|
.interact_pos()
|
||||||
|
.is_some_and(|pos| !response.response.rect.contains(pos))
|
||||||
|
});
|
||||||
|
if close_menu || clicked_outside {
|
||||||
|
self.context_menu_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch_mode(&mut self, next: SerialMode) {
|
||||||
|
self.connect_state.mode = next;
|
||||||
|
self.config_state.mode = next;
|
||||||
|
self.config_state.baud_rate = next.baud_rate();
|
||||||
|
self.connection.disconnect();
|
||||||
|
self.pressure_matrix.fill([0.0, 0.0]);
|
||||||
|
self.hand_pressure.clear();
|
||||||
|
self.force_estimator.reset();
|
||||||
|
self.latest_spatial_force = None;
|
||||||
|
self.signal_history.clear();
|
||||||
|
self.hand_signal_histories
|
||||||
|
.iter_mut()
|
||||||
|
.for_each(|history| history.clear());
|
||||||
|
|
||||||
|
self.active_mode = match next {
|
||||||
|
SerialMode::Finger => ActiveMode::Finger(FingerMode {
|
||||||
|
rows: self.matrix_config.rows,
|
||||||
|
cols: self.matrix_config.cols,
|
||||||
|
range: 0..7000,
|
||||||
|
dot: true,
|
||||||
|
}),
|
||||||
|
SerialMode::Hand => ActiveMode::Hand(HandGatewayMode { range: 0..7000 }),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_pressure_sample(raw: &[u32], rows: u32, cols: u32) {
|
fn update_hand_signal_histories(
|
||||||
let max = raw.iter().copied().max().unwrap_or(0);
|
histories: &mut [Vec<f32>; HAND_FORCE_PANEL_COUNT],
|
||||||
let sum: u64 = raw.iter().map(|value| *value as u64).sum();
|
raw_values: &[u32],
|
||||||
let non_zero = raw.iter().filter(|value| **value != 0).count();
|
) {
|
||||||
let preview = raw
|
let mut offset = 0usize;
|
||||||
.iter()
|
for (history, sample_count) in histories.iter_mut().zip(HAND_FORCE_SEGMENT_COUNTS) {
|
||||||
.take(12)
|
let raw_total = raw_values
|
||||||
.map(u32::to_string)
|
.get(offset..offset + sample_count)
|
||||||
.collect::<Vec<_>>()
|
.unwrap_or(&[])
|
||||||
.join(", ");
|
.iter()
|
||||||
|
.fold(0_u64, |sum, value| sum + *value as u64)
|
||||||
|
.min(u32::MAX as u64) as u32;
|
||||||
|
let force = raw_to_g1(raw_total).min(25.6);
|
||||||
|
let force = if force <= 0.1 { 0.0 } else { force };
|
||||||
|
|
||||||
println!(
|
history.push(force);
|
||||||
"[pressure] {rows}x{cols} cells={} non_zero={non_zero} max={max} sum={sum} first=[{preview}]",
|
if history.len() > SUMMARY_POINTS_PER_SERIES {
|
||||||
raw.len()
|
history.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += sample_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_to_g1(raw: u32) -> f32 {
|
||||||
|
const RAW: [u32; 12] = [
|
||||||
|
0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444,
|
||||||
|
];
|
||||||
|
const FORCE_CENTI_N: [f32; 12] = [
|
||||||
|
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0, 2557.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if raw <= RAW[0] {
|
||||||
|
return FORCE_CENTI_N[0] / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw >= RAW[RAW.len() - 1] {
|
||||||
|
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut left = 0usize;
|
||||||
|
let mut right = RAW.len() - 1;
|
||||||
|
while left + 1 < right {
|
||||||
|
let mid = (left + right) / 2;
|
||||||
|
if RAW[mid] <= raw {
|
||||||
|
left = mid;
|
||||||
|
} else {
|
||||||
|
right = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_left = RAW[left] as f32;
|
||||||
|
let raw_right = RAW[right] as f32;
|
||||||
|
let span = (raw_right - raw_left).max(1.0);
|
||||||
|
let ratio = (raw as f32 - raw_left) / span;
|
||||||
|
|
||||||
|
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_split_viewport_shell(
|
||||||
|
ui: &egui::Ui,
|
||||||
|
rect: egui::Rect,
|
||||||
|
title: &'static str,
|
||||||
|
subtitle: &'static str,
|
||||||
|
) {
|
||||||
|
let painter = ui.painter();
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(8),
|
||||||
|
egui::Color32::from_rgb(11, 17, 23),
|
||||||
|
);
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(8),
|
||||||
|
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
rect.left_top() + egui::vec2(12.0, 34.0),
|
||||||
|
rect.right_top() + egui::vec2(-12.0, 34.0),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
rect.left_top() + egui::vec2(14.0, 16.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
title,
|
||||||
|
egui::FontId::monospace(13.0),
|
||||||
|
ONE_DARK_PRO.accent,
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
rect.left_top() + egui::vec2(122.0, 16.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
subtitle,
|
||||||
|
egui::FontId::proportional(12.0),
|
||||||
|
ONE_DARK_PRO.text_dim,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
|
fn split_viewport_body(rect: egui::Rect) -> egui::Rect {
|
||||||
const RANGE_MIN: f32 = 0.0;
|
egui::Rect::from_min_max(
|
||||||
const RANGE_MAX: f32 = 7000.0;
|
rect.left_top() + egui::vec2(10.0, 44.0),
|
||||||
|
rect.right_bottom() - egui::vec2(10.0, 10.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
|
||||||
normalized.fill([0.0, 0.0]);
|
normalized.fill([0.0, 0.0]);
|
||||||
|
|
||||||
let src_cols = cols.max(1);
|
let src_cols = cols.max(1);
|
||||||
@@ -250,26 +635,40 @@ fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut
|
|||||||
let src_index = (row * src_cols + col) as usize;
|
let src_index = (row * src_cols + col) as usize;
|
||||||
let dst_index = (row * MATRIX_COLS + col) as usize;
|
let dst_index = (row * MATRIX_COLS + col) as usize;
|
||||||
if let Some(value) = raw.get(src_index) {
|
if let Some(value) = raw.get(src_index) {
|
||||||
let raw_value = *value as f32;
|
normalized[dst_index] = normalize_pressure_value(*value);
|
||||||
let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0);
|
|
||||||
let display_value = if raw_value <= RANGE_MIN + 4.0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
raw_value.round().min(9999.0)
|
|
||||||
};
|
|
||||||
normalized[dst_index] = [mapped, display_value];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_pressure_samples(raw: &[u32]) -> PressureSamples {
|
||||||
|
raw.iter().copied().map(normalize_pressure_value).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_pressure_value(value: u32) -> [f32; 2] {
|
||||||
|
const RANGE_MIN: f32 = 0.0;
|
||||||
|
const RANGE_MAX: f32 = 7000.0;
|
||||||
|
|
||||||
|
let raw_value = value as f32;
|
||||||
|
let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0);
|
||||||
|
let display_value = if raw_value <= RANGE_MIN + 4.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
raw_value.round().min(9999.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
[mapped, display_value]
|
||||||
|
}
|
||||||
|
|
||||||
impl eframe::App for EskinDesktopApp {
|
impl eframe::App for EskinDesktopApp {
|
||||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
|
|
||||||
self.draw_wgpu_background(ui);
|
self.update_pressure_matrix();
|
||||||
|
self.draw_workspace(ui);
|
||||||
self.draw_title_bar(ui, frame);
|
self.draw_title_bar(ui, frame);
|
||||||
self.draw_floating_panels(&ctx);
|
self.draw_floating_panels(&ctx);
|
||||||
|
self.draw_panel_context_menu(&ctx);
|
||||||
|
|
||||||
// Keep repainting while the wgpu background is a realtime viewport.
|
// Keep repainting while the wgpu background is a realtime viewport.
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
|
|||||||
643
src/breakout.rs
Normal file
643
src/breakout.rs
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use crate::style::{self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, ONE_DARK_PRO};
|
||||||
|
|
||||||
|
const BRICK_ROWS: usize = 7;
|
||||||
|
const BRICK_COLS: usize = 12;
|
||||||
|
const PADDLE_W: f32 = 0.24;
|
||||||
|
const PADDLE_H: f32 = 0.030;
|
||||||
|
const PADDLE_Y: f32 = 0.88;
|
||||||
|
const PADDLE_SPEED: f32 = 0.92;
|
||||||
|
const BALL_RADIUS: f32 = 0.010;
|
||||||
|
const BALL_SPEED: f32 = 0.52;
|
||||||
|
const MAX_BALL_SPEED: f32 = 0.86;
|
||||||
|
const PRESSURE_RANGE_MIN: f32 = 0.0;
|
||||||
|
const PRESSURE_RANGE_MAX: f32 = 7000.0;
|
||||||
|
const PAUSE_COOLDOWN: f64 = 0.64;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum BreakoutPhase {
|
||||||
|
Idle,
|
||||||
|
Running,
|
||||||
|
Paused,
|
||||||
|
Over,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Brick {
|
||||||
|
rect: egui::Rect,
|
||||||
|
alive: bool,
|
||||||
|
flash: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Copy)]
|
||||||
|
pub struct BreakoutControl {
|
||||||
|
pub axis: f32,
|
||||||
|
pub top_force: f32,
|
||||||
|
pub left_force: f32,
|
||||||
|
pub right_force: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BreakoutGame {
|
||||||
|
phase: BreakoutPhase,
|
||||||
|
bricks: Vec<Brick>,
|
||||||
|
paddle_x: f32,
|
||||||
|
ball_pos: egui::Pos2,
|
||||||
|
ball_vel: egui::Vec2,
|
||||||
|
score: u32,
|
||||||
|
combo: u32,
|
||||||
|
lives: u32,
|
||||||
|
level: u32,
|
||||||
|
last_time: Option<f64>,
|
||||||
|
previous_pause_gesture: bool,
|
||||||
|
pause_locked_until: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BreakoutGame {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut game = Self {
|
||||||
|
phase: BreakoutPhase::Idle,
|
||||||
|
bricks: Vec::new(),
|
||||||
|
paddle_x: 0.5,
|
||||||
|
ball_pos: egui::pos2(0.5, PADDLE_Y - PADDLE_H - BALL_RADIUS),
|
||||||
|
ball_vel: egui::vec2(0.0, 0.0),
|
||||||
|
score: 0,
|
||||||
|
combo: 0,
|
||||||
|
lives: 3,
|
||||||
|
level: 1,
|
||||||
|
last_time: None,
|
||||||
|
previous_pause_gesture: false,
|
||||||
|
pause_locked_until: 0.0,
|
||||||
|
};
|
||||||
|
game.rebuild_bricks();
|
||||||
|
game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BreakoutGame {
|
||||||
|
pub fn draw_viewport(
|
||||||
|
&mut self,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
rect: egui::Rect,
|
||||||
|
visible: &mut bool,
|
||||||
|
control: BreakoutControl,
|
||||||
|
) {
|
||||||
|
if !*visible {
|
||||||
|
self.last_time = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.request_repaint();
|
||||||
|
let now = ctx.input(|input| input.time);
|
||||||
|
let dt = self
|
||||||
|
.last_time
|
||||||
|
.map(|last| (now - last) as f32)
|
||||||
|
.unwrap_or(1.0 / 60.0)
|
||||||
|
.clamp(0.0, 1.0 / 30.0);
|
||||||
|
self.last_time = Some(now);
|
||||||
|
|
||||||
|
let mut close_requested = false;
|
||||||
|
egui::Area::new(egui::Id::new("breakout_game_viewport"))
|
||||||
|
.fixed_pos(rect.min)
|
||||||
|
.order(egui::Order::Middle)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_min_size(rect.size());
|
||||||
|
ui.set_max_size(rect.size());
|
||||||
|
style::group_frame().show(ui, |ui| {
|
||||||
|
ui.set_min_size(rect.size() - egui::vec2(20.0, 16.0));
|
||||||
|
if self.draw_contents(ui, control, dt, now, true) {
|
||||||
|
close_requested = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if close_requested {
|
||||||
|
*visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_contents(
|
||||||
|
&mut self,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
control: BreakoutControl,
|
||||||
|
dt: f32,
|
||||||
|
now: f64,
|
||||||
|
embedded: bool,
|
||||||
|
) -> bool {
|
||||||
|
let mut close_requested = false;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.colored_label(
|
||||||
|
ACCENT_BLUE,
|
||||||
|
egui::RichText::new("NEON BREAKOUT").monospace().size(13.0),
|
||||||
|
);
|
||||||
|
ui.label(style::subtle_text("压力矩阵控制 / 左右分区驱动"));
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if embedded && ui.add(style::tag_button("退出分屏")).clicked() {
|
||||||
|
close_requested = true;
|
||||||
|
}
|
||||||
|
if ui.add(style::tag_button("重开")).clicked() {
|
||||||
|
self.reset();
|
||||||
|
self.start();
|
||||||
|
}
|
||||||
|
if ui.add(style::tag_button("暂停")).clicked() {
|
||||||
|
self.toggle_pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(5.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
status_chip(ui, "状态", self.status_text(), status_color(self.phase));
|
||||||
|
status_chip(ui, "分数", self.score.to_string(), ACCENT_GREEN);
|
||||||
|
status_chip(ui, "连击", self.combo.to_string(), ACCENT_ORANGE);
|
||||||
|
status_chip(ui, "生命", self.lives.to_string(), ACCENT_RED);
|
||||||
|
status_chip(ui, "关卡", self.level.to_string(), ACCENT_BLUE);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(7.0);
|
||||||
|
let available = ui.available_size();
|
||||||
|
let game_size = egui::vec2(available.x.max(500.0), (available.y - 6.0).max(330.0));
|
||||||
|
let (rect, response) = ui.allocate_exact_size(game_size, egui::Sense::click());
|
||||||
|
if response.clicked() && self.phase != BreakoutPhase::Running {
|
||||||
|
self.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyboard_axis = ui.input(|input| {
|
||||||
|
(input.key_down(egui::Key::ArrowRight) as i32
|
||||||
|
- input.key_down(egui::Key::ArrowLeft) as i32) as f32
|
||||||
|
});
|
||||||
|
if ui.input(|input| input.key_pressed(egui::Key::Space)) {
|
||||||
|
self.start();
|
||||||
|
}
|
||||||
|
if ui.input(|input| input.key_pressed(egui::Key::P)) {
|
||||||
|
self.toggle_pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
let axis = if keyboard_axis.abs() > 0.0 {
|
||||||
|
keyboard_axis
|
||||||
|
} else {
|
||||||
|
control.axis
|
||||||
|
};
|
||||||
|
self.handle_pressure_gesture(control.top_force, now);
|
||||||
|
self.update(dt, axis);
|
||||||
|
self.paint(ui, rect, control);
|
||||||
|
close_requested
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.phase = BreakoutPhase::Idle;
|
||||||
|
self.paddle_x = 0.5;
|
||||||
|
self.ball_pos = egui::pos2(0.5, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||||
|
self.ball_vel = egui::Vec2::ZERO;
|
||||||
|
self.score = 0;
|
||||||
|
self.combo = 0;
|
||||||
|
self.lives = 3;
|
||||||
|
self.level = 1;
|
||||||
|
self.rebuild_bricks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&mut self) {
|
||||||
|
if self.phase == BreakoutPhase::Over {
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
if self.phase != BreakoutPhase::Running {
|
||||||
|
self.phase = BreakoutPhase::Running;
|
||||||
|
if self.ball_vel == egui::Vec2::ZERO {
|
||||||
|
self.launch_ball();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_pause(&mut self) {
|
||||||
|
self.phase = match self.phase {
|
||||||
|
BreakoutPhase::Running => BreakoutPhase::Paused,
|
||||||
|
BreakoutPhase::Paused => BreakoutPhase::Running,
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_pressure_gesture(&mut self, top_force: f32, now: f64) {
|
||||||
|
let threshold = pressure_pause_threshold();
|
||||||
|
let active = top_force >= threshold;
|
||||||
|
if active && !self.previous_pause_gesture && now >= self.pause_locked_until {
|
||||||
|
if self.phase == BreakoutPhase::Idle || self.phase == BreakoutPhase::Over {
|
||||||
|
self.start();
|
||||||
|
} else {
|
||||||
|
self.toggle_pause();
|
||||||
|
}
|
||||||
|
self.pause_locked_until = now + PAUSE_COOLDOWN;
|
||||||
|
}
|
||||||
|
self.previous_pause_gesture = active;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, dt: f32, axis: f32) {
|
||||||
|
for brick in &mut self.bricks {
|
||||||
|
brick.flash = (brick.flash - dt * 2.4).max(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.phase != BreakoutPhase::Running {
|
||||||
|
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.paddle_x = (self.paddle_x + axis.clamp(-1.0, 1.0) * PADDLE_SPEED * dt)
|
||||||
|
.clamp(PADDLE_W * 0.5, 1.0 - PADDLE_W * 0.5);
|
||||||
|
|
||||||
|
self.ball_pos += self.ball_vel * dt;
|
||||||
|
self.resolve_wall_collision();
|
||||||
|
self.resolve_paddle_collision();
|
||||||
|
self.resolve_brick_collision();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_ball(&mut self) {
|
||||||
|
let direction = if self.level % 2 == 0 { -0.24 } else { 0.24 };
|
||||||
|
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||||
|
self.ball_vel = egui::vec2(direction, -1.0).normalized() * BALL_SPEED;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_wall_collision(&mut self) {
|
||||||
|
if self.ball_pos.x - BALL_RADIUS <= 0.0 {
|
||||||
|
self.ball_pos.x = BALL_RADIUS;
|
||||||
|
self.ball_vel.x = self.ball_vel.x.abs();
|
||||||
|
} else if self.ball_pos.x + BALL_RADIUS >= 1.0 {
|
||||||
|
self.ball_pos.x = 1.0 - BALL_RADIUS;
|
||||||
|
self.ball_vel.x = -self.ball_vel.x.abs();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ball_pos.y - BALL_RADIUS <= 0.0 {
|
||||||
|
self.ball_pos.y = BALL_RADIUS;
|
||||||
|
self.ball_vel.y = self.ball_vel.y.abs();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ball_pos.y - BALL_RADIUS > 1.0 {
|
||||||
|
self.lives = self.lives.saturating_sub(1);
|
||||||
|
self.combo = 0;
|
||||||
|
if self.lives == 0 {
|
||||||
|
self.phase = BreakoutPhase::Over;
|
||||||
|
self.ball_vel = egui::Vec2::ZERO;
|
||||||
|
} else {
|
||||||
|
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||||
|
self.launch_ball();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_paddle_collision(&mut self) {
|
||||||
|
if self.ball_vel.y <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let paddle = paddle_rect(self.paddle_x);
|
||||||
|
if !circle_hits_rect(self.ball_pos, BALL_RADIUS, paddle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ball_pos.y = paddle.top() - BALL_RADIUS;
|
||||||
|
let offset = ((self.ball_pos.x - self.paddle_x) / (PADDLE_W * 0.5)).clamp(-1.0, 1.0);
|
||||||
|
let speed = self.ball_vel.length().clamp(BALL_SPEED, MAX_BALL_SPEED);
|
||||||
|
self.ball_vel = egui::vec2(offset * 0.82, -1.0).normalized() * speed;
|
||||||
|
self.combo = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_brick_collision(&mut self) {
|
||||||
|
for brick in &mut self.bricks {
|
||||||
|
if !brick.alive || !circle_hits_rect(self.ball_pos, BALL_RADIUS, brick.rect) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlap_x = (self.ball_pos.x + BALL_RADIUS - brick.rect.left())
|
||||||
|
.min(brick.rect.right() - (self.ball_pos.x - BALL_RADIUS));
|
||||||
|
let overlap_y = (self.ball_pos.y + BALL_RADIUS - brick.rect.top())
|
||||||
|
.min(brick.rect.bottom() - (self.ball_pos.y - BALL_RADIUS));
|
||||||
|
if overlap_x < overlap_y {
|
||||||
|
self.ball_vel.x *= -1.0;
|
||||||
|
} else {
|
||||||
|
self.ball_vel.y *= -1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
brick.alive = false;
|
||||||
|
brick.flash = 1.0;
|
||||||
|
self.combo += 1;
|
||||||
|
self.score += 40 + self.combo * 5;
|
||||||
|
let speed = (self.ball_vel.length() * 1.014).min(MAX_BALL_SPEED);
|
||||||
|
self.ball_vel = self.ball_vel.normalized() * speed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.bricks.iter().all(|brick| !brick.alive) {
|
||||||
|
self.level += 1;
|
||||||
|
self.rebuild_bricks();
|
||||||
|
self.launch_ball();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_bricks(&mut self) {
|
||||||
|
self.bricks.clear();
|
||||||
|
let gap_x = 0.012;
|
||||||
|
let gap_y = 0.017;
|
||||||
|
let left = 0.075;
|
||||||
|
let top = 0.10;
|
||||||
|
let total_w = 0.85;
|
||||||
|
let brick_w = (total_w - gap_x * (BRICK_COLS - 1) as f32) / BRICK_COLS as f32;
|
||||||
|
let brick_h = 0.040;
|
||||||
|
|
||||||
|
for row in 0..BRICK_ROWS {
|
||||||
|
for col in 0..BRICK_COLS {
|
||||||
|
let x = left + col as f32 * (brick_w + gap_x);
|
||||||
|
let y = top + row as f32 * (brick_h + gap_y);
|
||||||
|
self.bricks.push(Brick {
|
||||||
|
rect: egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(brick_w, brick_h)),
|
||||||
|
alive: true,
|
||||||
|
flash: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_text(&self) -> &'static str {
|
||||||
|
match self.phase {
|
||||||
|
BreakoutPhase::Idle => "待机",
|
||||||
|
BreakoutPhase::Running => "运行",
|
||||||
|
BreakoutPhase::Paused => "暂停",
|
||||||
|
BreakoutPhase::Over => "结束",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&self, ui: &egui::Ui, rect: egui::Rect, control: BreakoutControl) {
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
egui::Color32::from_rgb(8, 14, 19),
|
||||||
|
);
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.accent, 110)),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
|
||||||
|
paint_arena_grid(&painter, rect);
|
||||||
|
for (index, brick) in self.bricks.iter().enumerate() {
|
||||||
|
paint_brick(&painter, rect, brick, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
paint_paddle(&painter, rect, self.paddle_x);
|
||||||
|
paint_ball(&painter, rect, self.ball_pos);
|
||||||
|
paint_control_meter(&painter, rect, control);
|
||||||
|
|
||||||
|
if self.phase == BreakoutPhase::Idle
|
||||||
|
|| self.phase == BreakoutPhase::Paused
|
||||||
|
|| self.phase == BreakoutPhase::Over
|
||||||
|
{
|
||||||
|
paint_center_overlay(&painter, rect, self.phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl {
|
||||||
|
if raw.is_empty() {
|
||||||
|
return BreakoutControl::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = rows.max(1) as usize;
|
||||||
|
let cols = cols.max(1) as usize;
|
||||||
|
let sample_rows = rows.min(2);
|
||||||
|
let sample_cols = cols.min(2);
|
||||||
|
let avg = |row_start: usize, row_end: usize, col_start: usize, col_end: usize| -> f32 {
|
||||||
|
let mut sum = 0.0;
|
||||||
|
let mut count = 0.0;
|
||||||
|
for row in row_start..row_end {
|
||||||
|
for col in col_start..col_end {
|
||||||
|
let index = row * cols + col;
|
||||||
|
if let Some(value) = raw.get(index) {
|
||||||
|
sum += *value as f32;
|
||||||
|
count += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count > 0.0 { sum / count } else { 0.0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
let tl = avg(0, sample_rows, 0, sample_cols);
|
||||||
|
let tr = avg(0, sample_rows, cols.saturating_sub(sample_cols), cols);
|
||||||
|
let bl = avg(rows.saturating_sub(sample_rows), rows, 0, sample_cols);
|
||||||
|
let br = avg(
|
||||||
|
rows.saturating_sub(sample_rows),
|
||||||
|
rows,
|
||||||
|
cols.saturating_sub(sample_cols),
|
||||||
|
cols,
|
||||||
|
);
|
||||||
|
|
||||||
|
let left_force = tl + bl;
|
||||||
|
let right_force = tr + br;
|
||||||
|
let top_force = tl + tr;
|
||||||
|
let span = 1200.0_f32.max((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN) * 0.22);
|
||||||
|
let raw_axis = ((right_force - left_force) / span).clamp(-1.0, 1.0);
|
||||||
|
let axis = if raw_axis.abs() < 0.045 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
raw_axis
|
||||||
|
};
|
||||||
|
|
||||||
|
BreakoutControl {
|
||||||
|
axis,
|
||||||
|
top_force,
|
||||||
|
left_force,
|
||||||
|
right_force,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pressure_pause_threshold() -> f32 {
|
||||||
|
420.0_f32.max(((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN).max(1000.0) * 0.07).round())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paddle_rect(paddle_x: f32) -> egui::Rect {
|
||||||
|
egui::Rect::from_center_size(
|
||||||
|
egui::pos2(paddle_x, PADDLE_Y),
|
||||||
|
egui::vec2(PADDLE_W, PADDLE_H),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn circle_hits_rect(center: egui::Pos2, radius: f32, rect: egui::Rect) -> bool {
|
||||||
|
let closest_x = center.x.clamp(rect.left(), rect.right());
|
||||||
|
let closest_y = center.y.clamp(rect.top(), rect.bottom());
|
||||||
|
let dx = center.x - closest_x;
|
||||||
|
let dy = center.y - closest_y;
|
||||||
|
dx * dx + dy * dy <= radius * radius
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_chip(ui: &mut egui::Ui, label: &'static str, value: impl ToString, color: egui::Color32) {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(color_alpha(ONE_DARK_PRO.panel_deep, 190))
|
||||||
|
.stroke(egui::Stroke::new(1.0, color_alpha(color, 92)))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4))
|
||||||
|
.inner_margin(egui::Margin::symmetric(8, 4))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.colored_label(ONE_DARK_PRO.text_subtle, label);
|
||||||
|
ui.colored_label(color, value.to_string());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_color(phase: BreakoutPhase) -> egui::Color32 {
|
||||||
|
match phase {
|
||||||
|
BreakoutPhase::Idle => ONE_DARK_PRO.text_dim,
|
||||||
|
BreakoutPhase::Running => ACCENT_GREEN,
|
||||||
|
BreakoutPhase::Paused => ACCENT_ORANGE,
|
||||||
|
BreakoutPhase::Over => ACCENT_RED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_screen(rect: egui::Rect, point: egui::Pos2) -> egui::Pos2 {
|
||||||
|
egui::pos2(
|
||||||
|
rect.left() + point.x * rect.width(),
|
||||||
|
rect.top() + point.y * rect.height(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_screen_rect(rect: egui::Rect, local: egui::Rect) -> egui::Rect {
|
||||||
|
egui::Rect::from_min_max(
|
||||||
|
rect.left_top() + local.min.to_vec2() * rect.size(),
|
||||||
|
rect.left_top() + local.max.to_vec2() * rect.size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_arena_grid(painter: &egui::Painter, rect: egui::Rect) {
|
||||||
|
// 游戏背景保持纯净,避免网格和外框干扰砖块本身的色块节奏。
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
egui::Color32::from_rgb(2, 8, 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.accent, 70)),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_brick(painter: &egui::Painter, arena: egui::Rect, brick: &Brick, index: usize) {
|
||||||
|
let rect = to_screen_rect(arena, brick.rect);
|
||||||
|
if brick.alive {
|
||||||
|
let row = index / BRICK_COLS;
|
||||||
|
let col = index % BRICK_COLS;
|
||||||
|
// 参考示例图:纯色矩形砖块,黑色间隔由砖块之间的空隙自然露出。
|
||||||
|
painter.rect_filled(rect, egui::CornerRadius::ZERO, brick_color(row, col));
|
||||||
|
} else if brick.flash > 0.0 {
|
||||||
|
let alpha = (brick.flash * 150.0) as u8;
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect.expand(4.0 * brick.flash),
|
||||||
|
egui::CornerRadius::same(4),
|
||||||
|
egui::Stroke::new(1.4, color_alpha(ONE_DARK_PRO.accent_hot, alpha)),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_paddle(painter: &egui::Painter, arena: egui::Rect, paddle_x: f32) {
|
||||||
|
let rect = to_screen_rect(arena, paddle_rect(paddle_x));
|
||||||
|
// 托盘也改成纯色块,和砖块保持同一种视觉语言。
|
||||||
|
painter.rect_filled(rect, egui::CornerRadius::ZERO, ACCENT_BLUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_ball(painter: &egui::Painter, arena: egui::Rect, ball_pos: egui::Pos2) {
|
||||||
|
let center = to_screen(arena, ball_pos);
|
||||||
|
let radius = BALL_RADIUS * arena.width().min(arena.height());
|
||||||
|
// 球保留为一个普通实心圆,避免额外高光和光晕。
|
||||||
|
painter.circle_filled(center, radius, egui::Color32::from_rgb(241, 207, 70));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_control_meter(painter: &egui::Painter, arena: egui::Rect, control: BreakoutControl) {
|
||||||
|
let meter_width = arena.width().mul_add(0.30, 0.0).clamp(180.0, 240.0);
|
||||||
|
let meter = egui::Rect::from_min_size(
|
||||||
|
arena.left_bottom() + egui::vec2(18.0, -34.0),
|
||||||
|
egui::vec2(meter_width, 18.0),
|
||||||
|
);
|
||||||
|
painter.rect_filled(
|
||||||
|
meter,
|
||||||
|
egui::CornerRadius::same(4),
|
||||||
|
color_alpha(ONE_DARK_PRO.panel_deep, 220),
|
||||||
|
);
|
||||||
|
painter.rect_stroke(
|
||||||
|
meter,
|
||||||
|
egui::CornerRadius::same(4),
|
||||||
|
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border, 120)),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
let center_x = meter.center().x;
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
egui::pos2(center_x, meter.top()),
|
||||||
|
egui::pos2(center_x, meter.bottom()),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||||||
|
);
|
||||||
|
let marker_x = center_x + control.axis.clamp(-1.0, 1.0) * meter.width() * 0.45;
|
||||||
|
painter.circle_filled(
|
||||||
|
egui::pos2(marker_x, meter.center().y),
|
||||||
|
5.0,
|
||||||
|
color_alpha(ACCENT_GREEN, 210),
|
||||||
|
);
|
||||||
|
painter.circle_filled(
|
||||||
|
egui::pos2(marker_x, meter.center().y),
|
||||||
|
2.3,
|
||||||
|
color_alpha(egui::Color32::WHITE, 150),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
meter.right_center() + egui::vec2(10.0, 0.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
format!(
|
||||||
|
"L {:>4.0} R {:>4.0} T {:>4.0}",
|
||||||
|
control.left_force, control.right_force, control.top_force
|
||||||
|
),
|
||||||
|
egui::FontId::monospace(10.0),
|
||||||
|
ONE_DARK_PRO.text_dim,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_center_overlay(painter: &egui::Painter, rect: egui::Rect, phase: BreakoutPhase) {
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
color_alpha(egui::Color32::BLACK, 82),
|
||||||
|
);
|
||||||
|
let (title, detail) = match phase {
|
||||||
|
BreakoutPhase::Idle => ("按压顶部或点击开始", "左右分区压力控制挡板"),
|
||||||
|
BreakoutPhase::Paused => ("已暂停", "再次按压顶部、P 或点击暂停继续"),
|
||||||
|
BreakoutPhase::Over => ("游戏结束", "点击或空格重开"),
|
||||||
|
BreakoutPhase::Running => ("", ""),
|
||||||
|
};
|
||||||
|
painter.text(
|
||||||
|
rect.center() - egui::vec2(0.0, 10.0),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
title,
|
||||||
|
egui::FontId::proportional(24.0),
|
||||||
|
ONE_DARK_PRO.text,
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
rect.center() + egui::vec2(0.0, 20.0),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
detail,
|
||||||
|
egui::FontId::proportional(13.0),
|
||||||
|
ONE_DARK_PRO.text_dim,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn brick_color(row: usize, col: usize) -> egui::Color32 {
|
||||||
|
const PALETTE: [egui::Color32; 5] = [
|
||||||
|
egui::Color32::from_rgb(46, 178, 104),
|
||||||
|
egui::Color32::from_rgb(73, 188, 220),
|
||||||
|
egui::Color32::from_rgb(234, 199, 73),
|
||||||
|
egui::Color32::from_rgb(232, 125, 64),
|
||||||
|
egui::Color32::from_rgb(237, 68, 62),
|
||||||
|
];
|
||||||
|
|
||||||
|
PALETTE[(col + row * 2) % PALETTE.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_alpha(color: egui::Color32, alpha: u8) -> egui::Color32 {
|
||||||
|
egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), alpha)
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
|
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
|
||||||
|
|
||||||
use crate::serial_core::serial::{SerialPortReadWrite, run_serial_loop};
|
use crate::recording::Recorder;
|
||||||
|
use crate::serial_core::serial::{
|
||||||
|
SerialIoStats, SerialPortReadWrite, SerialProtocol, run_serial_loop,
|
||||||
|
};
|
||||||
|
|
||||||
/// Connection state visible to the UI.
|
/// Connection state visible to the UI.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -27,6 +30,9 @@ struct Session {
|
|||||||
cancel_tx: Sender<()>,
|
cancel_tx: Sender<()>,
|
||||||
handle: JoinHandle<()>,
|
handle: JoinHandle<()>,
|
||||||
sample_rx: Receiver<Vec<i32>>,
|
sample_rx: Receiver<Vec<i32>>,
|
||||||
|
stats_rx: Receiver<SerialIoStats>,
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thread-safe connection manager that the UI and renderer can share.
|
/// Thread-safe connection manager that the UI and renderer can share.
|
||||||
@@ -34,6 +40,7 @@ pub struct ConnectionManager {
|
|||||||
state: Arc<Mutex<ConnectionState>>,
|
state: Arc<Mutex<ConnectionState>>,
|
||||||
session: Arc<Mutex<Option<Session>>>,
|
session: Arc<Mutex<Option<Session>>>,
|
||||||
latest_sample: Arc<Mutex<Option<PressureSample>>>,
|
latest_sample: Arc<Mutex<Option<PressureSample>>>,
|
||||||
|
stats: Arc<Mutex<SerialIoStats>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectionManager {
|
impl ConnectionManager {
|
||||||
@@ -42,6 +49,7 @@ impl ConnectionManager {
|
|||||||
state: Arc::new(Mutex::new(ConnectionState::Disconnected)),
|
state: Arc::new(Mutex::new(ConnectionState::Disconnected)),
|
||||||
session: Arc::new(Mutex::new(None)),
|
session: Arc::new(Mutex::new(None)),
|
||||||
latest_sample: Arc::new(Mutex::new(None)),
|
latest_sample: Arc::new(Mutex::new(None)),
|
||||||
|
stats: Arc::new(Mutex::new(SerialIoStats::default())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +61,31 @@ impl ConnectionManager {
|
|||||||
*self.state.lock().unwrap() = new_state;
|
*self.state.lock().unwrap() = new_state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn stats(&self) -> SerialIoStats {
|
||||||
|
let session = self.session.lock().unwrap();
|
||||||
|
if let Some(ref session) = *session {
|
||||||
|
loop {
|
||||||
|
match session.stats_rx.try_recv() {
|
||||||
|
Ok(stats) => *self.stats.lock().unwrap() = stats,
|
||||||
|
Err(TryRecvError::Empty) => break,
|
||||||
|
Err(TryRecvError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.stats.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to the given serial port and start streaming in a background thread.
|
/// Connect to the given serial port and start streaming in a background thread.
|
||||||
pub fn connect(&self, port_name: &str, rows: u32, cols: u32) {
|
pub fn connect(
|
||||||
|
&self,
|
||||||
|
port_name: &str,
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
|
baud_rate: u32,
|
||||||
|
protocol: SerialProtocol,
|
||||||
|
recorder: Recorder,
|
||||||
|
) {
|
||||||
self.disconnect();
|
self.disconnect();
|
||||||
self.set_state(ConnectionState::Connecting);
|
self.set_state(ConnectionState::Connecting);
|
||||||
|
|
||||||
@@ -64,16 +95,22 @@ impl ConnectionManager {
|
|||||||
let latest_sample = Arc::clone(&self.latest_sample);
|
let latest_sample = Arc::clone(&self.latest_sample);
|
||||||
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1);
|
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1);
|
||||||
let (sample_tx, sample_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
|
let (sample_tx, sample_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
|
||||||
|
let (stats_tx, stats_rx) = crossbeam_channel::bounded::<SerialIoStats>(16);
|
||||||
|
*self.stats.lock().unwrap() = SerialIoStats::default();
|
||||||
|
|
||||||
let handle = thread::spawn(move || {
|
let handle = thread::spawn(move || {
|
||||||
let result = run_device_loop(
|
let result = run_device_loop(
|
||||||
&port,
|
&port,
|
||||||
rows,
|
rows,
|
||||||
cols,
|
cols,
|
||||||
|
baud_rate,
|
||||||
|
protocol,
|
||||||
&state,
|
&state,
|
||||||
&cancel_rx,
|
&cancel_rx,
|
||||||
&sample_tx,
|
&sample_tx,
|
||||||
|
&stats_tx,
|
||||||
&latest_sample,
|
&latest_sample,
|
||||||
|
recorder,
|
||||||
);
|
);
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
eprintln!("[connection] device loop error: {e}");
|
eprintln!("[connection] device loop error: {e}");
|
||||||
@@ -85,6 +122,9 @@ impl ConnectionManager {
|
|||||||
cancel_tx,
|
cancel_tx,
|
||||||
handle,
|
handle,
|
||||||
sample_rx,
|
sample_rx,
|
||||||
|
stats_rx,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +142,7 @@ impl ConnectionManager {
|
|||||||
|
|
||||||
self.set_state(ConnectionState::Disconnected);
|
self.set_state(ConnectionState::Disconnected);
|
||||||
*self.latest_sample.lock().unwrap() = None;
|
*self.latest_sample.lock().unwrap() = None;
|
||||||
|
*self.stats.lock().unwrap() = SerialIoStats::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain pending samples (non-blocking) and return the last one.
|
/// Drain pending samples (non-blocking) and return the last one.
|
||||||
@@ -112,10 +153,12 @@ impl ConnectionManager {
|
|||||||
loop {
|
loop {
|
||||||
match session.sample_rx.try_recv() {
|
match session.sample_rx.try_recv() {
|
||||||
Ok(vals) => {
|
Ok(vals) => {
|
||||||
let rows = 12u32;
|
|
||||||
let cols = 7u32;
|
|
||||||
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
|
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
|
||||||
last = Some(PressureSample { matrix, rows, cols });
|
last = Some(PressureSample {
|
||||||
|
matrix,
|
||||||
|
rows: session.rows,
|
||||||
|
cols: session.cols,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(TryRecvError::Empty) => break,
|
Err(TryRecvError::Empty) => break,
|
||||||
Err(TryRecvError::Disconnected) => break,
|
Err(TryRecvError::Disconnected) => break,
|
||||||
@@ -140,13 +183,17 @@ fn run_device_loop(
|
|||||||
port_name: &str,
|
port_name: &str,
|
||||||
rows: u32,
|
rows: u32,
|
||||||
cols: u32,
|
cols: u32,
|
||||||
|
baud_rate: u32,
|
||||||
|
protocol: SerialProtocol,
|
||||||
state: &Arc<Mutex<ConnectionState>>,
|
state: &Arc<Mutex<ConnectionState>>,
|
||||||
cancel_rx: &Receiver<()>,
|
cancel_rx: &Receiver<()>,
|
||||||
sample_tx: &Sender<Vec<i32>>,
|
sample_tx: &Sender<Vec<i32>>,
|
||||||
|
stats_tx: &Sender<SerialIoStats>,
|
||||||
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
|
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
|
||||||
|
recorder: Recorder,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let port = serialport::new(port_name, 921_600)
|
let port = serialport::new(port_name, baud_rate)
|
||||||
.timeout(Duration::from_millis(100))
|
.timeout(Duration::from_millis(1))
|
||||||
.open()?;
|
.open()?;
|
||||||
|
|
||||||
*state.lock().unwrap() = ConnectionState::Connected;
|
*state.lock().unwrap() = ConnectionState::Connected;
|
||||||
@@ -154,36 +201,19 @@ fn run_device_loop(
|
|||||||
let mut rw = SerialPortReadWrite::new(port);
|
let mut rw = SerialPortReadWrite::new(port);
|
||||||
*state.lock().unwrap() = ConnectionState::Streaming;
|
*state.lock().unwrap() = ConnectionState::Streaming;
|
||||||
|
|
||||||
// We need to also forward samples to latest_sample
|
run_serial_loop(
|
||||||
let (inner_tx, inner_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
|
&mut rw,
|
||||||
let latest = Arc::clone(latest_sample);
|
rows as usize,
|
||||||
let outer_tx = sample_tx.clone();
|
cols as usize,
|
||||||
|
protocol,
|
||||||
|
cancel_rx,
|
||||||
|
sample_tx,
|
||||||
|
Some(stats_tx),
|
||||||
|
Some(&recorder),
|
||||||
|
);
|
||||||
|
|
||||||
// Bridge thread: reads from inner channel, forwards to both sample_tx and latest_sample
|
if let Ok(mut latest) = latest_sample.lock() {
|
||||||
let bridge_cancel = cancel_rx.clone();
|
*latest = None;
|
||||||
let bridge_handle = thread::spawn(move || {
|
}
|
||||||
loop {
|
|
||||||
if bridge_cancel.try_recv().is_ok() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match inner_rx.try_recv() {
|
|
||||||
Ok(vals) => {
|
|
||||||
// Store latest
|
|
||||||
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
|
|
||||||
*latest.lock().unwrap() = Some(PressureSample { matrix, rows, cols });
|
|
||||||
// Forward
|
|
||||||
let _ = outer_tx.try_send(vals);
|
|
||||||
}
|
|
||||||
Err(TryRecvError::Empty) => {
|
|
||||||
std::thread::sleep(Duration::from_millis(1));
|
|
||||||
}
|
|
||||||
Err(TryRecvError::Disconnected) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
run_serial_loop(&mut rw, rows as usize, cols as usize, cancel_rx, &inner_tx);
|
|
||||||
|
|
||||||
let _ = bridge_handle.join();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/force.rs
Normal file
49
src/force.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use crate::serial_core::multi_dim_force::PztProcessor;
|
||||||
|
|
||||||
|
const FINGER_SAMPLE_COUNT: usize = 84;
|
||||||
|
const MIN_TANGENTIAL_MAGNITUDE: f32 = 0.02;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct HudSpatialForce {
|
||||||
|
pub angle_deg: f32,
|
||||||
|
pub magnitude: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ForceEstimatorState {
|
||||||
|
pzt_processor: PztProcessor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForceEstimatorState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
pzt_processor: PztProcessor::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.pzt_processor.reset_baseline();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn analyze(&mut self, values: &[u32]) -> Option<HudSpatialForce> {
|
||||||
|
if values.len() != FINGER_SAMPLE_COUNT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pzt_values = values.iter().map(|value| *value as f32).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
self.pzt_processor
|
||||||
|
.get_pzt_analysis(&pzt_values)
|
||||||
|
.ok()
|
||||||
|
.filter(|analysis| analysis.magnitude > MIN_TANGENTIAL_MAGNITUDE)
|
||||||
|
.map(|analysis| HudSpatialForce {
|
||||||
|
angle_deg: analysis.angle_deg,
|
||||||
|
magnitude: analysis.magnitude,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ForceEstimatorState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
10
src/main.rs
10
src/main.rs
@@ -1,9 +1,17 @@
|
|||||||
|
#![allow(dead_code)]
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
mod app;
|
mod app;
|
||||||
|
mod breakout;
|
||||||
mod connection;
|
mod connection;
|
||||||
|
mod force;
|
||||||
mod matrix;
|
mod matrix;
|
||||||
|
mod model;
|
||||||
|
mod recording;
|
||||||
mod render;
|
mod render;
|
||||||
|
mod resources;
|
||||||
mod serial_core;
|
mod serial_core;
|
||||||
mod theme;
|
mod style;
|
||||||
|
mod texture;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod utils;
|
mod utils;
|
||||||
use app::EskinDesktopApp;
|
use app::EskinDesktopApp;
|
||||||
|
|||||||
195
src/model.rs
Normal file
195
src/model.rs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
use eframe::egui_wgpu::wgpu;
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use crate::texture;
|
||||||
|
|
||||||
|
pub trait Vertex {
|
||||||
|
fn desc<'a>() -> wgpu::VertexBufferLayout<'a>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
pub struct ModelVertex {
|
||||||
|
pub position: [f32; 3],
|
||||||
|
pub tex_coords: [f32; 2],
|
||||||
|
pub normal: [f32; 3],
|
||||||
|
pub color: [f32; 4],
|
||||||
|
pub tangent: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vertex for ModelVertex {
|
||||||
|
fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
|
||||||
|
use core::mem;
|
||||||
|
wgpu::VertexBufferLayout {
|
||||||
|
array_stride: mem::size_of::<ModelVertex>() as wgpu::BufferAddress,
|
||||||
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
|
attributes: &[
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: 0,
|
||||||
|
shader_location: 0,
|
||||||
|
format: wgpu::VertexFormat::Float32x3,
|
||||||
|
},
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
|
||||||
|
shader_location: 1,
|
||||||
|
format: wgpu::VertexFormat::Float32x2,
|
||||||
|
},
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: mem::size_of::<[f32; 5]>() as wgpu::BufferAddress,
|
||||||
|
shader_location: 2,
|
||||||
|
format: wgpu::VertexFormat::Float32x3,
|
||||||
|
},
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: mem::size_of::<[f32; 8]>() as wgpu::BufferAddress,
|
||||||
|
shader_location: 3,
|
||||||
|
format: wgpu::VertexFormat::Float32x4,
|
||||||
|
},
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: mem::size_of::<[f32; 12]>() as wgpu::BufferAddress,
|
||||||
|
shader_location: 4,
|
||||||
|
format: wgpu::VertexFormat::Float32x4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Instance {
|
||||||
|
position: glam::Vec3,
|
||||||
|
rotation: glam::Quat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Instance {
|
||||||
|
pub fn new(position: glam::Vec3, rotation: glam::Quat) -> Self {
|
||||||
|
Self { position, rotation }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_raw(&self) -> InstanceRaw {
|
||||||
|
InstanceRaw {
|
||||||
|
model: (glam::Mat4::from_translation(self.position)
|
||||||
|
* glam::Mat4::from_quat(self.rotation)
|
||||||
|
* glam::Mat4::from_scale(glam::Vec3::splat(128.0)))
|
||||||
|
.to_cols_array_2d(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
pub struct InstanceRaw {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
model: [[f32; 4]; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstanceRaw {
|
||||||
|
pub fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
|
||||||
|
use core::mem;
|
||||||
|
wgpu::VertexBufferLayout {
|
||||||
|
array_stride: mem::size_of::<InstanceRaw>() as wgpu::BufferAddress,
|
||||||
|
// We need to switch from using a step mode of Vertex to Instance
|
||||||
|
// This means that our shaders will only change to use the next
|
||||||
|
// instance when the shader starts processing a new instance
|
||||||
|
step_mode: wgpu::VertexStepMode::Instance,
|
||||||
|
attributes: &[
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: 0,
|
||||||
|
// While our vertex shader only uses locations 0, and 1 now, in later tutorials we'll
|
||||||
|
// be using 2, 3, and 4, for Vertex. We'll start at slot 5 not conflict with them later
|
||||||
|
shader_location: 5,
|
||||||
|
format: wgpu::VertexFormat::Float32x4,
|
||||||
|
},
|
||||||
|
// A mat4 takes up 4 vertex slots as it is technically 4 vec4s. We need to define a slot
|
||||||
|
// for each vec4. We don't have to do this in code though.
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: mem::size_of::<[f32; 4]>() as wgpu::BufferAddress,
|
||||||
|
shader_location: 6,
|
||||||
|
format: wgpu::VertexFormat::Float32x4,
|
||||||
|
},
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: mem::size_of::<[f32; 8]>() as wgpu::BufferAddress,
|
||||||
|
shader_location: 7,
|
||||||
|
format: wgpu::VertexFormat::Float32x4,
|
||||||
|
},
|
||||||
|
wgpu::VertexAttribute {
|
||||||
|
offset: mem::size_of::<[f32; 12]>() as wgpu::BufferAddress,
|
||||||
|
shader_location: 8,
|
||||||
|
format: wgpu::VertexFormat::Float32x4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Model {
|
||||||
|
pub meshes: Vec<Mesh>,
|
||||||
|
pub materials: Vec<Material>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AlphaMode {
|
||||||
|
Opaque,
|
||||||
|
Mask,
|
||||||
|
Blend,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlphaMode {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Opaque => "OPAQUE",
|
||||||
|
Self::Mask => "MASK",
|
||||||
|
Self::Blend => "BLEND",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shader_value(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Opaque => 0.0,
|
||||||
|
Self::Mask => 1.0,
|
||||||
|
Self::Blend => 2.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Material {
|
||||||
|
pub name: String,
|
||||||
|
pub alpha_mode: AlphaMode,
|
||||||
|
pub alpha_cutoff: f32,
|
||||||
|
pub base_color_factor: [f32; 4],
|
||||||
|
pub double_sided: bool,
|
||||||
|
pub base_color_texture: texture::Texture,
|
||||||
|
pub metallic_roughness_texture: texture::Texture,
|
||||||
|
pub normal_texture: texture::Texture,
|
||||||
|
pub occlusion_texture: texture::Texture,
|
||||||
|
pub emissive_texture: texture::Texture,
|
||||||
|
pub params_buffer: wgpu::Buffer,
|
||||||
|
pub bind_group: wgpu::BindGroup,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Mesh {
|
||||||
|
pub name: String,
|
||||||
|
pub vertex_buffer: wgpu::Buffer,
|
||||||
|
pub index_buffer: wgpu::Buffer,
|
||||||
|
pub num_elements: u32,
|
||||||
|
pub material: usize,
|
||||||
|
pub center: [f32; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait DrawModel<'a> {
|
||||||
|
fn draw_mesh(&mut self, mesh: &'a Mesh);
|
||||||
|
fn draw_mesh_instanced(&mut self, mesh: &'a Mesh, instances: Range<u32>);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> DrawModel<'b> for wgpu::RenderPass<'a>
|
||||||
|
where
|
||||||
|
'b: 'a,
|
||||||
|
{
|
||||||
|
fn draw_mesh(&mut self, mesh: &'b Mesh) {
|
||||||
|
self.draw_mesh_instanced(mesh, 0..1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_mesh_instanced(&mut self, mesh: &'b Mesh, instances: Range<u32>) {
|
||||||
|
self.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
|
||||||
|
self.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||||
|
self.draw_indexed(0..mesh.num_elements, 0, instances);
|
||||||
|
}
|
||||||
|
}
|
||||||
444
src/recording.rs
Normal file
444
src/recording.rs
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
//! Recording and CSV export for pressure sensor data.
|
||||||
|
//!
|
||||||
|
//! Provides two recording modes (Full and Snapshot), state management
|
||||||
|
//! (Idle / Recording / Paused), and CSV import/export for replay.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
// ── Public types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A single frame of pressure data captured at a point in time.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Frame {
|
||||||
|
/// Pressure values, one per channel (same order every frame).
|
||||||
|
pub pressures: Vec<u32>,
|
||||||
|
/// Milliseconds elapsed since the recording started.
|
||||||
|
pub timestamp_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the recording captures everything from connect (Full) or
|
||||||
|
/// only between explicit start/stop calls (Snapshot).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RecordingMode {
|
||||||
|
Full,
|
||||||
|
Snapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transient state of a recording.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RecordingState {
|
||||||
|
Idle,
|
||||||
|
Recording,
|
||||||
|
Paused,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inner (unlocked) recorder ───────────────────────────────────────────
|
||||||
|
|
||||||
|
struct RecorderInner {
|
||||||
|
mode: RecordingMode,
|
||||||
|
state: RecordingState,
|
||||||
|
frames: Vec<Frame>,
|
||||||
|
start: Option<Instant>,
|
||||||
|
/// Accumulated wall-clock ms while paused (subtracted from elapsed).
|
||||||
|
paused_duration_ms: u64,
|
||||||
|
/// Instant when we entered Paused (None when not paused).
|
||||||
|
pause_start: Option<Instant>,
|
||||||
|
/// Number of channels in the first frame (used for CSV header).
|
||||||
|
channel_count: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecorderInner {
|
||||||
|
fn new(mode: RecordingMode) -> Self {
|
||||||
|
Self {
|
||||||
|
mode,
|
||||||
|
state: RecordingState::Idle,
|
||||||
|
frames: Vec::new(),
|
||||||
|
start: None,
|
||||||
|
paused_duration_ms: 0,
|
||||||
|
pause_start: None,
|
||||||
|
channel_count: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn elapsed_ms(&self) -> u64 {
|
||||||
|
let Some(start) = self.start else { return 0 };
|
||||||
|
let raw = start.elapsed().as_millis() as u64;
|
||||||
|
let paused = if let Some(ps) = self.pause_start {
|
||||||
|
self.paused_duration_ms + ps.elapsed().as_millis() as u64
|
||||||
|
} else {
|
||||||
|
self.paused_duration_ms
|
||||||
|
};
|
||||||
|
raw.saturating_sub(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_frame(&mut self, pressures: Vec<u32>) {
|
||||||
|
if self.channel_count.is_none() {
|
||||||
|
self.channel_count = Some(pressures.len());
|
||||||
|
}
|
||||||
|
let ts = self.elapsed_ms();
|
||||||
|
self.frames.push(Frame {
|
||||||
|
pressures,
|
||||||
|
timestamp_ms: ts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thread-safe wrapper ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Thread-safe recorder. Clone the `Arc` to share across threads.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Recorder {
|
||||||
|
inner: Arc<Mutex<RecorderInner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Recorder {
|
||||||
|
/// Create a recorder in the given mode (starts in `Idle` state).
|
||||||
|
pub fn new(mode: RecordingMode) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(RecorderInner::new(mode))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Start a **Full** recording (records every frame pushed from now on).
|
||||||
|
pub fn start_full_recording(&self) -> Result<()> {
|
||||||
|
let mut r = self.inner.lock().unwrap();
|
||||||
|
anyhow::ensure!(
|
||||||
|
r.state == RecordingState::Idle,
|
||||||
|
"can only start from Idle state"
|
||||||
|
);
|
||||||
|
r.state = RecordingState::Recording;
|
||||||
|
r.start = Some(Instant::now());
|
||||||
|
r.paused_duration_ms = 0;
|
||||||
|
r.pause_start = None;
|
||||||
|
r.frames.clear();
|
||||||
|
r.channel_count = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a **Snapshot** recording window.
|
||||||
|
pub fn start_snapshot_recording(&self) -> Result<()> {
|
||||||
|
let mut r = self.inner.lock().unwrap();
|
||||||
|
anyhow::ensure!(
|
||||||
|
r.state == RecordingState::Idle,
|
||||||
|
"can only start from Idle state"
|
||||||
|
);
|
||||||
|
r.state = RecordingState::Recording;
|
||||||
|
r.start = Some(Instant::now());
|
||||||
|
r.paused_duration_ms = 0;
|
||||||
|
r.pause_start = None;
|
||||||
|
r.frames.clear();
|
||||||
|
r.channel_count = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop recording (transitions to `Idle`).
|
||||||
|
pub fn stop_recording(&self) -> Result<()> {
|
||||||
|
let mut r = self.inner.lock().unwrap();
|
||||||
|
anyhow::ensure!(
|
||||||
|
r.state == RecordingState::Recording || r.state == RecordingState::Paused,
|
||||||
|
"nothing to stop"
|
||||||
|
);
|
||||||
|
if r.state == RecordingState::Paused {
|
||||||
|
if let Some(ps) = r.pause_start.take() {
|
||||||
|
r.paused_duration_ms += ps.elapsed().as_millis() as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.state = RecordingState::Idle;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause an active recording.
|
||||||
|
pub fn pause_recording(&self) -> Result<()> {
|
||||||
|
let mut r = self.inner.lock().unwrap();
|
||||||
|
anyhow::ensure!(r.state == RecordingState::Recording, "not recording");
|
||||||
|
r.pause_start = Some(Instant::now());
|
||||||
|
r.state = RecordingState::Paused;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume a paused recording.
|
||||||
|
pub fn resume_recording(&self) -> Result<()> {
|
||||||
|
let mut r = self.inner.lock().unwrap();
|
||||||
|
anyhow::ensure!(r.state == RecordingState::Paused, "not paused");
|
||||||
|
if let Some(ps) = r.pause_start.take() {
|
||||||
|
r.paused_duration_ms += ps.elapsed().as_millis() as u64;
|
||||||
|
}
|
||||||
|
r.state = RecordingState::Recording;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Feed one frame of pressure data. Ignored when not recording.
|
||||||
|
pub fn add_frame(&self, pressures: &[u32]) {
|
||||||
|
let mut r = self.inner.lock().unwrap();
|
||||||
|
if r.state == RecordingState::Recording {
|
||||||
|
r.push_frame(pressures.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of recorded frames.
|
||||||
|
pub fn frame_count(&self) -> usize {
|
||||||
|
self.inner.lock().unwrap().frames.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the recorder is currently in the `Recording` state.
|
||||||
|
pub fn is_recording(&self) -> bool {
|
||||||
|
self.inner.lock().unwrap().state == RecordingState::Recording
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current recording state.
|
||||||
|
pub fn state(&self) -> RecordingState {
|
||||||
|
self.inner.lock().unwrap().state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds elapsed (excludes paused time).
|
||||||
|
pub fn duration_ms(&self) -> u64 {
|
||||||
|
self.inner.lock().unwrap().elapsed_ms()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of all recorded frames.
|
||||||
|
pub fn recorded_frames(&self) -> Vec<Frame> {
|
||||||
|
self.inner.lock().unwrap().frames.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSV export / import ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Export recorded frames to CSV.
|
||||||
|
///
|
||||||
|
/// Header: `channel1,channel2,...,channelN,timestamp_ms`
|
||||||
|
pub fn export_csv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||||
|
let r = self.inner.lock().unwrap();
|
||||||
|
anyhow::ensure!(!r.frames.is_empty(), "no frames to export");
|
||||||
|
|
||||||
|
let file = File::create(path.as_ref())
|
||||||
|
.with_context(|| format!("creating {}", path.as_ref().display()))?;
|
||||||
|
let mut w = BufWriter::new(file);
|
||||||
|
|
||||||
|
let n = r.channel_count.unwrap_or(r.frames[0].pressures.len());
|
||||||
|
|
||||||
|
// Header
|
||||||
|
for i in 0..n {
|
||||||
|
write!(w, "channel{}", i + 1)?;
|
||||||
|
if i < n - 1 {
|
||||||
|
write!(w, ",")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeln!(w, ",timestamp_ms")?;
|
||||||
|
|
||||||
|
// Rows
|
||||||
|
for frame in &r.frames {
|
||||||
|
for (i, val) in frame.pressures_iter().enumerate() {
|
||||||
|
write!(w, "{}", val)?;
|
||||||
|
if i < n - 1 {
|
||||||
|
write!(w, ",")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeln!(w, ",{}", frame.timestamp_ms)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
w.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import frames from a CSV file (same format as `export_csv`).
|
||||||
|
///
|
||||||
|
/// The recorder must be in `Idle` state. After import it stays `Idle`
|
||||||
|
/// so you can inspect / re-export; call `start_*` to continue recording.
|
||||||
|
pub fn import_csv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||||
|
let mut r = self.inner.lock().unwrap();
|
||||||
|
anyhow::ensure!(r.state == RecordingState::Idle, "must be Idle to import");
|
||||||
|
|
||||||
|
let file = File::open(path.as_ref())
|
||||||
|
.with_context(|| format!("opening {}", path.as_ref().display()))?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
|
||||||
|
// Parse header to learn channel count
|
||||||
|
let header = lines
|
||||||
|
.next()
|
||||||
|
.context("empty CSV file")?
|
||||||
|
.context("reading header")?;
|
||||||
|
let cols: Vec<&str> = header.split(',').map(str::trim).collect();
|
||||||
|
anyhow::ensure!(cols.len() >= 2, "need at least 1 channel + timestamp_ms");
|
||||||
|
let n_channels = cols.len() - 1; // last column is timestamp_ms
|
||||||
|
|
||||||
|
r.frames.clear();
|
||||||
|
r.channel_count = Some(n_channels);
|
||||||
|
|
||||||
|
let mut first_ts: Option<u64> = None;
|
||||||
|
|
||||||
|
for (lineno, line) in lines.enumerate() {
|
||||||
|
let line = line.with_context(|| format!("reading line {}", lineno + 2))?;
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let fields: Vec<&str> = line.split(',').map(str::trim).collect();
|
||||||
|
anyhow::ensure!(
|
||||||
|
fields.len() == cols.len(),
|
||||||
|
"line {}: expected {} columns, got {}",
|
||||||
|
lineno + 2,
|
||||||
|
cols.len(),
|
||||||
|
fields.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut pressures = Vec::with_capacity(n_channels);
|
||||||
|
for f in &fields[..n_channels] {
|
||||||
|
pressures.push(
|
||||||
|
f.parse::<u32>().with_context(|| {
|
||||||
|
format!("line {}: bad channel value '{}'", lineno + 2, f)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let raw_ts = fields[n_channels]
|
||||||
|
.parse::<u64>()
|
||||||
|
.with_context(|| format!("line {}: bad timestamp", lineno + 2))?;
|
||||||
|
|
||||||
|
// Normalise timestamps so the first frame starts at 0
|
||||||
|
let ts = if let Some(first) = first_ts {
|
||||||
|
raw_ts.saturating_sub(first)
|
||||||
|
} else {
|
||||||
|
first_ts = Some(raw_ts);
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
r.frames.push(Frame {
|
||||||
|
pressures,
|
||||||
|
timestamp_ms: ts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the start instant so duration_ms() reports the imported span
|
||||||
|
if !r.frames.is_empty() {
|
||||||
|
if let Some(last) = r.frames.last() {
|
||||||
|
// Pretend the recording happened `last.timestamp_ms` ago
|
||||||
|
// so that elapsed_ms() would return that value.
|
||||||
|
// We store a "fake" start by noting the offset.
|
||||||
|
r.start =
|
||||||
|
Some(Instant::now() - std::time::Duration::from_millis(last.timestamp_ms));
|
||||||
|
r.paused_duration_ms = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convenience constructors ────────────────────────────────────────────
|
||||||
|
|
||||||
|
impl Recorder {
|
||||||
|
/// Shorthand: new Full recorder.
|
||||||
|
pub fn full() -> Self {
|
||||||
|
Self::new(RecordingMode::Full)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand: new Snapshot recorder.
|
||||||
|
pub fn snapshot() -> Self {
|
||||||
|
Self::new(RecordingMode::Snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper extension on Frame ───────────────────────────────────────────
|
||||||
|
|
||||||
|
impl Frame {
|
||||||
|
fn pressures_iter(&self) -> impl Iterator<Item = u32> + '_ {
|
||||||
|
self.pressures.iter().copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_recording_lifecycle() {
|
||||||
|
let rec = Recorder::full();
|
||||||
|
assert_eq!(rec.state(), RecordingState::Idle);
|
||||||
|
assert!(!rec.is_recording());
|
||||||
|
assert_eq!(rec.frame_count(), 0);
|
||||||
|
|
||||||
|
rec.start_full_recording().unwrap();
|
||||||
|
assert!(rec.is_recording());
|
||||||
|
|
||||||
|
rec.add_frame(&[10, 20, 30]);
|
||||||
|
rec.add_frame(&[40, 50, 60]);
|
||||||
|
assert_eq!(rec.frame_count(), 2);
|
||||||
|
|
||||||
|
rec.stop_recording().unwrap();
|
||||||
|
assert_eq!(rec.state(), RecordingState::Idle);
|
||||||
|
assert!(!rec.is_recording());
|
||||||
|
assert!(rec.duration_ms() < 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_pause_resume() {
|
||||||
|
let rec = Recorder::snapshot();
|
||||||
|
rec.start_snapshot_recording().unwrap();
|
||||||
|
|
||||||
|
rec.add_frame(&[1, 2]);
|
||||||
|
rec.pause_recording().unwrap();
|
||||||
|
assert_eq!(rec.state(), RecordingState::Paused);
|
||||||
|
rec.add_frame(&[9, 9]); // should be ignored
|
||||||
|
assert_eq!(rec.frame_count(), 1);
|
||||||
|
|
||||||
|
rec.resume_recording().unwrap();
|
||||||
|
rec.add_frame(&[3, 4]);
|
||||||
|
assert_eq!(rec.frame_count(), 2);
|
||||||
|
|
||||||
|
rec.stop_recording().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_round_trip() {
|
||||||
|
let rec = Recorder::full();
|
||||||
|
rec.start_full_recording().unwrap();
|
||||||
|
rec.add_frame(&[100, 200, 300]);
|
||||||
|
rec.add_frame(&[101, 201, 301]);
|
||||||
|
rec.stop_recording().unwrap();
|
||||||
|
|
||||||
|
let dir = std::env::temp_dir().join("eskin_recording_test");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let csv_path = dir.join("test_roundtrip.csv");
|
||||||
|
rec.export_csv(&csv_path).unwrap();
|
||||||
|
|
||||||
|
let rec2 = Recorder::snapshot();
|
||||||
|
rec2.import_csv(&csv_path).unwrap();
|
||||||
|
assert_eq!(rec2.frame_count(), 2);
|
||||||
|
|
||||||
|
let frames = rec2.recorded_frames();
|
||||||
|
assert_eq!(frames[0].pressures, vec![100, 200, 300]);
|
||||||
|
assert_eq!(frames[1].pressures, vec![101, 201, 301]);
|
||||||
|
assert_eq!(frames[0].timestamp_ms, 0);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thread_safety() {
|
||||||
|
let rec = Recorder::full();
|
||||||
|
let rec2 = rec.clone();
|
||||||
|
|
||||||
|
rec.start_full_recording().unwrap();
|
||||||
|
|
||||||
|
let h = thread::spawn(move || {
|
||||||
|
for i in 0..100u32 {
|
||||||
|
rec2.add_frame(&[i, i + 1, i + 2]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
h.join().unwrap();
|
||||||
|
|
||||||
|
rec.stop_recording().unwrap();
|
||||||
|
assert_eq!(rec.frame_count(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
1073
src/render.rs
1073
src/render.rs
File diff suppressed because it is too large
Load Diff
1007
src/resources.rs
Normal file
1007
src/resources.rs
Normal file
File diff suppressed because it is too large
Load Diff
404
src/serial_core/codecs/hand_gateway.rs
Normal file
404
src/serial_core/codecs/hand_gateway.rs
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
use crate::serial_core::codec::Codec;
|
||||||
|
use crate::serial_core::error::CodecError;
|
||||||
|
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
const FRAME_HEADER: [u8; 2] = [0x55, 0xAA];
|
||||||
|
const RESPONSE_DATA_CMD: u8 = 0x81;
|
||||||
|
const RESPONSE_DATA_LEGACY_FIXED_LENGTH: usize = 16;
|
||||||
|
const RESPONSE_DATA_LEGACY_PREFIX_LENGTH: usize = 19;
|
||||||
|
const RESPONSE_DATA_TEMP_FIXED_LENGTH: usize = 20;
|
||||||
|
const RESPONSE_DATA_TEMP_PREFIX_LENGTH: usize = 23;
|
||||||
|
const MIN_FRAME_LENGTH: usize = 7;
|
||||||
|
const DEFAULT_MAX_FRAME_LENGTH: usize = 64 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct HandGatewayResponseLayout {
|
||||||
|
fixed_length: usize,
|
||||||
|
prefix_length: usize,
|
||||||
|
timestamp_len: usize,
|
||||||
|
config_offset: usize,
|
||||||
|
valid_config_offset: usize,
|
||||||
|
block_count_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMP_RESPONSE_LAYOUT: HandGatewayResponseLayout = HandGatewayResponseLayout {
|
||||||
|
fixed_length: RESPONSE_DATA_TEMP_FIXED_LENGTH,
|
||||||
|
prefix_length: RESPONSE_DATA_TEMP_PREFIX_LENGTH,
|
||||||
|
timestamp_len: 8,
|
||||||
|
config_offset: 14,
|
||||||
|
valid_config_offset: 18,
|
||||||
|
block_count_offset: 22,
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_RESPONSE_LAYOUT: HandGatewayResponseLayout = HandGatewayResponseLayout {
|
||||||
|
fixed_length: RESPONSE_DATA_LEGACY_FIXED_LENGTH,
|
||||||
|
prefix_length: RESPONSE_DATA_LEGACY_PREFIX_LENGTH,
|
||||||
|
timestamp_len: 4,
|
||||||
|
config_offset: 10,
|
||||||
|
valid_config_offset: 14,
|
||||||
|
block_count_offset: 18,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HandGatewayNodeConfig {
|
||||||
|
pub config_mask: u32,
|
||||||
|
pub sample_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HandGatewayNodeConfig {
|
||||||
|
pub const fn new(config_mask: u32, sample_count: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
config_mask,
|
||||||
|
sample_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_len(&self, bytes_per_sample: usize) -> Option<usize> {
|
||||||
|
self.sample_count.checked_mul(bytes_per_sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HandGatewayConfig {
|
||||||
|
pub protocol_version: u8,
|
||||||
|
pub bytes_per_sample: usize,
|
||||||
|
pub max_frame_length: usize,
|
||||||
|
pub nodes: Vec<HandGatewayNodeConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HandGatewayConfig {
|
||||||
|
pub fn new(nodes: Vec<HandGatewayNodeConfig>) -> Self {
|
||||||
|
Self {
|
||||||
|
protocol_version: 0x01,
|
||||||
|
bytes_per_sample: 2,
|
||||||
|
max_frame_length: DEFAULT_MAX_FRAME_LENGTH,
|
||||||
|
nodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), CodecError> {
|
||||||
|
if self.bytes_per_sample == 0 || self.max_frame_length < MIN_FRAME_LENGTH {
|
||||||
|
return Err(CodecError::InvalidLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut used_masks = 0u32;
|
||||||
|
for node in &self.nodes {
|
||||||
|
if node.config_mask == 0
|
||||||
|
|| node.config_mask.count_ones() != 1
|
||||||
|
|| used_masks & node.config_mask != 0
|
||||||
|
|| node.payload_len(self.bytes_per_sample).is_none()
|
||||||
|
{
|
||||||
|
return Err(CodecError::InvalidLength);
|
||||||
|
}
|
||||||
|
used_masks |= node.config_mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HandGatewayFrameNode {
|
||||||
|
pub config_mask: u32,
|
||||||
|
pub valid: bool,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HandGatewayDataRepFrame {
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub config: u32,
|
||||||
|
pub valid_config: u32,
|
||||||
|
pub block_count: u8,
|
||||||
|
pub raw: Vec<u8>,
|
||||||
|
pub nodes: Vec<HandGatewayFrameNode>,
|
||||||
|
pub dts_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum HandGatewayFrame {
|
||||||
|
DataRep(HandGatewayDataRepFrame),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HandGatewayCodec {
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
config: HandGatewayConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HandGatewayCodec {
|
||||||
|
pub fn new(node_sample_counts: &[u16]) -> Self {
|
||||||
|
let nodes = node_sample_counts
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, &sample_count)| {
|
||||||
|
HandGatewayNodeConfig::new(
|
||||||
|
1u32.checked_shl(index as u32).unwrap_or(0),
|
||||||
|
sample_count as usize,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
buffer: Vec::new(),
|
||||||
|
config: HandGatewayConfig::new(nodes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_node_payload(data: &[u8]) -> Result<Vec<u16>, CodecError> {
|
||||||
|
if data.len() % 2 != 0 {
|
||||||
|
return Err(CodecError::InvalidLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(data
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retain_possible_header_prefix(&mut self) {
|
||||||
|
if self.buffer.last() == Some(&FRAME_HEADER[0]) {
|
||||||
|
self.buffer.drain(..self.buffer.len() - 1);
|
||||||
|
} else {
|
||||||
|
self.buffer.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_data_response_with_layout(
|
||||||
|
&self,
|
||||||
|
frame: &[u8],
|
||||||
|
length: u16,
|
||||||
|
session_started_at: Instant,
|
||||||
|
layout: HandGatewayResponseLayout,
|
||||||
|
) -> Result<HandGatewayFrame, CodecError> {
|
||||||
|
let payload_len = usize::from(length)
|
||||||
|
.checked_sub(layout.fixed_length)
|
||||||
|
.ok_or(CodecError::InvalidLength)?;
|
||||||
|
let payload_end = layout
|
||||||
|
.prefix_length
|
||||||
|
.checked_add(payload_len)
|
||||||
|
.ok_or(CodecError::PayloadTooLarge)?;
|
||||||
|
|
||||||
|
if payload_end + 1 != frame.len() {
|
||||||
|
return Err(CodecError::InvalidLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = frame[5];
|
||||||
|
if version != self.config.protocol_version {
|
||||||
|
return Err(CodecError::InvalidFrameType);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = match layout.timestamp_len {
|
||||||
|
4 => u32::from_le_bytes(frame[6..10].try_into().unwrap()) as u64,
|
||||||
|
8 => u64::from_le_bytes(frame[6..14].try_into().unwrap()),
|
||||||
|
_ => return Err(CodecError::InvalidLength),
|
||||||
|
};
|
||||||
|
let config = u32::from_le_bytes(
|
||||||
|
frame[layout.config_offset..layout.config_offset + 4]
|
||||||
|
.try_into()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let valid_config = u32::from_le_bytes(
|
||||||
|
frame[layout.valid_config_offset..layout.valid_config_offset + 4]
|
||||||
|
.try_into()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let block_count = frame[layout.block_count_offset];
|
||||||
|
|
||||||
|
let active_nodes = self
|
||||||
|
.config
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|node| config & node.config_mask != 0)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if active_nodes.len() != usize::from(block_count) {
|
||||||
|
return Err(CodecError::InvalidLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_payload_len = active_nodes.iter().try_fold(0usize, |total, node| {
|
||||||
|
let node_len = node
|
||||||
|
.payload_len(self.config.bytes_per_sample)
|
||||||
|
.ok_or(CodecError::PayloadTooLarge)?;
|
||||||
|
total
|
||||||
|
.checked_add(node_len)
|
||||||
|
.ok_or(CodecError::PayloadTooLarge)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if expected_payload_len != payload_len {
|
||||||
|
return Err(CodecError::InvalidLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cursor = layout.prefix_length;
|
||||||
|
let mut nodes = Vec::with_capacity(active_nodes.len());
|
||||||
|
for node in active_nodes {
|
||||||
|
let node_len = node
|
||||||
|
.payload_len(self.config.bytes_per_sample)
|
||||||
|
.ok_or(CodecError::PayloadTooLarge)?;
|
||||||
|
let next = cursor + node_len;
|
||||||
|
nodes.push(HandGatewayFrameNode {
|
||||||
|
config_mask: node.config_mask,
|
||||||
|
valid: valid_config & node.config_mask != 0,
|
||||||
|
payload: frame[cursor..next].to_vec(),
|
||||||
|
});
|
||||||
|
cursor = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HandGatewayFrame::DataRep(HandGatewayDataRepFrame {
|
||||||
|
timestamp,
|
||||||
|
config,
|
||||||
|
valid_config,
|
||||||
|
block_count,
|
||||||
|
raw: frame.to_vec(),
|
||||||
|
nodes,
|
||||||
|
dts_ms: elapsed_millis(session_started_at),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_data_response(
|
||||||
|
&self,
|
||||||
|
frame: &[u8],
|
||||||
|
length: u16,
|
||||||
|
session_started_at: Instant,
|
||||||
|
) -> Result<HandGatewayFrame, CodecError> {
|
||||||
|
self.parse_data_response_with_layout(
|
||||||
|
frame,
|
||||||
|
length,
|
||||||
|
session_started_at,
|
||||||
|
TEMP_RESPONSE_LAYOUT,
|
||||||
|
)
|
||||||
|
.or_else(|_| {
|
||||||
|
self.parse_data_response_with_layout(
|
||||||
|
frame,
|
||||||
|
length,
|
||||||
|
session_started_at,
|
||||||
|
LEGACY_RESPONSE_LAYOUT,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Codec<HandGatewayFrame> for HandGatewayCodec {
|
||||||
|
fn decode(
|
||||||
|
&mut self,
|
||||||
|
input: &[u8],
|
||||||
|
session_started_at: Instant,
|
||||||
|
) -> Result<Vec<HandGatewayFrame>, CodecError> {
|
||||||
|
self.config.validate()?;
|
||||||
|
self.buffer.extend_from_slice(input);
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(header_pos) = self
|
||||||
|
.buffer
|
||||||
|
.windows(FRAME_HEADER.len())
|
||||||
|
.position(|window| window == FRAME_HEADER)
|
||||||
|
else {
|
||||||
|
self.retain_possible_header_prefix();
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
if header_pos > 0 {
|
||||||
|
self.buffer.drain(..header_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.buffer.len() < 4 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let length = u16::from_le_bytes([self.buffer[2], self.buffer[3]]);
|
||||||
|
let frame_len = usize::from(length)
|
||||||
|
.checked_add(4)
|
||||||
|
.ok_or(CodecError::PayloadTooLarge)?;
|
||||||
|
|
||||||
|
if frame_len < MIN_FRAME_LENGTH || frame_len > self.config.max_frame_length {
|
||||||
|
log::debug!("invalid hand gateway frame length: {frame_len}");
|
||||||
|
self.buffer.drain(..1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.buffer.len() < frame_len {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_checksum = calc_crc8_itu(&self.buffer[..frame_len - 1]);
|
||||||
|
let received_checksum = self.buffer[frame_len - 1];
|
||||||
|
if expected_checksum != received_checksum {
|
||||||
|
log::debug!(
|
||||||
|
"hand gateway checksum mismatch: expected {expected_checksum:02X}, got {received_checksum:02X}"
|
||||||
|
);
|
||||||
|
self.buffer.drain(..1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.buffer[4] == RESPONSE_DATA_CMD {
|
||||||
|
match self.parse_data_response(
|
||||||
|
&self.buffer[..frame_len],
|
||||||
|
length,
|
||||||
|
session_started_at,
|
||||||
|
) {
|
||||||
|
Ok(frame) => frames.push(frame),
|
||||||
|
Err(error) => log::debug!("invalid hand gateway data response: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.buffer.drain(..frame_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(&self, frame: &HandGatewayFrame) -> Result<Vec<u8>, CodecError> {
|
||||||
|
let _ = frame;
|
||||||
|
Err(CodecError::InvalidFrameType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn data_response(config: u32, valid_config: u32, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let block_count = config.count_ones() as u8;
|
||||||
|
let length = (RESPONSE_DATA_LEGACY_FIXED_LENGTH + payload.len()) as u16;
|
||||||
|
let mut frame = Vec::new();
|
||||||
|
frame.extend_from_slice(&FRAME_HEADER);
|
||||||
|
frame.extend_from_slice(&length.to_le_bytes());
|
||||||
|
frame.push(RESPONSE_DATA_CMD);
|
||||||
|
frame.push(0x01);
|
||||||
|
frame.extend_from_slice(&0x0102_0304u32.to_le_bytes());
|
||||||
|
frame.extend_from_slice(&config.to_le_bytes());
|
||||||
|
frame.extend_from_slice(&valid_config.to_le_bytes());
|
||||||
|
frame.push(block_count);
|
||||||
|
frame.extend_from_slice(payload);
|
||||||
|
let checksum = calc_crc8_itu(&frame);
|
||||||
|
frame.push(checksum);
|
||||||
|
frame
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_data_response_frame() {
|
||||||
|
let payload = [0x10, 0x00, 0x34, 0x12, 0x08, 0x00];
|
||||||
|
let bytes = data_response(0b11, 0b01, &payload);
|
||||||
|
let frames = HandGatewayCodec::new(&[2, 1])
|
||||||
|
.decode(&bytes, Instant::now())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
let HandGatewayFrame::DataRep(frame) = &frames[0];
|
||||||
|
assert_eq!(frame.timestamp, 0x0102_0304);
|
||||||
|
assert_eq!(frame.config, 0b11);
|
||||||
|
assert_eq!(frame.valid_config, 0b01);
|
||||||
|
assert_eq!(frame.block_count, 2);
|
||||||
|
assert_eq!(frame.raw, bytes);
|
||||||
|
assert_eq!(frame.nodes.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
HandGatewayCodec::parse_node_payload(&frame.nodes[0].payload).unwrap(),
|
||||||
|
vec![16, 0x1234]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
HandGatewayCodec::parse_node_payload(&frame.nodes[1].payload).unwrap(),
|
||||||
|
vec![8]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
pub mod hand_gateway;
|
||||||
pub mod tactile_a;
|
pub mod tactile_a;
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ pub struct TactileACodec {
|
|||||||
expected_data_len: usize,
|
expected_data_len: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<u8> for TactileAFrameStatusCode {
|
||||||
|
fn from(value: u8) -> Self {
|
||||||
|
match value {
|
||||||
|
0 => TactileAFrameStatusCode::Success,
|
||||||
|
_ => TactileAFrameStatusCode::Failure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TactileACodec {
|
impl TactileACodec {
|
||||||
pub fn new(cols: usize, rows: usize) -> TactileACodec {
|
pub fn new(cols: usize, rows: usize) -> TactileACodec {
|
||||||
Self {
|
Self {
|
||||||
@@ -37,7 +46,7 @@ impl TactileACodec {
|
|||||||
Ok(vals)
|
Ok(vals)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_req_frame(cols: usize, rows: usize) -> TactileAFrame {
|
pub fn build_req_frame(cols: usize, rows: usize) -> anyhow::Result<TactileAFrame> {
|
||||||
let header = [0x55, 0xAA];
|
let header = [0x55, 0xAA];
|
||||||
let payload_len: usize = 9;
|
let payload_len: usize = 9;
|
||||||
let device_addr: u8 = 0x34;
|
let device_addr: u8 = 0x34;
|
||||||
@@ -46,7 +55,7 @@ impl TactileACodec {
|
|||||||
let start_addr: u32 = 7168;
|
let start_addr: u32 = 7168;
|
||||||
let except_data_len: usize = cols * rows * 2;
|
let except_data_len: usize = cols * rows * 2;
|
||||||
let checksum: u8 = 0;
|
let checksum: u8 = 0;
|
||||||
TactileAFrame::Req(TactileAReqFrame {
|
Ok(TactileAFrame::Req(TactileAReqFrame {
|
||||||
meta: TactileAFrameMetaData {
|
meta: TactileAFrameMetaData {
|
||||||
header,
|
header,
|
||||||
payload_len,
|
payload_len,
|
||||||
@@ -57,7 +66,7 @@ impl TactileACodec {
|
|||||||
except_data_len,
|
except_data_len,
|
||||||
checksum,
|
checksum,
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,10 +111,7 @@ impl Codec<TactileAFrame> for TactileACodec {
|
|||||||
self.buffer[10],
|
self.buffer[10],
|
||||||
]);
|
]);
|
||||||
let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize;
|
let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize;
|
||||||
let status = match self.buffer[13] {
|
let status = TactileAFrameStatusCode::from(self.buffer[13]);
|
||||||
0 => TactileAFrameStatusCode::Success,
|
|
||||||
_ => TactileAFrameStatusCode::Failure,
|
|
||||||
};
|
|
||||||
|
|
||||||
if except_data_len != self.expected_data_len {
|
if except_data_len != self.expected_data_len {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ pub enum SerialError {
|
|||||||
AlreadyConnected,
|
AlreadyConnected,
|
||||||
StateError,
|
StateError,
|
||||||
NoRecordedData,
|
NoRecordedData,
|
||||||
|
ExportError,
|
||||||
|
ImportError,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for SerialError {
|
impl fmt::Display for SerialError {
|
||||||
@@ -21,6 +23,8 @@ impl fmt::Display for SerialError {
|
|||||||
SerialError::AlreadyConnected => write!(f, "Already Connected"),
|
SerialError::AlreadyConnected => write!(f, "Already Connected"),
|
||||||
SerialError::StateError => write!(f, "State Error"),
|
SerialError::StateError => write!(f, "State Error"),
|
||||||
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
|
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
|
||||||
|
SerialError::ExportError => write!(f, "Export Error"),
|
||||||
|
SerialError::ImportError => write!(f, "Import Error"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TestFrame {
|
||||||
|
pub header: [u8; 2],
|
||||||
|
pub cmd: u8,
|
||||||
|
pub length: usize,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
pub checksum: u8,
|
||||||
|
pub dts_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TactileAFrameMetaData {
|
pub struct TactileAFrameMetaData {
|
||||||
pub header: [u8; 2],
|
pub header: [u8; 2],
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ pub mod codec;
|
|||||||
pub mod codecs;
|
pub mod codecs;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
|
pub mod multi_dim_force;
|
||||||
pub mod serial;
|
pub mod serial;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|||||||
234
src/serial_core/multi_dim_force.rs
Normal file
234
src/serial_core/multi_dim_force.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
const SENSOR_ROWS: usize = 12;
|
||||||
|
const SENSOR_COLS: usize = 7;
|
||||||
|
const SENSOR_COUNT: usize = SENSOR_ROWS * SENSOR_COLS;
|
||||||
|
|
||||||
|
const TOTAL_PRESSURE_LOW_THRESHOLD: f32 = 500.0;
|
||||||
|
const COP_STABILITY_FRAMES_REQUIRED: usize = 15;
|
||||||
|
|
||||||
|
const POST_INIT_WINDOW_CNT: usize = 100;
|
||||||
|
const POST_INIT_STABLE_CNT: usize = 50;
|
||||||
|
const POST_INIT_STABLE_THRESH: f32 = 0.1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct PztSpatialAnalysis {
|
||||||
|
pub angle_deg: f32,
|
||||||
|
pub magnitude: f32,
|
||||||
|
pub planar_x: f32,
|
||||||
|
pub planar_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PztProcessor {
|
||||||
|
first_frame: Option<Vec<f32>>,
|
||||||
|
|
||||||
|
first_contact_cop_x: Option<f32>,
|
||||||
|
first_contact_cop_y: Option<f32>,
|
||||||
|
contact_initialized: bool,
|
||||||
|
|
||||||
|
total_pressure_low_counter: usize,
|
||||||
|
|
||||||
|
cop_init_x_buf: Vec<f32>,
|
||||||
|
cop_init_y_buf: Vec<f32>,
|
||||||
|
|
||||||
|
post_init_frame_cnt: usize,
|
||||||
|
post_stable_cnt: usize,
|
||||||
|
post_refined_flag: bool,
|
||||||
|
post_cand_x: Option<f32>,
|
||||||
|
post_cand_y: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PztProcessor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
first_frame: None,
|
||||||
|
first_contact_cop_x: None,
|
||||||
|
first_contact_cop_y: None,
|
||||||
|
contact_initialized: false,
|
||||||
|
total_pressure_low_counter: 0,
|
||||||
|
cop_init_x_buf: Vec::with_capacity(COP_STABILITY_FRAMES_REQUIRED),
|
||||||
|
cop_init_y_buf: Vec::with_capacity(COP_STABILITY_FRAMES_REQUIRED),
|
||||||
|
post_init_frame_cnt: 0,
|
||||||
|
post_stable_cnt: 0,
|
||||||
|
post_refined_flag: false,
|
||||||
|
post_cand_x: None,
|
||||||
|
post_cand_y: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subtract_baseline(&mut self, current_frame: &[f32]) -> Vec<f32> {
|
||||||
|
if self.first_frame.is_none() {
|
||||||
|
self.first_frame = Some(current_frame.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseline = self.first_frame.as_ref().unwrap();
|
||||||
|
current_frame
|
||||||
|
.iter()
|
||||||
|
.zip(baseline.iter())
|
||||||
|
.map(|(current, baseline)| (current - baseline).max(0.0))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_cop_state(&mut self) {
|
||||||
|
self.first_contact_cop_x = None;
|
||||||
|
self.first_contact_cop_y = None;
|
||||||
|
self.contact_initialized = false;
|
||||||
|
self.total_pressure_low_counter = 0;
|
||||||
|
self.cop_init_x_buf.clear();
|
||||||
|
self.cop_init_y_buf.clear();
|
||||||
|
self.post_init_frame_cnt = 0;
|
||||||
|
self.post_stable_cnt = 0;
|
||||||
|
self.post_refined_flag = false;
|
||||||
|
self.post_cand_x = None;
|
||||||
|
self.post_cand_y = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_median(sorted: &[f32]) -> f32 {
|
||||||
|
let n = sorted.len();
|
||||||
|
if n == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if n % 2 == 0 {
|
||||||
|
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
|
||||||
|
} else {
|
||||||
|
sorted[n / 2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_pressure_direction(&mut self, frame: &[f32]) -> (f32, f32) {
|
||||||
|
let total_pressure = frame.iter().sum::<f32>();
|
||||||
|
if total_pressure < TOTAL_PRESSURE_LOW_THRESHOLD {
|
||||||
|
self.total_pressure_low_counter += 1;
|
||||||
|
} else {
|
||||||
|
self.total_pressure_low_counter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.total_pressure_low_counter >= COP_STABILITY_FRAMES_REQUIRED {
|
||||||
|
self.reset_cop_state();
|
||||||
|
return (0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_pressure == 0.0 {
|
||||||
|
return (0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sum_x = 0.0;
|
||||||
|
let mut sum_y = 0.0;
|
||||||
|
for row in 0..SENSOR_ROWS {
|
||||||
|
for col in 0..SENSOR_COLS {
|
||||||
|
let value = frame[row * SENSOR_COLS + col];
|
||||||
|
sum_x += value * col as f32;
|
||||||
|
sum_y += value * row as f32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cop_x = sum_x / total_pressure;
|
||||||
|
let cop_y = sum_y / total_pressure;
|
||||||
|
|
||||||
|
if !self.contact_initialized {
|
||||||
|
self.cop_init_x_buf.push(cop_x);
|
||||||
|
self.cop_init_y_buf.push(cop_y);
|
||||||
|
|
||||||
|
if self.cop_init_x_buf.len() >= COP_STABILITY_FRAMES_REQUIRED {
|
||||||
|
let mut xs = self.cop_init_x_buf.clone();
|
||||||
|
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
let mut ys = self.cop_init_y_buf.clone();
|
||||||
|
ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
|
||||||
|
self.first_contact_cop_x = Some(Self::compute_median(&xs));
|
||||||
|
self.first_contact_cop_y = Some(Self::compute_median(&ys));
|
||||||
|
self.contact_initialized = true;
|
||||||
|
self.cop_init_x_buf.clear();
|
||||||
|
self.cop_init_y_buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.post_init_frame_cnt += 1;
|
||||||
|
if !self.post_refined_flag && self.post_init_frame_cnt <= POST_INIT_WINDOW_CNT {
|
||||||
|
if let (Some(cx), Some(cy)) = (self.post_cand_x, self.post_cand_y) {
|
||||||
|
let dist = ((cop_x - cx).powi(2) + (cop_y - cy).powi(2)).sqrt();
|
||||||
|
if dist <= POST_INIT_STABLE_THRESH {
|
||||||
|
self.post_stable_cnt += 1;
|
||||||
|
} else {
|
||||||
|
self.post_cand_x = Some(cop_x);
|
||||||
|
self.post_cand_y = Some(cop_y);
|
||||||
|
self.post_stable_cnt = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.post_cand_x = Some(cop_x);
|
||||||
|
self.post_cand_y = Some(cop_y);
|
||||||
|
self.post_stable_cnt = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.post_stable_cnt >= POST_INIT_STABLE_CNT {
|
||||||
|
self.first_contact_cop_x = self.post_cand_x;
|
||||||
|
self.first_contact_cop_y = self.post_cand_y;
|
||||||
|
self.post_refined_flag = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.post_refined_flag = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_x = self.first_contact_cop_x.unwrap_or(cop_x);
|
||||||
|
let base_y = self.first_contact_cop_y.unwrap_or(cop_y);
|
||||||
|
let delta_x = cop_x - base_x;
|
||||||
|
let delta_y = base_y - cop_y;
|
||||||
|
(delta_x, delta_y)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_vector_angle(x: f32, y: f32) -> (f32, f32) {
|
||||||
|
let epsilon = 1e-8f32;
|
||||||
|
let magnitude = (x * x + y * y).sqrt();
|
||||||
|
let mut angle = y.atan2(x + epsilon).to_degrees();
|
||||||
|
if angle < 0.0 {
|
||||||
|
angle += 360.0;
|
||||||
|
}
|
||||||
|
(angle, magnitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_pzt_analysis(
|
||||||
|
&mut self,
|
||||||
|
adc_data: &[f32],
|
||||||
|
) -> Result<PztSpatialAnalysis, &'static str> {
|
||||||
|
if adc_data.len() != SENSOR_COUNT {
|
||||||
|
return Err("ADC data length must be 84");
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseline = self.subtract_baseline(adc_data);
|
||||||
|
let (dx, dy) = self.compute_pressure_direction(&baseline);
|
||||||
|
|
||||||
|
let planar_x = dx;
|
||||||
|
let planar_y = -dy;
|
||||||
|
let (angle_deg, magnitude) = Self::compute_vector_angle(planar_x, planar_y);
|
||||||
|
|
||||||
|
Ok(PztSpatialAnalysis {
|
||||||
|
angle_deg,
|
||||||
|
magnitude,
|
||||||
|
planar_x,
|
||||||
|
planar_y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_pzt_angle(&mut self, adc_data: &[f32]) -> Result<f32, &'static str> {
|
||||||
|
Ok(self.get_pzt_analysis(adc_data)?.angle_deg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_baseline(&mut self) {
|
||||||
|
self.first_frame = None;
|
||||||
|
self.reset_cop_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idle_frame_returns_zero() {
|
||||||
|
let mut processor = PztProcessor::new();
|
||||||
|
let frame = [0.0f32; SENSOR_COUNT];
|
||||||
|
let analysis = processor.get_pzt_analysis(&frame).unwrap();
|
||||||
|
assert_eq!(analysis.magnitude, 0.0);
|
||||||
|
assert_eq!(analysis.angle_deg, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,26 @@
|
|||||||
|
use crate::recording::Recorder;
|
||||||
use crate::serial_core::codec::Codec;
|
use crate::serial_core::codec::Codec;
|
||||||
|
use crate::serial_core::codecs::hand_gateway::{HandGatewayCodec, HandGatewayFrame};
|
||||||
use crate::serial_core::codecs::tactile_a::TactileACodec;
|
use crate::serial_core::codecs::tactile_a::TactileACodec;
|
||||||
use crate::serial_core::frame::TactileAFrame;
|
use crate::serial_core::frame::TactileAFrame;
|
||||||
use crate::serial_core::utils::elapsed_millis;
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
use crossbeam_channel::{Receiver, Sender, TryRecvError};
|
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
const POLL_INTERVAL_MS: u64 = 10;
|
const POLL_INTERVAL_MS: u64 = 5;
|
||||||
|
const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84, 84, 84, 84, 84, 70, 44];
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SerialIoStats {
|
||||||
|
pub rx_bytes: u64,
|
||||||
|
pub tx_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SerialProtocol {
|
||||||
|
TactileA,
|
||||||
|
HandGateway,
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs the serial polling loop on the calling (background) thread.
|
/// Runs the serial polling loop on the calling (background) thread.
|
||||||
/// Sends decoded pressure matrix data (Vec<i32>) to the output channel.
|
/// Sends decoded pressure matrix data (Vec<i32>) to the output channel.
|
||||||
@@ -14,14 +28,43 @@ pub fn run_serial_loop(
|
|||||||
port: &mut dyn ReadWrite,
|
port: &mut dyn ReadWrite,
|
||||||
rows: usize,
|
rows: usize,
|
||||||
cols: usize,
|
cols: usize,
|
||||||
|
protocol: SerialProtocol,
|
||||||
cancel_rx: &Receiver<()>,
|
cancel_rx: &Receiver<()>,
|
||||||
sample_tx: &Sender<Vec<i32>>,
|
sample_tx: &Sender<Vec<i32>>,
|
||||||
|
stats_tx: Option<&Sender<SerialIoStats>>,
|
||||||
|
recorder: Option<&Recorder>,
|
||||||
|
) {
|
||||||
|
match protocol {
|
||||||
|
SerialProtocol::TactileA => {
|
||||||
|
run_tactile_a_loop(port, rows, cols, cancel_rx, sample_tx, stats_tx, recorder)
|
||||||
|
}
|
||||||
|
SerialProtocol::HandGateway => {
|
||||||
|
run_hand_gateway_loop(port, cancel_rx, sample_tx, stats_tx, recorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_tactile_a_loop(
|
||||||
|
port: &mut dyn ReadWrite,
|
||||||
|
rows: usize,
|
||||||
|
cols: usize,
|
||||||
|
cancel_rx: &Receiver<()>,
|
||||||
|
sample_tx: &Sender<Vec<i32>>,
|
||||||
|
stats_tx: Option<&Sender<SerialIoStats>>,
|
||||||
|
recorder: Option<&Recorder>,
|
||||||
) {
|
) {
|
||||||
let session_started_at = Instant::now();
|
let session_started_at = Instant::now();
|
||||||
let mut codec = TactileACodec::new(cols, rows);
|
let mut codec = TactileACodec::new(cols, rows);
|
||||||
let req_frame = TactileACodec::build_req_frame(cols, rows);
|
let req_frame = match TactileACodec::build_req_frame(cols, rows) {
|
||||||
|
Ok(frame) => frame,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[serial] request frame build error: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut buffer = [0u8; 1024];
|
let mut buffer = [0u8; 1024];
|
||||||
let mut poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
|
let poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
|
||||||
|
let mut io_stats = SerialIoStats::default();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Check cancel
|
// Check cancel
|
||||||
@@ -31,7 +74,10 @@ pub fn run_serial_loop(
|
|||||||
|
|
||||||
// Send poll request
|
// Send poll request
|
||||||
if let Ok(req_bytes) = codec.encode(&req_frame) {
|
if let Ok(req_bytes) = codec.encode(&req_frame) {
|
||||||
let _ = port.write_all(&req_bytes);
|
if port.write_all(&req_bytes).is_ok() {
|
||||||
|
io_stats.tx_bytes += req_bytes.len() as u64;
|
||||||
|
publish_stats(stats_tx, io_stats);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response with poll interval
|
// Read response with poll interval
|
||||||
@@ -43,10 +89,17 @@ pub fn run_serial_loop(
|
|||||||
|
|
||||||
match port.read(&mut buffer) {
|
match port.read(&mut buffer) {
|
||||||
Ok(n) if n > 0 => {
|
Ok(n) if n > 0 => {
|
||||||
|
io_stats.rx_bytes += n as u64;
|
||||||
|
publish_stats(stats_tx, io_stats);
|
||||||
if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) {
|
if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) {
|
||||||
for frame in frames {
|
for frame in frames {
|
||||||
if let TactileAFrame::Rep(rep) = frame {
|
if let TactileAFrame::Rep(rep) = frame {
|
||||||
if let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload) {
|
if let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload) {
|
||||||
|
if let Some(recorder) = recorder {
|
||||||
|
let pressures: Vec<u32> =
|
||||||
|
vals.iter().map(|v| (*v).max(0) as u32).collect();
|
||||||
|
recorder.add_frame(&pressures);
|
||||||
|
}
|
||||||
let _ = sample_tx.try_send(vals);
|
let _ = sample_tx.try_send(vals);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +121,109 @@ pub fn run_serial_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_hand_gateway_loop(
|
||||||
|
port: &mut dyn ReadWrite,
|
||||||
|
cancel_rx: &Receiver<()>,
|
||||||
|
sample_tx: &Sender<Vec<i32>>,
|
||||||
|
stats_tx: Option<&Sender<SerialIoStats>>,
|
||||||
|
recorder: Option<&Recorder>,
|
||||||
|
) {
|
||||||
|
let session_started_at = Instant::now();
|
||||||
|
let mut codec = HandGatewayCodec::new(DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS);
|
||||||
|
let mut buffer = [0u8; 1024];
|
||||||
|
let poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
|
||||||
|
let mut io_stats = SerialIoStats::default();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if cancel_rx.try_recv().is_ok() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = Instant::now() + poll_interval;
|
||||||
|
loop {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
match port.read(&mut buffer) {
|
||||||
|
Ok(n) if n > 0 => {
|
||||||
|
io_stats.rx_bytes += n as u64;
|
||||||
|
publish_stats(stats_tx, io_stats);
|
||||||
|
if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) {
|
||||||
|
for frame in frames {
|
||||||
|
let HandGatewayFrame::DataRep(rep) = frame;
|
||||||
|
// println!(
|
||||||
|
// "[hand-packet-raw] bytes={} timestamp_us={} config=0x{:08X} valid_config=0x{:08X} block_count={} payload_bytes={} raw={}",
|
||||||
|
// rep.raw.len(),
|
||||||
|
// rep.timestamp,
|
||||||
|
// rep.config,
|
||||||
|
// rep.valid_config,
|
||||||
|
// rep.block_count,
|
||||||
|
// rep.nodes
|
||||||
|
// .iter()
|
||||||
|
// .map(|node| node.payload.len())
|
||||||
|
// .sum::<usize>(),
|
||||||
|
// format_hex_bytes(&rep.raw)
|
||||||
|
// );
|
||||||
|
let mut vals = Vec::new();
|
||||||
|
let mut parse_ok = true;
|
||||||
|
for node in &rep.nodes {
|
||||||
|
match HandGatewayCodec::parse_node_payload(&node.payload) {
|
||||||
|
Ok(node_values) => {
|
||||||
|
vals.extend(node_values.into_iter().map(|raw| {
|
||||||
|
let raw = raw as i32;
|
||||||
|
if raw < 15 { 0 } else { raw }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
parse_ok = false;
|
||||||
|
eprintln!("[hand-packet-values] parse error: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parse_ok {
|
||||||
|
// println!("[hand-packet-values] samples={}", vals.len());
|
||||||
|
if let Some(recorder) = recorder {
|
||||||
|
let pressures: Vec<u32> =
|
||||||
|
vals.iter().map(|v| (*v).max(0) as u32).collect();
|
||||||
|
recorder.add_frame(&pressures);
|
||||||
|
}
|
||||||
|
let _ = sample_tx.try_send(vals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
std::thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[serial] hand gateway read error: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_hex_bytes(bytes: &[u8]) -> String {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02X}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_stats(stats_tx: Option<&Sender<SerialIoStats>>, stats: SerialIoStats) {
|
||||||
|
if let Some(tx) = stats_tx {
|
||||||
|
let _ = tx.try_send(stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Trait abstracting read+write for the serial port
|
/// Trait abstracting read+write for the serial port
|
||||||
pub trait ReadWrite: Send {
|
pub trait ReadWrite: Send {
|
||||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
|
||||||
|
|||||||
@@ -1,10 +1,64 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub fn usize_to_u16_be_bytes(n: usize) -> [u8; 2] {
|
||||||
|
(n as u16).to_be_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn usize_to_u16_le_bytes(n: usize) -> [u8; 2] {
|
||||||
|
(n as u16).to_be_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn u16_to_hex_be_bytes(n: u16) -> [u8; 2] {
|
||||||
|
n.to_be_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn u16_to_hex_le_bytes(n: u16) -> [u8; 2] {
|
||||||
|
n.to_le_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn calc_crc8_smbus(c: &[u8]) -> u8 {
|
||||||
|
let crc8_smbus = crc::Crc::<u8>::new(&crc::CRC_8_SMBUS);
|
||||||
|
crc8_smbus.checksum(c)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn calc_crc8_itu(c: &[u8]) -> u8 {
|
pub fn calc_crc8_itu(c: &[u8]) -> u8 {
|
||||||
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
|
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
|
||||||
crc8_itu_alg.checksum(c)
|
crc8_itu_alg.checksum(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn calc_crc8_itu_xor55(c: &[u8]) -> u8 {
|
||||||
|
calc_crc8_itu(c) ^ 0x55
|
||||||
|
}
|
||||||
|
|
||||||
pub fn elapsed_millis(start_at: Instant) -> u64 {
|
pub fn elapsed_millis(start_at: Instant) -> u64 {
|
||||||
start_at.elapsed().as_millis() as u64
|
start_at.elapsed().as_millis() as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use crate::serial_core::utils::{calc_crc8_itu, calc_crc8_itu_xor55, calc_crc8_smbus};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crc8_itu() {
|
||||||
|
let req_vec = vec![
|
||||||
|
0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
|
||||||
|
];
|
||||||
|
let checksum = calc_crc8_itu(req_vec.as_slice());
|
||||||
|
assert_eq!(checksum, 0x7A);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crc8_smbus() {
|
||||||
|
let req_vec = vec![
|
||||||
|
0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
|
||||||
|
];
|
||||||
|
let checksum = calc_crc8_smbus(req_vec.as_slice());
|
||||||
|
assert_eq!(checksum, 0x2F);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crc8_itu_xor55() {
|
||||||
|
let req_vec = vec![0x55, 0xAA, 0x07, 0x00, 0x01, 0x01, 0xFF, 0xFF, 0xFF, 0xFF];
|
||||||
|
assert_eq!(calc_crc8_itu_xor55(req_vec.as_slice()), 0xFD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
258
src/shader.wgsl
258
src/shader.wgsl
@@ -1,258 +0,0 @@
|
|||||||
struct MatrixUniform {
|
|
||||||
view_proj: mat4x4f,
|
|
||||||
viewport: vec4f,
|
|
||||||
glyph: vec4f,
|
|
||||||
color: vec4f,
|
|
||||||
}
|
|
||||||
|
|
||||||
@group(0) @binding(0)
|
|
||||||
var<uniform> u: MatrixUniform;
|
|
||||||
|
|
||||||
struct BackgroundVertexOutput {
|
|
||||||
@builtin(position) clip_position: vec4f,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlyphVertexInput {
|
|
||||||
@location(0) local: vec2f,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlyphInstanceInput {
|
|
||||||
@location(1) world_position: vec4f,
|
|
||||||
@location(2) style: vec4f,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlyphVertexOutput {
|
|
||||||
@builtin(position) clip_position: vec4f,
|
|
||||||
@location(0) local: vec2f,
|
|
||||||
@location(1) intensity: f32,
|
|
||||||
@location(2) display_value: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn saturate(value: f32) -> f32 {
|
|
||||||
return clamp(value, 0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capsule_distance(point: vec2f, start: vec2f, end: vec2f) -> f32 {
|
|
||||||
let segment = end - start;
|
|
||||||
let h = saturate(dot(point - start, segment) / dot(segment, segment));
|
|
||||||
return length(point - start - segment * h);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rotate2(point: vec2f, angle: f32) -> vec2f {
|
|
||||||
let s = sin(angle);
|
|
||||||
let c = cos(angle);
|
|
||||||
return vec2f(point.x * c - point.y * s, point.x * s + point.y * c);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rect_alpha(point: vec2f, center: vec2f, half_size: vec2f) -> f32 {
|
|
||||||
let delta = abs(point - center) - half_size;
|
|
||||||
let outside = length(max(delta, vec2f(0.0, 0.0)));
|
|
||||||
let inside = min(max(delta.x, delta.y), 0.0);
|
|
||||||
let dist = outside + inside;
|
|
||||||
return 1.0 - smoothstep(0.015, 0.045, dist);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn range_stop_color(index: u32) -> vec3f {
|
|
||||||
switch index {
|
|
||||||
case 0u: {
|
|
||||||
return vec3f(0.140, 0.690, 0.890);
|
|
||||||
}
|
|
||||||
case 1u: {
|
|
||||||
return vec3f(0.250, 0.760, 0.380);
|
|
||||||
}
|
|
||||||
case 2u: {
|
|
||||||
return vec3f(1.000, 0.670, 0.180);
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return vec3f(1.000, 0.255, 0.190);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_range_color(value: f32) -> vec3f {
|
|
||||||
let t = saturate(value);
|
|
||||||
|
|
||||||
if (t <= 0.33) {
|
|
||||||
let local = smoothstep(0.0, 0.33, t);
|
|
||||||
return mix(range_stop_color(0u), range_stop_color(1u), local);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t <= 0.66) {
|
|
||||||
let local = smoothstep(0.33, 0.66, t);
|
|
||||||
return mix(range_stop_color(1u), range_stop_color(2u), local);
|
|
||||||
}
|
|
||||||
|
|
||||||
let local = smoothstep(0.66, 1.0, t);
|
|
||||||
return mix(range_stop_color(2u), range_stop_color(3u), local);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn digit_segment_on(digit: u32, segment: u32) -> bool {
|
|
||||||
switch digit {
|
|
||||||
case 0u: { return segment != 6u; }
|
|
||||||
case 1u: { return segment == 1u || segment == 2u; }
|
|
||||||
case 2u: { return segment == 0u || segment == 1u || segment == 6u || segment == 4u || segment == 3u; }
|
|
||||||
case 3u: { return segment == 0u || segment == 1u || segment == 6u || segment == 2u || segment == 3u; }
|
|
||||||
case 4u: { return segment == 5u || segment == 6u || segment == 1u || segment == 2u; }
|
|
||||||
case 5u: { return segment == 0u || segment == 5u || segment == 6u || segment == 2u || segment == 3u; }
|
|
||||||
case 6u: { return segment == 0u || segment == 5u || segment == 4u || segment == 3u || segment == 2u || segment == 6u; }
|
|
||||||
case 7u: { return segment == 0u || segment == 1u || segment == 2u; }
|
|
||||||
case 8u: { return true; }
|
|
||||||
default: { return segment == 0u || segment == 1u || segment == 2u || segment == 3u || segment == 5u || segment == 6u; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seven_segment_digit_alpha(local: vec2f, digit: u32) -> f32 {
|
|
||||||
var alpha = 0.0;
|
|
||||||
if (digit_segment_on(digit, 0u)) {
|
|
||||||
alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.70), vec2f(0.38, 0.078)));
|
|
||||||
}
|
|
||||||
if (digit_segment_on(digit, 1u)) {
|
|
||||||
alpha = max(alpha, rect_alpha(local, vec2f(0.39, 0.36), vec2f(0.078, 0.335)));
|
|
||||||
}
|
|
||||||
if (digit_segment_on(digit, 2u)) {
|
|
||||||
alpha = max(alpha, rect_alpha(local, vec2f(0.39, -0.36), vec2f(0.078, 0.335)));
|
|
||||||
}
|
|
||||||
if (digit_segment_on(digit, 3u)) {
|
|
||||||
alpha = max(alpha, rect_alpha(local, vec2f(0.0, -0.70), vec2f(0.38, 0.078)));
|
|
||||||
}
|
|
||||||
if (digit_segment_on(digit, 4u)) {
|
|
||||||
alpha = max(alpha, rect_alpha(local, vec2f(-0.39, -0.36), vec2f(0.078, 0.335)));
|
|
||||||
}
|
|
||||||
if (digit_segment_on(digit, 5u)) {
|
|
||||||
alpha = max(alpha, rect_alpha(local, vec2f(-0.39, 0.36), vec2f(0.078, 0.335)));
|
|
||||||
}
|
|
||||||
if (digit_segment_on(digit, 6u)) {
|
|
||||||
alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.0), vec2f(0.35, 0.075)));
|
|
||||||
}
|
|
||||||
return alpha;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn digit_count(value: u32) -> u32 {
|
|
||||||
if (value >= 1000u) {
|
|
||||||
return 4u;
|
|
||||||
}
|
|
||||||
if (value >= 100u) {
|
|
||||||
return 3u;
|
|
||||||
}
|
|
||||||
if (value >= 10u) {
|
|
||||||
return 2u;
|
|
||||||
}
|
|
||||||
return 1u;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn digit_at(value: u32, slot: u32, count: u32) -> u32 {
|
|
||||||
if (count == 4u) {
|
|
||||||
switch slot {
|
|
||||||
case 0u: { return (value / 1000u) % 10u; }
|
|
||||||
case 1u: { return (value / 100u) % 10u; }
|
|
||||||
case 2u: { return (value / 10u) % 10u; }
|
|
||||||
default: { return value % 10u; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (count == 3u) {
|
|
||||||
switch slot {
|
|
||||||
case 0u: { return (value / 100u) % 10u; }
|
|
||||||
case 1u: { return (value / 10u) % 10u; }
|
|
||||||
default: { return value % 10u; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (count == 2u) {
|
|
||||||
return select(value % 10u, (value / 10u) % 10u, slot == 0u);
|
|
||||||
}
|
|
||||||
return value % 10u;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn number_alpha(local: vec2f, display_value: f32) -> f32 {
|
|
||||||
let value = min(u32(max(display_value + 0.5, 0.0)), 9999u);
|
|
||||||
let count = digit_count(value);
|
|
||||||
let count_f = f32(count);
|
|
||||||
let slot_width = 1.74 / count_f;
|
|
||||||
let start_x = -slot_width * (count_f - 1.0) * 0.5;
|
|
||||||
var alpha = 0.0;
|
|
||||||
|
|
||||||
for (var slot = 0u; slot < 4u; slot = slot + 1u) {
|
|
||||||
if (slot < count) {
|
|
||||||
let center_x = start_x + f32(slot) * slot_width;
|
|
||||||
let digit_local = vec2f((local.x - center_x) / (slot_width * 0.78), local.y / 0.92);
|
|
||||||
let digit = digit_at(value, slot, count);
|
|
||||||
let in_slot = step(abs(local.x - center_x), slot_width * 0.48);
|
|
||||||
alpha = max(alpha, seven_segment_digit_alpha(digit_local, digit) * in_slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return alpha;
|
|
||||||
}
|
|
||||||
|
|
||||||
@vertex
|
|
||||||
fn vs_background(@builtin(vertex_index) vertex_index: u32) -> BackgroundVertexOutput {
|
|
||||||
let positions = array<vec2f, 3>(
|
|
||||||
vec2f(-1.0, -3.0),
|
|
||||||
vec2f(3.0, 1.0),
|
|
||||||
vec2f(-1.0, 1.0),
|
|
||||||
);
|
|
||||||
|
|
||||||
var out: BackgroundVertexOutput;
|
|
||||||
out.clip_position = vec4f(positions[vertex_index], 0.0, 1.0);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@fragment
|
|
||||||
fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f {
|
|
||||||
let pixel = frag_coord.xy;
|
|
||||||
let viewport = u.viewport.xy;
|
|
||||||
let uv = pixel / max(viewport, vec2f(1.0, 1.0));
|
|
||||||
var color = mix(vec3f(0.112, 0.126, 0.160), vec3f(0.145, 0.160, 0.198), 1.0 - uv.y);
|
|
||||||
let vignette = smoothstep(0.18, 0.92, length((uv - vec2f(0.52, 0.48)) * vec2f(viewport.x / viewport.y, 1.0)));
|
|
||||||
color *= 1.0 - vignette * 0.30;
|
|
||||||
color += vec3f(0.035, 0.070, 0.090) * (1.0 - smoothstep(0.0, 0.9, abs(uv.y - 0.52)));
|
|
||||||
|
|
||||||
let track_width = clamp(viewport.x * 0.42, 260.0, 560.0);
|
|
||||||
let track_height = 12.0;
|
|
||||||
let track_center = vec2f(viewport.x * 0.5, viewport.y - 38.0);
|
|
||||||
let local = pixel - track_center;
|
|
||||||
let half_size = vec2f(track_width * 0.5, track_height * 0.5);
|
|
||||||
let inside_track = step(abs(local.x), half_size.x) * step(abs(local.y), half_size.y);
|
|
||||||
let border = step(abs(local.x), half_size.x + 1.0) * step(abs(local.y), half_size.y + 1.0) - inside_track;
|
|
||||||
let t = saturate((local.x + half_size.x) / track_width);
|
|
||||||
|
|
||||||
if (inside_track > 0.5) {
|
|
||||||
let shade = mix(0.72, 1.0, 1.0 - saturate((local.y + half_size.y) / track_height));
|
|
||||||
color = mix(color, sample_range_color(t) * shade, 0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
color = max(color, vec3f(0.34, 0.41, 0.46) * border);
|
|
||||||
|
|
||||||
let tick_area = step(abs(local.x), half_size.x) * step(abs(local.y), half_size.y + 8.0);
|
|
||||||
if (tick_area > 0.5) {
|
|
||||||
let ticks = array<f32, 4>(0.0, 0.33, 0.66, 1.0);
|
|
||||||
for (var index = 0u; index < 4u; index = index + 1u) {
|
|
||||||
let tick_x = (ticks[index] - 0.5) * track_width;
|
|
||||||
let tick = step(abs(local.x - tick_x), 1.0) * step(abs(local.y), half_size.y + 7.0);
|
|
||||||
color = max(color, vec3f(0.78, 0.84, 0.90) * tick);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return vec4f(color, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@vertex
|
|
||||||
fn vs_glyph(vertex: GlyphVertexInput, instance: GlyphInstanceInput) -> GlyphVertexOutput {
|
|
||||||
let center = u.view_proj * vec4f(instance.world_position.xyz, 1.0);
|
|
||||||
let shaped = pow(saturate(instance.style.x), 0.9);
|
|
||||||
let pixel_size = u.glyph.x * mix(1.08, 2.20, shaped);
|
|
||||||
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
|
|
||||||
|
|
||||||
var out: GlyphVertexOutput;
|
|
||||||
out.clip_position = vec4f(center.xy + ndc_offset * center.w, center.z, center.w);
|
|
||||||
out.local = vertex.local;
|
|
||||||
out.intensity = instance.style.x;
|
|
||||||
out.display_value = instance.style.y;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@fragment
|
|
||||||
fn fs_glyph(in: GlyphVertexOutput) -> @location(0) vec4f {
|
|
||||||
let alpha = number_alpha(in.local, in.display_value);
|
|
||||||
let color = sample_range_color(in.intensity) * mix(0.82, 1.16, saturate(in.intensity));
|
|
||||||
return vec4f(color, alpha);
|
|
||||||
}
|
|
||||||
332
src/style.rs
Normal file
332
src/style.rs
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
use eframe::egui::{self, Color32};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct AppTheme {
|
||||||
|
pub bg: egui::Color32,
|
||||||
|
pub panel: egui::Color32,
|
||||||
|
pub panel_strong: egui::Color32,
|
||||||
|
pub panel_deep: egui::Color32,
|
||||||
|
pub border: egui::Color32,
|
||||||
|
pub border_soft: egui::Color32,
|
||||||
|
pub text: egui::Color32,
|
||||||
|
pub text_dim: egui::Color32,
|
||||||
|
pub text_subtle: egui::Color32,
|
||||||
|
pub accent: egui::Color32,
|
||||||
|
pub accent_hot: egui::Color32,
|
||||||
|
pub radius: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct DesignMetrics {
|
||||||
|
pub row_gap: f32,
|
||||||
|
pub item_gap: f32,
|
||||||
|
pub field_height: f32,
|
||||||
|
pub button_height: f32,
|
||||||
|
pub icon_button: egui::Vec2,
|
||||||
|
pub panel_padding: i8,
|
||||||
|
pub group_padding_x: i8,
|
||||||
|
pub group_padding_y: i8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const ONE_DARK_PRO: AppTheme = AppTheme {
|
||||||
|
bg: egui::Color32::BLACK,
|
||||||
|
panel: egui::Color32::from_rgb(22, 28, 35),
|
||||||
|
panel_strong: egui::Color32::from_rgb(34, 43, 54),
|
||||||
|
panel_deep: egui::Color32::from_rgb(15, 20, 27),
|
||||||
|
border: egui::Color32::from_rgb(78, 93, 110),
|
||||||
|
border_soft: egui::Color32::from_rgb(50, 62, 76),
|
||||||
|
text: egui::Color32::from_rgb(235, 241, 247),
|
||||||
|
text_dim: egui::Color32::from_rgb(174, 185, 198),
|
||||||
|
text_subtle: egui::Color32::from_rgb(126, 139, 154),
|
||||||
|
accent: egui::Color32::from_rgb(41, 192, 204),
|
||||||
|
accent_hot: egui::Color32::from_rgb(247, 190, 84),
|
||||||
|
radius: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ACCENT_GREEN: egui::Color32 = egui::Color32::from_rgb(126, 203, 137);
|
||||||
|
pub const ACCENT_RED: egui::Color32 = egui::Color32::from_rgb(240, 99, 94);
|
||||||
|
pub const ACCENT_BLUE: egui::Color32 = egui::Color32::from_rgb(96, 170, 240);
|
||||||
|
pub const ACCENT_ORANGE: egui::Color32 = egui::Color32::from_rgb(242, 163, 87);
|
||||||
|
|
||||||
|
pub const METRICS: DesignMetrics = DesignMetrics {
|
||||||
|
row_gap: 10.0,
|
||||||
|
item_gap: 8.0,
|
||||||
|
field_height: 24.0,
|
||||||
|
button_height: 28.0,
|
||||||
|
icon_button: egui::vec2(30.0, 26.0),
|
||||||
|
panel_padding: 12,
|
||||||
|
group_padding_x: 10,
|
||||||
|
group_padding_y: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod layout {
|
||||||
|
pub const TITLE_BAR_HEIGHT: f32 = 36.0;
|
||||||
|
pub const CENTER_PANEL_TOP: f32 = 48.0;
|
||||||
|
pub const LEFT_X: f32 = 24.0;
|
||||||
|
pub const RIGHT_X: f32 = 1328.0;
|
||||||
|
pub const TOP_Y: f32 = 56.0;
|
||||||
|
pub const LEFT_SECOND_Y: f32 = 292.0;
|
||||||
|
pub const RIGHT_SECOND_Y: f32 = 388.0;
|
||||||
|
pub const LEFT_TAG_X: f32 = 24.0;
|
||||||
|
pub const RIGHT_TAG_X: f32 = 1810.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
|
||||||
|
let mut visuals = egui::Visuals::dark();
|
||||||
|
visuals.override_text_color = Some(theme.text);
|
||||||
|
visuals.panel_fill = theme.bg;
|
||||||
|
visuals.window_fill = theme.panel;
|
||||||
|
visuals.window_stroke = egui::Stroke::new(1.0, theme.border);
|
||||||
|
visuals.extreme_bg_color = theme.panel_deep;
|
||||||
|
visuals.faint_bg_color = theme.panel_strong;
|
||||||
|
visuals.code_bg_color = theme.panel_deep;
|
||||||
|
visuals.warn_fg_color = theme.accent_hot;
|
||||||
|
visuals.error_fg_color = ACCENT_RED;
|
||||||
|
|
||||||
|
visuals.widgets.noninteractive.bg_fill = theme.panel_strong;
|
||||||
|
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft);
|
||||||
|
visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
||||||
|
visuals.widgets.inactive.bg_fill = theme.panel_strong;
|
||||||
|
visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft);
|
||||||
|
visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
||||||
|
visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(44, 57, 70);
|
||||||
|
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, theme.accent);
|
||||||
|
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
|
||||||
|
visuals.widgets.active.bg_fill = theme.accent;
|
||||||
|
visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, theme.accent_hot);
|
||||||
|
visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
|
||||||
|
visuals.widgets.open.bg_fill = egui::Color32::from_rgb(39, 50, 62);
|
||||||
|
visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, theme.accent);
|
||||||
|
visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
||||||
|
|
||||||
|
visuals.selection.bg_fill = egui::Color32::from_rgb(35, 123, 140);
|
||||||
|
visuals.selection.stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
|
||||||
|
visuals.hyperlink_color = theme.accent_hot;
|
||||||
|
ctx.set_visuals(visuals);
|
||||||
|
|
||||||
|
let mut style = (*ctx.global_style()).clone();
|
||||||
|
style.spacing.item_spacing = egui::vec2(METRICS.item_gap, 6.0);
|
||||||
|
style.spacing.button_padding = egui::vec2(12.0, 5.0);
|
||||||
|
style.spacing.window_margin = egui::Margin::same(METRICS.panel_padding);
|
||||||
|
style.spacing.interact_size = egui::vec2(72.0, METRICS.field_height);
|
||||||
|
style.spacing.combo_width = 112.0;
|
||||||
|
style.spacing.text_edit_width = 112.0;
|
||||||
|
style.visuals.window_corner_radius = egui::CornerRadius::same(theme.radius);
|
||||||
|
style.visuals.menu_corner_radius = egui::CornerRadius::same(4);
|
||||||
|
style.visuals.window_shadow = egui::epaint::Shadow {
|
||||||
|
offset: [0, 14],
|
||||||
|
blur: 28,
|
||||||
|
spread: 0,
|
||||||
|
color: egui::Color32::from_black_alpha(135),
|
||||||
|
};
|
||||||
|
ctx.set_global_style(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_fonts(ctx: &egui::Context) {
|
||||||
|
let mut fonts = egui::FontDefinitions::default();
|
||||||
|
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"Hack-Bold".to_owned(),
|
||||||
|
egui::FontData::from_static(include_bytes!("../static/Hack-Bold.ttf")).into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let has_yahei = std::fs::read(r"C:\Windows\Fonts\msyh.ttc")
|
||||||
|
.or_else(|_| std::fs::read(r"C:\Windows\Fonts\msyhbd.ttc"))
|
||||||
|
.map(|font_data| {
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"Microsoft-YaHei".to_owned(),
|
||||||
|
egui::FontData::from_owned(font_data).into(),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.is_ok();
|
||||||
|
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(egui::FontFamily::Proportional)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, "Hack-Bold".to_owned());
|
||||||
|
if has_yahei {
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(egui::FontFamily::Proportional)
|
||||||
|
.or_default()
|
||||||
|
.push("Microsoft-YaHei".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(egui::FontFamily::Monospace)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, "Hack-Bold".to_owned());
|
||||||
|
if has_yahei {
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(egui::FontFamily::Monospace)
|
||||||
|
.or_default()
|
||||||
|
.push("Microsoft-YaHei".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set_fonts(fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn panel_frame(ctx: &egui::Context) -> egui::Frame {
|
||||||
|
let style = ctx.global_style();
|
||||||
|
egui::Frame::window(&style)
|
||||||
|
.fill(ONE_DARK_PRO.panel)
|
||||||
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||||
|
.corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius))
|
||||||
|
.inner_margin(egui::Margin::same(METRICS.panel_padding))
|
||||||
|
.shadow(egui::epaint::Shadow {
|
||||||
|
offset: [0, 14],
|
||||||
|
blur: 28,
|
||||||
|
spread: 0,
|
||||||
|
color: egui::Color32::from_black_alpha(135),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn center_panel_frame() -> egui::Frame {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(ONE_DARK_PRO.panel)
|
||||||
|
.corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius))
|
||||||
|
.inner_margin(egui::Margin::symmetric(
|
||||||
|
METRICS.panel_padding + 2,
|
||||||
|
METRICS.panel_padding,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn group_frame() -> egui::Frame {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(ONE_DARK_PRO.panel_deep)
|
||||||
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4))
|
||||||
|
.inner_margin(egui::Margin::symmetric(
|
||||||
|
METRICS.group_padding_x,
|
||||||
|
METRICS.group_padding_y,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tag_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
|
||||||
|
egui::Button::new(label)
|
||||||
|
.fill(ONE_DARK_PRO.panel_strong)
|
||||||
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4))
|
||||||
|
.min_size(egui::vec2(0.0, METRICS.button_height))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rich_tag_button(
|
||||||
|
label: impl Into<String>,
|
||||||
|
color: impl Into<Color32>,
|
||||||
|
) -> egui::Button<'static> {
|
||||||
|
let text = egui::RichText::new(label.into()).color(color);
|
||||||
|
|
||||||
|
egui::Button::new(text)
|
||||||
|
.fill(ONE_DARK_PRO.panel_strong)
|
||||||
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4))
|
||||||
|
.min_size(egui::vec2(0.0, METRICS.button_height))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn primary_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
|
||||||
|
egui::Button::new(label)
|
||||||
|
.fill(ONE_DARK_PRO.accent)
|
||||||
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.accent_hot))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4))
|
||||||
|
.min_size(egui::vec2(112.0, METRICS.button_height))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn danger_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
|
||||||
|
egui::Button::new(label)
|
||||||
|
.fill(ACCENT_RED)
|
||||||
|
.stroke(egui::Stroke::new(
|
||||||
|
1.0,
|
||||||
|
egui::Color32::from_rgb(255, 138, 126),
|
||||||
|
))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4))
|
||||||
|
.min_size(egui::vec2(112.0, METRICS.button_height))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accent_button(
|
||||||
|
label: impl Into<egui::WidgetText>,
|
||||||
|
fill: egui::Color32,
|
||||||
|
) -> egui::Button<'static> {
|
||||||
|
egui::Button::new(label)
|
||||||
|
.fill(fill)
|
||||||
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4))
|
||||||
|
.min_size(egui::vec2(0.0, METRICS.button_height))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mode_button(label: &'static str, selected: bool) -> egui::Button<'static> {
|
||||||
|
let fill = if selected {
|
||||||
|
ONE_DARK_PRO.accent
|
||||||
|
} else {
|
||||||
|
ONE_DARK_PRO.panel_strong
|
||||||
|
};
|
||||||
|
let stroke = if selected {
|
||||||
|
ONE_DARK_PRO.accent_hot
|
||||||
|
} else {
|
||||||
|
ONE_DARK_PRO.border_soft
|
||||||
|
};
|
||||||
|
|
||||||
|
egui::Button::new(egui::RichText::new(label).color(egui::Color32::WHITE))
|
||||||
|
.fill(fill)
|
||||||
|
.stroke(egui::Stroke::new(1.0, stroke))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4))
|
||||||
|
.min_size(egui::vec2(96.0, METRICS.button_height))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn icon_button<'a>(icon: impl Into<egui::WidgetText>, size: egui::Vec2) -> egui::Button<'a> {
|
||||||
|
egui::Button::new(icon)
|
||||||
|
.fill(ONE_DARK_PRO.panel_strong)
|
||||||
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4))
|
||||||
|
.min_size(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn panel_title(title: &'static str) -> egui::RichText {
|
||||||
|
egui::RichText::new(title)
|
||||||
|
.color(ONE_DARK_PRO.text_dim)
|
||||||
|
.size(13.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn field_label(label: impl Into<String>) -> egui::RichText {
|
||||||
|
egui::RichText::new(label.into())
|
||||||
|
.color(ONE_DARK_PRO.text_dim)
|
||||||
|
.size(12.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value_text(text: impl Into<String>) -> egui::RichText {
|
||||||
|
egui::RichText::new(text.into())
|
||||||
|
.color(ONE_DARK_PRO.text)
|
||||||
|
.size(12.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subtle_text(text: impl Into<String>) -> egui::RichText {
|
||||||
|
egui::RichText::new(text.into())
|
||||||
|
.color(ONE_DARK_PRO.text_subtle)
|
||||||
|
.size(12.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dim_text() -> egui::Color32 {
|
||||||
|
ONE_DARK_PRO.text_dim
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accent_text() -> egui::Color32 {
|
||||||
|
ONE_DARK_PRO.accent_hot
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_color_ok() -> egui::Color32 {
|
||||||
|
ACCENT_GREEN
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_color_warn() -> egui::Color32 {
|
||||||
|
ONE_DARK_PRO.accent_hot
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_color_error() -> egui::Color32 {
|
||||||
|
ACCENT_RED
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_color_info() -> egui::Color32 {
|
||||||
|
ACCENT_BLUE
|
||||||
|
}
|
||||||
178
src/texture.rs
178
src/texture.rs
@@ -1,15 +1,101 @@
|
|||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use anyhow::*;
|
use anyhow::*;
|
||||||
use eframe::egui_wgpu::{self, wgpu};
|
use eframe::wgpu;
|
||||||
use image::GenericImageView;
|
use image::GenericImageView;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub enum TextureColorSpace {
|
||||||
|
Srgb,
|
||||||
|
Linear,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextureColorSpace {
|
||||||
|
pub fn format(self) -> wgpu::TextureFormat {
|
||||||
|
match self {
|
||||||
|
Self::Srgb => wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||||
|
Self::Linear => wgpu::TextureFormat::Rgba8Unorm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct TextureSamplerDescriptor {
|
||||||
|
pub address_mode_u: wgpu::AddressMode,
|
||||||
|
pub address_mode_v: wgpu::AddressMode,
|
||||||
|
pub mag_filter: wgpu::FilterMode,
|
||||||
|
pub min_filter: wgpu::FilterMode,
|
||||||
|
pub mipmap_filter: wgpu::MipmapFilterMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TextureSamplerDescriptor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
address_mode_u: wgpu::AddressMode::Repeat,
|
||||||
|
address_mode_v: wgpu::AddressMode::Repeat,
|
||||||
|
mag_filter: wgpu::FilterMode::Linear,
|
||||||
|
min_filter: wgpu::FilterMode::Linear,
|
||||||
|
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Texture {
|
pub struct Texture {
|
||||||
pub texture: wgpu::Texture,
|
pub texture: wgpu::Texture,
|
||||||
pub view: wgpu::TextureView,
|
pub view: wgpu::TextureView,
|
||||||
pub sampler: wgpu::Sampler,
|
pub sampler: wgpu::Sampler,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Texture {
|
impl Texture {
|
||||||
|
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
||||||
|
|
||||||
|
pub fn create_depth_texture(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
config: &wgpu::SurfaceConfiguration,
|
||||||
|
label: &str,
|
||||||
|
) -> Self {
|
||||||
|
let size = wgpu::Extent3d {
|
||||||
|
width: config.width,
|
||||||
|
height: config.height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
};
|
||||||
|
let desc = wgpu::TextureDescriptor {
|
||||||
|
label: Some(label),
|
||||||
|
size,
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: Self::DEPTH_FORMAT,
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||||
|
view_formats: &[],
|
||||||
|
};
|
||||||
|
let texture = device.create_texture(&desc);
|
||||||
|
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||||
|
mag_filter: wgpu::FilterMode::Linear,
|
||||||
|
min_filter: wgpu::FilterMode::Linear,
|
||||||
|
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||||
|
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||||
|
lod_min_clamp: 0.0,
|
||||||
|
lod_max_clamp: 200.0,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
texture,
|
||||||
|
view,
|
||||||
|
sampler,
|
||||||
|
width: config.width,
|
||||||
|
height: config.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn from_bytes(
|
pub fn from_bytes(
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
queue: &wgpu::Queue,
|
queue: &wgpu::Queue,
|
||||||
@@ -26,8 +112,8 @@ impl Texture {
|
|||||||
img: &image::DynamicImage,
|
img: &image::DynamicImage,
|
||||||
label: Option<&str>,
|
label: Option<&str>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let rgba = img.to_rgba8();
|
|
||||||
let dimensions = img.dimensions();
|
let dimensions = img.dimensions();
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
|
||||||
let size = wgpu::Extent3d {
|
let size = wgpu::Extent3d {
|
||||||
width: dimensions.0,
|
width: dimensions.0,
|
||||||
@@ -40,7 +126,7 @@ impl Texture {
|
|||||||
mip_level_count: 1,
|
mip_level_count: 1,
|
||||||
sample_count: 1,
|
sample_count: 1,
|
||||||
dimension: wgpu::TextureDimension::D2,
|
dimension: wgpu::TextureDimension::D2,
|
||||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
format: TextureColorSpace::Srgb.format(),
|
||||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
view_formats: &[],
|
view_formats: &[],
|
||||||
});
|
});
|
||||||
@@ -76,6 +162,90 @@ impl Texture {
|
|||||||
texture,
|
texture,
|
||||||
view,
|
view,
|
||||||
sampler,
|
sampler,
|
||||||
|
width: dimensions.0,
|
||||||
|
height: dimensions.1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_rgba8(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
rgba: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<Self> {
|
||||||
|
Self::from_rgba8_with_sampler(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
rgba,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
label,
|
||||||
|
TextureColorSpace::Srgb,
|
||||||
|
TextureSamplerDescriptor::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_rgba8_with_sampler(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
rgba: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
label: &str,
|
||||||
|
color_space: TextureColorSpace,
|
||||||
|
sampler_desc: TextureSamplerDescriptor,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let size = wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
};
|
||||||
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some(label),
|
||||||
|
size,
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: color_space.format(),
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
texture: &texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
},
|
||||||
|
rgba,
|
||||||
|
wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(4 * width),
|
||||||
|
rows_per_image: Some(height),
|
||||||
|
},
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
|
||||||
|
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
address_mode_u: sampler_desc.address_mode_u,
|
||||||
|
address_mode_v: sampler_desc.address_mode_v,
|
||||||
|
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||||
|
mag_filter: sampler_desc.mag_filter,
|
||||||
|
min_filter: sampler_desc.min_filter,
|
||||||
|
mipmap_filter: sampler_desc.mipmap_filter,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
texture,
|
||||||
|
view,
|
||||||
|
sampler,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/theme.rs
55
src/theme.rs
@@ -15,20 +15,27 @@ pub struct AppTheme {
|
|||||||
pub radius: u8,
|
pub radius: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const ENGINEERING_DARK: AppTheme = AppTheme {
|
pub const ONE_DARK_PRO: AppTheme = AppTheme {
|
||||||
bg: egui::Color32::from_rgb(33, 35, 44),
|
bg: egui::Color32::from_rgb(40, 44, 52), // #282C34 editor background
|
||||||
panel: egui::Color32::from_rgb(25, 36, 48),
|
panel: egui::Color32::from_rgb(33, 37, 43), // #21252B sidebar/darker
|
||||||
panel_strong: egui::Color32::from_rgb(47, 58, 70),
|
panel_strong: egui::Color32::from_rgb(44, 49, 58), // #2C313A slightly lighter
|
||||||
panel_deep: egui::Color32::from_rgb(14, 18, 24),
|
panel_deep: egui::Color32::from_rgb(27, 31, 39), // #1B1F27 darkest
|
||||||
border: egui::Color32::from_rgb(53, 75, 92),
|
border: egui::Color32::from_rgb(62, 68, 81), // #3E4451 border
|
||||||
border_soft: egui::Color32::from_rgb(36, 53, 66),
|
border_soft: egui::Color32::from_rgb(44, 49, 58), // #2C313A soft border
|
||||||
text: egui::Color32::from_rgb(242, 246, 252),
|
text: egui::Color32::from_rgb(171, 178, 191), // #ABB2BF default text
|
||||||
text_dim: egui::Color32::from_rgb(206, 216, 230),
|
text_dim: egui::Color32::from_rgb(92, 99, 112), // #5C6370 dimmed text
|
||||||
accent: egui::Color32::from_rgb(255, 118, 47),
|
accent: egui::Color32::from_rgb(198, 120, 221), // #C678DD purple (signature)
|
||||||
accent_hot: egui::Color32::from_rgb(255, 169, 77),
|
accent_hot: egui::Color32::from_rgb(229, 192, 123), // #E5C07B yellow/gold
|
||||||
radius: 2,
|
radius: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Additional One Dark Pro accent colors (standalone constants)
|
||||||
|
pub const ACCENT_GREEN: egui::Color32 = egui::Color32::from_rgb(152, 195, 121); // #98C379
|
||||||
|
pub const ACCENT_RED: egui::Color32 = egui::Color32::from_rgb(224, 108, 117); // #E06C75
|
||||||
|
pub const ACCENT_BLUE: egui::Color32 = egui::Color32::from_rgb(97, 175, 239); // #61AFEF
|
||||||
|
pub const ACCENT_CYAN: egui::Color32 = egui::Color32::from_rgb(86, 182, 194); // #56B6C2
|
||||||
|
pub const ACCENT_ORANGE: egui::Color32 = egui::Color32::from_rgb(209, 154, 102); // #D19A66
|
||||||
|
|
||||||
pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
|
pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
|
||||||
let mut visuals = egui::Visuals::dark();
|
let mut visuals = egui::Visuals::dark();
|
||||||
visuals.override_text_color = Some(theme.text);
|
visuals.override_text_color = Some(theme.text);
|
||||||
@@ -39,7 +46,7 @@ pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
|
|||||||
visuals.faint_bg_color = theme.panel_strong;
|
visuals.faint_bg_color = theme.panel_strong;
|
||||||
visuals.code_bg_color = theme.panel_deep;
|
visuals.code_bg_color = theme.panel_deep;
|
||||||
visuals.warn_fg_color = theme.accent_hot;
|
visuals.warn_fg_color = theme.accent_hot;
|
||||||
visuals.error_fg_color = egui::Color32::from_rgb(255, 98, 82);
|
visuals.error_fg_color = ACCENT_RED;
|
||||||
|
|
||||||
visuals.widgets.noninteractive.bg_fill = theme.panel_strong;
|
visuals.widgets.noninteractive.bg_fill = theme.panel_strong;
|
||||||
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft);
|
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft);
|
||||||
@@ -47,13 +54,13 @@ pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
|
|||||||
visuals.widgets.inactive.bg_fill = theme.panel_strong;
|
visuals.widgets.inactive.bg_fill = theme.panel_strong;
|
||||||
visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft);
|
visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft);
|
||||||
visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
||||||
visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(58, 69, 82);
|
visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(55, 61, 72);
|
||||||
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, theme.border);
|
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, theme.border);
|
||||||
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
||||||
visuals.widgets.active.bg_fill = theme.accent;
|
visuals.widgets.active.bg_fill = theme.accent;
|
||||||
visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, theme.accent_hot);
|
visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, theme.accent_hot);
|
||||||
visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
|
visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
|
||||||
visuals.widgets.open.bg_fill = egui::Color32::from_rgb(43, 55, 68);
|
visuals.widgets.open.bg_fill = egui::Color32::from_rgb(50, 56, 66);
|
||||||
visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, theme.border);
|
visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, theme.border);
|
||||||
visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
||||||
|
|
||||||
@@ -126,9 +133,9 @@ pub fn apply_fonts(ctx: &egui::Context) {
|
|||||||
pub fn panel_frame(ctx: &egui::Context) -> egui::Frame {
|
pub fn panel_frame(ctx: &egui::Context) -> egui::Frame {
|
||||||
let style = ctx.global_style();
|
let style = ctx.global_style();
|
||||||
egui::Frame::window(&style)
|
egui::Frame::window(&style)
|
||||||
.fill(ENGINEERING_DARK.panel)
|
.fill(ONE_DARK_PRO.panel)
|
||||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border))
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||||
.corner_radius(egui::CornerRadius::same(ENGINEERING_DARK.radius))
|
.corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius))
|
||||||
.shadow(egui::epaint::Shadow {
|
.shadow(egui::epaint::Shadow {
|
||||||
offset: [0, 12],
|
offset: [0, 12],
|
||||||
blur: 26,
|
blur: 26,
|
||||||
@@ -139,23 +146,23 @@ pub fn panel_frame(ctx: &egui::Context) -> egui::Frame {
|
|||||||
|
|
||||||
pub fn group_frame() -> egui::Frame {
|
pub fn group_frame() -> egui::Frame {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(ENGINEERING_DARK.panel_deep)
|
.fill(ONE_DARK_PRO.panel_deep)
|
||||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft))
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft))
|
||||||
.corner_radius(egui::CornerRadius::same(2))
|
.corner_radius(egui::CornerRadius::same(2))
|
||||||
.inner_margin(egui::Margin::symmetric(6, 5))
|
.inner_margin(egui::Margin::symmetric(6, 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tag_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
|
pub fn tag_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
|
||||||
egui::Button::new(label)
|
egui::Button::new(label)
|
||||||
.fill(ENGINEERING_DARK.panel_strong)
|
.fill(ONE_DARK_PRO.panel_strong)
|
||||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border))
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||||
.corner_radius(egui::CornerRadius::same(2))
|
.corner_radius(egui::CornerRadius::same(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dim_text() -> egui::Color32 {
|
pub fn dim_text() -> egui::Color32 {
|
||||||
ENGINEERING_DARK.text_dim
|
ONE_DARK_PRO.text_dim
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn accent_text() -> egui::Color32 {
|
pub fn accent_text() -> egui::Color32 {
|
||||||
ENGINEERING_DARK.accent_hot
|
ONE_DARK_PRO.accent_hot
|
||||||
}
|
}
|
||||||
|
|||||||
856
static/wgsl/shader.wgsl
Normal file
856
static/wgsl/shader.wgsl
Normal file
@@ -0,0 +1,856 @@
|
|||||||
|
struct MatrixUniform {
|
||||||
|
view_proj: mat4x4f,
|
||||||
|
viewport: vec4f,
|
||||||
|
glyph: vec4f,
|
||||||
|
color: vec4f,
|
||||||
|
render_options: vec4f,
|
||||||
|
image: vec4f,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0)
|
||||||
|
var<uniform> u: MatrixUniform;
|
||||||
|
|
||||||
|
@group(1) @binding(0)
|
||||||
|
var base_color_texture: texture_2d<f32>;
|
||||||
|
|
||||||
|
@group(1) @binding(1)
|
||||||
|
var base_color_sampler: sampler;
|
||||||
|
|
||||||
|
struct MaterialParams {
|
||||||
|
base_color: vec4f,
|
||||||
|
metallic_roughness: vec4f,
|
||||||
|
emissive_alpha: vec4f,
|
||||||
|
flags: vec4f,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(1) @binding(2)
|
||||||
|
var<uniform> material: MaterialParams;
|
||||||
|
|
||||||
|
@group(1) @binding(3)
|
||||||
|
var metallic_roughness_texture: texture_2d<f32>;
|
||||||
|
|
||||||
|
@group(1) @binding(4)
|
||||||
|
var metallic_roughness_sampler: sampler;
|
||||||
|
|
||||||
|
@group(1) @binding(5)
|
||||||
|
var normal_texture: texture_2d<f32>;
|
||||||
|
|
||||||
|
@group(1) @binding(6)
|
||||||
|
var normal_sampler: sampler;
|
||||||
|
|
||||||
|
@group(1) @binding(7)
|
||||||
|
var occlusion_texture: texture_2d<f32>;
|
||||||
|
|
||||||
|
@group(1) @binding(8)
|
||||||
|
var occlusion_sampler: sampler;
|
||||||
|
|
||||||
|
@group(1) @binding(9)
|
||||||
|
var emissive_texture: texture_2d<f32>;
|
||||||
|
|
||||||
|
@group(1) @binding(10)
|
||||||
|
var emissive_sampler: sampler;
|
||||||
|
|
||||||
|
fn saturate(value: f32) -> f32 {
|
||||||
|
return clamp(value, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn linear_to_srgb(linear: vec3f) -> vec3f {
|
||||||
|
let x = max(linear, vec3f(0.0));
|
||||||
|
let low = x * 12.92;
|
||||||
|
let high = 1.055 * pow(x, vec3f(1.0 / 2.4)) - vec3f(0.055);
|
||||||
|
return select(high, low, x <= vec3f(0.0031308));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_color(linear_rgb: vec3f, alpha: f32) -> vec4f {
|
||||||
|
let clamped = clamp(linear_rgb, vec3f(0.0), vec3f(1.0));
|
||||||
|
if (u.color.w > 0.5) {
|
||||||
|
return vec4f(clamped, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoded = linear_to_srgb(clamped);
|
||||||
|
return vec4f(encoded, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range_stop_color(index: u32) -> vec3f {
|
||||||
|
switch index {
|
||||||
|
case 0u: {
|
||||||
|
return vec3f(0.140, 0.690, 0.890);
|
||||||
|
}
|
||||||
|
case 1u: {
|
||||||
|
return vec3f(0.250, 0.760, 0.380);
|
||||||
|
}
|
||||||
|
case 2u: {
|
||||||
|
return vec3f(1.000, 0.670, 0.180);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return vec3f(1.000, 0.255, 0.190);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_range_color(value: f32) -> vec3f {
|
||||||
|
let t = saturate(value);
|
||||||
|
|
||||||
|
if (t <= 0.33) {
|
||||||
|
let local = smoothstep(0.0, 0.33, t);
|
||||||
|
return mix(range_stop_color(0u), range_stop_color(1u), local);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t <= 0.66) {
|
||||||
|
let local = smoothstep(0.33, 0.66, t);
|
||||||
|
return mix(range_stop_color(1u), range_stop_color(2u), local);
|
||||||
|
}
|
||||||
|
|
||||||
|
let local = smoothstep(0.66, 1.0, t);
|
||||||
|
return mix(range_stop_color(2u), range_stop_color(3u), local);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// background
|
||||||
|
struct BackgroundVertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4f,
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_background(@builtin(vertex_index) vertex_index: u32) -> BackgroundVertexOutput {
|
||||||
|
let positions = array<vec2f, 3>(
|
||||||
|
vec2f(-1.0, -3.0),
|
||||||
|
vec2f(3.0, 1.0),
|
||||||
|
vec2f(-1.0, 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
var out: BackgroundVertexOutput;
|
||||||
|
out.clip_position = vec4f(positions[vertex_index], 0.0, 1.0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f {
|
||||||
|
return output_color(vec3f(0.0, 0.0, 0.0), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// hand image background
|
||||||
|
struct HandImageVertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4f,
|
||||||
|
@location(0) screen_uv: vec2f,
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_hand_image(@builtin(vertex_index) vertex_index: u32) -> HandImageVertexOutput {
|
||||||
|
let positions = array<vec2f, 6>(
|
||||||
|
vec2f(-1.0, -1.0),
|
||||||
|
vec2f(1.0, -1.0),
|
||||||
|
vec2f(-1.0, 1.0),
|
||||||
|
vec2f(-1.0, 1.0),
|
||||||
|
vec2f(1.0, -1.0),
|
||||||
|
vec2f(1.0, 1.0),
|
||||||
|
);
|
||||||
|
let uvs = array<vec2f, 6>(
|
||||||
|
vec2f(0.0, 1.0),
|
||||||
|
vec2f(1.0, 1.0),
|
||||||
|
vec2f(0.0, 0.0),
|
||||||
|
vec2f(0.0, 0.0),
|
||||||
|
vec2f(1.0, 1.0),
|
||||||
|
vec2f(1.0, 0.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
var out: HandImageVertexOutput;
|
||||||
|
out.clip_position = vec4f(positions[vertex_index], 0.0, 1.0);
|
||||||
|
out.screen_uv = uvs[vertex_index];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_hand_image(in: HandImageVertexOutput) -> @location(0) vec4f {
|
||||||
|
let viewport_aspect = u.viewport.x / max(u.viewport.y, 1.0);
|
||||||
|
let image_aspect = u.image.x / max(u.image.y, 1.0);
|
||||||
|
|
||||||
|
var uv = in.screen_uv;
|
||||||
|
if (viewport_aspect > image_aspect) {
|
||||||
|
let image_width = image_aspect / viewport_aspect;
|
||||||
|
uv.x = (uv.x - (1.0 - image_width) * 0.5) / image_width;
|
||||||
|
} else {
|
||||||
|
let image_height = viewport_aspect / image_aspect;
|
||||||
|
uv.y = (uv.y - (1.0 - image_height) * 0.5) / image_height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
let color = textureSample(base_color_texture, base_color_sampler, uv);
|
||||||
|
return output_color(color.rgb, color.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// glyph
|
||||||
|
struct GlyphVertexInput {
|
||||||
|
@location(0) local: vec2f,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlyphInstanceInput {
|
||||||
|
@location(1) world_position: vec4f,
|
||||||
|
@location(2) style: vec4f,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlyphVertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4f,
|
||||||
|
@location(0) local: vec2f,
|
||||||
|
@location(1) intensity: f32,
|
||||||
|
@location(2) display_value: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rect_alpha(point: vec2f, center: vec2f, half_size: vec2f) -> f32 {
|
||||||
|
let delta = abs(point - center) - half_size;
|
||||||
|
let outside = length(max(delta, vec2f(0.0, 0.0)));
|
||||||
|
let inside = min(max(delta.x, delta.y), 0.0);
|
||||||
|
let dist = outside + inside;
|
||||||
|
return 1.0 - smoothstep(0.015, 0.045, dist);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digit_segment_on(digit: u32, segment: u32) -> bool {
|
||||||
|
switch digit {
|
||||||
|
case 0u: { return segment != 6u; }
|
||||||
|
case 1u: { return segment == 1u || segment == 2u; }
|
||||||
|
case 2u: { return segment == 0u || segment == 1u || segment == 6u || segment == 4u || segment == 3u; }
|
||||||
|
case 3u: { return segment == 0u || segment == 1u || segment == 6u || segment == 2u || segment == 3u; }
|
||||||
|
case 4u: { return segment == 5u || segment == 6u || segment == 1u || segment == 2u; }
|
||||||
|
case 5u: { return segment == 0u || segment == 5u || segment == 6u || segment == 2u || segment == 3u; }
|
||||||
|
case 6u: { return segment == 0u || segment == 5u || segment == 4u || segment == 3u || segment == 2u || segment == 6u; }
|
||||||
|
case 7u: { return segment == 0u || segment == 1u || segment == 2u; }
|
||||||
|
case 8u: { return true; }
|
||||||
|
default: { return segment == 0u || segment == 1u || segment == 2u || segment == 3u || segment == 5u || segment == 6u; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seven_segment_digit_alpha(local: vec2f, digit: u32) -> f32 {
|
||||||
|
var alpha = 0.0;
|
||||||
|
if (digit_segment_on(digit, 0u)) {
|
||||||
|
alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.70), vec2f(0.38, 0.078)));
|
||||||
|
}
|
||||||
|
if (digit_segment_on(digit, 1u)) {
|
||||||
|
alpha = max(alpha, rect_alpha(local, vec2f(0.39, 0.36), vec2f(0.078, 0.335)));
|
||||||
|
}
|
||||||
|
if (digit_segment_on(digit, 2u)) {
|
||||||
|
alpha = max(alpha, rect_alpha(local, vec2f(0.39, -0.36), vec2f(0.078, 0.335)));
|
||||||
|
}
|
||||||
|
if (digit_segment_on(digit, 3u)) {
|
||||||
|
alpha = max(alpha, rect_alpha(local, vec2f(0.0, -0.70), vec2f(0.38, 0.078)));
|
||||||
|
}
|
||||||
|
if (digit_segment_on(digit, 4u)) {
|
||||||
|
alpha = max(alpha, rect_alpha(local, vec2f(-0.39, -0.36), vec2f(0.078, 0.335)));
|
||||||
|
}
|
||||||
|
if (digit_segment_on(digit, 5u)) {
|
||||||
|
alpha = max(alpha, rect_alpha(local, vec2f(-0.39, 0.36), vec2f(0.078, 0.335)));
|
||||||
|
}
|
||||||
|
if (digit_segment_on(digit, 6u)) {
|
||||||
|
alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.0), vec2f(0.35, 0.075)));
|
||||||
|
}
|
||||||
|
return alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digit_count(value: u32) -> u32 {
|
||||||
|
if (value >= 1000u) {
|
||||||
|
return 4u;
|
||||||
|
}
|
||||||
|
if (value >= 100u) {
|
||||||
|
return 3u;
|
||||||
|
}
|
||||||
|
if (value >= 10u) {
|
||||||
|
return 2u;
|
||||||
|
}
|
||||||
|
return 1u;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digit_at(value: u32, slot: u32, count: u32) -> u32 {
|
||||||
|
if (count == 4u) {
|
||||||
|
switch slot {
|
||||||
|
case 0u: { return (value / 1000u) % 10u; }
|
||||||
|
case 1u: { return (value / 100u) % 10u; }
|
||||||
|
case 2u: { return (value / 10u) % 10u; }
|
||||||
|
default: { return value % 10u; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count == 3u) {
|
||||||
|
switch slot {
|
||||||
|
case 0u: { return (value / 100u) % 10u; }
|
||||||
|
case 1u: { return (value / 10u) % 10u; }
|
||||||
|
default: { return value % 10u; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count == 2u) {
|
||||||
|
return select(value % 10u, (value / 10u) % 10u, slot == 0u);
|
||||||
|
}
|
||||||
|
return value % 10u;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number_alpha(local: vec2f, display_value: f32) -> f32 {
|
||||||
|
let value = min(u32(max(display_value + 0.5, 0.0)), 9999u);
|
||||||
|
let count = digit_count(value);
|
||||||
|
let count_f = f32(count);
|
||||||
|
let slot_width = 1.74 / count_f;
|
||||||
|
let start_x = -slot_width * (count_f - 1.0) * 0.5;
|
||||||
|
var alpha = 0.0;
|
||||||
|
|
||||||
|
for (var slot = 0u; slot < 4u; slot = slot + 1u) {
|
||||||
|
if (slot < count) {
|
||||||
|
let center_x = start_x + f32(slot) * slot_width;
|
||||||
|
let digit_local = vec2f((local.x - center_x) / (slot_width * 0.78), local.y / 0.92);
|
||||||
|
let digit = digit_at(value, slot, count);
|
||||||
|
let in_slot = step(abs(local.x - center_x), slot_width * 0.48);
|
||||||
|
alpha = max(alpha, seven_segment_digit_alpha(digit_local, digit) * in_slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_glyph(vertex: GlyphVertexInput, instance: GlyphInstanceInput) -> GlyphVertexOutput {
|
||||||
|
let center = u.view_proj * vec4f(instance.world_position.xyz, 1.0);
|
||||||
|
let shaped = pow(saturate(instance.style.x), 0.9);
|
||||||
|
let pixel_size = u.glyph.x * mix(1.08, 2.20, shaped);
|
||||||
|
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
|
||||||
|
|
||||||
|
var out: GlyphVertexOutput;
|
||||||
|
out.clip_position = vec4f(center.xy + ndc_offset * center.w, center.z, center.w);
|
||||||
|
out.local = vertex.local;
|
||||||
|
out.intensity = instance.style.x;
|
||||||
|
out.display_value = instance.style.y;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_glyph(in: GlyphVertexOutput) -> @location(0) vec4f {
|
||||||
|
let alpha = number_alpha(in.local, in.display_value);
|
||||||
|
let color = sample_range_color(in.intensity) * mix(0.82, 1.16, saturate(in.intensity));
|
||||||
|
return output_color(color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// dot
|
||||||
|
struct DotVertexInput {
|
||||||
|
@location(0) local: vec2f,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DotInstanceInput {
|
||||||
|
@location(1) world_position: vec4f,
|
||||||
|
@location(2) style: vec4f
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DotVertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4f,
|
||||||
|
@location(0) local: vec2f,
|
||||||
|
@location(1) intensity: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn circle_alpha(local: vec2f, radius: f32, softness: f32) -> f32 {
|
||||||
|
let dist = length(local);
|
||||||
|
return 1.0 - smoothstep(radius, radius + softness, dist);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a point authored in hand.png UV space into clip space.
|
||||||
|
// This mirrors fs_hand_image's aspect-fit math, so fingertip dots stay attached
|
||||||
|
// to the same image pixels when the app window changes shape.
|
||||||
|
fn hand_image_uv_to_clip(image_uv: vec2f) -> vec2f {
|
||||||
|
let viewport_aspect = u.viewport.x / max(u.viewport.y, 1.0);
|
||||||
|
let image_aspect = u.image.x / max(u.image.y, 1.0);
|
||||||
|
|
||||||
|
var screen_uv = image_uv;
|
||||||
|
|
||||||
|
if (viewport_aspect > image_aspect) {
|
||||||
|
let image_width = image_aspect / viewport_aspect;
|
||||||
|
screen_uv.x = image_uv.x * image_width + (1.0 - image_width) * 0.5;
|
||||||
|
} else {
|
||||||
|
let image_height = viewport_aspect / image_aspect;
|
||||||
|
screen_uv.y = image_uv.y * image_height + (1.0 - image_height) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vec2f(screen_uv.x * 2.0 - 1.0, 1.0 - screen_uv.y * 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HandMembraneVertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4f,
|
||||||
|
@location(0) local: vec2f,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_2d(point: vec2f, angle: f32) -> vec2f {
|
||||||
|
let c = cos(angle);
|
||||||
|
let s = sin(angle);
|
||||||
|
return vec2f(point.x * c - point.y * s, point.x * s + point.y * c);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rounded_rect_alpha(local: vec2f, radius: f32, softness: f32) -> f32 {
|
||||||
|
let q = abs(local) - vec2f(1.0 - radius, 1.0 - radius);
|
||||||
|
let dist = length(max(q, vec2f(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - radius;
|
||||||
|
return 1.0 - smoothstep(0.0, softness, dist);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fingertip_film_alpha(
|
||||||
|
local: vec2f,
|
||||||
|
half_width: f32,
|
||||||
|
cap_center_y: f32,
|
||||||
|
rear_edge_y: f32,
|
||||||
|
rear_bulge: f32,
|
||||||
|
softness: f32,
|
||||||
|
) -> f32 {
|
||||||
|
// Top/front of the film is a closed round fingertip cap.
|
||||||
|
let cap_dist = length(vec2f(local.x, local.y - cap_center_y));
|
||||||
|
let cap = (1.0 - smoothstep(half_width, half_width + softness, cap_dist))
|
||||||
|
* (1.0 - smoothstep(cap_center_y - softness, cap_center_y + softness, local.y));
|
||||||
|
|
||||||
|
// The rear half keeps nearly parallel sides and ends with a shallow arc;
|
||||||
|
// it deliberately does not converge back into another capsule end.
|
||||||
|
let x_norm = clamp(abs(local.x) / max(half_width, 0.001), 0.0, 1.0);
|
||||||
|
let rear_curve_y = rear_edge_y + rear_bulge * (1.0 - x_norm * x_norm);
|
||||||
|
let side = 1.0 - smoothstep(half_width, half_width + softness, abs(local.x));
|
||||||
|
let rear = 1.0 - smoothstep(rear_curve_y, rear_curve_y + softness, local.y);
|
||||||
|
let body_gate = smoothstep(cap_center_y - softness, cap_center_y + softness, local.y);
|
||||||
|
let body = side * rear * body_gate;
|
||||||
|
|
||||||
|
return max(cap, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_hand_membrane(vertex: DotVertexInput, instance: DotInstanceInput) -> HandMembraneVertexOutput {
|
||||||
|
let center_px = instance.world_position.xy * u.image.xy;
|
||||||
|
let size_px = instance.style.yz;
|
||||||
|
let angle = instance.style.x;
|
||||||
|
let local_px = vertex.local * size_px * 0.5;
|
||||||
|
let image_uv = (center_px + rotate_2d(local_px, angle)) / max(u.image.xy, vec2f(1.0, 1.0));
|
||||||
|
|
||||||
|
var out: HandMembraneVertexOutput;
|
||||||
|
out.clip_position = vec4f(hand_image_uv_to_clip(image_uv), 0.0, 1.0);
|
||||||
|
out.local = vertex.local;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_hand_membrane(in: HandMembraneVertexOutput) -> @location(0) vec4f {
|
||||||
|
// The film shape matches the fingertip: closed round front, parallel rear sides,
|
||||||
|
// and a shallow rear arc instead of a second capsule end.
|
||||||
|
let panel = fingertip_film_alpha(in.local, 0.66, -0.38, 0.72, 0.18, 0.045);
|
||||||
|
let inner = fingertip_film_alpha(in.local, 0.54, -0.36, 0.64, 0.12, 0.060);
|
||||||
|
let border = clamp(panel - inner * 0.52, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Dense mesh: the live 12x7 pressure dots sit over this finer sensor lattice.
|
||||||
|
let uv = clamp(in.local * 0.5 + vec2f(0.5, 0.5), vec2f(0.0, 0.0), vec2f(1.0, 1.0));
|
||||||
|
let grid = vec2f(18.0, 34.0);
|
||||||
|
let cell = uv * grid - vec2f(0.5, 0.5);
|
||||||
|
let nearest = abs(fract(cell + vec2f(0.5, 0.5)) - vec2f(0.5, 0.5));
|
||||||
|
let grid_line = max(
|
||||||
|
1.0 - smoothstep(0.014, 0.038, nearest.x),
|
||||||
|
1.0 - smoothstep(0.014, 0.038, nearest.y),
|
||||||
|
);
|
||||||
|
let joint = 1.0 - smoothstep(0.070, 0.150, length(nearest * vec2f(1.18, 1.0)));
|
||||||
|
let center_shadow = 1.0 - smoothstep(0.10, 0.76, length(in.local * vec2f(0.92, 0.66)));
|
||||||
|
|
||||||
|
let top_light = smoothstep(-0.52, 0.16, -in.local.y) * 0.20;
|
||||||
|
let side_glow = smoothstep(0.30, 0.70, abs(in.local.x)) * 0.26;
|
||||||
|
let lift_shadow = smoothstep(0.48, 0.86, in.local.y) * (1.0 - smoothstep(0.50, 0.86, abs(in.local.x))) * 0.13;
|
||||||
|
let scan = (0.5 + 0.5 * sin((uv.y * 76.0 + uv.x * 9.0) * 6.28318)) * 0.030;
|
||||||
|
|
||||||
|
let membrane_color = vec3f(0.006, 0.28, 0.38);
|
||||||
|
let line_color = vec3f(0.12, 0.72, 0.88);
|
||||||
|
let rim_color = vec3f(0.18, 0.98, 1.0);
|
||||||
|
let color = membrane_color * (0.68 + top_light + side_glow + scan)
|
||||||
|
+ line_color * (grid_line * 0.20 + joint * 0.42)
|
||||||
|
+ rim_color * (border * 0.92 + side_glow * 0.18)
|
||||||
|
- vec3f(0.0, 0.16, 0.24) * center_shadow * 0.30
|
||||||
|
- vec3f(0.0, 0.10, 0.16) * lift_shadow;
|
||||||
|
let alpha = panel * (0.24 + grid_line * 0.10 + joint * 0.23 + border * 0.42);
|
||||||
|
|
||||||
|
return output_color(color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_hand_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexOutput {
|
||||||
|
let intensity = saturate(instance.style.x);
|
||||||
|
|
||||||
|
let shaped = smoothstep(0.0, 1.0, intensity);
|
||||||
|
|
||||||
|
// Hand instances store hand.png UV in world_position.xy instead of 3D world space.
|
||||||
|
let center = hand_image_uv_to_clip(instance.world_position.xy);
|
||||||
|
// Hand fingertip matrices are much smaller than the full Finger view.
|
||||||
|
// Keep each bead below the local cell spacing so the 12x7 matrix remains visibly separated.
|
||||||
|
let pixel_size = u.glyph.x * mix(0.22, 0.34, shaped);
|
||||||
|
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
|
||||||
|
|
||||||
|
var out: DotVertexOutput;
|
||||||
|
out.clip_position = vec4f(center + ndc_offset, 0.0, 1.0);
|
||||||
|
out.local = vertex.local;
|
||||||
|
out.intensity = intensity;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_hand_dot(in: DotVertexOutput) -> @location(0) vec4f {
|
||||||
|
let intensity = saturate(in.intensity);
|
||||||
|
|
||||||
|
// Use a compact bead so the response feels like it lives on the membrane mesh.
|
||||||
|
let core = circle_alpha(in.local, 0.48, 0.07);
|
||||||
|
let halo = circle_alpha(in.local, 0.74, 0.14) * (0.10 + intensity * 0.26);
|
||||||
|
|
||||||
|
// Match Finger mode's pressure gradient so each hand region remains readable.
|
||||||
|
let idle = vec3f(0.060, 0.250, 0.320);
|
||||||
|
let gradient = sample_range_color(intensity);
|
||||||
|
let color = mix(idle, gradient, smoothstep(0.0, 0.18, intensity))
|
||||||
|
* mix(0.78, 1.18, intensity);
|
||||||
|
|
||||||
|
return output_color(color, max(core, halo));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chip_pixel_alpha(local: vec2f, half_size: f32, softness: f32) -> f32 {
|
||||||
|
let q = abs(local) - vec2f(half_size, half_size);
|
||||||
|
let dist = length(max(q, vec2f(0.0, 0.0))) + min(max(q.x, q.y), 0.0);
|
||||||
|
return 1.0 - smoothstep(0.0, softness, dist);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HandPalmChipVertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4f,
|
||||||
|
@location(0) local: vec2f,
|
||||||
|
@location(1) grid: vec2f,
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_hand_palm_chip(vertex: DotVertexInput, instance: DotInstanceInput) -> HandPalmChipVertexOutput {
|
||||||
|
let center_px = instance.world_position.xy * u.image.xy;
|
||||||
|
let size_px = instance.style.yz;
|
||||||
|
let angle = instance.style.x;
|
||||||
|
let packed_shape = instance.style.w;
|
||||||
|
let shape_rows = floor(packed_shape / 100.0);
|
||||||
|
let shape_cols = max(packed_shape - shape_rows * 100.0, 1.0);
|
||||||
|
let local_px = vertex.local * size_px * 0.5;
|
||||||
|
let image_uv = (center_px + rotate_2d(local_px, angle)) / max(u.image.xy, vec2f(1.0, 1.0));
|
||||||
|
|
||||||
|
var out: HandPalmChipVertexOutput;
|
||||||
|
out.clip_position = vec4f(hand_image_uv_to_clip(image_uv), 0.0, 1.0);
|
||||||
|
out.local = vertex.local;
|
||||||
|
out.grid = vec2f(shape_cols, max(shape_rows, 1.0));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_hand_palm_chip(in: HandPalmChipVertexOutput) -> @location(0) vec4f {
|
||||||
|
// Dark rounded tile: this is the inset chip body sitting inside the palm surface.
|
||||||
|
let panel = rounded_rect_alpha(in.local, 0.10, 0.040);
|
||||||
|
let inset = rounded_rect_alpha(in.local * vec2f(1.10, 1.08), 0.08, 0.052);
|
||||||
|
let rim = clamp(panel - inset * 0.72, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Inactive chip pixels use the chip's real hand layout:
|
||||||
|
// horizontal 14 columns x 5 rows, or vertical 4 columns x 11 rows.
|
||||||
|
let uv = clamp(in.local * 0.5 + vec2f(0.5, 0.5), vec2f(0.0, 0.0), vec2f(1.0, 1.0));
|
||||||
|
let cell = abs(fract(uv * in.grid) - vec2f(0.5, 0.5));
|
||||||
|
let micro_pixel = 1.0 - smoothstep(0.105, 0.178, length(cell * vec2f(1.04, 0.94)));
|
||||||
|
|
||||||
|
let top_bevel = smoothstep(-0.96, -0.18, -in.local.y) * 0.16;
|
||||||
|
let lower_shadow = smoothstep(0.20, 0.92, in.local.y) * 0.22;
|
||||||
|
let side_bevel = smoothstep(0.58, 0.96, abs(in.local.x)) * 0.12;
|
||||||
|
let scan = (0.5 + 0.5 * sin((uv.y * 36.0 + uv.x * 7.0) * 6.28318)) * 0.026;
|
||||||
|
|
||||||
|
let base = vec3f(0.004, 0.012, 0.018);
|
||||||
|
let glass = vec3f(0.012, 0.048, 0.064);
|
||||||
|
let pixel_color = vec3f(0.075, 0.300, 0.360);
|
||||||
|
let rim_color = vec3f(0.060, 0.560, 0.670);
|
||||||
|
let color = base * (0.92 - lower_shadow)
|
||||||
|
+ glass * (0.52 + top_bevel + side_bevel + scan)
|
||||||
|
+ pixel_color * micro_pixel * 0.70
|
||||||
|
+ rim_color * rim * 0.60;
|
||||||
|
|
||||||
|
let alpha = panel * (0.64 + micro_pixel * 0.18 + rim * 0.20);
|
||||||
|
return output_color(color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_hand_palm_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexOutput {
|
||||||
|
let intensity = saturate(instance.style.x);
|
||||||
|
let shaped = smoothstep(0.0, 1.0, intensity);
|
||||||
|
|
||||||
|
// Palm chip pixels are deliberately smaller than fingertip beads so they read as a chip matrix.
|
||||||
|
let center = hand_image_uv_to_clip(instance.world_position.xy);
|
||||||
|
let pixel_size = u.glyph.x * mix(0.13, 0.25, shaped);
|
||||||
|
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
|
||||||
|
|
||||||
|
var out: DotVertexOutput;
|
||||||
|
out.clip_position = vec4f(center + ndc_offset, 0.0, 1.0);
|
||||||
|
out.local = vertex.local;
|
||||||
|
out.intensity = intensity;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_hand_palm_dot(in: DotVertexOutput) -> @location(0) vec4f {
|
||||||
|
let intensity = saturate(in.intensity);
|
||||||
|
let pixel = chip_pixel_alpha(in.local, 0.52, 0.070);
|
||||||
|
let glow = circle_alpha(in.local, 0.95, 0.22) * intensity * 0.36;
|
||||||
|
|
||||||
|
let cold = vec3f(0.070, 0.340, 0.360);
|
||||||
|
let gradient = sample_range_color(intensity);
|
||||||
|
let color = mix(cold, gradient, smoothstep(0.0, 0.20, intensity))
|
||||||
|
* (0.58 + intensity * 1.04)
|
||||||
|
+ gradient * glow * 0.72;
|
||||||
|
|
||||||
|
let alpha = max(pixel * (0.20 + intensity * 0.76), glow);
|
||||||
|
return output_color(color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexOutput {
|
||||||
|
let center = u.view_proj * vec4f(instance.world_position.xyz, 1.0);
|
||||||
|
let intensity = saturate(instance.style.x);
|
||||||
|
let shaped = smoothstep(0.0, 1.0, intensity);
|
||||||
|
|
||||||
|
let pixel_size = u.glyph.x * mix(1.07, 2.23, shaped);
|
||||||
|
|
||||||
|
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
|
||||||
|
var out: DotVertexOutput;
|
||||||
|
out.clip_position = vec4f(center.xy + ndc_offset * center.w, center.z, center.w);
|
||||||
|
out.local = vertex.local;
|
||||||
|
out.intensity = intensity;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_dot(in: DotVertexOutput) -> @location(0) vec4f {
|
||||||
|
let intensity = saturate(in.intensity);
|
||||||
|
let base_color = sample_range_color(intensity);
|
||||||
|
|
||||||
|
let alpha = circle_alpha(in.local, 0.46, 0.045);
|
||||||
|
let color = base_color * mix(0.86, 1.06, intensity);
|
||||||
|
|
||||||
|
return output_color(color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// model
|
||||||
|
struct ModelVertexInput {
|
||||||
|
@location(0) position: vec3f,
|
||||||
|
@location(1) tex_coords: vec2f,
|
||||||
|
@location(2) normal: vec3f,
|
||||||
|
@location(3) color: vec4f,
|
||||||
|
@location(4) tangent: vec4f,
|
||||||
|
@location(5) model_0: vec4f,
|
||||||
|
@location(6) model_1: vec4f,
|
||||||
|
@location(7) model_2: vec4f,
|
||||||
|
@location(8) model_3: vec4f,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ModelVertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4f,
|
||||||
|
@location(0) world_normal: vec3f,
|
||||||
|
@location(1) world_position: vec3f,
|
||||||
|
@location(2) tex_coords: vec2f,
|
||||||
|
@location(3) color: vec4f,
|
||||||
|
@location(4) world_tangent: vec4f,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inverse_transpose3x3(m: mat3x3f) -> mat3x3f {
|
||||||
|
let c0 = m[0];
|
||||||
|
let c1 = m[1];
|
||||||
|
let c2 = m[2];
|
||||||
|
let r0 = cross(c1, c2);
|
||||||
|
let r1 = cross(c2, c0);
|
||||||
|
let r2 = cross(c0, c1);
|
||||||
|
let det = dot(c0, r0);
|
||||||
|
let safe_det = select(det, select(-0.000001, 0.000001, det >= 0.0), abs(det) < 0.000001);
|
||||||
|
let inv_det = 1.0 / safe_det;
|
||||||
|
return mat3x3f(r0 * inv_det, r1 * inv_det, r2 * inv_det);
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_model(vertex: ModelVertexInput) -> ModelVertexOutput {
|
||||||
|
let model = mat4x4f(vertex.model_0, vertex.model_1, vertex.model_2, vertex.model_3);
|
||||||
|
let world_position = model * vec4f(vertex.position, 1.0);
|
||||||
|
let model3 = mat3x3f(model[0].xyz, model[1].xyz, model[2].xyz);
|
||||||
|
let normal_matrix = inverse_transpose3x3(model3);
|
||||||
|
|
||||||
|
var out: ModelVertexOutput;
|
||||||
|
out.clip_position = u.view_proj * world_position;
|
||||||
|
out.world_position = world_position.xyz;
|
||||||
|
out.world_normal = normalize(normal_matrix * vertex.normal);
|
||||||
|
out.tex_coords = vertex.tex_coords;
|
||||||
|
out.color = vertex.color;
|
||||||
|
out.world_tangent = vec4f(normalize(normal_matrix * vertex.tangent.xyz), vertex.tangent.w);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn distribution_ggx(normal: vec3f, half_dir: vec3f, roughness: f32) -> f32 {
|
||||||
|
let a = roughness * roughness;
|
||||||
|
let a2 = a * a;
|
||||||
|
let n_dot_h = max(dot(normal, half_dir), 0.0);
|
||||||
|
let n_dot_h2 = n_dot_h * n_dot_h;
|
||||||
|
let denom = (n_dot_h2 * (a2 - 1.0) + 1.0);
|
||||||
|
return a2 / max(3.14159265 * denom * denom, 0.000001);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn geometry_schlick_ggx(n_dot_v: f32, roughness: f32) -> f32 {
|
||||||
|
let r = roughness + 1.0;
|
||||||
|
let k = (r * r) / 8.0;
|
||||||
|
return n_dot_v / max(n_dot_v * (1.0 - k) + k, 0.000001);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn geometry_smith(normal: vec3f, view_dir: vec3f, light_dir: vec3f, roughness: f32) -> f32 {
|
||||||
|
let n_dot_v = max(dot(normal, view_dir), 0.0);
|
||||||
|
let n_dot_l = max(dot(normal, light_dir), 0.0);
|
||||||
|
let ggx_v = geometry_schlick_ggx(n_dot_v, roughness);
|
||||||
|
let ggx_l = geometry_schlick_ggx(n_dot_l, roughness);
|
||||||
|
return ggx_v * ggx_l;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresnel_schlick(cos_theta: f32, f0: vec3f) -> vec3f {
|
||||||
|
let factor = pow(clamp(1.0 - cos_theta, 0.0, 1.0), 5.0);
|
||||||
|
return f0 + (vec3f(1.0) - f0) * factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pbr_directional_light(
|
||||||
|
albedo: vec3f,
|
||||||
|
normal: vec3f,
|
||||||
|
view_dir: vec3f,
|
||||||
|
light_dir: vec3f,
|
||||||
|
radiance: vec3f,
|
||||||
|
metallic: f32,
|
||||||
|
roughness: f32,
|
||||||
|
) -> vec3f {
|
||||||
|
let half_dir = normalize(view_dir + light_dir);
|
||||||
|
let n_dot_l = max(dot(normal, light_dir), 0.0);
|
||||||
|
let n_dot_v = max(dot(normal, view_dir), 0.0);
|
||||||
|
let h_dot_v = max(dot(half_dir, view_dir), 0.0);
|
||||||
|
|
||||||
|
let f0 = mix(vec3f(0.04), albedo, metallic);
|
||||||
|
let f = fresnel_schlick(h_dot_v, f0);
|
||||||
|
let d = distribution_ggx(normal, half_dir, roughness);
|
||||||
|
let g = geometry_smith(normal, view_dir, light_dir, roughness);
|
||||||
|
let specular = (d * g * f) / max(4.0 * n_dot_v * n_dot_l, 0.000001);
|
||||||
|
|
||||||
|
let k_s = f;
|
||||||
|
let k_d = (vec3f(1.0) - k_s) * (1.0 - metallic);
|
||||||
|
let diffuse = k_d * albedo / 3.14159265;
|
||||||
|
|
||||||
|
return (diffuse + specular) * radiance * n_dot_l;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aces_tonemap(x: vec3f) -> vec3f {
|
||||||
|
let a = 2.51;
|
||||||
|
let b = 0.03;
|
||||||
|
let c = 2.43;
|
||||||
|
let d = 0.59;
|
||||||
|
let e = 0.14;
|
||||||
|
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3f(0.0), vec3f(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn material_normal(in: ModelVertexOutput, front_facing: bool) -> vec3f {
|
||||||
|
var n = normalize(in.world_normal);
|
||||||
|
if (!front_facing && material.flags.w > 0.5) {
|
||||||
|
n = -n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (material.flags.z < 0.5) {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = normalize(in.world_tangent.xyz);
|
||||||
|
let b = normalize(cross(n, t) * in.world_tangent.w);
|
||||||
|
let tangent_normal_sample = textureSample(normal_texture, normal_sampler, in.tex_coords).xyz;
|
||||||
|
var tangent_normal = tangent_normal_sample * 2.0 - vec3f(1.0);
|
||||||
|
tangent_normal.x = tangent_normal.x * material.metallic_roughness.z;
|
||||||
|
tangent_normal.y = tangent_normal.y * material.metallic_roughness.z;
|
||||||
|
|
||||||
|
return normalize(mat3x3f(t, b, n) * tangent_normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_model(in: ModelVertexOutput, @builtin(front_facing) front_facing: bool) -> @location(0) vec4f {
|
||||||
|
let base_sample = textureSample(base_color_texture, base_color_sampler, in.tex_coords);
|
||||||
|
let base_color = base_sample * material.base_color * in.color;
|
||||||
|
let alpha_mode = material.emissive_alpha.w;
|
||||||
|
var alpha = base_color.a;
|
||||||
|
if (alpha_mode < 0.5) {
|
||||||
|
alpha = 1.0;
|
||||||
|
} else if (alpha_mode < 1.5) {
|
||||||
|
if (alpha < material.flags.x) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
alpha = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let debug_mode = u32(u.render_options.x + 0.5);
|
||||||
|
if (debug_mode == 1u) {
|
||||||
|
return output_color(base_color.rgb, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mr_sample = textureSample(
|
||||||
|
metallic_roughness_texture,
|
||||||
|
metallic_roughness_sampler,
|
||||||
|
in.tex_coords,
|
||||||
|
);
|
||||||
|
let roughness = clamp(mr_sample.g * material.metallic_roughness.y, 0.04, 1.0);
|
||||||
|
let metallic = clamp(mr_sample.b * material.metallic_roughness.x, 0.0, 1.0);
|
||||||
|
let ao_sample = textureSample(occlusion_texture, occlusion_sampler, in.tex_coords).r;
|
||||||
|
let ao = mix(1.0, ao_sample, clamp(material.metallic_roughness.w, 0.0, 1.0));
|
||||||
|
let emissive = textureSample(emissive_texture, emissive_sampler, in.tex_coords).rgb
|
||||||
|
* material.emissive_alpha.rgb;
|
||||||
|
let albedo = base_color.rgb;
|
||||||
|
let normal = material_normal(in, front_facing);
|
||||||
|
|
||||||
|
switch debug_mode {
|
||||||
|
case 2u: {
|
||||||
|
return output_color(normal * 0.5 + vec3f(0.5), 1.0);
|
||||||
|
}
|
||||||
|
case 3u: {
|
||||||
|
return output_color(vec3f(roughness), 1.0);
|
||||||
|
}
|
||||||
|
case 4u: {
|
||||||
|
return output_color(vec3f(metallic), 1.0);
|
||||||
|
}
|
||||||
|
case 5u: {
|
||||||
|
return output_color(vec3f(ao), 1.0);
|
||||||
|
}
|
||||||
|
case 6u: {
|
||||||
|
return output_color(emissive, 1.0);
|
||||||
|
}
|
||||||
|
case 7u: {
|
||||||
|
return output_color(vec3f(fract(in.tex_coords), 0.0), 1.0);
|
||||||
|
}
|
||||||
|
default: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let view_dir = normalize(vec3f(0.0, 0.72, 0.70));
|
||||||
|
let key = pbr_directional_light(
|
||||||
|
albedo,
|
||||||
|
normal,
|
||||||
|
view_dir,
|
||||||
|
normalize(vec3f(-0.35, 0.82, 0.44)),
|
||||||
|
vec3f(2.15, 2.08, 1.92),
|
||||||
|
metallic,
|
||||||
|
roughness,
|
||||||
|
);
|
||||||
|
let fill = pbr_directional_light(
|
||||||
|
albedo,
|
||||||
|
normal,
|
||||||
|
view_dir,
|
||||||
|
normalize(vec3f(0.62, 0.44, -0.36)),
|
||||||
|
vec3f(0.50, 0.50, 0.50),
|
||||||
|
metallic,
|
||||||
|
roughness,
|
||||||
|
);
|
||||||
|
|
||||||
|
let f0 = mix(vec3f(0.04), albedo, metallic);
|
||||||
|
let f_ambient = fresnel_schlick(max(dot(normal, view_dir), 0.0), f0);
|
||||||
|
let ambient_color = vec3f(1.0);
|
||||||
|
let ambient_strength = 0.08;
|
||||||
|
// Temporary neutral linear ambient term until a real IBL/HDR environment is added.
|
||||||
|
var ambient = vec3f(0.0);
|
||||||
|
if (u.render_options.y > 0.5) {
|
||||||
|
let ambient_diffuse = albedo * (1.0 - metallic) * ambient_color * ambient_strength;
|
||||||
|
let ambient_specular = f_ambient * ambient_color * ambient_strength * (1.0 - roughness * 0.55);
|
||||||
|
ambient = (ambient_diffuse + ambient_specular) * ao;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exposed_color = (ambient + key + fill + emissive) * max(u.render_options.w, 0.0);
|
||||||
|
var color = exposed_color;
|
||||||
|
if (u.render_options.z > 0.5) {
|
||||||
|
color = aces_tonemap(exposed_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output_color(color, alpha);
|
||||||
|
}
|
||||||
254
wix/main.wxs
Normal file
254
wix/main.wxs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<?xml version='1.0' encoding='windows-1252'?>
|
||||||
|
<!--
|
||||||
|
Copyright (C) 2017 Christopher R. Field.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The "cargo wix" subcommand provides a variety of predefined variables available
|
||||||
|
for customization of this template. The values for each variable are set at
|
||||||
|
installer creation time. The following variables are available:
|
||||||
|
|
||||||
|
TargetTriple = The rustc target triple name.
|
||||||
|
TargetEnv = The rustc target environment. This is typically either
|
||||||
|
"msvc" or "gnu" depending on the toolchain downloaded and
|
||||||
|
installed.
|
||||||
|
TargetVendor = The rustc target vendor. This is typically "pc", but Rust
|
||||||
|
does support other vendors, like "uwp".
|
||||||
|
CargoTargetBinDir = The complete path to the directory containing the
|
||||||
|
binaries (exes) to include. The default would be
|
||||||
|
"target\release\". If an explicit rustc target triple is
|
||||||
|
used, i.e. cross-compiling, then the default path would
|
||||||
|
be "target\<CARGO_TARGET>\<CARGO_PROFILE>",
|
||||||
|
where "<CARGO_TARGET>" is replaced with the "CargoTarget"
|
||||||
|
variable value and "<CARGO_PROFILE>" is replaced with the
|
||||||
|
value from the "CargoProfile" variable. This can also
|
||||||
|
be overridden manually with the "target-bin-dir" flag.
|
||||||
|
CargoTargetDir = The path to the directory for the build artifacts, i.e.
|
||||||
|
"target".
|
||||||
|
CargoProfile = The cargo profile used to build the binaries
|
||||||
|
(usually "debug" or "release").
|
||||||
|
Version = The version for the installer. The default is the
|
||||||
|
"Major.Minor.Fix" semantic versioning number of the Rust
|
||||||
|
package.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Please do not remove these pre-processor If-Else blocks. These are used with
|
||||||
|
the `cargo wix` subcommand to automatically determine the installation
|
||||||
|
destination for 32-bit versus 64-bit installers. Removal of these lines will
|
||||||
|
cause installation errors.
|
||||||
|
-->
|
||||||
|
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
|
||||||
|
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
|
||||||
|
<?else ?>
|
||||||
|
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
|
||||||
|
<?endif ?>
|
||||||
|
|
||||||
|
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
|
||||||
|
|
||||||
|
<Product
|
||||||
|
Id='*'
|
||||||
|
Name='eskin-model-player'
|
||||||
|
UpgradeCode='7CEF316B-BE23-4533-B4B2-87D06D2230B5'
|
||||||
|
Manufacturer='JOYSONQUIN'
|
||||||
|
Language='1033'
|
||||||
|
Codepage='1252'
|
||||||
|
Version='$(var.Version)'>
|
||||||
|
|
||||||
|
<Package Id='*'
|
||||||
|
Keywords='Installer'
|
||||||
|
Description='Desktop pressure sensor visualization and playback application.'
|
||||||
|
Manufacturer='JOYSONQUIN'
|
||||||
|
InstallerVersion='450'
|
||||||
|
Languages='1033'
|
||||||
|
Compressed='yes'
|
||||||
|
InstallScope='perUser'
|
||||||
|
InstallPrivileges='limited'
|
||||||
|
SummaryCodepage='1252'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MajorUpgrade
|
||||||
|
Schedule='afterInstallInitialize'
|
||||||
|
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
|
||||||
|
|
||||||
|
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
|
||||||
|
<Property Id='DiskPrompt' Value='eskin-model-player Installation'/>
|
||||||
|
|
||||||
|
<Directory Id='TARGETDIR' Name='SourceDir'>
|
||||||
|
<Directory Id='LocalAppDataFolder'>
|
||||||
|
<Directory Id='APPLICATIONFOLDER' Name='eskin-model-player'>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Enabling the license sidecar file in the installer is a four step process:
|
||||||
|
|
||||||
|
1. Uncomment the `Component` tag and its contents.
|
||||||
|
2. Change the value for the `Source` attribute in the `File` tag to a path
|
||||||
|
to the file that should be included as the license sidecar file. The path
|
||||||
|
can, and probably should be, relative to this file.
|
||||||
|
3. Change the value for the `Name` attribute in the `File` tag to the
|
||||||
|
desired name for the file when it is installed alongside the `bin` folder
|
||||||
|
in the installation directory. This can be omitted if the desired name is
|
||||||
|
the same as the file name.
|
||||||
|
4. Uncomment the `ComponentRef` tag with the Id attribute value of "License"
|
||||||
|
further down in this file.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
<Component Id='License' Guid='*'>
|
||||||
|
<File Id='LicenseFile' Name='ChangeMe' DiskId='1' Source='C:\Path\To\File' KeyPath='yes'/>
|
||||||
|
</Component>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Directory Id='Bin' Name='bin'>
|
||||||
|
<Component Id='Path' Guid='FD230ABD-75B9-47D4-870B-CF2EA72B76BE'>
|
||||||
|
<RegistryValue
|
||||||
|
Root='HKCU'
|
||||||
|
Key='Software\JOYSONQUIN\eskin-model-player'
|
||||||
|
Name='PathComponent'
|
||||||
|
Type='integer'
|
||||||
|
Value='1'
|
||||||
|
KeyPath='yes'/>
|
||||||
|
<RemoveFolder Id='RemoveBinFolder' Directory='Bin' On='uninstall'/>
|
||||||
|
<RemoveFolder Id='RemoveApplicationFolder' Directory='APPLICATIONFOLDER' On='uninstall'/>
|
||||||
|
<Environment
|
||||||
|
Id='PATH'
|
||||||
|
Name='PATH'
|
||||||
|
Value='[Bin]'
|
||||||
|
Permanent='no'
|
||||||
|
Part='last'
|
||||||
|
Action='set'
|
||||||
|
System='no'/>
|
||||||
|
</Component>
|
||||||
|
<Component Id='binary0' Guid='302CAFB5-6951-426B-BC5A-988C351A2CF2'>
|
||||||
|
<RegistryValue
|
||||||
|
Root='HKCU'
|
||||||
|
Key='Software\JOYSONQUIN\eskin-model-player'
|
||||||
|
Name='BinaryComponent'
|
||||||
|
Type='integer'
|
||||||
|
Value='1'
|
||||||
|
KeyPath='yes'/>
|
||||||
|
<File
|
||||||
|
Id='exe0'
|
||||||
|
Name='ESkinPlayer.exe'
|
||||||
|
DiskId='1'
|
||||||
|
Source='$(var.CargoTargetBinDir)\ESkinPlayer.exe'/>
|
||||||
|
</Component>
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<Feature
|
||||||
|
Id='Binaries'
|
||||||
|
Title='Application'
|
||||||
|
Description='Installs all binaries and the license.'
|
||||||
|
Level='1'
|
||||||
|
ConfigurableDirectory='APPLICATIONFOLDER'
|
||||||
|
AllowAdvertise='no'
|
||||||
|
Display='expand'
|
||||||
|
Absent='disallow'>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Uncomment the following `ComponentRef` tag to add the license
|
||||||
|
sidecar file to the installer.
|
||||||
|
-->
|
||||||
|
<!--<ComponentRef Id='License'/>-->
|
||||||
|
|
||||||
|
<ComponentRef Id='binary0'/>
|
||||||
|
|
||||||
|
<Feature
|
||||||
|
Id='Environment'
|
||||||
|
Title='PATH Environment Variable'
|
||||||
|
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
|
||||||
|
Level='1'
|
||||||
|
Absent='allow'>
|
||||||
|
<ComponentRef Id='Path'/>
|
||||||
|
</Feature>
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
|
||||||
|
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Uncomment the following `Icon` and `Property` tags to change the product icon.
|
||||||
|
|
||||||
|
The product icon is the graphic that appears in the Add/Remove
|
||||||
|
Programs control panel for the application.
|
||||||
|
-->
|
||||||
|
<!--<Icon Id='ProductICO' SourceFile='wix\Product.ico'/>-->
|
||||||
|
<!--<Property Id='ARPPRODUCTICON' Value='ProductICO' />-->
|
||||||
|
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Adding a URL to Add/Remove Programs control panel listing for the
|
||||||
|
application is a two step process:
|
||||||
|
|
||||||
|
1. Uncomment the following `Property` tag with the "ARPHELPLINK" Id
|
||||||
|
attribute value.
|
||||||
|
2. Change the value for `Value` attribute of the following
|
||||||
|
`Property` tag to a valid URL.
|
||||||
|
-->
|
||||||
|
<!--<Property Id='ARPHELPLINK' Value='ChangeMe'/>-->
|
||||||
|
|
||||||
|
<UI>
|
||||||
|
<UIRef Id='WixUI_FeatureTree'/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Enabling the EULA dialog in the installer is a three step process:
|
||||||
|
|
||||||
|
1. Comment out or remove the two `Publish` tags that follow the
|
||||||
|
`WixVariable` tag.
|
||||||
|
2. Uncomment the `<WixVariable Id='WixUILicenseRtf' Value='Path\to\Eula.rft'>` tag further down
|
||||||
|
3. Replace the `Value` attribute of the `WixVariable` tag with
|
||||||
|
the path to a RTF file that will be used as the EULA and
|
||||||
|
displayed in the license agreement dialog.
|
||||||
|
-->
|
||||||
|
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
|
||||||
|
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
|
||||||
|
|
||||||
|
</UI>
|
||||||
|
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Enabling the EULA dialog in the installer requires uncommenting
|
||||||
|
the following `WixUILicenseRTF` tag and changing the `Value`
|
||||||
|
attribute.
|
||||||
|
-->
|
||||||
|
<!-- <WixVariable Id='WixUILicenseRtf' Value='Relative\Path\to\Eula.rtf'/> -->
|
||||||
|
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Uncomment the next `WixVariable` tag to customize the installer's
|
||||||
|
Graphical User Interface (GUI) and add a custom banner image across
|
||||||
|
the top of each screen. See the WiX Toolset documentation for details
|
||||||
|
about customization.
|
||||||
|
|
||||||
|
The banner BMP dimensions are 493 x 58 pixels.
|
||||||
|
-->
|
||||||
|
<!--<WixVariable Id='WixUIBannerBmp' Value='wix\Banner.bmp'/>-->
|
||||||
|
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Uncomment the next `WixVariable` tag to customize the installer's
|
||||||
|
Graphical User Interface (GUI) and add a custom image to the first
|
||||||
|
dialog, or screen. See the WiX Toolset documentation for details about
|
||||||
|
customization.
|
||||||
|
|
||||||
|
The dialog BMP dimensions are 493 x 312 pixels.
|
||||||
|
-->
|
||||||
|
<!--<WixVariable Id='WixUIDialogBmp' Value='wix\Dialog.bmp'/>-->
|
||||||
|
|
||||||
|
</Product>
|
||||||
|
|
||||||
|
</Wix>
|
||||||
Reference in New Issue
Block a user