25 Commits

Author SHA1 Message Date
lenn
4d0e65c02b toml config support 2026-07-17 13:55:22 +08:00
lenn
ca6ca377df Skip spatial force below display threshold 2026-07-14 15:30:32 +08:00
lenn
801a909c64 Update hand force calibration 2026-07-14 13:51:50 +08:00
lenn
bf7907ce6f Adjust pressure color response 2026-07-14 11:16:17 +08:00
lenn
466bb5dec7 Tune hand pressure visualization 2026-07-14 11:08:00 +08:00
lenn
5af3c2862b 重构手部分段查表逻辑并新增 Excel 数据提取脚本
- src/app.rs: 将 update_hand_signal_histories 改为返回总力作为汇总路径;新增 raw_to_g6/raw_to_g7 与 raw_to_hand_segment_g 索引映射,把 segment 5/6 从兜底 raw_to_gd 拆出,并按 H 版本校准数据更新 g1~g5 的查表数组
2026-07-08 17:45:27 +08:00
lenn
9fb4c0edf4 对五个手指分别进行查表插值 2026-07-07 21:13:37 +08:00
lenn
1e3cfdcf21 shader: collapse palm chip background pass and refine dot rendering 2026-07-07 16:35:19 +08:00
lenn
a04b903e96 Integrate hand fingertip force overlays 2026-07-07 09:43:25 +08:00
lenn
c4bccc1747 refactor: 配置面板改造为顶部固定栏 + 压力归一化优化 2026-07-06 14:07:23 +08:00
lenn
5264f0c247 Tune hand-tip and palm chip layout coordinates
Adjust HandTipMatrix / HandPalmChip center, angle, and size in render.rs and propagate the spacing tweak through shader.wgsl.
2026-07-03 15:30:25 +08:00
lenn
a999d9c064 Refactor to hand-mode UI and reorganize WGSL shader
Replace spatial force / stats panels with unified hand force panels; simplify app.rs wiring and reorganize shader.wgsl for the hand rendering layout.
2026-07-03 13:41:02 +08:00
lenn
71d314ac44 Add finger mode UI and refine breakout/serial integration 2026-07-02 15:22:13 +08:00
lenn
0a9fea0f0a Improve panel layout and per-user installer 2026-07-02 13:38:00 +08:00
lenn
da77f2f194 Improve hand mode panels and recording 2026-07-01 17:08:07 +08:00
lenn
1ed729f8da Prepare Windows installer release 2026-06-30 17:43:02 +08:00
lenn
444c20f233 Add animated force panels 2026-06-30 11:10:29 +08:00
lenn
d4f160af75 Integrate hand gateway and spatial force rendering 2026-06-29 18:55:42 +08:00
lennlouisgeek
f30ebcf20b Add pressure visual modes and breakout game 2026-06-29 03:13:57 +08:00
lennlouisgeek
eab24fc2bf update 2026-06-28 23:29:13 +08:00
lennlouisgeek
f74d8e5013 daily update 2026-06-28 23:07:10 +08:00
lenn
b3fa3c8341 Use hand texture as render background 2026-06-26 16:59:58 +08:00
lennlouisgeek
0b658d11c5 Refine UI panels and render mode flow 2026-06-26 02:03:07 +08:00
lenn
156df50d33 Add textured OBJ model rendering 2026-06-25 17:41:18 +08:00
lenn
ec46e53c75 refactor: 迁移 shader.wgsl 至 static/wgsl/,新增 model 模块 2026-06-24 17:32:14 +08:00
48 changed files with 11026 additions and 896 deletions

View 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)

View 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

View File

@@ -0,0 +1,2 @@
"filename", "language", "", "comment", "blank", "total"
"Total", "-", , 0, 0, 0
1 filename language comment blank total
2 Total - 0 0 0

View 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)

View 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 |
+----------+----------+------------+------------+------------+------------+

View 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 filename language Rust Markdown JSON JSON with Comments Shell Script WGSL PowerShell YAML comment blank total
2 d:\rust-ws\eskin-player\.openclaw\settings.json JSON with Comments 0 0 0 35 0 0 0 0 0 0 35
3 d:\rust-ws\eskin-player\.scale\assets.json JSON 0 0 4 0 0 0 0 0 0 1 5
4 d:\rust-ws\eskin-player\.scale\engineering-standards-baseline.json JSON 0 0 6 0 0 0 0 0 0 1 7
5 d:\rust-ws\eskin-player\.scale\engineering-standards.json JSON 0 0 67 0 0 0 0 0 0 1 68
6 d:\rust-ws\eskin-player\.scale\frameworks.json JSON 0 0 23 0 0 0 0 0 0 1 24
7 d:\rust-ws\eskin-player\.scale\governance.lock.json JSON 0 0 200 0 0 0 0 0 0 1 201
8 d:\rust-ws\eskin-player\.scale\output-policy.json JSON 0 0 72 0 0 0 0 0 0 1 73
9 d:\rust-ws\eskin-player\.scale\product-smoke.json JSON 0 0 33 0 0 0 0 0 0 1 34
10 d:\rust-ws\eskin-player\.scale\quality-contract.json JSON 0 0 70 0 0 0 0 0 0 1 71
11 d:\rust-ws\eskin-player\.scale\resource-policy.json JSON 0 0 39 0 0 0 0 0 0 1 40
12 d:\rust-ws\eskin-player\.scale\skills-registry.json JSON 0 0 82 0 0 0 0 0 0 1 83
13 d:\rust-ws\eskin-player\.scale\skills.json JSON 0 0 630 0 0 0 0 0 0 1 631
14 d:\rust-ws\eskin-player\.scale\tools.json JSON 0 0 118 0 0 0 0 0 0 1 119
15 d:\rust-ws\eskin-player\.scale\verification.json JSON 0 0 35 0 0 0 0 0 0 1 36
16 d:\rust-ws\eskin-player\.scale\workflow.json JSON 0 0 51 0 0 0 0 0 0 1 52
17 d:\rust-ws\eskin-player\AGENTS.md Markdown 0 21 0 0 0 0 0 0 0 6 27
18 d:\rust-ws\eskin-player\README.md Markdown 0 33 0 0 0 0 0 0 0 10 43
19 d:\rust-ws\eskin-player\TOOLS.md Markdown 0 56 0 0 0 0 0 0 0 6 62
20 d:\rust-ws\eskin-player\build.rs Rust 24 0 0 0 0 0 0 0 0 10 34
21 d:\rust-ws\eskin-player\docs\workflow\QUALITY_CONTRACT.md Markdown 0 28 0 0 0 0 0 0 0 8 36
22 d:\rust-ws\eskin-player\docs\workflow\README.md Markdown 0 143 0 0 0 0 0 0 0 45 188
23 d:\rust-ws\eskin-player\docs\workflow\templates\api-contract.md Markdown 0 15 0 0 0 0 0 0 0 15 30
24 d:\rust-ws\eskin-player\docs\workflow\templates\architecture-review.md Markdown 0 15 0 0 0 0 0 0 0 9 24
25 d:\rust-ws\eskin-player\docs\workflow\templates\db-change-plan.md Markdown 0 11 0 0 0 0 0 0 0 10 21
26 d:\rust-ws\eskin-player\docs\workflow\templates\docs-impact.md Markdown 0 9 0 0 0 0 0 0 0 9 18
27 d:\rust-ws\eskin-player\docs\workflow\templates\e2e-plan.md Markdown 0 11 0 0 0 0 0 0 0 10 21
28 d:\rust-ws\eskin-player\docs\workflow\templates\explore.md Markdown 0 11 0 0 0 0 0 0 0 10 21
29 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
30 d:\rust-ws\eskin-player\docs\workflow\templates\mini-prd.md Markdown 0 23 0 0 0 0 0 0 0 20 43
31 d:\rust-ws\eskin-player\docs\workflow\templates\plan.md Markdown 0 17 0 0 0 0 0 0 0 12 29
32 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
33 d:\rust-ws\eskin-player\docs\workflow\templates\product-smoke.md Markdown 0 44 0 0 0 0 0 0 0 18 62
34 d:\rust-ws\eskin-player\docs\workflow\templates\reality-check.md Markdown 0 13 0 0 0 0 0 0 0 13 26
35 d:\rust-ws\eskin-player\docs\workflow\templates\resource-cleanup.md Markdown 0 10 0 0 0 0 0 0 0 5 15
36 d:\rust-ws\eskin-player\docs\workflow\templates\resource-impact.md Markdown 0 15 0 0 0 0 0 0 0 11 26
37 d:\rust-ws\eskin-player\docs\workflow\templates\review.md Markdown 0 9 0 0 0 0 0 0 0 8 17
38 d:\rust-ws\eskin-player\docs\workflow\templates\runtime.md Markdown 0 15 0 0 0 0 0 0 0 7 22
39 d:\rust-ws\eskin-player\docs\workflow\templates\security-review.md Markdown 0 15 0 0 0 0 0 0 0 12 27
40 d:\rust-ws\eskin-player\docs\workflow\templates\skill-evidence.md Markdown 0 21 0 0 0 0 0 0 0 13 34
41 d:\rust-ws\eskin-player\docs\workflow\templates\skill-plan.md Markdown 0 25 0 0 0 0 0 0 0 15 40
42 d:\rust-ws\eskin-player\docs\workflow\templates\standards-impact.md Markdown 0 20 0 0 0 0 0 0 0 9 29
43 d:\rust-ws\eskin-player\docs\workflow\templates\summary.md Markdown 0 11 0 0 0 0 0 0 0 9 20
44 d:\rust-ws\eskin-player\docs\workflow\templates\ui-spec.md Markdown 0 17 0 0 0 0 0 0 0 13 30
45 d:\rust-ws\eskin-player\docs\workflow\templates\verification.md Markdown 0 11 0 0 0 0 0 0 0 8 19
46 d:\rust-ws\eskin-player\docs\workflow\templates\visual-review.md Markdown 0 11 0 0 0 0 0 0 0 10 21
47 d:\rust-ws\eskin-player\docs\worklog\metrics.md Markdown 0 15 0 0 0 0 0 0 2 12 29
48 d:\rust-ws\eskin-player\render.md Markdown 0 631 0 0 0 0 0 0 0 236 867
49 d:\rust-ws\eskin-player\scripts\gates\all.sh Shell Script 0 0 0 0 43 0 0 0 2 10 55
50 d:\rust-ws\eskin-player\scripts\qa\product-smoke.ps1 PowerShell 0 0 0 0 0 0 94 0 1 13 108
51 d:\rust-ws\eskin-player\scripts\qa\product-smoke.sh Shell Script 0 0 0 0 86 0 0 0 1 12 99
52 d:\rust-ws\eskin-player\scripts\tests\run.sh Shell Script 0 0 0 0 6 0 0 0 2 1 9
53 d:\rust-ws\eskin-player\scripts\validate-config.sh Shell Script 0 0 0 0 53 0 0 0 2 11 66
54 d:\rust-ws\eskin-player\scripts\workflow\verify.sh Shell Script 0 0 0 0 17 0 0 0 6 7 30
55 d:\rust-ws\eskin-player\src\app.rs Rust 330 0 0 0 0 0 0 0 12 39 381
56 d:\rust-ws\eskin-player\src\connection.rs Rust 153 0 0 0 0 0 0 0 11 26 190
57 d:\rust-ws\eskin-player\src\main.rs Rust 30 0 0 0 0 0 0 0 0 4 34
58 d:\rust-ws\eskin-player\src\matrix.rs Rust 101 0 0 0 0 0 0 0 0 10 111
59 d:\rust-ws\eskin-player\src\model.rs Rust 170 0 0 0 0 0 0 0 7 19 196
60 d:\rust-ws\eskin-player\src\recording.rs Rust 324 0 0 0 0 0 0 0 52 69 445
61 d:\rust-ws\eskin-player\src\render.rs Rust 855 0 0 0 0 0 0 0 8 61 924
62 d:\rust-ws\eskin-player\src\resources.rs Rust 937 0 0 0 0 0 0 0 0 71 1008
63 d:\rust-ws\eskin-player\src\serial_core\codec.rs Rust 6 0 0 0 0 0 0 0 0 2 8
64 d:\rust-ws\eskin-player\src\serial_core\codecs\mod.rs Rust 1 0 0 0 0 0 0 0 0 1 2
65 d:\rust-ws\eskin-player\src\serial_core\codecs\tactile_a.rs Rust 163 0 0 0 0 0 0 0 1 21 185
66 d:\rust-ws\eskin-player\src\serial_core\error.rs Rust 45 0 0 0 0 0 0 0 0 7 52
67 d:\rust-ws\eskin-player\src\serial_core\frame.rs Rust 32 0 0 0 0 0 0 0 0 5 37
68 d:\rust-ws\eskin-player\src\serial_core\mod.rs Rust 6 0 0 0 0 0 0 0 0 1 7
69 d:\rust-ws\eskin-player\src\serial_core\serial.rs Rust 78 0 0 0 0 0 0 0 7 12 97
70 d:\rust-ws\eskin-player\src\serial_core\utils.rs Rust 8 0 0 0 0 0 0 0 0 3 11
71 d:\rust-ws\eskin-player\src\style.rs Rust 283 0 0 0 0 0 0 0 0 37 320
72 d:\rust-ws\eskin-player\src\texture.rs Rust 231 0 0 0 0 0 0 0 0 21 252
73 d:\rust-ws\eskin-player\src\theme.rs Rust 149 0 0 0 0 0 0 0 1 19 169
74 d:\rust-ws\eskin-player\src\ui.rs Rust 1088 0 0 0 0 0 0 0 57 123 1268
75 d:\rust-ws\eskin-player\src\utils.rs Rust 10 0 0 0 0 0 0 0 0 2 12
76 d:\rust-ws\eskin-player\static\wgsl\shader.wgsl WGSL 0 0 0 0 0 556 0 0 7 105 668
77 Total - 5024 1276 1430 35 211 556 94 27 180 1313 10146

File diff suppressed because one or more lines are too long

View 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)

View 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 |
+------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+

View File

@@ -1,35 +0,0 @@
{
"hooks": {
"pre-exec": [
{
"matcher": "",
"command": "scale gate pre-tool Bash --args-json \"$ARGS\" --session-id \"$SESSION_ID\""
},
{
"matcher": "edit|write",
"command": "scale gate pre-tool Edit --args-json \"$ARGS\" --session-id \"$SESSION_ID\""
}
],
"post-exec": [
{
"matcher": "edit|write",
"command": "scale gate post-tool Edit --args-json \"$ARGS\" --exit-code \"$EXIT_CODE\" --session-id \"$SESSION_ID\""
},
{
"matcher": "",
"command": "scale gate post-tool Bash --args-json \"$ARGS\" --exit-code \"$EXIT_CODE\" --session-id \"$SESSION_ID\""
}
],
"before-stop": [
{
"matcher": "",
"command": "scale gate before-stop --session-id \"$SESSION_ID\""
}
]
},
"permissions": {
"allow": [
"scale:*"
]
}
}

132
Cargo.lock generated
View File

@@ -559,6 +559,12 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "bit-set"
version = "0.9.1"
@@ -664,6 +670,12 @@ dependencies = [
"syn",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -1292,7 +1304,7 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "eskin-model-player"
version = "0.5.0"
version = "5.0.0"
dependencies = [
"anyhow",
"bytemuck",
@@ -1301,10 +1313,16 @@ dependencies = [
"eframe",
"egui_extras",
"env_logger",
"fs_extra",
"glam",
"gltf",
"image",
"log",
"serde",
"serialport",
"tobj",
"toml",
"winresource",
]
[[package]]
@@ -1455,6 +1473,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-core"
version = "0.3.32"
@@ -1584,6 +1608,45 @@ dependencies = [
"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]]
name = "glutin"
version = "0.32.3"
@@ -1907,6 +1970,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inflections"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
[[package]]
name = "interpolate_name"
version = "0.2.4"
@@ -2082,6 +2151,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -3573,6 +3648,15 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "serialport"
version = "4.9.0"
@@ -3908,6 +3992,30 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tobj"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da83817ac7401436633efc35c19edae9f326fa741fae775a1caa406de9db07c"
dependencies = [
"ahash",
]
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -3938,6 +4046,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -4047,6 +4161,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4930,6 +5050,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml",
"version_check",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"

View File

@@ -1,7 +1,13 @@
[package]
name = "eskin-model-player"
version = "0.5.0"
version = "5.0.0"
edition = "2024"
authors = ["JOYSONQUIN"]
description = "Desktop pressure sensor visualization and playback application."
build = "build.rs"
[package.metadata.wix]
eula = false
[dependencies]
eframe = { version = "0.34.2", features = ["default", "wgpu", "__screenshot"] }
@@ -15,3 +21,17 @@ egui_extras = { version = "0.34.2", features = ["image"] }
crossbeam-channel = "0.5.15"
crc = "3.4.0"
log = "0.4.29"
tobj = "4.0.4"
gltf = "1.4.1"
toml = "1.1.3"
serde = { version = "1.0.228", features = ["derive"] }
[build-dependencies]
anyhow = "1.0.102"
fs_extra = "1.3.0"
winresource = "0.1.31"
[[bin]]
name = "ESkinPlayer"
path = "src/main.rs"

41
build.rs Normal file
View File

@@ -0,0 +1,41 @@
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, &copy_options)?;
}
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut resource = winresource::WindowsResource::new();
resource.set_icon("res/icon.ico");
resource.compile().expect("compile windows icon failed");
}
println!("cargo:rustc-env=RESOURCE_DIR={}", resource_dir.display());
Ok(())
}

59
config.toml Normal file
View File

@@ -0,0 +1,59 @@
[thumb]
"0.57" = 35694
"2.57" = 226640
"3.57" = 319868
"4.57" = 398021
"5.57" = 450448
"6.57" = 510506
"8.57" = 592075
"10.57" = 655760
"15.57" = 771671
"20.57" = 813967
[index]
"0.57" = 45490
"2.57" = 267579
"3.57" = 352066
"4.57" = 431681
"5.57" = 457605
"6.57" = 550875
"8.57" = 624327
"10.57" = 732735
"15.57" = 883397
"20.57" = 899338
[middle]
"0.57" = 51562
"2.57" = 252840
"3.57" = 326531
"4.57" = 386571
"5.57" = 415819
"6.57" = 514679
"8.57" = 631745
"10.57" = 678945
"15.57" = 810847
"20.57" = 900422
[ring]
"0.57" = 33213
"2.57" = 220611
"3.57" = 307383
"4.57" = 369637
"5.57" = 430462
"6.57" = 489659
"8.57" = 584789
"10.57" = 664960
"15.57" = 802749
"20.57" = 926907
[little]
"0.57" = 44291
"2.57" = 216657
"3.57" = 301950
"4.57" = 366186
"5.57" = 384791
"6.57" = 496521
"8.57" = 566189
"10.57" = 701459
"15.57" = 845608
"20.57" = 940796

1060
docs/cargo-wix-guide.md Normal file

File diff suppressed because it is too large Load Diff

866
render.md Normal file
View 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/128x128@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
res/hand.glb Normal file

Binary file not shown.

BIN
res/hand.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
res/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
提取 展会数据-H版本.xlsx 中每一组数据并按指定格式输出。
数据组织5 个数据组1#、3#、7#、8#、10#),由空行分隔。
每组包含 10 行指标:
指标 / 1s 均值 / 240s 输出值 / 240s 均值 / 误差(%) /
精度 / b值平均值 / AD值增幅 / AD值漂移 / 增幅/漂移
每行有 12 个数值列C~N与 指标 行的 12 个挡位一一对应。
输出格式(示例):
1#, 115181, 365308, 485911, ...,
3#, 116825, 371568, 475160, ...,
用法:
python extract_excel_data.py [xlsx_path] [选项]
--row LABEL 只输出指定指标行(如 "1s 均值"),可多次指定
--skip-indicators V 跳过指定挡位(按 指标 行数值匹配),可多次指定
--with-title 在每行首部加上组编号(如 "1#, 115181, ..."
--raw 保留原始数值(不取整)
--annotate 在每行末尾附加 " // 组号 指标名" 注释
-o FILE 输出到文件(默认输出到标准输出)
"""
import argparse
import os
import sys
import openpyxl
# 默认 Excel 文件路径
DEFAULT_XLSX = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"展会数据-H版本.xlsx",
)
def is_blank_row(row_values):
"""判断是否为空白行(全为 None 或空字符串)。"""
for v in row_values:
if v is None:
continue
if isinstance(v, str) and v.strip() == "":
continue
return False
return True
def is_group_header(row_values):
"""判断是否为组标题行A 列为类似 '1#' '3#' 的编号)。"""
first = row_values[0]
if first is None:
return False
return isinstance(first, str) and first.strip().endswith("#")
def extract_data_rows(ws):
"""
返回一个列表,每个元素是 (group_name, row_label, [12 个数值])。
group_name 为当前组编号row_label 为 B 列标签;数值列表为 C~N 列。
"""
results = []
current_group = None
for row in ws.iter_rows(min_row=1, max_row=ws.max_row, values_only=True):
row = list(row[:14]) # 仅取前 14 列
if is_blank_row(row):
continue
if is_group_header(row):
current_group = row[0]
label = row[1] if len(row) > 1 and row[1] is not None else "指标"
values = row[2:]
results.append((current_group, label, values))
continue
label = row[1] if len(row) > 1 and row[1] is not None else ""
values = row[2:]
if current_group is None:
continue
results.append((current_group, label, values))
return results
def parse_skip_indicators(values):
"""
把命令行传入的 --skip-indicators 值解析为浮点数集合。
支持 "0.97""0.97,1.97" 形式。
"""
out = set()
if not values:
return out
for raw in values:
for token in str(raw).split(","):
token = token.strip()
if not token:
continue
try:
out.add(float(token))
except ValueError:
print(f"警告:无法解析指标值 '{token}',已忽略。", file=sys.stderr)
return out
def indicators_match(a, b, tol=1e-9):
"""浮点数比较(容忍微小误差)。"""
if a is None or b is None:
return False
try:
return abs(float(a) - float(b)) < tol
except (TypeError, ValueError):
return False
def format_value(v, raw=False):
"""
将单元格值格式化为字符串。
raw=False: 数值四舍五入到整数(匹配示例样式)。
raw=True : 保留原始数值。
"""
if v is None:
return "0"
if isinstance(v, bool):
return "1" if v else "0"
if isinstance(v, (int,)):
return str(v)
if isinstance(v, float):
if raw:
if v.is_integer():
return str(int(v))
return f"{v:g}" if abs(v) >= 1e-4 else repr(v)
return str(int(round(v)))
s = str(v).strip()
if s == "":
return "0"
try:
f = float(s)
if raw:
if f.is_integer():
return str(int(f))
return f"{f:g}"
return str(int(round(f)))
except ValueError:
return s
def format_line(values, raw=False):
"""把数值列表格式化为 'v1, v2, ..., vn,' 形式。"""
return ", ".join(format_value(v, raw=raw) for v in values) + ","
def parse_args():
p = argparse.ArgumentParser(
description="提取展会数据 Excel 中的每一行数据。",
)
p.add_argument(
"xlsx",
nargs="?",
default=DEFAULT_XLSX,
help="Excel 文件路径(默认: 展会数据-H版本.xlsx",
)
p.add_argument(
"--row",
action="append",
default=None,
metavar="LABEL",
help='只输出指定指标行(如 "1s 均值"),可多次指定。'
"未指定时输出全部行。",
)
p.add_argument(
"--skip-indicators",
action="append",
default=None,
metavar="V",
help="跳过指定挡位(按 指标 行数值匹配)。可多次指定,"
"也支持逗号分隔。例: --skip-indicators 0.97 --skip-indicators 1.97",
)
p.add_argument(
"--with-title",
action="store_true",
help="在每行首部加上组编号(如 \"1#, 115181, 365308, ...\"",
)
p.add_argument(
"--raw",
action="store_true",
help="保留原始数值(不取整)",
)
p.add_argument(
"--annotate",
action="store_true",
help="在每行末尾附加 // 组号 指标名 注释",
)
p.add_argument(
"-o",
"--output",
default=None,
help="输出文件路径(默认输出到标准输出)",
)
return p.parse_args()
def main():
args = parse_args()
xlsx_path = os.path.abspath(args.xlsx)
if not os.path.exists(xlsx_path):
print(f"错误:找不到文件 {xlsx_path}", file=sys.stderr)
return 1
wb = openpyxl.load_workbook(xlsx_path, data_only=True)
ws = wb.active
rows = extract_data_rows(ws)
if not rows:
print("未找到任何数据。", file=sys.stderr)
return 1
# ===== 列筛选(先做,依赖 指标 行) =====
skip_set = parse_skip_indicators(args.skip_indicators)
if skip_set:
filtered = []
current_indicators = None
for group, label, values in rows:
if label == "指标":
current_indicators = values
if current_indicators is None:
# 没有任何组提供 指标 行作对照,原样保留
filtered.append((group, label, values))
continue
kept = [
v for v, ind in zip(values, current_indicators)
if not any(indicators_match(ind, s) for s in skip_set)
]
filtered.append((group, label, kept))
rows = filtered
# ===== 行筛选(在列筛选之后) =====
if args.row:
wanted = {name.strip() for name in args.row}
rows = [(g, l, v) for (g, l, v) in rows if l in wanted]
if not rows:
print(
f"未找到匹配的行 {args.row}。可用行名请参考 Excel 文件 B 列。",
file=sys.stderr,
)
return 1
out_lines = []
last_group = None
for group, label, values in rows:
if last_group is not None and group != last_group:
out_lines.append("") # 组之间插入空行
last_group = group
line = format_line(values, raw=args.raw)
if args.with_title:
line = f"{group}, {line}"
if args.annotate:
line = f"{line} // {group} {label}"
out_lines.append(line)
text = "\n".join(out_lines) + "\n"
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
print(f"已写入 {len(rows)} 行数据到 {args.output}", file=sys.stderr)
else:
sys.stdout.write(text)
return 0
if __name__ == "__main__":
sys.exit(main())

1090
src/app.rs

File diff suppressed because it is too large Load Diff

655
src/breakout.rs Normal file
View File

@@ -0,0 +1,655 @@
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,
Won,
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,
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,
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);
});
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.rebuild_bricks();
}
fn start(&mut self) {
if self.phase == BreakoutPhase::Over || self.phase == BreakoutPhase::Won {
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 matches!(
self.phase,
BreakoutPhase::Idle | BreakoutPhase::Over | BreakoutPhase::Won
) {
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 = 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.phase = BreakoutPhase::Won;
self.ball_vel = egui::Vec2::ZERO;
}
}
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::Won => "过关",
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::Won
|| 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 top_gesture_rows = rows.min(1);
let top_gesture_cols = (cols / 3).clamp(1, cols);
let top_gesture_col_start = (cols - top_gesture_cols) / 2;
let top_gesture_col_end = top_gesture_col_start + top_gesture_cols;
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 = avg(
0,
top_gesture_rows,
top_gesture_col_start,
top_gesture_col_end,
);
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::Won => ACCENT_GREEN,
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::Won => ("恭喜过关", "按压顶部、点击或空格重新开始"),
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)
}

70
src/config.rs Normal file
View File

@@ -0,0 +1,70 @@
use serde::Deserialize;
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Deserialize)]
pub struct HandForceConfig {
pub thumb: BTreeMap<String, u32>,
pub index: BTreeMap<String, u32>,
pub middle: BTreeMap<String, u32>,
pub ring: BTreeMap<String, u32>,
pub little: BTreeMap<String, u32>,
}
pub fn try_load_hand_force_config() -> Option<HandForceConfig> {
let path: PathBuf = std::env::current_exe().ok()?.parent()?.join("config.toml");
load_hand_force_config(&path)
}
fn load_hand_force_config(path: &Path) -> Option<HandForceConfig> {
let content = fs::read_to_string(path).ok()?;
toml::from_str(&content).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn loads_hand_force_config_toml() {
let path = std::env::temp_dir().join(format!(
"eskin-hand-force-config-{}-{}.toml",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after Unix epoch")
.as_nanos()
));
fs::write(
&path,
r#"
[thumb]
"0.57" = 35694
"2.57" = 226640
[index]
"0.57" = 45490
[middle]
"0.57" = 51562
[ring]
"0.57" = 33213
[little]
"0.57" = 44291
"#,
)
.expect("test config should be writable");
let config = load_hand_force_config(&path).expect("valid TOML should load");
fs::remove_file(&path).expect("test config should be removable");
assert_eq!(config.thumb["2.57"], 226640);
assert_eq!(config.index["0.57"], 45490);
}
}

View File

@@ -4,7 +4,10 @@ use std::time::Duration;
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -27,6 +30,9 @@ struct Session {
cancel_tx: Sender<()>,
handle: JoinHandle<()>,
sample_rx: Receiver<Vec<i32>>,
stats_rx: Receiver<SerialIoStats>,
rows: u32,
cols: u32,
}
/// Thread-safe connection manager that the UI and renderer can share.
@@ -34,6 +40,7 @@ pub struct ConnectionManager {
state: Arc<Mutex<ConnectionState>>,
session: Arc<Mutex<Option<Session>>>,
latest_sample: Arc<Mutex<Option<PressureSample>>>,
stats: Arc<Mutex<SerialIoStats>>,
}
impl ConnectionManager {
@@ -42,6 +49,7 @@ impl ConnectionManager {
state: Arc::new(Mutex::new(ConnectionState::Disconnected)),
session: 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;
}
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.
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.set_state(ConnectionState::Connecting);
@@ -64,16 +95,22 @@ impl ConnectionManager {
let latest_sample = Arc::clone(&self.latest_sample);
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1);
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 result = run_device_loop(
&port,
rows,
cols,
baud_rate,
protocol,
&state,
&cancel_rx,
&sample_tx,
&stats_tx,
&latest_sample,
recorder,
);
if let Err(e) = result {
eprintln!("[connection] device loop error: {e}");
@@ -85,6 +122,9 @@ impl ConnectionManager {
cancel_tx,
handle,
sample_rx,
stats_rx,
rows,
cols,
});
}
@@ -102,6 +142,7 @@ impl ConnectionManager {
self.set_state(ConnectionState::Disconnected);
*self.latest_sample.lock().unwrap() = None;
*self.stats.lock().unwrap() = SerialIoStats::default();
}
/// Drain pending samples (non-blocking) and return the last one.
@@ -112,10 +153,12 @@ impl ConnectionManager {
loop {
match session.sample_rx.try_recv() {
Ok(vals) => {
let rows = 12u32;
let cols = 7u32;
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::Disconnected) => break,
@@ -140,13 +183,17 @@ fn run_device_loop(
port_name: &str,
rows: u32,
cols: u32,
baud_rate: u32,
protocol: SerialProtocol,
state: &Arc<Mutex<ConnectionState>>,
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
stats_tx: &Sender<SerialIoStats>,
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
recorder: Recorder,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let port = serialport::new(port_name, 921_600)
.timeout(Duration::from_millis(100))
let port = serialport::new(port_name, baud_rate)
.timeout(Duration::from_millis(1))
.open()?;
*state.lock().unwrap() = ConnectionState::Connected;
@@ -154,36 +201,19 @@ fn run_device_loop(
let mut rw = SerialPortReadWrite::new(port);
*state.lock().unwrap() = ConnectionState::Streaming;
// We need to also forward samples to latest_sample
let (inner_tx, inner_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
let latest = Arc::clone(latest_sample);
let outer_tx = sample_tx.clone();
run_serial_loop(
&mut rw,
rows as usize,
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
let bridge_cancel = cancel_rx.clone();
let bridge_handle = thread::spawn(move || {
loop {
if bridge_cancel.try_recv().is_ok() {
break;
if let Ok(mut latest) = latest_sample.lock() {
*latest = None;
}
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(())
}

98
src/force.rs Normal file
View File

@@ -0,0 +1,98 @@
use crate::serial_core::multi_dim_force::PztProcessor;
pub const FINGER_SAMPLE_COUNT: usize = 84;
pub const HAND_FINGERTIP_COUNT: usize = 5;
const MIN_TANGENTIAL_MAGNITUDE: f32 = 0.1;
#[derive(Debug, Clone, Copy)]
pub struct HudSpatialForce {
pub angle_deg: f32,
pub magnitude: f32,
}
pub struct ForceEstimatorState {
pzt_processor: PztProcessor,
fingertip_processors: [PztProcessor; HAND_FINGERTIP_COUNT],
}
impl ForceEstimatorState {
pub fn new() -> Self {
Self {
pzt_processor: PztProcessor::new(),
fingertip_processors: std::array::from_fn(|_| PztProcessor::new()),
}
}
pub fn reset(&mut self) {
self.pzt_processor.reset_baseline();
self.fingertip_processors
.iter_mut()
.for_each(PztProcessor::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,
})
}
pub fn analyze_fingertips(
&mut self,
values: &[u32],
) -> [Option<HudSpatialForce>; HAND_FINGERTIP_COUNT] {
self.analyze_fingertips_masked(values, &[true; HAND_FINGERTIP_COUNT])
}
pub fn analyze_fingertips_masked(
&mut self,
values: &[u32],
enabled: &[bool; HAND_FINGERTIP_COUNT],
) -> [Option<HudSpatialForce>; HAND_FINGERTIP_COUNT] {
let mut forces = [None; HAND_FINGERTIP_COUNT];
for (tip_index, processor) in self.fingertip_processors.iter_mut().enumerate() {
if !enabled[tip_index] {
continue;
}
let start = tip_index * FINGER_SAMPLE_COUNT;
let end = start + FINGER_SAMPLE_COUNT;
let Some(segment) = values.get(start..end) else {
break;
};
let pzt_values = segment
.iter()
.map(|value| *value as f32)
.collect::<Vec<_>>();
forces[tip_index] = 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,
});
}
forces
}
}
impl Default for ForceEstimatorState {
fn default() -> Self {
Self::new()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -1,11 +1,19 @@
#![allow(dead_code)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app;
mod breakout;
mod config;
mod connection;
mod force;
mod matrix;
mod render;
mod serial_core;
mod theme;
mod ui;
mod model;
mod recording;
mod render;
mod resources;
mod serial_core;
mod style;
mod texture;
mod ui;
mod utils;
use app::EskinDesktopApp;
use eframe::egui;
@@ -13,12 +21,15 @@ use eframe::egui;
fn main() -> eframe::Result<()> {
env_logger::init();
let window_icon = eframe::icon_data::from_png_bytes(include_bytes!("../res/128x128@2x.png"))
.expect("load window icon failed");
let options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu,
viewport: egui::ViewportBuilder::default()
.with_inner_size([1920.0, 1080.0])
.with_min_inner_size([1280.0, 720.0])
.with_decorations(false),
.with_decorations(false)
.with_icon(window_icon),
..Default::default()
};

195
src/model.rs Normal file
View 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);
}
}

View File

@@ -292,9 +292,11 @@ impl Recorder {
let mut pressures = Vec::with_capacity(n_channels);
for f in &fields[..n_channels] {
pressures.push(f.parse::<u32>().with_context(|| {
pressures.push(
f.parse::<u32>().with_context(|| {
format!("line {}: bad channel value '{}'", lineno + 2, f)
})?);
})?,
);
}
let raw_ts = fields[n_channels]
.parse::<u64>()
@@ -320,7 +322,8 @@ impl Recorder {
// 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.start =
Some(Instant::now() - std::time::Duration::from_millis(last.timestamp_ms));
r.paused_duration_ms = 0;
}
}

File diff suppressed because it is too large Load Diff

1008
src/resources.rs Normal file

File diff suppressed because it is too large Load Diff

View 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]
);
}
}

View File

@@ -1 +1,2 @@
pub mod hand_gateway;
pub mod tactile_a;

View File

@@ -13,6 +13,15 @@ pub struct TactileACodec {
expected_data_len: usize,
}
impl From<u8> for TactileAFrameStatusCode {
fn from(value: u8) -> Self {
match value {
0 => TactileAFrameStatusCode::Success,
_ => TactileAFrameStatusCode::Failure,
}
}
}
impl TactileACodec {
pub fn new(cols: usize, rows: usize) -> TactileACodec {
Self {
@@ -37,7 +46,7 @@ impl TactileACodec {
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 payload_len: usize = 9;
let device_addr: u8 = 0x34;
@@ -46,7 +55,7 @@ impl TactileACodec {
let start_addr: u32 = 7168;
let except_data_len: usize = cols * rows * 2;
let checksum: u8 = 0;
TactileAFrame::Req(TactileAReqFrame {
Ok(TactileAFrame::Req(TactileAReqFrame {
meta: TactileAFrameMetaData {
header,
payload_len,
@@ -57,7 +66,7 @@ impl TactileACodec {
except_data_len,
checksum,
},
})
}))
}
}
@@ -102,10 +111,7 @@ impl Codec<TactileAFrame> for TactileACodec {
self.buffer[10],
]);
let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize;
let status = match self.buffer[13] {
0 => TactileAFrameStatusCode::Success,
_ => TactileAFrameStatusCode::Failure,
};
let status = TactileAFrameStatusCode::from(self.buffer[13]);
if except_data_len != self.expected_data_len {
log::debug!(

View File

@@ -9,6 +9,8 @@ pub enum SerialError {
AlreadyConnected,
StateError,
NoRecordedData,
ExportError,
ImportError,
}
impl fmt::Display for SerialError {
@@ -21,6 +23,8 @@ impl fmt::Display for SerialError {
SerialError::AlreadyConnected => write!(f, "Already Connected"),
SerialError::StateError => write!(f, "State Error"),
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
SerialError::ExportError => write!(f, "Export Error"),
SerialError::ImportError => write!(f, "Import Error"),
}
}
}

View File

@@ -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)]
pub struct TactileAFrameMetaData {
pub header: [u8; 2],

View File

@@ -2,5 +2,6 @@ pub mod codec;
pub mod codecs;
pub mod error;
pub mod frame;
pub mod multi_dim_force;
pub mod serial;
pub mod utils;

View 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 = 50;
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);
}
}

View File

@@ -1,12 +1,27 @@
use crate::recording::Recorder;
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::frame::TactileAFrame;
use crate::serial_core::utils::elapsed_millis;
use crossbeam_channel::{Receiver, Sender, TryRecvError};
use crossbeam_channel::{Receiver, Sender};
use std::io::{Read, Write};
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,
pub rx_frames: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SerialProtocol {
TactileA,
HandGateway,
}
/// Runs the serial polling loop on the calling (background) thread.
/// Sends decoded pressure matrix data (Vec<i32>) to the output channel.
@@ -14,14 +29,43 @@ pub fn run_serial_loop(
port: &mut dyn ReadWrite,
rows: usize,
cols: usize,
protocol: SerialProtocol,
cancel_rx: &Receiver<()>,
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 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 poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
let poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
let mut io_stats = SerialIoStats::default();
loop {
// Check cancel
@@ -31,7 +75,10 @@ pub fn run_serial_loop(
// Send poll request
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
@@ -43,11 +90,20 @@ pub fn run_serial_loop(
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 {
if let TactileAFrame::Rep(rep) = frame {
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);
io_stats.rx_frames += 1;
publish_stats(stats_tx, io_stats);
}
}
}
@@ -68,6 +124,111 @@ 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);
io_stats.rx_frames += 1;
publish_stats(stats_tx, io_stats);
}
}
}
}
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
pub trait ReadWrite: Send {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;

View File

@@ -1,10 +1,64 @@
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 {
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
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 {
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);
}
}

View File

@@ -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);
}

333
src/style.rs Normal file
View File

@@ -0,0 +1,333 @@
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 CONFIG_BAR_HEIGHT: f32 = 38.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
}

View File

@@ -1,15 +1,101 @@
#![allow(dead_code)]
use anyhow::*;
use eframe::egui_wgpu::{self, wgpu};
use eframe::wgpu;
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 texture: wgpu::Texture,
pub view: wgpu::TextureView,
pub sampler: wgpu::Sampler,
pub width: u32,
pub height: u32,
}
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(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -26,8 +112,8 @@ impl Texture {
img: &image::DynamicImage,
label: Option<&str>,
) -> Result<Self> {
let rgba = img.to_rgba8();
let dimensions = img.dimensions();
let rgba = img.to_rgba8();
let size = wgpu::Extent3d {
width: dimensions.0,
@@ -40,7 +126,7 @@ impl Texture {
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
format: TextureColorSpace::Srgb.format(),
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
@@ -76,6 +162,90 @@ impl Texture {
texture,
view,
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,
})
}
}

1580
src/ui.rs

File diff suppressed because it is too large Load Diff

892
static/wgsl/shader.wgsl Normal file
View File

@@ -0,0 +1,892 @@
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, 0.92, 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,
@location(1) intensity: f32,
}
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 dot_matrix(uv: vec2f, grid: vec2f, dot_radius: f32, dot_softness: f32) -> f32 {
let cell = fract(uv * grid) - vec2f(0.5, 0.5);
return 1.0 - smoothstep(dot_radius, dot_radius + dot_softness, length(cell));
}
fn fingertip_film_alpha(
local: vec2f,
half_width: f32,
cap_center_y: 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 remains open; the carrier quad clips the membrane at its end.
let side = 1.0 - smoothstep(half_width, half_width + softness, abs(local.x));
let body_gate = smoothstep(cap_center_y - softness, cap_center_y + softness, local.y);
let body = side * 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;
out.intensity = saturate(instance.style.w);
return out;
}
@fragment
fn fs_hand_membrane(in: HandMembraneVertexOutput) -> @location(0) vec4f {
let p = in.local;
let live = saturate(in.intensity);
let response = smoothstep(0.03, 0.96, live);
let hot = smoothstep(0.72, 0.96, live);
let live_color = sample_range_color(live);
let halo_shape = fingertip_film_alpha(p, 0.72, -0.20, 0.055);
let panel = fingertip_film_alpha(p, 0.66, -0.22, 0.035);
let inner = fingertip_film_alpha(p, 0.58, -0.25, 0.045);
let clear_inner = fingertip_film_alpha(p, 0.48, -0.24, 0.160);
let halo = clamp(halo_shape - panel, 0.0, 1.0);
let rim = clamp(panel - inner, 0.0, 1.0);
let edge_fade = pow(clamp(1.0 - clear_inner, 0.0, 1.0), 1.20) * panel;
// Strong pseudo extrusion / bevel.
let depth_offset = vec2f(0.045, 0.055);
let back_shape_1 = fingertip_film_alpha(p - depth_offset * 0.55, 0.66, -0.22, 0.045);
let back_shape_2 = fingertip_film_alpha(p - depth_offset, 0.66, -0.22, 0.060);
let extrusion = clamp(max(back_shape_1, back_shape_2) - panel, 0.0, 1.0);
let bevel_offset = vec2f(0.028, 0.034);
let shifted_down_right = fingertip_film_alpha(p - bevel_offset, 0.66, -0.22, 0.035);
let bevel_light = clamp(panel - shifted_down_right, 0.0, 1.0);
let shifted_up_left = fingertip_film_alpha(p + bevel_offset, 0.66, -0.22, 0.035);
let bevel_dark = clamp(panel - shifted_up_left, 0.0, 1.0);
let broad_bevel = edge_fade * clamp(0.58 - p.x * 0.20 - p.y * 0.18, 0.0, 1.0);
// Micro lattice.
let uv = clamp(
p * vec2f(0.52, 0.46) + vec2f(0.5, 0.46),
vec2f(0.0, 0.0),
vec2f(1.0, 1.0),
);
let grid = vec2f(18.0, 34.0);
let cell_uv = fract(uv * grid) - vec2f(0.5, 0.5);
let dot_dist = length(cell_uv * vec2f(1.0, 1.06));
let dot_aa = max(fwidth(dot_dist) * 1.35, 0.006);
let dots = (1.0 - smoothstep(0.105 - dot_aa, 0.105 + dot_aa, dot_dist)) * inner;
let sheen_axis = p.x * 0.88 + p.y * 0.22 + 0.16;
let sheen = (1.0 - smoothstep(0.018, 0.105, abs(sheen_axis))) * panel;
let shell_sheen = sheen * (0.26 + edge_fade * 0.74);
// Temporary synthetic pressure hotspot. Real hand pressure dots are drawn above this pass.
let pressure_center = vec2f(-0.08, -0.12);
let pressure_dist = length((p - pressure_center) * vec2f(1.0, 0.74));
let pressure = (1.0 - smoothstep(0.05, 0.36, pressure_dist)) * inner;
let pressure_hot = (1.0 - smoothstep(0.02, 0.13, pressure_dist)) * inner;
let glass_base = vec3f(0.006, 0.055, 0.105);
let glass_cyan = vec3f(0.025, 0.42, 0.58);
let glass_live = mix(glass_cyan, live_color * 0.58, response);
let rim_color = mix(vec3f(0.10, 0.88, 1.00), live_color, response);
let extrusion_color = vec3f(0.004, 0.080, 0.110);
let extrusion_edge_color = mix(vec3f(0.015, 0.30, 0.38), live_color * 0.72, response);
let bevel_highlight = mix(vec3f(0.48, 1.00, 1.00), live_color, response * 0.82);
let bevel_dark_color = vec3f(0.004, 0.035, 0.060);
let dot_color = mix(vec3f(0.08, 0.48, 0.58), live_color, response);
let pressure_color = mix(dot_color, live_color, response);
let pressure_hot_color = live_color;
let membrane_color = extrusion_color * extrusion * 0.90
+ extrusion_edge_color * extrusion * halo_shape * 0.32
+ glass_base * panel * 0.26
+ glass_live * edge_fade * 0.46
+ glass_live * broad_bevel * 0.24
+ rim_color * rim * 0.88
+ bevel_highlight * bevel_light * 1.35
+ bevel_dark_color * bevel_dark * 0.95
+ bevel_highlight * shell_sheen * 0.28
+ rim_color * halo * 0.16
+ dot_color * dots * 0.68
+ pressure_color * dots * pressure * 0.82
+ pressure_hot_color * dots * pressure_hot * 0.90;
let live_wash = live_color * response * (inner * 0.12 + dots * 0.72 + rim * 0.30)
+ live_color * hot * (inner * 0.08 + dots * 0.22);
let color = membrane_color + live_wash;
let alpha = clamp(
extrusion * 0.44
+ panel * 0.055
+ edge_fade * 0.30
+ rim * 0.34
+ bevel_light * 0.34
+ bevel_dark * 0.20
+ shell_sheen * 0.09
+ halo * 0.045
+ dots * 0.09
+ dots * pressure * 0.10,
0.0,
0.90,
) + response * (dots * 0.07 + rim * 0.035) + hot * dots * 0.04;
return output_color(color, clamp(alpha, 0.0, 0.96));
}
@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 {
// The live palm-dot pass draws every chip cell, including the idle dots.
// Keeping this background pass transparent prevents a second offset dot grid.
return output_color(vec3f(0.0, 0.0, 0.0), 0.0);
}
@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);
let center = hand_image_uv_to_clip(instance.world_position.xy);
// Palm boards have more tightly packed cells than the fingertips, so use
// a larger circular mask to keep each sensor visibly readable.
let pixel_size = u.glyph.x * mix(0.40, 0.54, 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 core = circle_alpha(in.local, 0.48, 0.07);
let halo = circle_alpha(in.local, 0.74, 0.14) * (0.10 + intensity * 0.26);
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));
}
@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
View 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>