Compare commits
8 Commits
e0fbbd46a6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2497cb93ff | |||
| 174adb5366 | |||
| 2f16c4762f | |||
|
|
5f1c217853 | ||
|
|
d2c9fad556 | ||
|
|
a7b617419d | ||
|
|
83faa0be1e | ||
|
|
aff9c2a75c |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1 +1,10 @@
|
||||
/target
|
||||
JE-Skin/
|
||||
eskin-finger-sdk/
|
||||
*.err
|
||||
*.out
|
||||
*.exe
|
||||
*.pdb
|
||||
*.d
|
||||
*.rlib
|
||||
*.rmeta
|
||||
|
||||
35
.openclaw/settings.json
Normal file
35
.openclaw/settings.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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:*"
|
||||
]
|
||||
}
|
||||
}
|
||||
7
.scale/.gitignore
vendored
Normal file
7
.scale/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
*.db
|
||||
*.db-journal
|
||||
events/
|
||||
checkpoints/
|
||||
evidence/
|
||||
state/
|
||||
hooks/*.sh
|
||||
4
.scale/assets.json
Normal file
4
.scale/assets.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"assets": []
|
||||
}
|
||||
6
.scale/engineering-standards-baseline.json
Normal file
6
.scale/engineering-standards-baseline.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": "",
|
||||
"scope": "Legacy findings tracked separately. New findings and changed-file findings must still be fixed before completion.",
|
||||
"findings": []
|
||||
}
|
||||
67
.scale/engineering-standards.json
Normal file
67
.scale/engineering-standards.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "warn",
|
||||
"sourceDirectories": [
|
||||
"src",
|
||||
"app",
|
||||
"packages",
|
||||
"services",
|
||||
"cmd",
|
||||
"internal",
|
||||
"pkg"
|
||||
],
|
||||
"ignoredDirectories": [
|
||||
".git",
|
||||
".scale",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"coverage",
|
||||
"test-results",
|
||||
"playwright-report",
|
||||
"tmp",
|
||||
"temp",
|
||||
"docs",
|
||||
"tests",
|
||||
"__tests__",
|
||||
"e2e"
|
||||
],
|
||||
"allowedConsoleDirectories": [
|
||||
"src/api",
|
||||
"src/cli",
|
||||
"scripts"
|
||||
],
|
||||
"allowedConsoleFiles": [
|
||||
"src/dashboard/DashboardServer.ts"
|
||||
],
|
||||
"maxFileLines": 500,
|
||||
"logging": {
|
||||
"approvedLoggers": [
|
||||
"pino",
|
||||
"winston",
|
||||
"zap",
|
||||
"zerolog",
|
||||
"logrus",
|
||||
"slog"
|
||||
],
|
||||
"sensitiveFields": [
|
||||
"password",
|
||||
"passwd",
|
||||
"token",
|
||||
"accessToken",
|
||||
"refreshToken",
|
||||
"secret",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"apiKey",
|
||||
"credential",
|
||||
"privateKey"
|
||||
]
|
||||
},
|
||||
"architecture": {
|
||||
"enforceLayering": true
|
||||
},
|
||||
"blockingRules": [],
|
||||
"allowedFindingPatterns": [],
|
||||
"baselineFindings": []
|
||||
}
|
||||
23
.scale/frameworks.json
Normal file
23
.scale/frameworks.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lastReviewedAt": "",
|
||||
"reviewIntervalDays": 90,
|
||||
"frameworks": [],
|
||||
"orm": [],
|
||||
"ui": {
|
||||
"designSystem": "",
|
||||
"componentLibrary": "",
|
||||
"visualReviewRequired": true
|
||||
},
|
||||
"architecture": {
|
||||
"layers": [
|
||||
"api",
|
||||
"service",
|
||||
"domain",
|
||||
"repository",
|
||||
"infrastructure"
|
||||
],
|
||||
"dependencyRule": "outer layers depend inward through explicit interfaces"
|
||||
},
|
||||
"bannedImports": []
|
||||
}
|
||||
200
.scale/governance.lock.json
Normal file
200
.scale/governance.lock.json
Normal file
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"version": 1,
|
||||
"scalePackage": "@hongmaple0820/scale-engine",
|
||||
"scaleVersion": "0.0.0-dev",
|
||||
"pack": "standard",
|
||||
"packVersion": 1,
|
||||
"generatedAt": "2026-05-20T06:59:28.689Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/workflow/README.md",
|
||||
"sha256": "95568223be7593850dd117af82c7a983bb361a19651884b7dd8a1240a4a37956",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/explore.md",
|
||||
"sha256": "e3845d0ecc4d85d11cbd2ac49de2a6fc3fad4debf071c31a4567c76fc44bceea",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/mini-prd.md",
|
||||
"sha256": "276bec99769152ed90c4ab56e789c4fd8756de4fb1794db260a70a6a5ef032b8",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/skill-plan.md",
|
||||
"sha256": "60f48a9d780bf0029bb7281a92d3ff9ca702e6cae1c49d9ca17fda790f04439e",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/skill-evidence.md",
|
||||
"sha256": "fa697a67437bae8b3372d620cfab4e64daff7a693a0603327389da362cf805d7",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/runtime.md",
|
||||
"sha256": "e1f4c575dec24c95e9b9f72d52d47badbd1bd0e8fb207193b671803a0c59fa9c",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/reality-check.md",
|
||||
"sha256": "11a9102b87b6f08b118a21edc5fcdda49f27741078e463a44a6e4dd904513d0b",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/resource-cleanup.md",
|
||||
"sha256": "ba2b343cbee04d3037638f3751f64cb14ad63c528529be364967bb49619cfa25",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/ui-spec.md",
|
||||
"sha256": "328022424df35d7a221ff18fb5c04840c74f327deabf1d49f7a7e739fe06290c",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/visual-review.md",
|
||||
"sha256": "7ebf7765969a7cf36b46aeb4d9f1c04f5513cefdd2e57256c1badc83722b00cc",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/api-contract.md",
|
||||
"sha256": "a9091cc5f6d7b8178589dcff2253afa366d188903b175dd5c69dbc6c955bace0",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/docs-impact.md",
|
||||
"sha256": "a4ff6c98539b71ff7f6df2cce8253b9073c6c134ee2ee9d1e1fc34b819b961a0",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/resource-impact.md",
|
||||
"sha256": "af37cb2d4d97cfe46243269225732dfc10180dd6eb1e58b3085abb6ee0e3cc5f",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/standards-impact.md",
|
||||
"sha256": "eed804359ca45b0b7d4436b305ea44f9872bccaa3c6e4fcf3dfbf0b1f422545f",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/architecture-review.md",
|
||||
"sha256": "819390550452c3bb65e5eff2fee44d3d5767a3f4e164fd64cdb10420fd3f40aa",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/security-review.md",
|
||||
"sha256": "542bb035a0e45dc954056d95652952717ec34765debe0de3649246fb6e1290a8",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/db-change-plan.md",
|
||||
"sha256": "9e76ec7d3efae5374373bc1ad11c94da74ff59927b3778a1fed557dba0c51458",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/e2e-plan.md",
|
||||
"sha256": "e77779e7f0f61610c9d1ef594a53565599d0777f2c6c12a9c6507f82b6fbfb26",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/product-smoke.md",
|
||||
"sha256": "5b4be8ecddc47d1b4f890bd83532ed5652a1444a0ab2d30c0c91015a46f648d4",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/plan.md",
|
||||
"sha256": "08100fe1bb88e801e11c13cd1ad6d26dbebe6117027b93b26c44ad3ef907ff81",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/verification.md",
|
||||
"sha256": "b9cbe1f287115a15b18b201e8e25e2c40a2dc3aa4b347098c21414c66ea0fb49",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/review.md",
|
||||
"sha256": "62e3bee211f9e4381bfb3fb7463edb78e74da708bc804c35f4acab9c5f65d010",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/summary.md",
|
||||
"sha256": "00c99d6236ca749e330eb3769c1c0a55082f64db58362b73944419e7ec7753cf",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/github-actions-scale-preflight.yml",
|
||||
"sha256": "a218c4a23251e6da42b807ebc490d363d17490abd5e8ec7bcb11377444571f33",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/workflow/templates/pre-push-scale-preflight.sh",
|
||||
"sha256": "3b3a8e45ab3ac8774e54fbcbd5b44cddbf1df06a74aa671f535289b9629a3f7c",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "docs/worklog/metrics.md",
|
||||
"sha256": "66ced9a1d893b142e01d2e4aaf0e3d6509a8166d6274fbacfc4abee52874359b",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/qa/product-smoke.ps1",
|
||||
"sha256": "e5b0694cbb83dd3e722435b6133c45ea43eab576a3f013dc30e6bc5492f8d6fb",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/qa/product-smoke.sh",
|
||||
"sha256": "633ec087cc54081ca9aa52f4915274b427b0e2148cf1510a8dac56ce47a71c7e",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": ".scale/verification.json",
|
||||
"sha256": "483e73923114628149c7f31a984526cb07bf60a4c237273beb68697078dc1941",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": ".scale/skills.json",
|
||||
"sha256": "56228729c0fbb89ee31fee286b47a877002fc0972ea45db7f3153b7ec213d24e",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": ".scale/tools.json",
|
||||
"sha256": "f204f9ef206a7b0f25b2064661bd0d5ff25980b36c0f9bda0bb973af206074ad",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": ".scale/resource-policy.json",
|
||||
"sha256": "67d1e469d6cef5d68508ac3cc8bb5c9fd1eea33826f9c3f2fdf8e19cff10f6b9",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": ".scale/assets.json",
|
||||
"sha256": "a8174f5941573d1acd5d5df5dee4bca235701e21c29685694d143512d3247acf",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": ".scale/output-policy.json",
|
||||
"sha256": "c51b6f0b4ecc570f0b8a7738a1a3b0013cc76fb99dfb121c8ee9f51e7fa7c3bb",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": ".scale/product-smoke.json",
|
||||
"sha256": "9b07dd0d9dd0d3a1689c497cd1f79b1f72b8324d4059b5172c1b503a9d82585f",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": ".scale/engineering-standards.json",
|
||||
"sha256": "dd8a8bc0f0594f31ba9fd71120e73d4c78db84b7603aab50bdaf875401d78ad9",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": ".scale/engineering-standards-baseline.json",
|
||||
"sha256": "14b1ebdb505038b5eba18297b4e76ae1185cd60c98a6f131001633498bed2cf7",
|
||||
"owned": true
|
||||
},
|
||||
{
|
||||
"path": ".scale/frameworks.json",
|
||||
"sha256": "78158a49e9c1331e0222e6efa1bb3744c5fe2bdb043da66a5238855f433756cc",
|
||||
"owned": true
|
||||
}
|
||||
]
|
||||
}
|
||||
72
.scale/output-policy.json
Normal file
72
.scale/output-policy.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sourceFormat": "markdown",
|
||||
"artifactDirectory": "artifacts",
|
||||
"manifestFile": "artifact-manifest.json",
|
||||
"defaultTheme": "auto",
|
||||
"defaultGitPolicy": "review",
|
||||
"safety": {
|
||||
"allowRemoteScripts": false,
|
||||
"allowRemoteStyles": false,
|
||||
"detectSecrets": true
|
||||
},
|
||||
"templates": {
|
||||
"plan-comparison": {
|
||||
"label": "Plan Comparison",
|
||||
"sources": [
|
||||
"mini-prd.md",
|
||||
"explore.md",
|
||||
"plan.md"
|
||||
],
|
||||
"description": "Compare candidate approaches, tradeoffs, open questions, and decision criteria."
|
||||
},
|
||||
"implementation-plan": {
|
||||
"label": "Implementation Plan",
|
||||
"sources": [
|
||||
"plan.md",
|
||||
"verification.md"
|
||||
],
|
||||
"description": "Convert the implementation plan and verification strategy into a scannable delivery surface."
|
||||
},
|
||||
"code-review": {
|
||||
"label": "Code Review",
|
||||
"sources": [
|
||||
"review.md",
|
||||
"security-review.md",
|
||||
"standards-impact.md"
|
||||
],
|
||||
"description": "Summarize review findings, severity, evidence, and residual risks."
|
||||
},
|
||||
"status-report": {
|
||||
"label": "Status Report",
|
||||
"sources": [
|
||||
"summary.md",
|
||||
"verification.md",
|
||||
"resource-impact.md",
|
||||
"standards-impact.md"
|
||||
],
|
||||
"description": "Show current task status, proof, blockers, resource state, and follow-ups."
|
||||
},
|
||||
"incident-report": {
|
||||
"label": "Incident Report",
|
||||
"sources": [
|
||||
"explore.md",
|
||||
"plan.md",
|
||||
"verification.md",
|
||||
"review.md"
|
||||
],
|
||||
"description": "Explain incident context, diagnosis, fix, validation, and prevention work."
|
||||
},
|
||||
"release-report": {
|
||||
"label": "Release Report",
|
||||
"sources": [
|
||||
"summary.md",
|
||||
"verification.md",
|
||||
"review.md",
|
||||
"resource-impact.md",
|
||||
"standards-impact.md"
|
||||
],
|
||||
"description": "Package final release evidence, risk state, unverified items, and sign-off readiness."
|
||||
}
|
||||
}
|
||||
}
|
||||
33
.scale/product-smoke.json
Normal file
33
.scale/product-smoke.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": 1,
|
||||
"gate": "warn",
|
||||
"requiredForLevels": [
|
||||
"M",
|
||||
"L",
|
||||
"CRITICAL"
|
||||
],
|
||||
"emptyProbeBehavior": "block",
|
||||
"setupGuide": [
|
||||
"Set probes[].enabled=true only after replacing the example command with a real product path.",
|
||||
"Use a command that crosses the real boundary: client/UI -> gateway/router -> service -> persistence or observable result.",
|
||||
"Run: scale preflight --profile productSmoke --json",
|
||||
"Run: scale runtime final-check --level M --json"
|
||||
],
|
||||
"runtimeEvidence": {
|
||||
"requiredKind": "command",
|
||||
"requiredStatus": "passed",
|
||||
"requireArtifacts": true
|
||||
},
|
||||
"probes": [
|
||||
{
|
||||
"id": "example-business-flow",
|
||||
"enabled": false,
|
||||
"description": "Replace with a real user/product path such as UI -> gateway -> service -> database/storage.",
|
||||
"command": "curl -fsS http://127.0.0.1:3000/health",
|
||||
"expected": {
|
||||
"exitCode": 0,
|
||||
"evidenceArtifact": ".agent/logs/product-smoke.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
70
.scale/quality-contract.json
Normal file
70
.scale/quality-contract.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"project": "eskin-model-player",
|
||||
"description": "E-Skin 传感器数据可视化工具质量契约",
|
||||
"gates": {
|
||||
"pre_commit": {
|
||||
"enabled": true,
|
||||
"checks": [
|
||||
{
|
||||
"name": "type_check",
|
||||
"command": "cargo check",
|
||||
"required": true,
|
||||
"description": "类型检查"
|
||||
},
|
||||
{
|
||||
"name": "lint",
|
||||
"command": "cargo clippy -- -D warnings",
|
||||
"required": true,
|
||||
"description": "Clippy lint 检查"
|
||||
},
|
||||
{
|
||||
"name": "test",
|
||||
"command": "cargo test",
|
||||
"required": true,
|
||||
"description": "单元测试"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pre_push": {
|
||||
"enabled": true,
|
||||
"checks": [
|
||||
{
|
||||
"name": "build_release",
|
||||
"command": "cargo build --release",
|
||||
"required": true,
|
||||
"description": "Release 构建验证"
|
||||
},
|
||||
{
|
||||
"name": "full_test",
|
||||
"command": "cargo test --all",
|
||||
"required": true,
|
||||
"description": "完整测试套件"
|
||||
}
|
||||
]
|
||||
},
|
||||
"code_review": {
|
||||
"enabled": true,
|
||||
"checks": [
|
||||
{
|
||||
"name": "no_unwrap",
|
||||
"pattern": "\\.unwrap\\(\\)",
|
||||
"severity": "warning",
|
||||
"description": "避免裸 unwrap(),使用 expect() 或 ? 运算符"
|
||||
},
|
||||
{
|
||||
"name": "no_todo",
|
||||
"pattern": "todo!\\(\\)|unimplemented!\\(\\)",
|
||||
"severity": "warning",
|
||||
"description": "生产代码不应包含 todo!/unimplemented!"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"quality_metrics": {
|
||||
"max_complexity": 15,
|
||||
"min_test_coverage": 0,
|
||||
"max_file_lines": 500,
|
||||
"max_function_lines": 80
|
||||
}
|
||||
}
|
||||
39
.scale/resource-policy.json
Normal file
39
.scale/resource-policy.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"version": 1,
|
||||
"maxGitFileSizeBytes": 5242880,
|
||||
"ignoredDirectories": [
|
||||
".git",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"vendor",
|
||||
".next",
|
||||
".turbo"
|
||||
],
|
||||
"retainedRuntimeDirectories": [
|
||||
".scale/tmp",
|
||||
".scale/evidence",
|
||||
".scale/reports",
|
||||
".scale/resource-reports",
|
||||
"tmp",
|
||||
"temp",
|
||||
"test-results",
|
||||
"playwright-report",
|
||||
"coverage"
|
||||
],
|
||||
"owners": {
|
||||
"docs": "engineering",
|
||||
"docs/standards": "engineering",
|
||||
"docs/workflow": "engineering",
|
||||
"docs/decisions": "architecture",
|
||||
"docs/modules": "module-owner",
|
||||
".planning": "engineering",
|
||||
".scale": "engineering"
|
||||
},
|
||||
"modules": {
|
||||
"example": {
|
||||
"path": "src/example",
|
||||
"owner": "team-or-owner"
|
||||
}
|
||||
}
|
||||
}
|
||||
82
.scale/skills-registry.json
Normal file
82
.scale/skills-registry.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"project": "eskin-model-player",
|
||||
"skills": [
|
||||
{
|
||||
"name": "graphify",
|
||||
"category": "knowledge",
|
||||
"description": "代码知识图谱",
|
||||
"install": "pip install graphifyy && graphify install",
|
||||
"riskLevel": "LOW",
|
||||
"status": "optional"
|
||||
},
|
||||
{
|
||||
"name": "systematic-debugging",
|
||||
"category": "debugging",
|
||||
"description": "系统化调试方法论",
|
||||
"riskLevel": "LOW",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "freeze-guard",
|
||||
"category": "safety",
|
||||
"description": "防止代码冻结/死锁",
|
||||
"riskLevel": "LOW",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "tdd",
|
||||
"category": "testing",
|
||||
"description": "测试驱动开发",
|
||||
"riskLevel": "LOW",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "verification",
|
||||
"category": "quality",
|
||||
"description": "验证检查",
|
||||
"riskLevel": "LOW",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "review",
|
||||
"category": "quality",
|
||||
"description": "代码审查",
|
||||
"riskLevel": "LOW",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "ce-compound",
|
||||
"category": "workflow",
|
||||
"description": "复合编辑",
|
||||
"riskLevel": "LOW",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "ship",
|
||||
"category": "workflow",
|
||||
"description": "发布交付",
|
||||
"riskLevel": "LOW",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "scale-methodology",
|
||||
"category": "methodology",
|
||||
"description": "SCALE 求是方法论",
|
||||
"riskLevel": "LOW",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "codegraph",
|
||||
"category": "knowledge",
|
||||
"description": "代码图谱",
|
||||
"riskLevel": "LOW",
|
||||
"status": "active"
|
||||
}
|
||||
],
|
||||
"riskFlags": {
|
||||
"HIGH": "需要人工审查",
|
||||
"MEDIUM": "自动审查",
|
||||
"LOW": "自动信任"
|
||||
}
|
||||
}
|
||||
630
.scale/skills.json
Normal file
630
.scale/skills.json
Normal file
@@ -0,0 +1,630 @@
|
||||
{
|
||||
"version": 1,
|
||||
"policy": {
|
||||
"mode": "warn",
|
||||
"enforceLevels": [
|
||||
"M",
|
||||
"L",
|
||||
"CRITICAL"
|
||||
],
|
||||
"requireSkillPlan": true
|
||||
},
|
||||
"domains": {
|
||||
"ui": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.jsx",
|
||||
"app/**/*.tsx",
|
||||
"pages/**/*.tsx",
|
||||
"components/**/*.tsx",
|
||||
"**/*.css",
|
||||
"**/*.scss"
|
||||
],
|
||||
"keywords": [
|
||||
"ui",
|
||||
"ux",
|
||||
"frontend",
|
||||
"component",
|
||||
"page",
|
||||
"layout",
|
||||
"responsive",
|
||||
"visual",
|
||||
"界面",
|
||||
"页面",
|
||||
"交互",
|
||||
"视觉",
|
||||
"前端"
|
||||
]
|
||||
},
|
||||
"requiredSkills": [
|
||||
"frontend-design",
|
||||
"ui-ux-pro-max"
|
||||
],
|
||||
"recommendedSkills": [
|
||||
"awesome-design-md",
|
||||
"webapp-testing",
|
||||
"agent-browser",
|
||||
"mcp-chrome-devtools",
|
||||
"browser-testing-with-devtools",
|
||||
"design-review"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"mini-prd.md",
|
||||
"ui-spec.md",
|
||||
"visual-review.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"design-system",
|
||||
"screenshot",
|
||||
"responsive-check",
|
||||
"browser-run",
|
||||
"visual-review"
|
||||
]
|
||||
},
|
||||
"webResearch": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"docs/research/**",
|
||||
"docs/**/research.md",
|
||||
"**/research.md"
|
||||
],
|
||||
"keywords": [
|
||||
"web research",
|
||||
"search online",
|
||||
"online",
|
||||
"latest",
|
||||
"source citation",
|
||||
"source citations",
|
||||
"logged-in",
|
||||
"login",
|
||||
"dynamic web page",
|
||||
"authenticated page",
|
||||
"inspect page",
|
||||
"web-access",
|
||||
"network",
|
||||
"web fetch"
|
||||
]
|
||||
},
|
||||
"requiredSkills": [
|
||||
"web-access"
|
||||
],
|
||||
"recommendedSkills": [
|
||||
"agent-browser",
|
||||
"mcp-chrome-devtools",
|
||||
"source-driven-development",
|
||||
"browser-use"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"verification.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"source-citation",
|
||||
"browser-evidence",
|
||||
"network-console-check"
|
||||
]
|
||||
},
|
||||
"browserAutomation": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"tests/e2e/**",
|
||||
"e2e/**",
|
||||
"playwright.config.*",
|
||||
"**/*.spec.ts",
|
||||
"**/*.e2e.ts"
|
||||
],
|
||||
"keywords": [
|
||||
"browser automation",
|
||||
"browser interaction",
|
||||
"browser behavior",
|
||||
"browser",
|
||||
"playwright",
|
||||
"agent-browser",
|
||||
"chrome devtools",
|
||||
"cdp",
|
||||
"screenshot",
|
||||
"console log",
|
||||
"network request",
|
||||
"e2e",
|
||||
"end-to-end"
|
||||
]
|
||||
},
|
||||
"recommendedSkills": [
|
||||
"webapp-testing",
|
||||
"agent-browser",
|
||||
"web-access",
|
||||
"mcp-chrome-devtools",
|
||||
"playwright",
|
||||
"playwright-interactive"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"e2e-plan.md",
|
||||
"verification.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"browser-run",
|
||||
"screenshot",
|
||||
"console-log",
|
||||
"network-console-check"
|
||||
]
|
||||
},
|
||||
"e2e": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"tests/e2e/**",
|
||||
"e2e/**",
|
||||
"playwright.config.*"
|
||||
],
|
||||
"keywords": [
|
||||
"e2e",
|
||||
"browser",
|
||||
"playwright",
|
||||
"end-to-end",
|
||||
"端到端",
|
||||
"浏览器"
|
||||
]
|
||||
},
|
||||
"recommendedSkills": [
|
||||
"webapp-testing",
|
||||
"agent-browser",
|
||||
"web-access",
|
||||
"mcp-chrome-devtools",
|
||||
"playwright",
|
||||
"playwright-interactive"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"e2e-plan.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"browser-run",
|
||||
"screenshot",
|
||||
"console-log"
|
||||
]
|
||||
},
|
||||
"desktopAutomation": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"tests/desktop/**",
|
||||
"desktop/**",
|
||||
"e2e/desktop/**"
|
||||
],
|
||||
"keywords": [
|
||||
"desktop automation",
|
||||
"desktop app",
|
||||
"computer use",
|
||||
"cua",
|
||||
"gui automation",
|
||||
"operate desktop",
|
||||
"windows desktop",
|
||||
"wps",
|
||||
"wechat",
|
||||
"weixin",
|
||||
"office app"
|
||||
]
|
||||
},
|
||||
"requiredSkills": [
|
||||
"turix-cua"
|
||||
],
|
||||
"recommendedSkills": [
|
||||
"agent-browser",
|
||||
"web-access",
|
||||
"computer-use",
|
||||
"opencli"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"verification.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"desktop-screenshot",
|
||||
"operator-safety",
|
||||
"side-effect-boundary"
|
||||
],
|
||||
"blockLevels": [
|
||||
"CRITICAL"
|
||||
]
|
||||
},
|
||||
"externalCli": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"scripts/**",
|
||||
".github/workflows/**"
|
||||
],
|
||||
"keywords": [
|
||||
"external cli",
|
||||
"agent cli",
|
||||
"codex",
|
||||
"codex cli",
|
||||
"claude code",
|
||||
"gemini cli",
|
||||
"opencode",
|
||||
"aider",
|
||||
"cross-agent",
|
||||
"subagent",
|
||||
"wps cli",
|
||||
"wechat automation"
|
||||
]
|
||||
},
|
||||
"recommendedSkills": [
|
||||
"codex-cli",
|
||||
"gemini-cli",
|
||||
"opencode-cli",
|
||||
"git-workflow-and-versioning",
|
||||
"code-reviewer"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"verification.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"cli-version-check",
|
||||
"command-output",
|
||||
"dry-run-or-safe-mode",
|
||||
"side-effect-boundary"
|
||||
]
|
||||
},
|
||||
"api": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"**/api/**",
|
||||
"**/routes/**",
|
||||
"**/controller/**",
|
||||
"**/*.api",
|
||||
"**/*.proto"
|
||||
],
|
||||
"keywords": [
|
||||
"api",
|
||||
"endpoint",
|
||||
"route",
|
||||
"handler",
|
||||
"接口",
|
||||
"路由"
|
||||
]
|
||||
},
|
||||
"recommendedSkills": [
|
||||
"tdd-guide",
|
||||
"code-review"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"mini-prd.md",
|
||||
"api-contract.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"contract-check"
|
||||
]
|
||||
},
|
||||
"db": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"**/migration/**",
|
||||
"**/migrations/**",
|
||||
"**/*.sql",
|
||||
"**/schema.*",
|
||||
"**/model/**"
|
||||
],
|
||||
"keywords": [
|
||||
"database",
|
||||
"db",
|
||||
"migration",
|
||||
"schema",
|
||||
"sql",
|
||||
"数据表",
|
||||
"数据库",
|
||||
"迁移"
|
||||
]
|
||||
},
|
||||
"requiredSkills": [
|
||||
"security-review"
|
||||
],
|
||||
"recommendedSkills": [
|
||||
"systematic-debugging"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"db-change-plan.md",
|
||||
"security-review.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"rollback-plan",
|
||||
"migration-test"
|
||||
]
|
||||
},
|
||||
"security": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"**/auth/**",
|
||||
"**/permission/**",
|
||||
"**/security/**",
|
||||
"**/middleware/**"
|
||||
],
|
||||
"keywords": [
|
||||
"auth",
|
||||
"permission",
|
||||
"tenant",
|
||||
"token",
|
||||
"credential",
|
||||
"secret",
|
||||
"rbac",
|
||||
"鉴权",
|
||||
"权限",
|
||||
"租户",
|
||||
"密钥"
|
||||
]
|
||||
},
|
||||
"requiredSkills": [
|
||||
"security-review"
|
||||
],
|
||||
"recommendedSkills": [
|
||||
"code-review"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"security-review.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"threat-model",
|
||||
"rollback-plan"
|
||||
],
|
||||
"blockLevels": [
|
||||
"CRITICAL"
|
||||
]
|
||||
},
|
||||
"docs": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"docs/**",
|
||||
"**/*.md"
|
||||
],
|
||||
"keywords": [
|
||||
"docs",
|
||||
"documentation",
|
||||
"document",
|
||||
"readme",
|
||||
"文档"
|
||||
]
|
||||
},
|
||||
"recommendedSkills": [
|
||||
"update-docs",
|
||||
"workflow-guide"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"docs-impact.md"
|
||||
]
|
||||
},
|
||||
"resourceGovernance": {
|
||||
"detect": {
|
||||
"files": [
|
||||
".scale/resource-policy.json",
|
||||
".scale/assets.json",
|
||||
"docs/modules/**",
|
||||
"docs/decisions/**",
|
||||
"docs/worklog/tasks/**",
|
||||
"test-results/**",
|
||||
"playwright-report/**",
|
||||
"coverage/**",
|
||||
"tmp/**",
|
||||
"scripts/tmp/**",
|
||||
"**/*.png",
|
||||
"**/*.jpg",
|
||||
"**/*.jpeg",
|
||||
"**/*.webp",
|
||||
"**/*.gif",
|
||||
"**/*.mp4",
|
||||
"**/*.webm",
|
||||
"**/*.mov",
|
||||
"**/*.wav",
|
||||
"**/*.mp3"
|
||||
],
|
||||
"keywords": [
|
||||
"asset",
|
||||
"resource",
|
||||
"artifact retention",
|
||||
"lifecycle",
|
||||
"temporary file",
|
||||
"e2e report",
|
||||
"screenshot",
|
||||
"video",
|
||||
"documentation drift",
|
||||
"resource governance"
|
||||
]
|
||||
},
|
||||
"recommendedSkills": [
|
||||
"documentation-and-adrs",
|
||||
"git-workflow-and-versioning",
|
||||
"ai-slop-cleaner"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"docs-impact.md",
|
||||
"resource-impact.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"asset-scan",
|
||||
"asset-doctor"
|
||||
]
|
||||
},
|
||||
"engineeringStandards": {
|
||||
"detect": {
|
||||
"files": [
|
||||
".scale/engineering-standards.json",
|
||||
".scale/frameworks.json",
|
||||
"docs/standards/**",
|
||||
"src/**",
|
||||
"app/**",
|
||||
"packages/**",
|
||||
"services/**",
|
||||
"internal/**",
|
||||
"pkg/**"
|
||||
],
|
||||
"keywords": [
|
||||
"coding standard",
|
||||
"engineering standard",
|
||||
"logging",
|
||||
"redaction",
|
||||
"desensitization",
|
||||
"orm",
|
||||
"framework convention",
|
||||
"architecture boundary",
|
||||
"design pattern",
|
||||
"test rigor",
|
||||
"sql injection",
|
||||
"xss",
|
||||
"脱敏",
|
||||
"日志",
|
||||
"架构规范",
|
||||
"编码规范",
|
||||
"框架规范",
|
||||
"联调",
|
||||
"发版"
|
||||
]
|
||||
},
|
||||
"recommendedSkills": [
|
||||
"code-review-and-quality",
|
||||
"security-and-hardening",
|
||||
"documentation-and-adrs"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"standards-impact.md",
|
||||
"architecture-review.md",
|
||||
"security-review.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"standards-scan",
|
||||
"standards-doctor"
|
||||
],
|
||||
"blockLevels": [
|
||||
"L",
|
||||
"CRITICAL"
|
||||
]
|
||||
},
|
||||
"review": {
|
||||
"detect": {
|
||||
"files": [
|
||||
".github/PULL_REQUEST_TEMPLATE.md",
|
||||
".github/pull_request_template.md"
|
||||
],
|
||||
"keywords": [
|
||||
"review",
|
||||
"code review",
|
||||
"pull request",
|
||||
"pr",
|
||||
"merge request",
|
||||
"changes reviewed",
|
||||
"审查",
|
||||
"评审"
|
||||
]
|
||||
},
|
||||
"requiredSkills": [
|
||||
"code-reviewer"
|
||||
],
|
||||
"recommendedSkills": [
|
||||
"pr-creator"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"review.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"review-evidence"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"detect": {
|
||||
"files": [
|
||||
"CHANGELOG.md",
|
||||
"package.json",
|
||||
".github/workflows/**"
|
||||
],
|
||||
"keywords": [
|
||||
"release",
|
||||
"ship",
|
||||
"publish",
|
||||
"deploy",
|
||||
"pull request",
|
||||
"pr",
|
||||
"发版",
|
||||
"发布",
|
||||
"部署"
|
||||
]
|
||||
},
|
||||
"requiredSkills": [
|
||||
"code-reviewer"
|
||||
],
|
||||
"recommendedSkills": [
|
||||
"pr-creator",
|
||||
"fix",
|
||||
"verification",
|
||||
"code-review"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"review.md",
|
||||
"summary.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"preflight"
|
||||
]
|
||||
},
|
||||
"skillDiscovery": {
|
||||
"detect": {
|
||||
"keywords": [
|
||||
"skill",
|
||||
"capability",
|
||||
"missing capability",
|
||||
"install skill",
|
||||
"find skill"
|
||||
]
|
||||
},
|
||||
"recommendedSkills": [
|
||||
"find-skills"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md"
|
||||
]
|
||||
},
|
||||
"fullstackPrototype": {
|
||||
"detect": {
|
||||
"keywords": [
|
||||
"fullstack",
|
||||
"full-stack",
|
||||
"mvp",
|
||||
"prototype",
|
||||
"next.js",
|
||||
"react api",
|
||||
"node api"
|
||||
]
|
||||
},
|
||||
"recommendedSkills": [
|
||||
"fullstack-developer"
|
||||
],
|
||||
"requiredArtifacts": [
|
||||
"skill-plan.md",
|
||||
"skill-evidence.md",
|
||||
"mini-prd.md",
|
||||
"api-contract.md"
|
||||
],
|
||||
"requiredVerification": [
|
||||
"preflight"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
118
.scale/tools.json
Normal file
118
.scale/tools.json
Normal file
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "evidence-required",
|
||||
"tools": {
|
||||
"web-access": {
|
||||
"enabled": true,
|
||||
"requiredFor": [
|
||||
"webResearch"
|
||||
],
|
||||
"recommendedFor": [
|
||||
"browserAutomation"
|
||||
],
|
||||
"destructiveActions": "block",
|
||||
"evidenceRequired": true
|
||||
},
|
||||
"frontend-design": {
|
||||
"enabled": true,
|
||||
"requiredFor": [
|
||||
"ui"
|
||||
],
|
||||
"destructiveActions": "block",
|
||||
"evidenceRequired": true
|
||||
},
|
||||
"ui-ux-pro-max": {
|
||||
"enabled": true,
|
||||
"requiredFor": [
|
||||
"ui"
|
||||
],
|
||||
"destructiveActions": "block",
|
||||
"evidenceRequired": true
|
||||
},
|
||||
"agent-browser": {
|
||||
"enabled": true,
|
||||
"requiredFor": [
|
||||
"browserAutomation"
|
||||
],
|
||||
"recommendedFor": [
|
||||
"ui",
|
||||
"e2e"
|
||||
],
|
||||
"allowedDomains": [
|
||||
"localhost",
|
||||
"127.0.0.1"
|
||||
],
|
||||
"destructiveActions": "confirm",
|
||||
"command": "agent-browser",
|
||||
"evidenceRequired": true
|
||||
},
|
||||
"playwright": {
|
||||
"enabled": true,
|
||||
"requiredFor": [
|
||||
"e2e"
|
||||
],
|
||||
"recommendedFor": [
|
||||
"browserAutomation",
|
||||
"ui"
|
||||
],
|
||||
"destructiveActions": "confirm",
|
||||
"command": "npx playwright",
|
||||
"evidenceRequired": true
|
||||
},
|
||||
"mcp-chrome-devtools": {
|
||||
"enabled": true,
|
||||
"requiredFor": [
|
||||
"browserAutomation"
|
||||
],
|
||||
"recommendedFor": [
|
||||
"ui",
|
||||
"e2e"
|
||||
],
|
||||
"destructiveActions": "confirm",
|
||||
"mcpToolName": "chrome-devtools",
|
||||
"evidenceRequired": true
|
||||
},
|
||||
"desktop-cua": {
|
||||
"enabled": false,
|
||||
"requiredFor": [
|
||||
"desktopAutomation"
|
||||
],
|
||||
"destructiveActions": "block",
|
||||
"command": "cua",
|
||||
"evidenceRequired": true
|
||||
},
|
||||
"codex-cli": {
|
||||
"enabled": false,
|
||||
"requiredFor": [],
|
||||
"recommendedFor": [
|
||||
"externalCli",
|
||||
"review"
|
||||
],
|
||||
"destructiveActions": "block",
|
||||
"command": "codex",
|
||||
"evidenceRequired": true
|
||||
},
|
||||
"gemini-cli": {
|
||||
"enabled": false,
|
||||
"requiredFor": [],
|
||||
"recommendedFor": [
|
||||
"externalCli",
|
||||
"review"
|
||||
],
|
||||
"destructiveActions": "block",
|
||||
"command": "gemini",
|
||||
"evidenceRequired": true
|
||||
},
|
||||
"opencode-cli": {
|
||||
"enabled": false,
|
||||
"requiredFor": [],
|
||||
"recommendedFor": [
|
||||
"externalCli",
|
||||
"review"
|
||||
],
|
||||
"destructiveActions": "block",
|
||||
"command": "opencode",
|
||||
"evidenceRequired": true
|
||||
}
|
||||
}
|
||||
}
|
||||
35
.scale/verification.json
Normal file
35
.scale/verification.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"defaultProfile": "default",
|
||||
"profiles": {
|
||||
"default": {
|
||||
"commands": {},
|
||||
"services": []
|
||||
},
|
||||
"productSmoke": {
|
||||
"commands": {
|
||||
"smoke": "powershell -ExecutionPolicy Bypass -File scripts/qa/product-smoke.ps1"
|
||||
},
|
||||
"services": []
|
||||
}
|
||||
},
|
||||
"services": [],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"tmp",
|
||||
"vendor"
|
||||
],
|
||||
"policy": {
|
||||
"mode": "standard",
|
||||
"optionalToolsWarnOnly": true,
|
||||
"artifactGate": "warn",
|
||||
"artifactGateLevels": [
|
||||
"M",
|
||||
"L",
|
||||
"CRITICAL"
|
||||
],
|
||||
"engineeringStandardsGate": "block",
|
||||
"productSmokeGate": "warn"
|
||||
}
|
||||
}
|
||||
51
.scale/workflow.json
Normal file
51
.scale/workflow.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"project": "eskin-model-player",
|
||||
"workflow": {
|
||||
"phases": [
|
||||
{
|
||||
"name": "explore",
|
||||
"description": "理解需求和现有代码",
|
||||
"required": true,
|
||||
"artifacts": ["docs/workflow/templates/explore.md"]
|
||||
},
|
||||
{
|
||||
"name": "plan",
|
||||
"description": "制定实现方案",
|
||||
"required": true,
|
||||
"artifacts": ["docs/workflow/templates/plan.md"]
|
||||
},
|
||||
{
|
||||
"name": "implement",
|
||||
"description": "编码实现",
|
||||
"required": true,
|
||||
"gates": ["type_check", "lint"]
|
||||
},
|
||||
{
|
||||
"name": "test",
|
||||
"description": "测试验证",
|
||||
"required": true,
|
||||
"gates": ["test"]
|
||||
},
|
||||
{
|
||||
"name": "review",
|
||||
"description": "代码审查",
|
||||
"required": true,
|
||||
"artifacts": ["docs/workflow/templates/review.md"]
|
||||
},
|
||||
{
|
||||
"name": "ship",
|
||||
"description": "提交发布",
|
||||
"required": true,
|
||||
"gates": ["build_release", "full_test"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"skills_routing": {
|
||||
"debug": "systematic-debugging",
|
||||
"test": "tdd",
|
||||
"review": "review",
|
||||
"ship": "ship",
|
||||
"graph": "graphify"
|
||||
}
|
||||
}
|
||||
26
AGENTS.md
Normal file
26
AGENTS.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# .
|
||||
|
||||
## SCALE Engine Integration (OpenClaw)
|
||||
|
||||
This project uses SCALE Engine for AI engineering governance via OpenClaw.
|
||||
|
||||
### Commands
|
||||
- `scale create <type> <title>` — Create artifact
|
||||
- `scale transition <id> <action>` — Transition artifact state
|
||||
- `scale list --type Spec` — List artifacts
|
||||
- `scale role activate <role>` — Switch role
|
||||
- `scale doctor` — Health check
|
||||
|
||||
### Workflow
|
||||
1. **Explore** → Role: explorer (Read/Grep only)
|
||||
2. **Plan** → Create Spec → refine → approve (guard: ambiguity ≤ 0.2)
|
||||
3. **Implement** → Role: implementer (Edit/Write/Bash unlocked)
|
||||
4. **Verify** → Must run tests before claiming done
|
||||
5. **Learn** → Defects → Lessons → Rules → Hooks
|
||||
|
||||
### Rules
|
||||
- 🔴 Dangerous commands are physically blocked
|
||||
- 🔴 Hardcoded secrets are blocked on Edit/Write
|
||||
- 🟡 3 identical retries triggers brute-retry detection
|
||||
- 🟡 Claiming done without running tests is blocked
|
||||
- 🟢 All tool calls are tracked in .scale/events/
|
||||
243
Cargo.lock
generated
243
Cargo.lock
generated
@@ -36,7 +36,7 @@ dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.36.0",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"phf 0.13.1",
|
||||
"serde",
|
||||
"zvariant",
|
||||
]
|
||||
@@ -870,6 +870,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -879,6 +894,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -1079,6 +1103,21 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.34.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c609fc87f6c70ffd3afd679cbb294985096d2fc0be33e762ad5614bde4925bc"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"image",
|
||||
"log",
|
||||
"mime_guess2",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.34.2"
|
||||
@@ -1117,6 +1156,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "2.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -1237,10 +1296,15 @@ version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
"crc",
|
||||
"crossbeam-channel",
|
||||
"eframe",
|
||||
"egui_extras",
|
||||
"env_logger",
|
||||
"glam",
|
||||
"image",
|
||||
"log",
|
||||
"serialport",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1854,6 +1918,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-kit-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"mach2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -2064,6 +2138,26 @@ dependencies = [
|
||||
"redox_syscall 0.7.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libudev-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev-sys"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linebender_resource_handle"
|
||||
version = "0.1.1"
|
||||
@@ -2118,6 +2212,15 @@ dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
@@ -2152,6 +2255,24 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess2"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"phf 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2234,6 +2355,17 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.4"
|
||||
@@ -2756,17 +2888,37 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_macros 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
@@ -2774,7 +2926,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2783,13 +2949,23 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
@@ -3028,6 +3204,15 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
@@ -3035,7 +3220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3045,9 +3230,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
@@ -3090,7 +3281,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"paste",
|
||||
"profiling",
|
||||
"rand",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha",
|
||||
"simd_helpers",
|
||||
"thiserror 2.0.18",
|
||||
@@ -3382,6 +3573,25 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialport"
|
||||
version = "4.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"io-kit-sys",
|
||||
"libudev",
|
||||
"mach2",
|
||||
"nix",
|
||||
"scopeguard",
|
||||
"unescaper",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -3786,6 +3996,21 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unescaper"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
||||
@@ -10,3 +10,8 @@ bytemuck = { version = "1", features = ["derive"] }
|
||||
glam = "0.32.1"
|
||||
image = { version = "0.25.10", features = ["png", "jpeg"] }
|
||||
anyhow = "1.0.102"
|
||||
serialport = "4.9.0"
|
||||
egui_extras = { version = "0.34.2", features = ["image"] }
|
||||
crossbeam-channel = "0.5.15"
|
||||
crc = "3.4.0"
|
||||
log = "0.4.29"
|
||||
|
||||
42
README.md
Normal file
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Eskin Model Player
|
||||
|
||||
实时压力矩阵可视化桌面应用,用于连接 E-Skin 传感器设备并通过串口接收压力数据,以热力图方式实时渲染。
|
||||
|
||||
## 功能
|
||||
|
||||
- 串口连接 E-Skin 传感器(921600 baud)
|
||||
- 实时压力矩阵热力图渲染(wgpu)
|
||||
- 自定义无边框窗口,macOS 风格标题栏
|
||||
- 浮动面板:连接管理、场景视图、配置、数据统计
|
||||
|
||||
## 依赖
|
||||
|
||||
- Rust 2024 edition
|
||||
- [eframe](https://crates.io/crates/eframe) 0.34(egui + wgpu)
|
||||
- [serialport](https://crates.io/crates/serialport) 4.9
|
||||
|
||||
## 构建与运行
|
||||
|
||||
```bash
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # 入口,创建 eframe 窗口
|
||||
├── app.rs # 应用主循环与面板调度
|
||||
├── connection.rs # 串口连接管理(后台线程)
|
||||
├── serial_core/ # 串口协议编解码
|
||||
│ ├── serial.rs # 串口读写循环
|
||||
│ ├── codec.rs # 帧编解码器
|
||||
│ ├── frame.rs # 帧结构定义
|
||||
│ └── ...
|
||||
├── render.rs # wgpu 渲染管线(背景 + 数字叠加)
|
||||
├── matrix.rs # 矩阵布局与坐标变换
|
||||
├── ui.rs # egui 浮动面板 UI
|
||||
├── theme.rs # 深色工程主题
|
||||
├── shader.wgsl # WGSL 着色器
|
||||
└── utils.rs # 工具函数
|
||||
```
|
||||
61
TOOLS.md
Normal file
61
TOOLS.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# TOOLS.md — eskin-player 工具链文档
|
||||
|
||||
## 项目概述
|
||||
**eskin-model-player** 是一个 Rust GUI 应用,用于 E-Skin 传感器数据的实时可视化。
|
||||
- 框架:eframe/egui + wgpu(WebGPU)
|
||||
- 串口通信:serialport crate
|
||||
- 数据处理:glam(数学)、bytemuck(内存布局)、crc(校验)
|
||||
|
||||
## 构建工具
|
||||
| 工具 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Rust | 1.95.0+ | 主语言(edition 2024) |
|
||||
| Cargo | 1.95.0+ | 构建系统 |
|
||||
| SCALE Engine | 0.21.2 | 工程约束与质量门控 |
|
||||
|
||||
## 项目命令
|
||||
```bash
|
||||
cargo build --release # 构建
|
||||
cargo clippy # Lint
|
||||
cargo test # 测试
|
||||
cargo check # 类型检查
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
eskin-player/
|
||||
├── Cargo.toml # 依赖配置
|
||||
├── src/
|
||||
│ ├── main.rs # 入口
|
||||
│ ├── app.rs # 应用逻辑
|
||||
│ ├── ui.rs # UI 组件
|
||||
│ ├── render.rs # 渲染
|
||||
│ ├── connection.rs # 连接管理
|
||||
│ ├── matrix.rs # 矩阵数据
|
||||
│ ├── texture.rs # 纹理
|
||||
│ ├── theme.rs # 主题
|
||||
│ ├── utils.rs # 工具函数
|
||||
│ ├── shader.wgsl # WebGPU shader
|
||||
│ └── serial_core/ # 串口核心模块
|
||||
│ ├── mod.rs
|
||||
│ ├── serial.rs
|
||||
│ ├── codec.rs
|
||||
│ ├── codecs/
|
||||
│ │ ├── mod.rs
|
||||
│ │ └── tactile_a.rs
|
||||
│ ├── frame.rs
|
||||
│ ├── error.rs
|
||||
│ └── utils.rs
|
||||
├── static/ # 静态资源
|
||||
├── .scale/ # SCALE Engine 配置
|
||||
├── .openclaw/ # Agent 配置
|
||||
├── AGENTS.md # Agent 知识文档
|
||||
└── docs/workflow/ # 工作流模板
|
||||
```
|
||||
|
||||
## 关键依赖
|
||||
- **eframe 0.34.2** — 跨平台 GUI 框架(wgpu 后端)
|
||||
- **serialport 4.9.0** — 串口通信
|
||||
- **glam 0.32.1** — 线性代数
|
||||
- **crc 3.4.0** — CRC 校验
|
||||
- **crossbeam-channel 0.5.15** — 无锁消息通道
|
||||
35
docs/workflow/QUALITY_CONTRACT.md
Normal file
35
docs/workflow/QUALITY_CONTRACT.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 质量契约 (Quality Contract)
|
||||
|
||||
## 概述
|
||||
本文档定义 eskin-model-player 项目的质量标准和交付要求。
|
||||
|
||||
## 门控检查
|
||||
|
||||
### Pre-commit(提交前)
|
||||
| 检查项 | 命令 | 必须通过 |
|
||||
|--------|------|----------|
|
||||
| 类型检查 | `cargo check` | ✅ |
|
||||
| Lint | `cargo clippy -- -D warnings` | ✅ |
|
||||
| 单元测试 | `cargo test` | ✅ |
|
||||
|
||||
### Pre-push(推送前)
|
||||
| 检查项 | 命令 | 必须通过 |
|
||||
|--------|------|----------|
|
||||
| Release 构建 | `cargo build --release` | ✅ |
|
||||
| 完整测试 | `cargo test --all` | ✅ |
|
||||
|
||||
### 代码审查规则
|
||||
- ❌ 禁止裸 `unwrap()` — 使用 `expect("原因")` 或 `?` 运算符
|
||||
- ❌ 禁止 `todo!()` / `unimplemented!()` — 生产代码必须完整实现
|
||||
|
||||
## 质量指标
|
||||
| 指标 | 标准 |
|
||||
|------|------|
|
||||
| 圈复杂度 | ≤ 15 |
|
||||
| 文件行数 | ≤ 500 行 |
|
||||
| 函数行数 | ≤ 80 行 |
|
||||
|
||||
## 交付承诺
|
||||
1. **诚实交付**:所有验证必须真实运行,未验证项必须明确列出
|
||||
2. **逐步验证**:每完成一步,运行相关验证命令
|
||||
3. **知识沉淀**:将经验写入 AGENTS.md 和 TOOLS.md
|
||||
187
docs/workflow/README.md
Normal file
187
docs/workflow/README.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# . Workflow
|
||||
|
||||
Governance mode: standard
|
||||
Governance pack: standard
|
||||
|
||||
## Task Levels
|
||||
|
||||
| Level | Use for | Required artifacts |
|
||||
| --- | --- | --- |
|
||||
| S | typo, comments, small local edits | relevant validation only |
|
||||
| M | bug fixes, new APIs, 2-5 files | explore, skill plan, plan, verification, review, summary |
|
||||
| L | cross-module or architecture changes | full artifacts plus human confirmation |
|
||||
| CRITICAL | auth, permissions, migrations, production config | rollback plan, security review, full verification |
|
||||
|
||||
## Standard Task Directory
|
||||
|
||||
```text
|
||||
.planning/tasks/<yyyy-mm-dd>-<task-slug>/
|
||||
├── explore.md
|
||||
├── mini-prd.md
|
||||
├── plan.md
|
||||
├── runtime.md
|
||||
├── reality-check.md
|
||||
├── resource-cleanup.md
|
||||
├── verification.md
|
||||
├── review.md
|
||||
├── summary.md
|
||||
├── artifact-manifest.json
|
||||
└── artifacts/
|
||||
├── index.html
|
||||
└── release-report.html
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Use service-aware verification when configured:
|
||||
|
||||
```bash
|
||||
scale preflight --service all
|
||||
scale preflight --service all --preflight-profile full
|
||||
scale verify <task-id> --profile default
|
||||
scale verify <task-id> --service <service-name>
|
||||
scale verify <task-id> --artifact-gate warn
|
||||
scale verify <task-id> --artifact-gate block
|
||||
scale verify <task-id> --require-installed-skills
|
||||
scale verify <task-id> --profile productSmoke
|
||||
scale task-artifacts check --dir .planning/tasks/<task-dir> --level L
|
||||
scale artifact render --task-id <task-dir> --type release-report
|
||||
scale artifact doctor --task-id <task-dir>
|
||||
```
|
||||
|
||||
Keep `.scale/verification.json` as the source of truth for profiles and service commands.
|
||||
Keep `.scale/skills.json` as the source of truth for active skill routing policy.
|
||||
Keep `.scale/output-policy.json` as the source of truth for derived HTML artifact types, source Markdown mapping, security policy, and Git retention behavior.
|
||||
Keep `.scale/resource-policy.json` and `.scale/assets.json` as the source of truth for generated reports, temporary files, module documentation, media, reusable scripts, and Git retention policy.
|
||||
Keep `.scale/engineering-standards.json` and `.scale/frameworks.json` as the source of truth for logging, security, ORM, architecture, framework, UI/UX, testing, and coding standard checks.
|
||||
Keep `.scale/engineering-standards-baseline.json` as the temporary exception list for known legacy standards findings; it must not be used to hide new or changed-file problems.
|
||||
Use `artifactGate: "warn"` while introducing the workflow, then move M/L/CRITICAL work to `"block"` once templates and local gates are stable.
|
||||
|
||||
## Workflow Upgrade
|
||||
|
||||
Do not rerun `scale init` as a blind upgrade command. Generated governance files may contain local project adaptations.
|
||||
|
||||
Use the guarded upgrade flow:
|
||||
|
||||
```bash
|
||||
scale upgrade check --dir .
|
||||
scale upgrade plan --dir . --html
|
||||
scale upgrade apply --dir . --confirm
|
||||
scale upgrade rollback --dir .
|
||||
scale tools outdated --dir .
|
||||
scale skill outdated --dir .
|
||||
scale preflight --preflight-profile quick
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `.scale/governance.lock.json` records generated file hashes and pack versions.
|
||||
- Clean or missing generated files can be planned safely.
|
||||
- Locally changed generated files require manual review before replacement or merge.
|
||||
- `scale upgrade apply --confirm` only restores missing generated files and refreshes the lock after writing `.scale/backups/upgrade-*/manifest.json`.
|
||||
- `scale upgrade rollback` only rolls back the latest SCALE-managed safe apply.
|
||||
- Third-party skills, MCP servers, browser tools, desktop automation, and external CLI tools are never auto-installed by the upgrade flow.
|
||||
- Community sources require source, install script, permission, and changelog review. Desktop automation is treated as high risk.
|
||||
|
||||
## HTML Artifacts
|
||||
|
||||
Markdown remains the editable source of truth for task artifacts. HTML artifacts are derived human-review surfaces for plan comparison, implementation plans, code reviews, status reports, incident reports, and release reports.
|
||||
|
||||
Use HTML when a human needs to compare, review, or sign off. Keep source Markdown, manifest metadata, and safety checks in place so the derived HTML stays traceable and does not leak secrets or remote scripts.
|
||||
|
||||
## Active Skill Routing
|
||||
|
||||
SCALE plans required skills from task description, service selection, and changed files. UI/API work requires a Mini-PRD plus domain evidence such as `ui-spec.md`, `visual-review.md`, or `api-contract.md`. Security and database work require explicit review or rollback artifacts.
|
||||
|
||||
Tool orchestration is part of the workflow contract:
|
||||
|
||||
- UI/UX work requires `frontend-design` and `ui-ux-pro-max`, and should consider `awesome-design-md`, browser screenshots, responsive checks, and visual review evidence.
|
||||
- Web research, logged-in pages, and dynamic browser work require `web-access` evidence, source citations, and browser/network/console evidence when available.
|
||||
- Browser E2E work should combine `webapp-testing`, Playwright, Agent Browser, web-access, or Chrome DevTools MCP according to the target and record screenshots plus console/network findings.
|
||||
- Desktop or client-side GUI automation uses CUA/computer-use only with explicit operator-safety notes, desktop screenshots, and a side-effect boundary.
|
||||
- External agent or CLI orchestration such as Codex, Gemini CLI, OpenCode, WPS, or WeChat automation must record version checks, exact commands, output summaries, and dry-run or safe-mode evidence.
|
||||
|
||||
When a task records `servicesTouched`, `scale verify <task-id>` uses those services automatically. You can still override selection with `--service all`, `--service api`, or `--service api,gateway`.
|
||||
|
||||
Before M/L work, check whether required workflow skills are physically installed:
|
||||
|
||||
```bash
|
||||
scale skill doctor --json
|
||||
scale skill check --require-installed --json
|
||||
```
|
||||
|
||||
## Workspace Lifecycle
|
||||
|
||||
Before finishing an agent-created branch or deleting a temporary worktree, inspect root and child repository state:
|
||||
|
||||
```bash
|
||||
scale workspace status --json
|
||||
scale workspace finish --summary
|
||||
scale workspace finish --json
|
||||
scale workspace cleanup --dir <temporary-worktree> --dry-run --json
|
||||
scale workspace cleanup --dir <temporary-worktree> --apply --confirm <branch-or-head> --json
|
||||
```
|
||||
|
||||
Do not remove a temporary worktree while any submodule or nested repository has uncommitted or unpushed work. Child repositories must be committed and reviewed in their own remotes, then the root repository can record any required pointer or governance updates. Cleanup defaults to dry-run. Applying cleanup requires the reported confirmation token, normally the temporary branch name.
|
||||
|
||||
Use `scale ship <task-id>` for governed commits. It checks MOE/submodule child repository state before staging reviewed root files, so dirty or unpushed child work cannot be hidden inside a root commit. It also enforces the GitLab Flow branch lifecycle: work happens on short branches, merges target `dev`, production lands on `master`, and release publishing is triggered by user-created `vX.Y.Z` tags. Direct governed commits on `dev`, `master`, `main`, or detached HEAD are blocked. Raw `git add .` is outside the governed path and must not be used for MOE releases.
|
||||
|
||||
## Resource Governance
|
||||
|
||||
Use asset scanning before committing generated reports, media, temporary scripts, or long-lived documentation changes:
|
||||
|
||||
```bash
|
||||
scale assets scan --json
|
||||
scale assets doctor --json
|
||||
scale assets settle --task-id <task-id> --artifact-dir .planning/tasks/<task-dir>
|
||||
```
|
||||
|
||||
Default policy:
|
||||
|
||||
- maintained module docs, standards, contracts, ADRs, reusable scripts: commit and keep current.
|
||||
- task planning, verification, runtime-contract, reality-check, and cleanup artifacts: keep in `.planning/tasks`; promote final truth to maintained docs when useful.
|
||||
- screenshots, videos, E2E reports, coverage, temporary scripts, and runtime logs: keep out of Git unless explicitly promoted.
|
||||
- large media: use Git LFS or external artifact storage instead of normal Git history.
|
||||
|
||||
## Engineering Standards
|
||||
|
||||
Use standards scanning before reviewing or shipping M/L/CRITICAL work:
|
||||
|
||||
```bash
|
||||
scale standards scan --json
|
||||
scale standards doctor --json
|
||||
scale standards doctor --changed --json
|
||||
scale standards doctor --changed-files src/example.ts,src/example.test.ts --json
|
||||
scale standards baseline --write --artifact-dir .planning/tasks/<task-dir> --task-id <task-id> --json
|
||||
scale standards settle --task-id <task-id> --artifact-dir .planning/tasks/<task-dir>
|
||||
scale preflight --preflight-profile full --json
|
||||
scale verify <task-id> --json
|
||||
```
|
||||
|
||||
Default policy:
|
||||
|
||||
- ad-hoc console/output logging is allowed only for CLI/script paths.
|
||||
- sensitive fields such as token, password, secret, authorization, cookie, and credentials must not be logged.
|
||||
- hardcoded secret-like assignments are blocked before review or release.
|
||||
- SQL must use parameterized queries, ORM bind parameters, or safe query builders.
|
||||
- unsafe HTML sinks, dynamic code execution, empty catch blocks, and type suppressions require remediation before release.
|
||||
- framework and architecture rules live in `.scale/frameworks.json` and module standards docs.
|
||||
- `.scale/frameworks.json > bannedImports` blocks direct use of deprecated ORMs, unsafe SDKs, or off-system UI components.
|
||||
- `.scale/frameworks.json > lastReviewedAt/reviewIntervalDays` warns when module framework decisions need review.
|
||||
- `.scale/engineering-standards.json > blockingRules` promotes selected warning rule IDs to release-blocking findings.
|
||||
- `.scale/engineering-standards.json > allowedFindingPatterns` allows narrow rule/path/evidence exceptions without hiding unrelated findings in the same file.
|
||||
- `.scale/engineering-standards-baseline.json` may hold known legacy findings during rollout, but normal task gates should prefer `--changed` or `--changed-files` so new work is blocked without forcing a whole-repo cleanup.
|
||||
- `.scale/verification.json > policy.engineeringStandardsGate` controls whether preflight and task verification treat standards as `off`, `warn`, or `block`.
|
||||
- `.scale/product-smoke.json` defines real product-path probes. Use it to prove a routed user/business flow, not only build, unit tests, or `/health`.
|
||||
- `.scale/verification.json > policy.productSmokeGate` controls whether missing or failed product smoke evidence warns or blocks M/L/CRITICAL delivery.
|
||||
- Full standards scans are for release readiness, scheduled remediation, and architecture cleanup. Changed-file scans are the default for day-to-day feature and bug branches.
|
||||
- Use `scale standards baseline --write` only during an explicit rollout or remediation planning task. It writes the machine-readable baseline and a `standards-legacy-debt.md` classification report for staged cleanup.
|
||||
|
||||
## Automation Templates
|
||||
|
||||
Optional automation templates are generated under `docs/workflow/templates/`:
|
||||
|
||||
- `github-actions-scale-preflight.yml`: CI workflow that runs `scale preflight --service all --preflight-profile ci`.
|
||||
- `pre-push-scale-preflight.sh`: local pre-push hook template that runs the default quick preflight.
|
||||
|
||||
Keep these templates advisory until `scale preflight --service all --preflight-profile full` is reliable locally for the project.
|
||||
29
docs/workflow/templates/api-contract.md
Normal file
29
docs/workflow/templates/api-contract.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# API Contract
|
||||
|
||||
## Endpoint Or Interface
|
||||
|
||||
TBD
|
||||
|
||||
## Request
|
||||
|
||||
TBD
|
||||
|
||||
## Response
|
||||
|
||||
TBD
|
||||
|
||||
## Errors
|
||||
|
||||
TBD
|
||||
|
||||
## Permission Rules
|
||||
|
||||
TBD
|
||||
|
||||
## Compatibility Notes
|
||||
|
||||
TBD
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TBD
|
||||
23
docs/workflow/templates/architecture-review.md
Normal file
23
docs/workflow/templates/architecture-review.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Architecture Review
|
||||
|
||||
## Scope
|
||||
|
||||
- Modules touched:
|
||||
- Public contracts touched:
|
||||
- Data flow touched:
|
||||
|
||||
## Boundary Checks
|
||||
|
||||
- [ ] API/controller layer does not bypass service/usecase layer
|
||||
- [ ] Domain layer is not coupled to infrastructure details
|
||||
- [ ] Repository/ORM usage follows project conventions
|
||||
- [ ] Shared framework components are reused instead of duplicated
|
||||
- [ ] New abstractions remove real complexity
|
||||
|
||||
## Risks
|
||||
|
||||
- TBD
|
||||
|
||||
## Decision
|
||||
|
||||
- Approved/changes required:
|
||||
20
docs/workflow/templates/db-change-plan.md
Normal file
20
docs/workflow/templates/db-change-plan.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# DB Change Plan
|
||||
|
||||
## Schema Or Data Change
|
||||
|
||||
TBD
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
TBD
|
||||
|
||||
## Migration Steps
|
||||
|
||||
TBD
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
TBD
|
||||
|
||||
## Verification
|
||||
TBD
|
||||
17
docs/workflow/templates/docs-impact.md
Normal file
17
docs/workflow/templates/docs-impact.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Docs Impact
|
||||
|
||||
## Code Changes Requiring Docs
|
||||
|
||||
- TBD
|
||||
|
||||
## Documentation Updated
|
||||
|
||||
- TBD
|
||||
|
||||
## No-Docs-Needed Rationale
|
||||
|
||||
TBD
|
||||
|
||||
## Links Checked
|
||||
|
||||
- TBD
|
||||
20
docs/workflow/templates/e2e-plan.md
Normal file
20
docs/workflow/templates/e2e-plan.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# E2E Plan
|
||||
|
||||
## User Paths
|
||||
|
||||
TBD
|
||||
|
||||
## Browser Coverage
|
||||
|
||||
TBD
|
||||
|
||||
## Test Data
|
||||
|
||||
TBD
|
||||
|
||||
## Assertions
|
||||
|
||||
TBD
|
||||
|
||||
## Evidence
|
||||
TBD
|
||||
20
docs/workflow/templates/explore.md
Normal file
20
docs/workflow/templates/explore.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Explore
|
||||
|
||||
## Files Read
|
||||
|
||||
- TBD
|
||||
|
||||
## Current Behavior
|
||||
|
||||
TBD
|
||||
|
||||
## Main Conflict
|
||||
|
||||
TBD
|
||||
|
||||
## Affected Modules
|
||||
|
||||
TBD
|
||||
|
||||
## Evidence
|
||||
TBD
|
||||
32
docs/workflow/templates/github-actions-scale-preflight.yml
Normal file
32
docs/workflow/templates/github-actions-scale-preflight.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: SCALE Preflight
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install project dependencies when present
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f package-lock.json ]; then
|
||||
npm ci
|
||||
elif [ -f package.json ]; then
|
||||
npm install
|
||||
fi
|
||||
|
||||
- name: Run SCALE preflight
|
||||
run: npx @hongmaple0820/scale-engine@latest preflight --service all --preflight-profile ci
|
||||
42
docs/workflow/templates/mini-prd.md
Normal file
42
docs/workflow/templates/mini-prd.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Mini-PRD
|
||||
|
||||
## Background
|
||||
|
||||
TBD
|
||||
|
||||
## Target Users
|
||||
|
||||
TBD
|
||||
|
||||
## Core Scenario
|
||||
|
||||
TBD
|
||||
|
||||
## Non-Goals
|
||||
|
||||
TBD
|
||||
|
||||
## User Path
|
||||
|
||||
TBD
|
||||
|
||||
## Permission Rules
|
||||
|
||||
TBD
|
||||
|
||||
## Data Impact
|
||||
|
||||
TBD
|
||||
|
||||
## Exception Scenarios
|
||||
|
||||
1. TBD
|
||||
2. TBD
|
||||
3. TBD
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TBD
|
||||
|
||||
## Rollback Or Disable Strategy
|
||||
TBD
|
||||
28
docs/workflow/templates/plan.md
Normal file
28
docs/workflow/templates/plan.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Plan
|
||||
|
||||
## Approach
|
||||
|
||||
TBD
|
||||
|
||||
## Boundaries
|
||||
|
||||
TBD
|
||||
|
||||
## Exception Contract
|
||||
|
||||
1. TBD
|
||||
2. TBD
|
||||
3. TBD
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
TBD
|
||||
|
||||
## Human Confirmation
|
||||
|
||||
- Required for L/CRITICAL tasks:
|
||||
- Confirmation source:
|
||||
- Execution boundary approved:
|
||||
|
||||
## Test Strategy
|
||||
TBD
|
||||
8
docs/workflow/templates/pre-push-scale-preflight.sh
Normal file
8
docs/workflow/templates/pre-push-scale-preflight.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if command -v scale >/dev/null 2>&1; then
|
||||
scale preflight --service all
|
||||
else
|
||||
npx @hongmaple0820/scale-engine@latest preflight --service all
|
||||
fi
|
||||
61
docs/workflow/templates/product-smoke.md
Normal file
61
docs/workflow/templates/product-smoke.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Product Smoke
|
||||
|
||||
## Real Product Path
|
||||
|
||||
Describe the smallest end-to-end path that proves the change works through the real product boundary.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
UI or client -> gateway/router -> service -> database/storage/queue -> observable result
|
||||
```
|
||||
|
||||
Do not use a green health endpoint as the only proof when the user-facing path depends on routing, authentication, storage, async tasks, browser behavior, or third-party integration.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
1. Open `.scale/product-smoke.json`.
|
||||
2. Replace the example command with one real product path command.
|
||||
3. Set that probe's `enabled` field to `true`.
|
||||
4. Run `scale preflight --profile productSmoke --json`.
|
||||
5. Run `scale runtime final-check --level M --json`.
|
||||
|
||||
`status: "skipped"` means no real product path was exercised. It does not count as completion evidence.
|
||||
|
||||
## Setup
|
||||
|
||||
- Base URL:
|
||||
- Test user or tenant:
|
||||
- Required fixtures:
|
||||
- Services that must be running:
|
||||
|
||||
## Smoke Commands
|
||||
|
||||
| Command | Expected Result | Evidence Artifact |
|
||||
| --- | --- | --- |
|
||||
| TBD | TBD | TBD |
|
||||
|
||||
## Runtime Evidence
|
||||
|
||||
Record at least one runtime evidence item:
|
||||
|
||||
```bash
|
||||
scale runtime record \
|
||||
--kind command \
|
||||
--title "Product smoke: <flow>" \
|
||||
--status passed \
|
||||
--command "<exact smoke command>" \
|
||||
--exit-code 0 \
|
||||
--summary "<business result, task id, status, or observable output>" \
|
||||
--artifacts ".agent/logs/<service>/<smoke>.json" \
|
||||
--metadata-json '{"productSmoke":true,"realProductPath":true}'
|
||||
```
|
||||
|
||||
## Assertions
|
||||
|
||||
- [ ] Request crossed the real product boundary, not only an isolated unit.
|
||||
- [ ] Authentication or user identity path was exercised when relevant.
|
||||
- [ ] Persistence/storage/queue side effect was verified when relevant.
|
||||
- [ ] Async task or eventual state was polled to terminal status when relevant.
|
||||
- [ ] Failure output is specific enough to diagnose the failing layer.
|
||||
- [ ] Runtime artifacts are ignored or deliberately promoted according to resource governance.
|
||||
25
docs/workflow/templates/reality-check.md
Normal file
25
docs/workflow/templates/reality-check.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Reality Check
|
||||
|
||||
## Confirmed
|
||||
|
||||
- TBD
|
||||
|
||||
## Not Verified
|
||||
|
||||
- TBD
|
||||
|
||||
## Stub / Fake / Partial
|
||||
|
||||
- TBD
|
||||
|
||||
## Credential-Gated
|
||||
|
||||
- TBD
|
||||
|
||||
## Environment-Gated
|
||||
|
||||
- TBD
|
||||
|
||||
## User-Visible Risk
|
||||
|
||||
- TBD
|
||||
14
docs/workflow/templates/resource-cleanup.md
Normal file
14
docs/workflow/templates/resource-cleanup.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Resource Cleanup
|
||||
|
||||
## New Resources
|
||||
|
||||
| Resource | Location | Keep / Move / Delete | Reason |
|
||||
| --- | --- | --- | --- |
|
||||
| TBD | TBD | TBD | TBD |
|
||||
|
||||
## Docs Promotion
|
||||
|
||||
- Promote to docs:
|
||||
- Keep in planning:
|
||||
- Keep local/runtime only:
|
||||
- Delete before handoff:
|
||||
25
docs/workflow/templates/resource-impact.md
Normal file
25
docs/workflow/templates/resource-impact.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Resource Impact
|
||||
|
||||
## Resources Created
|
||||
|
||||
| Path | Type | Git Policy | Retention |
|
||||
| --- | --- | --- | --- |
|
||||
| TBD | canonical-doc/task-artifact/evidence-report/temporary/reusable-script/generated-media/contract/decision-record | commit/ignore/lfs/external/review | TBD |
|
||||
|
||||
## Resources Updated
|
||||
|
||||
- TBD
|
||||
|
||||
## Resources Promoted To Maintained Docs
|
||||
|
||||
- TBD
|
||||
|
||||
## Resources To Delete Or Archive Before Finish
|
||||
|
||||
- TBD
|
||||
|
||||
## Source Of Truth Updates
|
||||
|
||||
- [ ] .scale/resource-policy.json
|
||||
- [ ] .scale/assets.json
|
||||
- [ ] docs/modules/<module>/README.md
|
||||
16
docs/workflow/templates/review.md
Normal file
16
docs/workflow/templates/review.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Review
|
||||
|
||||
## Code Review
|
||||
|
||||
TBD
|
||||
|
||||
## Security Review
|
||||
|
||||
TBD
|
||||
|
||||
## Same-Pattern Scan
|
||||
|
||||
TBD
|
||||
|
||||
## Residual Risks
|
||||
TBD
|
||||
21
docs/workflow/templates/runtime.md
Normal file
21
docs/workflow/templates/runtime.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Runtime Contract
|
||||
|
||||
## Configuration Source
|
||||
|
||||
- Source: TBD
|
||||
- Environment/profile: TBD
|
||||
- Runtime overrides: TBD
|
||||
- Secrets boundary: TBD
|
||||
|
||||
## Service Topology
|
||||
|
||||
| Service | URL Or Command | Config Source | Auth Mode | Status |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| TBD | TBD | TBD | TBD | Not checked |
|
||||
|
||||
## Verification Boundary
|
||||
|
||||
- Confirmed:
|
||||
- Not covered:
|
||||
- Credential-gated:
|
||||
- Environment-gated:
|
||||
26
docs/workflow/templates/security-review.md
Normal file
26
docs/workflow/templates/security-review.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Security Review
|
||||
|
||||
## Assets And Trust Boundaries
|
||||
|
||||
TBD
|
||||
|
||||
## Authorization Rules
|
||||
|
||||
TBD
|
||||
|
||||
## Abuse Cases
|
||||
|
||||
1. TBD
|
||||
2. TBD
|
||||
3. TBD
|
||||
|
||||
## Sensitive Data Impact
|
||||
|
||||
TBD
|
||||
|
||||
## Rollback Or Disable Strategy
|
||||
|
||||
TBD
|
||||
|
||||
## Final Verdict
|
||||
TBD
|
||||
33
docs/workflow/templates/skill-evidence.md
Normal file
33
docs/workflow/templates/skill-evidence.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Skill Evidence
|
||||
|
||||
## Planned Skills
|
||||
|
||||
- TBD
|
||||
|
||||
## Tool Selection Rationale
|
||||
|
||||
TBD
|
||||
|
||||
## Used Skills
|
||||
|
||||
| Skill | Phase | Trigger | Evidence | Status |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| skill-id | plan/build/verify/review | why it was selected | command, screenshot, report, or artifact path | executed/skipped/fallback |
|
||||
|
||||
## Browser Or Web Evidence
|
||||
|
||||
| Tool | Target | Evidence | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| web-access/agent-browser/Chrome DevTools MCP | URL or local target | screenshot, console log, network finding, source URL | passed/failed/skipped |
|
||||
|
||||
## Desktop Or External CLI Evidence
|
||||
|
||||
| Tool | Scope | Safety Boundary | Evidence | Result |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| cua/codex/gemini/opencode/wps/wechat | command or app target | read-only/dry-run/test account/manual approval | output summary, screenshot, or report path | passed/failed/skipped |
|
||||
|
||||
## Skipped Skills
|
||||
|
||||
| Skill | Reason | Fallback Evidence |
|
||||
| --- | --- | --- |
|
||||
| skill-id | why it could not run | manual review, alternate command, or explicit risk |
|
||||
39
docs/workflow/templates/skill-plan.md
Normal file
39
docs/workflow/templates/skill-plan.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Skill Plan
|
||||
|
||||
## Detected Intents
|
||||
|
||||
| Domain | Score | Evidence |
|
||||
| --- | ---: | --- |
|
||||
| | | |
|
||||
|
||||
## Required Skills
|
||||
|
||||
- TBD
|
||||
|
||||
## Recommended Skills
|
||||
|
||||
- TBD
|
||||
|
||||
## Required Artifacts
|
||||
|
||||
- TBD
|
||||
|
||||
## Required Verification Evidence
|
||||
|
||||
- TBD
|
||||
|
||||
## Tool Orchestration
|
||||
|
||||
| Capability | Primary Tool Or Skill | Fallback | Required Evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| UI/UX design | frontend-design, ui-ux-pro-max | awesome-design-md | design-system, ui-spec.md, visual-review.md |
|
||||
| Web research or logged-in pages | web-access | agent-browser, Chrome DevTools MCP | source citations, browser evidence |
|
||||
| Browser E2E | webapp-testing, Playwright | agent-browser, web-access | screenshot, console, network evidence |
|
||||
| Desktop GUI automation | CUA/computer-use | manual verification | desktop screenshot, operator-safety notes |
|
||||
| External agent CLI | codex/gemini/opencode CLI | manual review | version check, exact command output |
|
||||
|
||||
## Skipped Skills
|
||||
|
||||
| Skill | Reason | Fallback Evidence |
|
||||
| --- | --- | --- |
|
||||
| | | |
|
||||
28
docs/workflow/templates/standards-impact.md
Normal file
28
docs/workflow/templates/standards-impact.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Standards Impact
|
||||
|
||||
## Standards Checked
|
||||
|
||||
- [ ] Logging and redaction
|
||||
- [ ] Architecture boundaries
|
||||
- [ ] ORM/database access
|
||||
- [ ] Framework/component conventions
|
||||
- [ ] UI/UX acceptance where user-facing
|
||||
- [ ] Test and verification rigor
|
||||
- [ ] Security-sensitive inputs and outputs
|
||||
|
||||
## Findings
|
||||
|
||||
| Severity | Rule | Path | Decision |
|
||||
| --- | --- | --- | --- |
|
||||
| TBD | TBD | TBD | fix/accept/escalate |
|
||||
|
||||
## Policy Updates
|
||||
|
||||
- [ ] .scale/engineering-standards.json
|
||||
- [ ] .scale/frameworks.json
|
||||
- [ ] docs/standards/
|
||||
|
||||
## Settlement
|
||||
|
||||
- Standards scan:
|
||||
- Standards doctor:
|
||||
19
docs/workflow/templates/summary.md
Normal file
19
docs/workflow/templates/summary.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Summary
|
||||
|
||||
## Delivered Changes
|
||||
|
||||
TBD
|
||||
|
||||
## Remaining Risks
|
||||
|
||||
TBD
|
||||
|
||||
## Follow-Ups
|
||||
|
||||
TBD
|
||||
|
||||
## Metric Row
|
||||
|
||||
| Date | Task | Level | Services | Files Changed | First Verification Pass | Fix Iterations | Artifact Complete | Residual Risk | Final Gate |
|
||||
| --- | --- | --- | --- | ---: | --- | ---: | --- | --- | --- |
|
||||
| | | | | | | | | | |
|
||||
29
docs/workflow/templates/ui-spec.md
Normal file
29
docs/workflow/templates/ui-spec.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# UI Spec
|
||||
|
||||
## User Goal
|
||||
|
||||
TBD
|
||||
|
||||
## Primary Flow
|
||||
|
||||
TBD
|
||||
|
||||
## Interaction States
|
||||
|
||||
- Default:
|
||||
- Loading:
|
||||
- Empty:
|
||||
- Error:
|
||||
- Success:
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
TBD
|
||||
|
||||
## Accessibility Requirements
|
||||
|
||||
TBD
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TBD
|
||||
18
docs/workflow/templates/verification.md
Normal file
18
docs/workflow/templates/verification.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Verification
|
||||
|
||||
## Commands Run
|
||||
|
||||
| Command | Result | Notes |
|
||||
| --- | --- | --- |
|
||||
| | | |
|
||||
|
||||
## Output Summary
|
||||
|
||||
TBD
|
||||
|
||||
## Failures And Fixes
|
||||
|
||||
TBD
|
||||
|
||||
## Final Status
|
||||
TBD
|
||||
20
docs/workflow/templates/visual-review.md
Normal file
20
docs/workflow/templates/visual-review.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Visual Review
|
||||
|
||||
## Screenshots Or Evidence
|
||||
|
||||
TBD
|
||||
|
||||
## Layout And Responsiveness
|
||||
|
||||
TBD
|
||||
|
||||
## Text Fit And Overlap
|
||||
|
||||
TBD
|
||||
|
||||
## Accessibility Notes
|
||||
|
||||
TBD
|
||||
|
||||
## Final Verdict
|
||||
TBD
|
||||
28
docs/worklog/metrics.md
Normal file
28
docs/worklog/metrics.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Workflow Metrics
|
||||
|
||||
<!-- SCALE_METRICS:START -->
|
||||
| Date | Task | Level | Services | Files Changed | First Verification Pass | Fix Iterations | Rework Needed | Artifact Complete | Residual Risk | Final Gate |
|
||||
| --- | --- | --- | --- | ---: | --- | ---: | --- | --- | --- | --- |
|
||||
| | | | | | | | | | | |
|
||||
<!-- SCALE_METRICS:END -->
|
||||
|
||||
## Monthly Review
|
||||
|
||||
### Repeated Failure Patterns
|
||||
|
||||
TBD
|
||||
|
||||
### Slowest Gates
|
||||
|
||||
TBD
|
||||
|
||||
### Documentation Gaps
|
||||
|
||||
TBD
|
||||
|
||||
### Product Design Misses
|
||||
|
||||
TBD
|
||||
|
||||
### Proposed Workflow Changes
|
||||
TBD
|
||||
54
scripts/gates/all.sh
Executable file
54
scripts/gates/all.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# scripts/gates/all.sh — 运行所有门控检查
|
||||
set -e
|
||||
source ~/.cargo/env 2>/dev/null || true
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
DRY_RUN=false
|
||||
if [ "$1" = "--dry-run" ]; then
|
||||
DRY_RUN=true
|
||||
echo "🔒 门控检查 (dry-run 模式)"
|
||||
else
|
||||
echo "🔒 运行门控检查..."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
run_gate() {
|
||||
local name="$1"
|
||||
local cmd="$2"
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " ⏭️ $name (跳过, dry-run)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " 🔍 $name..."
|
||||
if eval "$cmd" > /dev/null 2>&1; then
|
||||
echo " ✅ $name 通过"
|
||||
return 0
|
||||
else
|
||||
echo " ❌ $name 失败"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
FAILED=0
|
||||
|
||||
run_gate "类型检查 (cargo check)" "cargo check" || FAILED=$((FAILED + 1))
|
||||
run_gate "Lint (cargo clippy)" "cargo clippy -- -D warnings" || FAILED=$((FAILED + 1))
|
||||
run_gate "测试 (cargo test)" "cargo test" || FAILED=$((FAILED + 1))
|
||||
|
||||
if [ "$DRY_RUN" = "false" ]; then
|
||||
run_gate "Release 构建" "cargo build --release" || FAILED=$((FAILED + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "────────────────────────────────────"
|
||||
if [ $FAILED -gt 0 ]; then
|
||||
echo "❌ $FAILED 个门控失败"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ 所有门控通过"
|
||||
exit 0
|
||||
fi
|
||||
107
scripts/qa/product-smoke.ps1
Normal file
107
scripts/qa/product-smoke.ps1
Normal file
@@ -0,0 +1,107 @@
|
||||
# Product smoke probe runner generated by scale-engine.
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$ConfigPath = Join-Path $Root ".scale\product-smoke.json"
|
||||
$LogDir = Join-Path $Root ".agent\logs"
|
||||
$LogPath = Join-Path $LogDir "product-smoke.json"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||||
|
||||
$NodeProgram = @'
|
||||
const fs = require('fs');
|
||||
const cp = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = process.argv[2];
|
||||
const logPath = process.argv[3];
|
||||
|
||||
function writeReport(report) {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
fs.writeFileSync(logPath, JSON.stringify(report, null, 2) + '\n', 'utf8');
|
||||
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
writeReport({
|
||||
version: 1,
|
||||
status: 'failed',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
message: 'Missing .scale/product-smoke.json',
|
||||
results: []
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
|
||||
const probes = Array.isArray(config.probes) ? config.probes.filter(probe => probe && probe.enabled === true) : [];
|
||||
|
||||
if (probes.length === 0) {
|
||||
const status = config.emptyProbeBehavior === 'block' ? 'failed' : 'skipped';
|
||||
writeReport({
|
||||
version: 1,
|
||||
status,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
message: 'No enabled product smoke probes. Enable probes in .scale/product-smoke.json after defining the real product path.',
|
||||
results: []
|
||||
});
|
||||
process.exit(status === 'failed' ? 1 : 0);
|
||||
}
|
||||
|
||||
const results = probes.map((probe) => {
|
||||
const startedAt = new Date().toISOString();
|
||||
const expectedExitCode = Number.isInteger(probe.expected && probe.expected.exitCode) ? probe.expected.exitCode : 0;
|
||||
const command = String(probe.command || '');
|
||||
if (!command.trim()) {
|
||||
return {
|
||||
id: String(probe.id || 'unnamed-probe'),
|
||||
description: String(probe.description || ''),
|
||||
command,
|
||||
expectedExitCode,
|
||||
exitCode: 1,
|
||||
status: 'failed',
|
||||
startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
outputTail: 'Probe command is empty'
|
||||
};
|
||||
}
|
||||
const result = cp.spawnSync(command, {
|
||||
cwd: process.cwd(),
|
||||
shell: true,
|
||||
encoding: 'utf8',
|
||||
timeout: Number(config.timeoutMs || 180000)
|
||||
});
|
||||
const output = String(result.stdout || '') + String(result.stderr || '') + String(result.error ? result.error.message : '');
|
||||
const exitCode = typeof result.status === 'number' ? result.status : 1;
|
||||
return {
|
||||
id: String(probe.id || 'unnamed-probe'),
|
||||
description: String(probe.description || ''),
|
||||
command,
|
||||
expectedExitCode,
|
||||
exitCode,
|
||||
status: exitCode === expectedExitCode ? 'passed' : 'failed',
|
||||
startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
outputTail: output.length > 2000 ? output.slice(-2000) : output
|
||||
};
|
||||
});
|
||||
|
||||
const failed = results.filter(result => result.status !== 'passed');
|
||||
writeReport({
|
||||
version: 1,
|
||||
status: failed.length === 0 ? 'passed' : 'failed',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
results
|
||||
});
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
|
||||
'@
|
||||
|
||||
$TempFile = [System.IO.Path]::GetTempFileName() + ".js"
|
||||
Set-Content -Path $TempFile -Value $NodeProgram -Encoding UTF8
|
||||
try {
|
||||
node $TempFile $ConfigPath $LogPath
|
||||
exit $LASTEXITCODE
|
||||
} finally {
|
||||
Remove-Item -Force $TempFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
98
scripts/qa/product-smoke.sh
Executable file
98
scripts/qa/product-smoke.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
CONFIG_PATH="$ROOT/.scale/product-smoke.json"
|
||||
LOG_DIR="$ROOT/.agent/logs"
|
||||
LOG_PATH="$LOG_DIR/product-smoke.json"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
node - "$CONFIG_PATH" "$LOG_PATH" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const cp = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = process.argv[2];
|
||||
const logPath = process.argv[3];
|
||||
|
||||
function writeReport(report) {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
fs.writeFileSync(logPath, JSON.stringify(report, null, 2) + '\n', 'utf8');
|
||||
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
writeReport({
|
||||
version: 1,
|
||||
status: 'failed',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
message: 'Missing .scale/product-smoke.json',
|
||||
results: []
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
|
||||
const probes = Array.isArray(config.probes) ? config.probes.filter(probe => probe && probe.enabled === true) : [];
|
||||
|
||||
if (probes.length === 0) {
|
||||
const status = config.emptyProbeBehavior === 'block' ? 'failed' : 'skipped';
|
||||
writeReport({
|
||||
version: 1,
|
||||
status,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
message: 'No enabled product smoke probes. Enable probes in .scale/product-smoke.json after defining the real product path.',
|
||||
results: []
|
||||
});
|
||||
process.exit(status === 'failed' ? 1 : 0);
|
||||
}
|
||||
|
||||
const results = probes.map((probe) => {
|
||||
const startedAt = new Date().toISOString();
|
||||
const expectedExitCode = Number.isInteger(probe.expected && probe.expected.exitCode) ? probe.expected.exitCode : 0;
|
||||
const command = String(probe.command || '');
|
||||
if (!command.trim()) {
|
||||
return {
|
||||
id: String(probe.id || 'unnamed-probe'),
|
||||
description: String(probe.description || ''),
|
||||
command,
|
||||
expectedExitCode,
|
||||
exitCode: 1,
|
||||
status: 'failed',
|
||||
startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
outputTail: 'Probe command is empty'
|
||||
};
|
||||
}
|
||||
const result = cp.spawnSync(command, {
|
||||
cwd: process.cwd(),
|
||||
shell: true,
|
||||
encoding: 'utf8',
|
||||
timeout: Number(config.timeoutMs || 180000)
|
||||
});
|
||||
const output = String(result.stdout || '') + String(result.stderr || '') + String(result.error ? result.error.message : '');
|
||||
const exitCode = typeof result.status === 'number' ? result.status : 1;
|
||||
return {
|
||||
id: String(probe.id || 'unnamed-probe'),
|
||||
description: String(probe.description || ''),
|
||||
command,
|
||||
expectedExitCode,
|
||||
exitCode,
|
||||
status: exitCode === expectedExitCode ? 'passed' : 'failed',
|
||||
startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
outputTail: output.length > 2000 ? output.slice(-2000) : output
|
||||
};
|
||||
});
|
||||
|
||||
const failed = results.filter(result => result.status !== 'passed');
|
||||
writeReport({
|
||||
version: 1,
|
||||
status: failed.length === 0 ? 'passed' : 'failed',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
results
|
||||
});
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
|
||||
NODE
|
||||
8
scripts/tests/run.sh
Executable file
8
scripts/tests/run.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
# scripts/tests/run.sh — 运行测试
|
||||
set -e
|
||||
echo "🧪 运行测试..."
|
||||
source ~/.cargo/env 2>/dev/null || true
|
||||
cd "$(dirname "$0")/../.."
|
||||
cargo test 2>&1
|
||||
echo "✅ 测试完成"
|
||||
65
scripts/validate-config.sh
Executable file
65
scripts/validate-config.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# validate-config.sh — 验证 SCALE OS 配置完整性
|
||||
set -e
|
||||
|
||||
echo "🔍 验证 SCALE OS 配置..."
|
||||
echo ""
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
check() {
|
||||
local name="$1"
|
||||
local path="$2"
|
||||
local required="$3"
|
||||
|
||||
if [ -e "$path" ]; then
|
||||
echo " ✅ $name"
|
||||
PASS=$((PASS + 1))
|
||||
elif [ "$required" = "true" ]; then
|
||||
echo " ❌ $name (缺失: $path)"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
echo " ⚠️ $name (可选, 缺失: $path)"
|
||||
WARN=$((WARN + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "📁 项目级文件:"
|
||||
check "AGENTS.md" "AGENTS.md" "true"
|
||||
check "TOOLS.md" "TOOLS.md" "true"
|
||||
check ".openclaw/settings.json" ".openclaw/settings.json" "true"
|
||||
|
||||
echo ""
|
||||
echo "📁 SCALE 配置:"
|
||||
check ".scale/ 目录" ".scale" "true"
|
||||
check "workflow.json" ".scale/workflow.json" "true"
|
||||
check "quality-contract.json" ".scale/quality-contract.json" "true"
|
||||
check "skills-registry.json" ".scale/skills-registry.json" "true"
|
||||
check "verification.json" ".scale/verification.json" "true"
|
||||
check "skills.json" ".scale/skills.json" "true"
|
||||
check "tools.json" ".scale/tools.json" "true"
|
||||
check ".gitignore" ".scale/.gitignore" "true"
|
||||
|
||||
echo ""
|
||||
echo "📁 工作流模板:"
|
||||
check "docs/workflow/ 目录" "docs/workflow" "true"
|
||||
check "QUALITY_CONTRACT.md" "docs/workflow/QUALITY_CONTRACT.md" "true"
|
||||
|
||||
echo ""
|
||||
echo "📁 验证脚本:"
|
||||
check "scripts/validate-config.sh" "scripts/validate-config.sh" "true"
|
||||
check "scripts/qa/product-smoke.sh" "scripts/qa/product-smoke.sh" "true"
|
||||
|
||||
echo ""
|
||||
echo "────────────────────────────────────"
|
||||
echo "结果: ✅ $PASS 通过 | ❌ $FAIL 失败 | ⚠️ $WARN 警告"
|
||||
|
||||
if [ $FAIL -gt 0 ]; then
|
||||
echo "❌ 配置验证失败"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ 配置验证通过"
|
||||
exit 0
|
||||
fi
|
||||
29
scripts/workflow/verify.sh
Executable file
29
scripts/workflow/verify.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# scripts/workflow/verify.sh — 验证工作流配置
|
||||
set -e
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
PROFILE="${1:-default}"
|
||||
echo "📋 验证工作流 (profile: $PROFILE)..."
|
||||
|
||||
# 检查 workflow.json
|
||||
if [ ! -f ".scale/workflow.json" ]; then
|
||||
echo "❌ 缺少 .scale/workflow.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 quality-contract.json
|
||||
if [ ! -f ".scale/quality-contract.json" ]; then
|
||||
echo "❌ 缺少 .scale/quality-contract.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查模板文件
|
||||
TEMPLATE_COUNT=$(ls docs/workflow/templates/*.md 2>/dev/null | wc -l)
|
||||
echo " 📄 工作流模板: $TEMPLATE_COUNT 个"
|
||||
|
||||
# 验证 JSON 格式
|
||||
python3 -c "import json; json.load(open('.scale/workflow.json')); print(' ✅ workflow.json 格式正确')"
|
||||
python3 -c "import json; json.load(open('.scale/quality-contract.json')); print(' ✅ quality-contract.json 格式正确')"
|
||||
|
||||
echo "✅ 工作流验证通过"
|
||||
307
src/app.rs
Normal file
307
src/app.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use eframe::{egui, egui_wgpu};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::connection::ConnectionManager;
|
||||
use crate::recording::Recorder;
|
||||
use crate::theme::{ONE_DARK_PRO, apply_fonts, apply_theme};
|
||||
use crate::{
|
||||
matrix::{MATRIX_COLS, MATRIX_ROWS},
|
||||
render::{
|
||||
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback,
|
||||
},
|
||||
ui::{
|
||||
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
|
||||
draw_config_panel, draw_connect_panel, draw_export_panel, draw_matrix_config_panel,
|
||||
draw_scene_panel, draw_stats_panel,
|
||||
},
|
||||
};
|
||||
|
||||
const DATA_LOG_EVERY_FRAMES: u64 = 30;
|
||||
|
||||
pub struct EskinDesktopApp {
|
||||
connect_panel: FloatingPanelState,
|
||||
connect_state: ConnectPanelState,
|
||||
connection: Arc<ConnectionManager>,
|
||||
pressure_matrix: PressureFrame,
|
||||
data_log_frame: u64,
|
||||
scene_panel: FloatingPanelState,
|
||||
config_panel: FloatingPanelState,
|
||||
config_state: ConfigPanelState,
|
||||
stats_panel: FloatingPanelState,
|
||||
// New: recording, export, matrix config, signal charts
|
||||
recorder: Recorder,
|
||||
export_panel: FloatingPanelState,
|
||||
export_path: String,
|
||||
matrix_config_panel: FloatingPanelState,
|
||||
matrix_config: MatrixConfigState,
|
||||
signal_history: Vec<f32>,
|
||||
}
|
||||
|
||||
impl EskinDesktopApp {
|
||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||||
apply_fonts(&cc.egui_ctx);
|
||||
apply_theme(&cc.egui_ctx, &ONE_DARK_PRO);
|
||||
|
||||
let wgpu_state = cc
|
||||
.wgpu_render_state
|
||||
.as_ref()
|
||||
.expect("need open eframe wgpu renderer feature");
|
||||
|
||||
let mut renderer = wgpu_state.renderer.write();
|
||||
renderer
|
||||
.callback_resources
|
||||
.insert(BackgroundRenderResources::new(
|
||||
&wgpu_state.device,
|
||||
&wgpu_state.target_format,
|
||||
MATRIX_ROWS,
|
||||
MATRIX_COLS,
|
||||
));
|
||||
|
||||
Self {
|
||||
connect_panel: FloatingPanelState::new([0.0, 0.0], [0.0, 0.0]),
|
||||
connect_state: ConnectPanelState::default(),
|
||||
connection: Arc::new(ConnectionManager::new()),
|
||||
pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT],
|
||||
data_log_frame: 0,
|
||||
scene_panel: FloatingPanelState::new([16.0, 48.0], [16.0, 48.0]),
|
||||
config_panel: FloatingPanelState::new([840.0, 48.0], [128.0, 48.0]),
|
||||
config_state: ConfigPanelState::default(),
|
||||
stats_panel: FloatingPanelState::new([16.0, 520.0], [240.0, 48.0]),
|
||||
recorder: Recorder::full(),
|
||||
export_panel: FloatingPanelState::new([16.0, 280.0], [16.0, 280.0]),
|
||||
export_path: String::new(),
|
||||
matrix_config_panel: FloatingPanelState::new([840.0, 280.0], [400.0, 48.0]),
|
||||
matrix_config: MatrixConfigState::default(),
|
||||
signal_history: Vec::with_capacity(128),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) {
|
||||
self.update_pressure_matrix();
|
||||
|
||||
let rect = ui.max_rect();
|
||||
let width = rect.width().max(1.0);
|
||||
let height = rect.height().max(1.0);
|
||||
|
||||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||||
rect,
|
||||
WgpuBackgroundCallback {
|
||||
width,
|
||||
height,
|
||||
pressure: self.pressure_matrix,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
fn update_pressure_matrix(&mut self) {
|
||||
if let Some(sample) = self.connection.take_latest_sample() {
|
||||
normalize_pressure_sample(
|
||||
&sample.matrix,
|
||||
sample.rows,
|
||||
sample.cols,
|
||||
&mut self.pressure_matrix,
|
||||
);
|
||||
|
||||
// Feed data to recorder
|
||||
self.recorder.add_frame(&sample.matrix);
|
||||
|
||||
// Update signal history (sum of all pressure values)
|
||||
let total: f32 = sample.matrix.iter().map(|v| *v as f32).sum();
|
||||
self.signal_history.push(total);
|
||||
if self.signal_history.len() > 128 {
|
||||
self.signal_history.remove(0);
|
||||
}
|
||||
|
||||
self.data_log_frame += 1;
|
||||
if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 {
|
||||
log_pressure_sample(&sample.matrix, sample.rows, sample.cols);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_title_bar(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let title_bar_height = 36.0;
|
||||
let title_bar_rect = ui
|
||||
.allocate_space(egui::vec2(ui.available_width(), title_bar_height))
|
||||
.1;
|
||||
|
||||
// Paint background
|
||||
ui.painter().rect_filled(
|
||||
title_bar_rect,
|
||||
egui::CornerRadius::ZERO,
|
||||
ONE_DARK_PRO.panel_deep,
|
||||
);
|
||||
ui.painter().line_segment(
|
||||
[title_bar_rect.left_bottom(), title_bar_rect.right_bottom()],
|
||||
egui::Stroke::new(1.0, ONE_DARK_PRO.border),
|
||||
);
|
||||
|
||||
// Drag-to-move: double-click to maximize, drag to move
|
||||
let title_bar_response = ui.interact(
|
||||
title_bar_rect,
|
||||
egui::Id::new("title_bar"),
|
||||
egui::Sense::click_and_drag(),
|
||||
);
|
||||
if title_bar_response.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
if title_bar_response.double_clicked() {
|
||||
let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx()
|
||||
.send_viewport_cmd(egui::ViewportCommand::Maximized(!is_maximized));
|
||||
}
|
||||
|
||||
// macOS traffic light buttons (close / minimize / maximize)
|
||||
let btn_size = 14.0;
|
||||
let btn_spacing = 8.0;
|
||||
let btns_start = title_bar_rect.left_center() + egui::vec2(12.0, 0.0);
|
||||
|
||||
let btn_close_center = btns_start;
|
||||
let btn_min_center = btns_start + egui::vec2(btn_size + btn_spacing, 0.0);
|
||||
let btn_max_center = btns_start + egui::vec2((btn_size + btn_spacing) * 2.0, 0.0);
|
||||
|
||||
// Close (red)
|
||||
let close_rect =
|
||||
egui::Rect::from_center_size(btn_close_center, egui::vec2(btn_size, btn_size));
|
||||
let close_resp = ui.interact(close_rect, egui::Id::new("btn_close"), egui::Sense::click());
|
||||
let close_color = egui::Color32::from_rgb(255, 95, 86);
|
||||
ui.painter()
|
||||
.circle_filled(btn_close_center, btn_size / 2.0, close_color);
|
||||
// Draw × when hovered
|
||||
if close_resp.hovered() {
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
btn_close_center + egui::vec2(-3.0, -3.0),
|
||||
btn_close_center + egui::vec2(3.0, 3.0),
|
||||
],
|
||||
egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 0, 0)),
|
||||
);
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
btn_close_center + egui::vec2(3.0, -3.0),
|
||||
btn_close_center + egui::vec2(-3.0, 3.0),
|
||||
],
|
||||
egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 0, 0)),
|
||||
);
|
||||
}
|
||||
if close_resp.clicked() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
|
||||
// Minimize (yellow)
|
||||
let min_rect = egui::Rect::from_center_size(btn_min_center, egui::vec2(btn_size, btn_size));
|
||||
let min_resp = ui.interact(min_rect, egui::Id::new("btn_min"), egui::Sense::click());
|
||||
let min_color = egui::Color32::from_rgb(255, 189, 46);
|
||||
ui.painter()
|
||||
.circle_filled(btn_min_center, btn_size / 2.0, min_color);
|
||||
if min_resp.hovered() {
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
btn_min_center + egui::vec2(-3.0, 0.0),
|
||||
btn_min_center + egui::vec2(3.0, 0.0),
|
||||
],
|
||||
egui::Stroke::new(1.5, egui::Color32::from_rgb(120, 80, 0)),
|
||||
);
|
||||
}
|
||||
if min_resp.clicked() {
|
||||
ui.ctx()
|
||||
.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
|
||||
}
|
||||
|
||||
// Maximize (green)
|
||||
let max_rect = egui::Rect::from_center_size(btn_max_center, egui::vec2(btn_size, btn_size));
|
||||
let max_resp = ui.interact(max_rect, egui::Id::new("btn_max"), egui::Sense::click());
|
||||
let max_color = egui::Color32::from_rgb(39, 201, 63);
|
||||
ui.painter()
|
||||
.circle_filled(btn_max_center, btn_size / 2.0, max_color);
|
||||
if max_resp.hovered() {
|
||||
let s = 3.0;
|
||||
ui.painter().rect_stroke(
|
||||
egui::Rect::from_center_size(btn_max_center, egui::vec2(s * 2.0, s * 2.0)),
|
||||
egui::CornerRadius::same(1),
|
||||
egui::Stroke::new(1.5, egui::Color32::from_rgb(0, 80, 10)),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
if max_resp.clicked() {
|
||||
let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx()
|
||||
.send_viewport_cmd(egui::ViewportCommand::Maximized(!is_maximized));
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
|
||||
draw_connect_panel(
|
||||
ctx,
|
||||
&mut self.connect_panel,
|
||||
&mut self.connect_state,
|
||||
&self.connection,
|
||||
&self.recorder,
|
||||
&mut self.export_path,
|
||||
);
|
||||
draw_scene_panel(ctx, &mut self.scene_panel);
|
||||
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state);
|
||||
draw_stats_panel(ctx, &mut self.stats_panel);
|
||||
draw_export_panel(ctx, &mut self.export_panel, &self.recorder, &mut self.export_path);
|
||||
draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_pressure_sample(raw: &[u32], rows: u32, cols: u32) {
|
||||
let max = raw.iter().copied().max().unwrap_or(0);
|
||||
let sum: u64 = raw.iter().map(|value| *value as u64).sum();
|
||||
let non_zero = raw.iter().filter(|value| **value != 0).count();
|
||||
let preview = raw
|
||||
.iter()
|
||||
.take(12)
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
println!(
|
||||
"[pressure] {rows}x{cols} cells={} non_zero={non_zero} max={max} sum={sum} first=[{preview}]",
|
||||
raw.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
|
||||
const RANGE_MIN: f32 = 0.0;
|
||||
const RANGE_MAX: f32 = 7000.0;
|
||||
|
||||
normalized.fill([0.0, 0.0]);
|
||||
|
||||
let src_cols = cols.max(1);
|
||||
let copy_rows = MATRIX_ROWS.min(rows);
|
||||
let copy_cols = MATRIX_COLS.min(cols);
|
||||
|
||||
for row in 0..copy_rows {
|
||||
for col in 0..copy_cols {
|
||||
let src_index = (row * src_cols + col) as usize;
|
||||
let dst_index = (row * MATRIX_COLS + col) as usize;
|
||||
if let Some(value) = raw.get(src_index) {
|
||||
let raw_value = *value as f32;
|
||||
let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0);
|
||||
let display_value = if raw_value <= RANGE_MIN + 4.0 {
|
||||
0.0
|
||||
} else {
|
||||
raw_value.round().min(9999.0)
|
||||
};
|
||||
normalized[dst_index] = [mapped, display_value];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for EskinDesktopApp {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
self.draw_wgpu_background(ui);
|
||||
self.draw_title_bar(ui, frame);
|
||||
self.draw_floating_panels(&ctx);
|
||||
|
||||
// Keep repainting while the wgpu background is a realtime viewport.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
189
src/connection.rs
Normal file
189
src/connection.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
|
||||
|
||||
use crate::serial_core::serial::{SerialPortReadWrite, run_serial_loop};
|
||||
|
||||
/// Connection state visible to the UI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnectionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Streaming,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// A pressure sample forwarded to the render layer.
|
||||
pub struct PressureSample {
|
||||
pub matrix: Vec<u32>,
|
||||
pub rows: u32,
|
||||
pub cols: u32,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
cancel_tx: Sender<()>,
|
||||
handle: JoinHandle<()>,
|
||||
sample_rx: Receiver<Vec<i32>>,
|
||||
}
|
||||
|
||||
/// Thread-safe connection manager that the UI and renderer can share.
|
||||
pub struct ConnectionManager {
|
||||
state: Arc<Mutex<ConnectionState>>,
|
||||
session: Arc<Mutex<Option<Session>>>,
|
||||
latest_sample: Arc<Mutex<Option<PressureSample>>>,
|
||||
}
|
||||
|
||||
impl ConnectionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(ConnectionState::Disconnected)),
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
latest_sample: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> ConnectionState {
|
||||
*self.state.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_state(&self, new_state: ConnectionState) {
|
||||
*self.state.lock().unwrap() = new_state;
|
||||
}
|
||||
|
||||
/// Connect to the given serial port and start streaming in a background thread.
|
||||
pub fn connect(&self, port_name: &str, rows: u32, cols: u32) {
|
||||
self.disconnect();
|
||||
self.set_state(ConnectionState::Connecting);
|
||||
|
||||
let port = port_name.to_owned();
|
||||
let state = Arc::clone(&self.state);
|
||||
let session_guard = Arc::clone(&self.session);
|
||||
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 handle = thread::spawn(move || {
|
||||
let result = run_device_loop(
|
||||
&port,
|
||||
rows,
|
||||
cols,
|
||||
&state,
|
||||
&cancel_rx,
|
||||
&sample_tx,
|
||||
&latest_sample,
|
||||
);
|
||||
if let Err(e) = result {
|
||||
eprintln!("[connection] device loop error: {e}");
|
||||
*state.lock().unwrap() = ConnectionState::Error;
|
||||
}
|
||||
});
|
||||
|
||||
*session_guard.lock().unwrap() = Some(Session {
|
||||
cancel_tx,
|
||||
handle,
|
||||
sample_rx,
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnect from the device, stopping the background thread.
|
||||
pub fn disconnect(&self) {
|
||||
let session = {
|
||||
let mut guard = self.session.lock().unwrap();
|
||||
guard.take()
|
||||
};
|
||||
|
||||
if let Some(session) = session {
|
||||
let _ = session.cancel_tx.send(());
|
||||
let _ = session.handle.join();
|
||||
}
|
||||
|
||||
self.set_state(ConnectionState::Disconnected);
|
||||
*self.latest_sample.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Drain pending samples (non-blocking) and return the last one.
|
||||
pub fn take_latest_sample(&self) -> Option<PressureSample> {
|
||||
let session = self.session.lock().unwrap();
|
||||
if let Some(ref session) = *session {
|
||||
let mut last = None;
|
||||
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 });
|
||||
}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
if last.is_some() {
|
||||
return last;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConnectionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking device loop that runs on a background thread.
|
||||
fn run_device_loop(
|
||||
port_name: &str,
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
state: &Arc<Mutex<ConnectionState>>,
|
||||
cancel_rx: &Receiver<()>,
|
||||
sample_tx: &Sender<Vec<i32>>,
|
||||
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let port = serialport::new(port_name, 921_600)
|
||||
.timeout(Duration::from_millis(100))
|
||||
.open()?;
|
||||
|
||||
*state.lock().unwrap() = ConnectionState::Connected;
|
||||
|
||||
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();
|
||||
|
||||
// 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;
|
||||
}
|
||||
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(())
|
||||
}
|
||||
555
src/main.rs
555
src/main.rs
@@ -1,553 +1,30 @@
|
||||
pub mod texture;
|
||||
mod app;
|
||||
mod connection;
|
||||
mod matrix;
|
||||
mod render;
|
||||
mod serial_core;
|
||||
mod theme;
|
||||
mod ui;
|
||||
mod recording;
|
||||
mod utils;
|
||||
use app::EskinDesktopApp;
|
||||
use eframe::egui;
|
||||
|
||||
use bytemuck;
|
||||
use core::f32::consts;
|
||||
use eframe::{
|
||||
egui,
|
||||
egui_wgpu::{self, wgpu},
|
||||
wgpu::util::DeviceExt,
|
||||
};
|
||||
const NUM_INSTANCES_PER_ROW: u32 = 10;
|
||||
const INSTANCE_DISPLACEMENT: glam::Vec3 = glam::Vec3::new(
|
||||
NUM_INSTANCES_PER_ROW as f32 * 0.5,
|
||||
0.0,
|
||||
NUM_INSTANCES_PER_ROW as f32 * 0.5,
|
||||
);
|
||||
fn main() -> eframe::Result<()> {
|
||||
env_logger::init();
|
||||
|
||||
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),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"Eskin Model Player",
|
||||
"Eskin 模型播放器",
|
||||
options,
|
||||
Box::new(|cc| Ok(Box::new(EskinDesktopApp::new(cc)))),
|
||||
)
|
||||
}
|
||||
|
||||
struct EskinDesktopApp {
|
||||
show_scene_panel: bool,
|
||||
show_config_panel: bool,
|
||||
show_stats_panel: bool,
|
||||
}
|
||||
|
||||
impl EskinDesktopApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||
let wgpu_state = cc
|
||||
.wgpu_render_state
|
||||
.as_ref()
|
||||
.expect("need open eframe wgpu renderer feature");
|
||||
|
||||
let mut renderer = wgpu_state.renderer.write();
|
||||
renderer
|
||||
.callback_resources
|
||||
.insert(BackgroundRenderResources::new(
|
||||
&wgpu_state.device,
|
||||
&wgpu_state.target_format,
|
||||
&wgpu_state.queue,
|
||||
12,
|
||||
7,
|
||||
));
|
||||
|
||||
Self {
|
||||
show_scene_panel: true,
|
||||
show_config_panel: true,
|
||||
show_stats_panel: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) {
|
||||
let rect = ui.max_rect();
|
||||
let width = rect.width();
|
||||
let height = rect.height();
|
||||
|
||||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||||
rect,
|
||||
WgpuBackgroundCallback {
|
||||
aspect: width / height,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
fn draw_toolbar(&mut self, ui: &mut egui::Ui) {
|
||||
egui::Panel::top("main_menu").show_inside(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(&mut self.show_scene_panel, "Scene");
|
||||
ui.checkbox(&mut self.show_config_panel, "Config");
|
||||
ui.checkbox(&mut self.show_stats_panel, "Stats");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
|
||||
egui::Window::new("Scene")
|
||||
.open(&mut self.show_scene_panel)
|
||||
.default_pos([16.0, 48.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.label("Models / materials / lights");
|
||||
});
|
||||
|
||||
egui::Window::new("Config")
|
||||
.open(&mut self.show_config_panel)
|
||||
.default_pos([840.0, 48.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.label("Render and viewport settings");
|
||||
});
|
||||
|
||||
egui::Window::new("Stats")
|
||||
.open(&mut self.show_stats_panel)
|
||||
.default_pos([16.0, 520.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.label("FPS / GPU info");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for EskinDesktopApp {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
self.draw_wgpu_background(ui);
|
||||
self.draw_toolbar(ui);
|
||||
self.draw_floating_panels(&ctx);
|
||||
|
||||
// Keep repainting while the wgpu background is a realtime viewport.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
struct WgpuBackgroundCallback {
|
||||
aspect: f32,
|
||||
}
|
||||
|
||||
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
|
||||
fn prepare(
|
||||
&self,
|
||||
_device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
_screen_descriptor: &egui_wgpu::ScreenDescriptor,
|
||||
_egui_encoder: &mut wgpu::CommandEncoder,
|
||||
resources: &mut egui_wgpu::CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap();
|
||||
resources.prepare(queue, self.aspect);
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&self,
|
||||
_info: egui::PaintCallbackInfo,
|
||||
render_pass: &mut wgpu::RenderPass<'static>,
|
||||
resources: &egui_wgpu::CallbackResources,
|
||||
) {
|
||||
let resources: &BackgroundRenderResources = resources.get().unwrap();
|
||||
resources.paint(render_pass);
|
||||
}
|
||||
}
|
||||
|
||||
struct BackgroundRenderResources {
|
||||
camera: Camera,
|
||||
camera_uniform: CameraUniform,
|
||||
camera_buffer: wgpu::Buffer,
|
||||
camera_bind_group: wgpu::BindGroup,
|
||||
|
||||
#[allow(dead_code)]
|
||||
diffuse_texture: texture::Texture,
|
||||
diffuse_bind_group: wgpu::BindGroup,
|
||||
|
||||
render_pipeline: wgpu::RenderPipeline,
|
||||
vertex_buffer: wgpu::Buffer,
|
||||
index_buffer: wgpu::Buffer,
|
||||
num_indices: u32,
|
||||
|
||||
instance: Vec<Instance>,
|
||||
instance_buffer: wgpu::Buffer,
|
||||
cols: Option<u8>,
|
||||
rows: Option<u8>,
|
||||
}
|
||||
|
||||
impl BackgroundRenderResources {
|
||||
fn new(
|
||||
device: &wgpu::Device,
|
||||
target_format: &wgpu::TextureFormat,
|
||||
queue: &wgpu::Queue,
|
||||
rows: u8,
|
||||
cols: u8,
|
||||
) -> Self {
|
||||
let diffuse_bytes = include_bytes!("happy-tree.png");
|
||||
let diffuse_texture =
|
||||
texture::Texture::from_bytes(device, queue, diffuse_bytes, "happy-tree.png").unwrap();
|
||||
|
||||
let texture_group_bind_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("texture_bind_group_layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let diffuse_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("texture_bind_group"),
|
||||
layout: &texture_group_bind_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&diffuse_texture.view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&diffuse_texture.sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let camera = Camera {
|
||||
eye: (0.0, 5.0, 10.0).into(),
|
||||
target: (0.0, 0.0, 0.0).into(),
|
||||
up: glam::Vec3::Y,
|
||||
aspect: 1.0,
|
||||
fovy: 45.0,
|
||||
znear: 0.1,
|
||||
zfar: 100.0,
|
||||
};
|
||||
|
||||
let mut camera_uniform = CameraUniform::new();
|
||||
camera_uniform.update_view_proj(&camera);
|
||||
|
||||
let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Camera Buffer"),
|
||||
contents: bytemuck::cast_slice(&[camera_uniform]),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
|
||||
let instances = (0..NUM_INSTANCES_PER_ROW)
|
||||
.flat_map(|z| {
|
||||
(0..NUM_INSTANCES_PER_ROW).map(move |x| {
|
||||
let position = glam::Vec3 {
|
||||
x: x as f32,
|
||||
y: 0.0,
|
||||
z: z as f32,
|
||||
} - INSTANCE_DISPLACEMENT;
|
||||
|
||||
let rotation = if position.length().abs() <= f32::EPSILON {
|
||||
// this is needed so an object at (0, 0, 0) won't get scaled to zero
|
||||
// as Quaternions can effect scale if they're not create correctly
|
||||
glam::Quat::from_axis_angle(glam::Vec3::Z, 0.0)
|
||||
} else {
|
||||
glam::Quat::from_axis_angle(position.normalize(), consts::FRAC_PI_2)
|
||||
};
|
||||
|
||||
Instance { position, rotation }
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let instance_data = instances.iter().map(Instance::to_raw).collect::<Vec<_>>();
|
||||
let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Instance Buffer"),
|
||||
contents: bytemuck::cast_slice(&instance_data),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
|
||||
let camera_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("camera_bind_group_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("camera_bind_group"),
|
||||
layout: &camera_bind_group_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: camera_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
|
||||
});
|
||||
|
||||
let render_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Render Pipeline Layout"),
|
||||
bind_group_layouts: &[
|
||||
Some(&texture_group_bind_layout),
|
||||
Some(&camera_bind_group_layout),
|
||||
],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Render Pipeline"),
|
||||
layout: Some(&render_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Vertex::desc(), InstanceRaw::desc()],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: target_format.clone(),
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent::REPLACE,
|
||||
alpha: wgpu::BlendComponent::REPLACE,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
strip_index_format: None,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
// Requires Features::DEPTH_CLIP_CONTROL
|
||||
unclipped_depth: false,
|
||||
// Requires Features::CONSERVATIVE_RASTERIZATION
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: 1,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Vertex Buffer"),
|
||||
contents: bytemuck::cast_slice(VERTICES),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
|
||||
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Index Buffer"),
|
||||
contents: bytemuck::cast_slice(INDICES),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
|
||||
let num_indices = INDICES.len() as u32;
|
||||
|
||||
Self {
|
||||
camera,
|
||||
camera_uniform,
|
||||
camera_buffer,
|
||||
camera_bind_group,
|
||||
diffuse_texture,
|
||||
diffuse_bind_group,
|
||||
render_pipeline,
|
||||
vertex_buffer,
|
||||
index_buffer,
|
||||
num_indices,
|
||||
instance: instances,
|
||||
instance_buffer,
|
||||
cols: Some(cols),
|
||||
rows: Some(rows),
|
||||
}
|
||||
}
|
||||
|
||||
// fn with_dot_matrix()
|
||||
|
||||
fn prepare(&mut self, queue: &wgpu::Queue, aspect: f32) {
|
||||
self.camera.aspect = aspect;
|
||||
self.camera_uniform.update_view_proj(&self.camera);
|
||||
|
||||
queue.write_buffer(
|
||||
&self.camera_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[self.camera_uniform]),
|
||||
);
|
||||
}
|
||||
|
||||
fn paint(&self, render_pass: &mut wgpu::RenderPass<'_>) {
|
||||
// TODO: set pipeline / bind groups / buffers and draw the model viewport here.
|
||||
render_pass.set_pipeline(&self.render_pipeline);
|
||||
render_pass.set_bind_group(0, &self.diffuse_bind_group, &[]);
|
||||
render_pass.set_bind_group(1, &self.camera_bind_group, &[]);
|
||||
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
|
||||
render_pass.set_vertex_buffer(1, self.instance_buffer.slice(..));
|
||||
render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
|
||||
// UPDATED!
|
||||
render_pass.draw_indexed(0..self.num_indices, 0, 0..self.instance.len() as _);
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Vertex {
|
||||
position: [f32; 3],
|
||||
tex_coords: [f32; 2],
|
||||
}
|
||||
|
||||
impl Vertex {
|
||||
fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
|
||||
use core::mem;
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: mem::size_of::<Vertex>() 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const VERTICES: &[Vertex] = &[
|
||||
Vertex {
|
||||
position: [-0.0868241, 0.49240386, 0.0],
|
||||
tex_coords: [0.4131759, 0.00759614],
|
||||
}, // A
|
||||
Vertex {
|
||||
position: [-0.49513406, 0.06958647, 0.0],
|
||||
tex_coords: [0.0048659444, 0.43041354],
|
||||
}, // B
|
||||
Vertex {
|
||||
position: [-0.21918549, -0.44939706, 0.0],
|
||||
tex_coords: [0.28081453, 0.949397],
|
||||
}, // C
|
||||
Vertex {
|
||||
position: [0.35966998, -0.3473291, 0.0],
|
||||
tex_coords: [0.85967, 0.84732914],
|
||||
}, // D
|
||||
Vertex {
|
||||
position: [0.44147372, 0.2347359, 0.0],
|
||||
tex_coords: [0.9414737, 0.2652641],
|
||||
}, // E
|
||||
];
|
||||
|
||||
const INDICES: &[u16] = &[0, 1, 4, 1, 2, 4, 2, 3, 4];
|
||||
|
||||
struct Camera {
|
||||
eye: glam::Vec3,
|
||||
target: glam::Vec3,
|
||||
up: glam::Vec3,
|
||||
aspect: f32,
|
||||
fovy: f32,
|
||||
znear: f32,
|
||||
zfar: f32,
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
fn build_view_projection_matrix(&self) -> glam::Mat4 {
|
||||
let view = glam::Mat4::look_at_rh(self.eye, self.target, self.up);
|
||||
let projection =
|
||||
glam::Mat4::perspective_rh(self.fovy.to_radians(), self.aspect, self.znear, self.zfar);
|
||||
projection * view
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct CameraUniform {
|
||||
view_proj: [[f32; 4]; 4],
|
||||
}
|
||||
|
||||
impl CameraUniform {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_view_proj(&mut self, camera: &Camera) {
|
||||
self.view_proj = camera.build_view_projection_matrix().to_cols_array_2d();
|
||||
}
|
||||
}
|
||||
|
||||
struct Instance {
|
||||
position: glam::Vec3,
|
||||
rotation: glam::Quat,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct InstanceRaw {
|
||||
model: [[f32; 4]; 4],
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
fn to_raw(&self) -> InstanceRaw {
|
||||
InstanceRaw {
|
||||
model: (glam::Mat4::from_translation(self.position)
|
||||
* glam::Mat4::from_quat(self.rotation))
|
||||
.to_cols_array_2d(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InstanceRaw {
|
||||
fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
|
||||
use core::mem;
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: mem::size_of::<InstanceRaw>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &[
|
||||
wgpu::VertexAttribute {
|
||||
offset: 0,
|
||||
shader_location: 5,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: core::mem::size_of::<[f32; 4]>() as wgpu::BufferAddress,
|
||||
shader_location: 6,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: core::mem::size_of::<[f32; 8]>() as wgpu::BufferAddress,
|
||||
shader_location: 7,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: core::mem::size_of::<[f32; 12]>() as wgpu::BufferAddress,
|
||||
shader_location: 8,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
110
src/matrix.rs
Normal file
110
src/matrix.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
pub const MATRIX_ROWS: u32 = 12;
|
||||
pub const MATRIX_COLS: u32 = 7;
|
||||
|
||||
const BASE_MATRIX_SPAN: f32 = 24.0;
|
||||
const MATRIX_SPAN_GROWTH: f32 = 0.6;
|
||||
const MIN_MATRIX_SPAN: f32 = 24.0;
|
||||
const MAX_MATRIX_SPAN: f32 = 58.0;
|
||||
const MIN_CELL_SPACING: f32 = 0.52;
|
||||
const MAX_CELL_SPACING: f32 = 3.8;
|
||||
const MIN_BOARD_PADDING: f32 = 2.6;
|
||||
const MAX_BOARD_PADDING: f32 = 6.8;
|
||||
const MATRIX_OFFSET_Y: f32 = -2.4;
|
||||
const MATRIX_OFFSET_Z: f32 = 12.0;
|
||||
const CAMERA_FOV: f32 = 36.0;
|
||||
const CAMERA_DISTANCE_MIN: f32 = 30.0;
|
||||
const CAMERA_DISTANCE_MAX: f32 = 122.0;
|
||||
const CAMERA_FIT_PADDING: f32 = 1.04;
|
||||
const CAMERA_ELEVATION_DEG: f32 = 64.0;
|
||||
const CAMERA_TARGET_X: f32 = 0.0;
|
||||
const CAMERA_TARGET_Y: f32 = MATRIX_OFFSET_Y + 0.2;
|
||||
const CAMERA_TARGET_Z: f32 = MATRIX_OFFSET_Z - 0.4;
|
||||
|
||||
pub struct MatrixLayout {
|
||||
pub cell_spacing: f32,
|
||||
pub board_width: f32,
|
||||
pub board_depth: f32,
|
||||
pub board_padding: f32,
|
||||
pub label_float_offset: f32,
|
||||
}
|
||||
|
||||
impl MatrixLayout {
|
||||
pub fn new(rows: u32, cols: u32) -> Self {
|
||||
let longest_edge = rows.max(cols).max(1) as f32;
|
||||
let edge_span = (longest_edge - 1.0).max(1.0);
|
||||
let target_span = (BASE_MATRIX_SPAN + edge_span * MATRIX_SPAN_GROWTH)
|
||||
.clamp(MIN_MATRIX_SPAN, MAX_MATRIX_SPAN);
|
||||
let cell_spacing = (target_span / edge_span).clamp(MIN_CELL_SPACING, MAX_CELL_SPACING);
|
||||
let board_width = cols.max(1) as f32 * cell_spacing;
|
||||
let board_depth = rows.max(1) as f32 * cell_spacing;
|
||||
let board_padding = (cell_spacing * 1.62).clamp(MIN_BOARD_PADDING, MAX_BOARD_PADDING);
|
||||
let label_float_offset = (cell_spacing * 0.42).clamp(0.36, 1.12);
|
||||
|
||||
Self {
|
||||
cell_spacing,
|
||||
board_width,
|
||||
board_depth,
|
||||
board_padding,
|
||||
label_float_offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_view_projection(aspect: f32, layout: &MatrixLayout) -> [[f32; 4]; 4] {
|
||||
let camera_distance = fit_camera_distance(
|
||||
layout.board_width,
|
||||
layout.board_depth,
|
||||
layout.board_padding,
|
||||
aspect,
|
||||
);
|
||||
let elevation = CAMERA_ELEVATION_DEG.to_radians();
|
||||
let target = glam::Vec3::new(CAMERA_TARGET_X, CAMERA_TARGET_Y, CAMERA_TARGET_Z);
|
||||
let eye = glam::Vec3::new(
|
||||
CAMERA_TARGET_X,
|
||||
CAMERA_TARGET_Y + elevation.sin() * camera_distance,
|
||||
CAMERA_TARGET_Z + elevation.cos() * camera_distance,
|
||||
);
|
||||
let view = glam::Mat4::look_at_rh(eye, target, glam::Vec3::Y);
|
||||
let projection = glam::Mat4::perspective_rh(CAMERA_FOV.to_radians(), aspect, 0.1, 500.0);
|
||||
let open_gl_to_wgpu = glam::Mat4::from_cols_array(&[
|
||||
1.0, 0.0, 0.0, 0.0, //
|
||||
0.0, 1.0, 0.0, 0.0, //
|
||||
0.0, 0.0, 0.5, 0.0, //
|
||||
0.0, 0.0, 0.5, 1.0,
|
||||
]);
|
||||
|
||||
(open_gl_to_wgpu * projection * view).to_cols_array_2d()
|
||||
}
|
||||
|
||||
pub fn glyph_world_position(
|
||||
row: u32,
|
||||
col: u32,
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
layout: &MatrixLayout,
|
||||
pressure: f32,
|
||||
) -> ([f32; 4], f32) {
|
||||
let normalized = pressure.clamp(0.0, 1.0);
|
||||
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;
|
||||
|
||||
(
|
||||
[
|
||||
x,
|
||||
MATRIX_OFFSET_Y + layout.label_float_offset,
|
||||
MATRIX_OFFSET_Z + z,
|
||||
1.0,
|
||||
],
|
||||
normalized,
|
||||
)
|
||||
}
|
||||
|
||||
fn fit_camera_distance(board_width: f32, board_depth: f32, board_padding: f32, aspect: f32) -> f32 {
|
||||
let padded_width = board_width + board_padding * 2.0;
|
||||
let padded_depth = board_depth + board_padding * 2.0;
|
||||
let safe_aspect = aspect.max(0.5);
|
||||
let effective_half_span = (padded_depth * 0.5).max((padded_width * 0.5) / safe_aspect);
|
||||
let fov_radians = (CAMERA_FOV * 0.5).to_radians();
|
||||
let fit_distance = (effective_half_span / fov_radians.tan()) * CAMERA_FIT_PADDING;
|
||||
fit_distance.clamp(CAMERA_DISTANCE_MIN, CAMERA_DISTANCE_MAX)
|
||||
}
|
||||
441
src/recording.rs
Normal file
441
src/recording.rs
Normal file
@@ -0,0 +1,441 @@
|
||||
//! Recording and CSV export for pressure sensor data.
|
||||
//!
|
||||
//! Provides two recording modes (Full and Snapshot), state management
|
||||
//! (Idle / Recording / Paused), and CSV import/export for replay.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
// ── Public types ────────────────────────────────────────────────────────
|
||||
|
||||
/// A single frame of pressure data captured at a point in time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frame {
|
||||
/// Pressure values, one per channel (same order every frame).
|
||||
pub pressures: Vec<u32>,
|
||||
/// Milliseconds elapsed since the recording started.
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
/// Whether the recording captures everything from connect (Full) or
|
||||
/// only between explicit start/stop calls (Snapshot).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecordingMode {
|
||||
Full,
|
||||
Snapshot,
|
||||
}
|
||||
|
||||
/// Transient state of a recording.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecordingState {
|
||||
Idle,
|
||||
Recording,
|
||||
Paused,
|
||||
}
|
||||
|
||||
// ── Inner (unlocked) recorder ───────────────────────────────────────────
|
||||
|
||||
struct RecorderInner {
|
||||
mode: RecordingMode,
|
||||
state: RecordingState,
|
||||
frames: Vec<Frame>,
|
||||
start: Option<Instant>,
|
||||
/// Accumulated wall-clock ms while paused (subtracted from elapsed).
|
||||
paused_duration_ms: u64,
|
||||
/// Instant when we entered Paused (None when not paused).
|
||||
pause_start: Option<Instant>,
|
||||
/// Number of channels in the first frame (used for CSV header).
|
||||
channel_count: Option<usize>,
|
||||
}
|
||||
|
||||
impl RecorderInner {
|
||||
fn new(mode: RecordingMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
state: RecordingState::Idle,
|
||||
frames: Vec::new(),
|
||||
start: None,
|
||||
paused_duration_ms: 0,
|
||||
pause_start: None,
|
||||
channel_count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_ms(&self) -> u64 {
|
||||
let Some(start) = self.start else { return 0 };
|
||||
let raw = start.elapsed().as_millis() as u64;
|
||||
let paused = if let Some(ps) = self.pause_start {
|
||||
self.paused_duration_ms + ps.elapsed().as_millis() as u64
|
||||
} else {
|
||||
self.paused_duration_ms
|
||||
};
|
||||
raw.saturating_sub(paused)
|
||||
}
|
||||
|
||||
fn push_frame(&mut self, pressures: Vec<u32>) {
|
||||
if self.channel_count.is_none() {
|
||||
self.channel_count = Some(pressures.len());
|
||||
}
|
||||
let ts = self.elapsed_ms();
|
||||
self.frames.push(Frame {
|
||||
pressures,
|
||||
timestamp_ms: ts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thread-safe wrapper ─────────────────────────────────────────────────
|
||||
|
||||
/// Thread-safe recorder. Clone the `Arc` to share across threads.
|
||||
#[derive(Clone)]
|
||||
pub struct Recorder {
|
||||
inner: Arc<Mutex<RecorderInner>>,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
/// Create a recorder in the given mode (starts in `Idle` state).
|
||||
pub fn new(mode: RecordingMode) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(RecorderInner::new(mode))),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
/// Start a **Full** recording (records every frame pushed from now on).
|
||||
pub fn start_full_recording(&self) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(
|
||||
r.state == RecordingState::Idle,
|
||||
"can only start from Idle state"
|
||||
);
|
||||
r.state = RecordingState::Recording;
|
||||
r.start = Some(Instant::now());
|
||||
r.paused_duration_ms = 0;
|
||||
r.pause_start = None;
|
||||
r.frames.clear();
|
||||
r.channel_count = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a **Snapshot** recording window.
|
||||
pub fn start_snapshot_recording(&self) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(
|
||||
r.state == RecordingState::Idle,
|
||||
"can only start from Idle state"
|
||||
);
|
||||
r.state = RecordingState::Recording;
|
||||
r.start = Some(Instant::now());
|
||||
r.paused_duration_ms = 0;
|
||||
r.pause_start = None;
|
||||
r.frames.clear();
|
||||
r.channel_count = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop recording (transitions to `Idle`).
|
||||
pub fn stop_recording(&self) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(
|
||||
r.state == RecordingState::Recording || r.state == RecordingState::Paused,
|
||||
"nothing to stop"
|
||||
);
|
||||
if r.state == RecordingState::Paused {
|
||||
if let Some(ps) = r.pause_start.take() {
|
||||
r.paused_duration_ms += ps.elapsed().as_millis() as u64;
|
||||
}
|
||||
}
|
||||
r.state = RecordingState::Idle;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pause an active recording.
|
||||
pub fn pause_recording(&self) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(r.state == RecordingState::Recording, "not recording");
|
||||
r.pause_start = Some(Instant::now());
|
||||
r.state = RecordingState::Paused;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resume a paused recording.
|
||||
pub fn resume_recording(&self) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(r.state == RecordingState::Paused, "not paused");
|
||||
if let Some(ps) = r.pause_start.take() {
|
||||
r.paused_duration_ms += ps.elapsed().as_millis() as u64;
|
||||
}
|
||||
r.state = RecordingState::Recording;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Data ────────────────────────────────────────────────────────
|
||||
|
||||
/// Feed one frame of pressure data. Ignored when not recording.
|
||||
pub fn add_frame(&self, pressures: &[u32]) {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
if r.state == RecordingState::Recording {
|
||||
r.push_frame(pressures.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of recorded frames.
|
||||
pub fn frame_count(&self) -> usize {
|
||||
self.inner.lock().unwrap().frames.len()
|
||||
}
|
||||
|
||||
/// Whether the recorder is currently in the `Recording` state.
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.inner.lock().unwrap().state == RecordingState::Recording
|
||||
}
|
||||
|
||||
/// Current recording state.
|
||||
pub fn state(&self) -> RecordingState {
|
||||
self.inner.lock().unwrap().state
|
||||
}
|
||||
|
||||
/// Milliseconds elapsed (excludes paused time).
|
||||
pub fn duration_ms(&self) -> u64 {
|
||||
self.inner.lock().unwrap().elapsed_ms()
|
||||
}
|
||||
|
||||
/// Snapshot of all recorded frames.
|
||||
pub fn recorded_frames(&self) -> Vec<Frame> {
|
||||
self.inner.lock().unwrap().frames.clone()
|
||||
}
|
||||
|
||||
// ── CSV export / import ─────────────────────────────────────────
|
||||
|
||||
/// Export recorded frames to CSV.
|
||||
///
|
||||
/// Header: `channel1,channel2,...,channelN,timestamp_ms`
|
||||
pub fn export_csv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||
let r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(!r.frames.is_empty(), "no frames to export");
|
||||
|
||||
let file = File::create(path.as_ref())
|
||||
.with_context(|| format!("creating {}", path.as_ref().display()))?;
|
||||
let mut w = BufWriter::new(file);
|
||||
|
||||
let n = r.channel_count.unwrap_or(r.frames[0].pressures.len());
|
||||
|
||||
// Header
|
||||
for i in 0..n {
|
||||
write!(w, "channel{}", i + 1)?;
|
||||
if i < n - 1 {
|
||||
write!(w, ",")?;
|
||||
}
|
||||
}
|
||||
writeln!(w, ",timestamp_ms")?;
|
||||
|
||||
// Rows
|
||||
for frame in &r.frames {
|
||||
for (i, val) in frame.pressures_iter().enumerate() {
|
||||
write!(w, "{}", val)?;
|
||||
if i < n - 1 {
|
||||
write!(w, ",")?;
|
||||
}
|
||||
}
|
||||
writeln!(w, ",{}", frame.timestamp_ms)?;
|
||||
}
|
||||
|
||||
w.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import frames from a CSV file (same format as `export_csv`).
|
||||
///
|
||||
/// The recorder must be in `Idle` state. After import it stays `Idle`
|
||||
/// so you can inspect / re-export; call `start_*` to continue recording.
|
||||
pub fn import_csv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||
let mut r = self.inner.lock().unwrap();
|
||||
anyhow::ensure!(r.state == RecordingState::Idle, "must be Idle to import");
|
||||
|
||||
let file = File::open(path.as_ref())
|
||||
.with_context(|| format!("opening {}", path.as_ref().display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
// Parse header to learn channel count
|
||||
let header = lines
|
||||
.next()
|
||||
.context("empty CSV file")?
|
||||
.context("reading header")?;
|
||||
let cols: Vec<&str> = header.split(',').map(str::trim).collect();
|
||||
anyhow::ensure!(cols.len() >= 2, "need at least 1 channel + timestamp_ms");
|
||||
let n_channels = cols.len() - 1; // last column is timestamp_ms
|
||||
|
||||
r.frames.clear();
|
||||
r.channel_count = Some(n_channels);
|
||||
|
||||
let mut first_ts: Option<u64> = None;
|
||||
|
||||
for (lineno, line) in lines.enumerate() {
|
||||
let line = line.with_context(|| format!("reading line {}", lineno + 2))?;
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = line.split(',').map(str::trim).collect();
|
||||
anyhow::ensure!(
|
||||
fields.len() == cols.len(),
|
||||
"line {}: expected {} columns, got {}",
|
||||
lineno + 2,
|
||||
cols.len(),
|
||||
fields.len()
|
||||
);
|
||||
|
||||
let mut pressures = Vec::with_capacity(n_channels);
|
||||
for f in &fields[..n_channels] {
|
||||
pressures.push(f.parse::<u32>().with_context(|| {
|
||||
format!("line {}: bad channel value '{}'", lineno + 2, f)
|
||||
})?);
|
||||
}
|
||||
let raw_ts = fields[n_channels]
|
||||
.parse::<u64>()
|
||||
.with_context(|| format!("line {}: bad timestamp", lineno + 2))?;
|
||||
|
||||
// Normalise timestamps so the first frame starts at 0
|
||||
let ts = if let Some(first) = first_ts {
|
||||
raw_ts.saturating_sub(first)
|
||||
} else {
|
||||
first_ts = Some(raw_ts);
|
||||
0
|
||||
};
|
||||
|
||||
r.frames.push(Frame {
|
||||
pressures,
|
||||
timestamp_ms: ts,
|
||||
});
|
||||
}
|
||||
|
||||
// Set the start instant so duration_ms() reports the imported span
|
||||
if !r.frames.is_empty() {
|
||||
if let Some(last) = r.frames.last() {
|
||||
// Pretend the recording happened `last.timestamp_ms` ago
|
||||
// so that elapsed_ms() would return that value.
|
||||
// We store a "fake" start by noting the offset.
|
||||
r.start = Some(Instant::now() - std::time::Duration::from_millis(last.timestamp_ms));
|
||||
r.paused_duration_ms = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Convenience constructors ────────────────────────────────────────────
|
||||
|
||||
impl Recorder {
|
||||
/// Shorthand: new Full recorder.
|
||||
pub fn full() -> Self {
|
||||
Self::new(RecordingMode::Full)
|
||||
}
|
||||
|
||||
/// Shorthand: new Snapshot recorder.
|
||||
pub fn snapshot() -> Self {
|
||||
Self::new(RecordingMode::Snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper extension on Frame ───────────────────────────────────────────
|
||||
|
||||
impl Frame {
|
||||
fn pressures_iter(&self) -> impl Iterator<Item = u32> + '_ {
|
||||
self.pressures.iter().copied()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn full_recording_lifecycle() {
|
||||
let rec = Recorder::full();
|
||||
assert_eq!(rec.state(), RecordingState::Idle);
|
||||
assert!(!rec.is_recording());
|
||||
assert_eq!(rec.frame_count(), 0);
|
||||
|
||||
rec.start_full_recording().unwrap();
|
||||
assert!(rec.is_recording());
|
||||
|
||||
rec.add_frame(&[10, 20, 30]);
|
||||
rec.add_frame(&[40, 50, 60]);
|
||||
assert_eq!(rec.frame_count(), 2);
|
||||
|
||||
rec.stop_recording().unwrap();
|
||||
assert_eq!(rec.state(), RecordingState::Idle);
|
||||
assert!(!rec.is_recording());
|
||||
assert!(rec.duration_ms() < 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_pause_resume() {
|
||||
let rec = Recorder::snapshot();
|
||||
rec.start_snapshot_recording().unwrap();
|
||||
|
||||
rec.add_frame(&[1, 2]);
|
||||
rec.pause_recording().unwrap();
|
||||
assert_eq!(rec.state(), RecordingState::Paused);
|
||||
rec.add_frame(&[9, 9]); // should be ignored
|
||||
assert_eq!(rec.frame_count(), 1);
|
||||
|
||||
rec.resume_recording().unwrap();
|
||||
rec.add_frame(&[3, 4]);
|
||||
assert_eq!(rec.frame_count(), 2);
|
||||
|
||||
rec.stop_recording().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_round_trip() {
|
||||
let rec = Recorder::full();
|
||||
rec.start_full_recording().unwrap();
|
||||
rec.add_frame(&[100, 200, 300]);
|
||||
rec.add_frame(&[101, 201, 301]);
|
||||
rec.stop_recording().unwrap();
|
||||
|
||||
let dir = std::env::temp_dir().join("eskin_recording_test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let csv_path = dir.join("test_roundtrip.csv");
|
||||
rec.export_csv(&csv_path).unwrap();
|
||||
|
||||
let rec2 = Recorder::snapshot();
|
||||
rec2.import_csv(&csv_path).unwrap();
|
||||
assert_eq!(rec2.frame_count(), 2);
|
||||
|
||||
let frames = rec2.recorded_frames();
|
||||
assert_eq!(frames[0].pressures, vec![100, 200, 300]);
|
||||
assert_eq!(frames[1].pressures, vec![101, 201, 301]);
|
||||
assert_eq!(frames[0].timestamp_ms, 0);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_safety() {
|
||||
let rec = Recorder::full();
|
||||
let rec2 = rec.clone();
|
||||
|
||||
rec.start_full_recording().unwrap();
|
||||
|
||||
let h = thread::spawn(move || {
|
||||
for i in 0..100u32 {
|
||||
rec2.add_frame(&[i, i + 1, i + 2]);
|
||||
}
|
||||
});
|
||||
h.join().unwrap();
|
||||
|
||||
rec.stop_recording().unwrap();
|
||||
assert_eq!(rec.frame_count(), 100);
|
||||
}
|
||||
}
|
||||
367
src/render.rs
Normal file
367
src/render.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
use eframe::{
|
||||
egui,
|
||||
egui_wgpu::{self, wgpu},
|
||||
wgpu::util::DeviceExt,
|
||||
};
|
||||
|
||||
use crate::matrix::{MatrixLayout, build_view_projection, glyph_world_position};
|
||||
|
||||
pub const PRESSURE_CELL_COUNT: usize =
|
||||
(crate::matrix::MATRIX_ROWS * crate::matrix::MATRIX_COLS) as usize;
|
||||
pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT];
|
||||
|
||||
pub struct WgpuBackgroundCallback {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub pressure: PressureFrame,
|
||||
}
|
||||
|
||||
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
|
||||
fn prepare(
|
||||
&self,
|
||||
_device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
_screen_descriptor: &egui_wgpu::ScreenDescriptor,
|
||||
_egui_encoder: &mut wgpu::CommandEncoder,
|
||||
resources: &mut egui_wgpu::CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap();
|
||||
resources.prepare(queue, self.width, self.height, &self.pressure);
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&self,
|
||||
_info: egui::PaintCallbackInfo,
|
||||
render_pass: &mut wgpu::RenderPass<'static>,
|
||||
resources: &egui_wgpu::CallbackResources,
|
||||
) {
|
||||
let resources: &BackgroundRenderResources = resources.get().unwrap();
|
||||
resources.paint(render_pass);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
glyph_vertex_buffer: wgpu::Buffer,
|
||||
glyph_instance_buffer: wgpu::Buffer,
|
||||
glyph_instances: Vec<GlyphInstance>,
|
||||
}
|
||||
|
||||
impl BackgroundRenderResources {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
target_format: &wgpu::TextureFormat,
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
) -> Self {
|
||||
let layout = MatrixLayout::new(rows, cols);
|
||||
let uniform = MatrixUniform::new(1.0, 1.0, build_view_projection(1.0, &layout));
|
||||
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Pressure Matrix Uniform Buffer"),
|
||||
contents: bytemuck::cast_slice(&[uniform]),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
|
||||
let uniform_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("pressure_matrix_bind_group_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pressure_matrix_bind_group"),
|
||||
layout: &uniform_bind_group_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Pressure Matrix Shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
|
||||
});
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Pressure Matrix Pipeline Layout"),
|
||||
bind_group_layouts: &[Some(&uniform_bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let background_pipeline =
|
||||
create_background_pipeline(device, target_format, &shader, &pipeline_layout);
|
||||
let glyph_pipeline =
|
||||
create_glyph_pipeline(device, target_format, &shader, &pipeline_layout);
|
||||
|
||||
let glyph_vertices = [
|
||||
GlyphVertex {
|
||||
local: [-1.0, -1.0],
|
||||
},
|
||||
GlyphVertex { local: [1.0, -1.0] },
|
||||
GlyphVertex { local: [-1.0, 1.0] },
|
||||
GlyphVertex { local: [-1.0, 1.0] },
|
||||
GlyphVertex { local: [1.0, -1.0] },
|
||||
GlyphVertex { local: [1.0, 1.0] },
|
||||
];
|
||||
let glyph_vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Pressure Glyph Vertex Buffer"),
|
||||
contents: bytemuck::cast_slice(&glyph_vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
|
||||
let glyph_instances =
|
||||
build_glyph_instances(rows, cols, &layout, &[[0.0, 0.0]; PRESSURE_CELL_COUNT]);
|
||||
let glyph_instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Pressure Glyph Instance Buffer"),
|
||||
contents: bytemuck::cast_slice(&glyph_instances),
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
|
||||
Self {
|
||||
layout,
|
||||
rows,
|
||||
cols,
|
||||
uniform,
|
||||
uniform_buffer,
|
||||
uniform_bind_group,
|
||||
background_pipeline,
|
||||
glyph_pipeline,
|
||||
glyph_vertex_buffer,
|
||||
glyph_instance_buffer,
|
||||
glyph_instances,
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, pressure: &PressureFrame) {
|
||||
let aspect = width / height.max(1.0);
|
||||
self.uniform =
|
||||
MatrixUniform::new(width, height, build_view_projection(aspect, &self.layout));
|
||||
queue.write_buffer(
|
||||
&self.uniform_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[self.uniform]),
|
||||
);
|
||||
|
||||
update_glyph_instances(
|
||||
&mut self.glyph_instances,
|
||||
self.rows,
|
||||
self.cols,
|
||||
&self.layout,
|
||||
pressure,
|
||||
);
|
||||
queue.write_buffer(
|
||||
&self.glyph_instance_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.glyph_instances),
|
||||
);
|
||||
}
|
||||
|
||||
fn paint(&self, render_pass: &mut wgpu::RenderPass<'_>) {
|
||||
render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||||
|
||||
render_pass.set_pipeline(&self.background_pipeline);
|
||||
render_pass.draw(0..3, 0..1);
|
||||
|
||||
render_pass.set_pipeline(&self.glyph_pipeline);
|
||||
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..self.glyph_instances.len() as u32);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_background_pipeline(
|
||||
device: &wgpu::Device,
|
||||
target_format: &wgpu::TextureFormat,
|
||||
shader: &wgpu::ShaderModule,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
) -> wgpu::RenderPipeline {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Pressure Matrix Background Pipeline"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_background"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_background"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: *target_format,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_glyph_pipeline(
|
||||
device: &wgpu::Device,
|
||||
target_format: &wgpu::TextureFormat,
|
||||
shader: &wgpu::ShaderModule,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
) -> wgpu::RenderPipeline {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Pressure Matrix Glyph Pipeline"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_glyph"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[GlyphVertex::desc(), GlyphInstance::desc()],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_glyph"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: *target_format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_glyph_instances(
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
layout: &MatrixLayout,
|
||||
pressure: &PressureFrame,
|
||||
) -> Vec<GlyphInstance> {
|
||||
let mut instances = Vec::with_capacity((rows * cols) as usize);
|
||||
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let index = (row * cols + col) as usize;
|
||||
let [normalized, display_value] = pressure.get(index).copied().unwrap_or([0.0, 0.0]);
|
||||
let (world_position, normalized) =
|
||||
glyph_world_position(row, col, rows, cols, layout, normalized);
|
||||
instances.push(GlyphInstance {
|
||||
world_position,
|
||||
style: [normalized, display_value, 0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
instances
|
||||
}
|
||||
|
||||
fn update_glyph_instances(
|
||||
instances: &mut [GlyphInstance],
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
layout: &MatrixLayout,
|
||||
pressure: &PressureFrame,
|
||||
) {
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let index = (row * cols + col) as usize;
|
||||
let [normalized, display_value] = pressure.get(index).copied().unwrap_or([0.0, 0.0]);
|
||||
let (world_position, normalized) =
|
||||
glyph_world_position(row, col, rows, cols, layout, normalized);
|
||||
if let Some(instance) = instances.get_mut(index) {
|
||||
instance.world_position = world_position;
|
||||
instance.style = [normalized, display_value, 0.0, 0.0];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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],
|
||||
}
|
||||
|
||||
impl MatrixUniform {
|
||||
fn new(width: f32, height: f32, view_proj: [[f32; 4]; 4]) -> Self {
|
||||
Self {
|
||||
view_proj,
|
||||
viewport: [width.max(1.0), height.max(1.0), 0.0, 0.0],
|
||||
glyph: [16.0, 0.0, 0.0, 0.0],
|
||||
color: [0.05, 0.92, 0.32, 1.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct GlyphVertex {
|
||||
local: [f32; 2],
|
||||
}
|
||||
|
||||
impl GlyphVertex {
|
||||
fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<GlyphVertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &[wgpu::VertexAttribute {
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct GlyphInstance {
|
||||
world_position: [f32; 4],
|
||||
style: [f32; 4],
|
||||
}
|
||||
|
||||
impl GlyphInstance {
|
||||
fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<GlyphInstance>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &[
|
||||
wgpu::VertexAttribute {
|
||||
offset: 0,
|
||||
shader_location: 1,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: std::mem::size_of::<[f32; 4]>() as wgpu::BufferAddress,
|
||||
shader_location: 2,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/serial_core/codec.rs
Normal file
7
src/serial_core/codec.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use crate::serial_core::error::CodecError;
|
||||
use std::time::Instant;
|
||||
|
||||
pub trait Codec<F> {
|
||||
fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result<Vec<F>, CodecError>;
|
||||
fn encode(&self, frame: &F) -> Result<Vec<u8>, CodecError>;
|
||||
}
|
||||
1
src/serial_core/codecs/mod.rs
Normal file
1
src/serial_core/codecs/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod tactile_a;
|
||||
184
src/serial_core/codecs/tactile_a.rs
Normal file
184
src/serial_core/codecs/tactile_a.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use crate::serial_core::codec::Codec;
|
||||
use crate::serial_core::error::CodecError;
|
||||
use crate::serial_core::frame::{
|
||||
TactileAFrame, TactileAFrameMetaData, TactileAFrameStatusCode, TactileARepFrame,
|
||||
TactileAReqFrame,
|
||||
};
|
||||
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis};
|
||||
|
||||
const FRAME_BUFFER_MIN_LENGTH: usize = 15;
|
||||
|
||||
pub struct TactileACodec {
|
||||
buffer: Vec<u8>,
|
||||
expected_data_len: usize,
|
||||
}
|
||||
|
||||
impl TactileACodec {
|
||||
pub fn new(cols: usize, rows: usize) -> TactileACodec {
|
||||
Self {
|
||||
buffer: Vec::new(),
|
||||
expected_data_len: cols * rows * 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_data_frame(data: &[u8]) -> Result<Vec<i32>, CodecError> {
|
||||
if data.len() % 2 != 0 {
|
||||
return Err(CodecError::InvalidLength);
|
||||
}
|
||||
|
||||
let vals: Vec<i32> = data
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| {
|
||||
let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32;
|
||||
if raw < 15 { 0 } else { raw }
|
||||
})
|
||||
.collect::<Vec<i32>>();
|
||||
|
||||
Ok(vals)
|
||||
}
|
||||
|
||||
pub fn build_req_frame(cols: usize, rows: usize) -> TactileAFrame {
|
||||
let header = [0x55, 0xAA];
|
||||
let payload_len: usize = 9;
|
||||
let device_addr: u8 = 0x34;
|
||||
let extend_code: u8 = 0x00;
|
||||
let func_code: u8 = 0xFB;
|
||||
let start_addr: u32 = 7168;
|
||||
let except_data_len: usize = cols * rows * 2;
|
||||
let checksum: u8 = 0;
|
||||
TactileAFrame::Req(TactileAReqFrame {
|
||||
meta: TactileAFrameMetaData {
|
||||
header,
|
||||
payload_len,
|
||||
device_addr,
|
||||
extend_code,
|
||||
func_code,
|
||||
start_addr,
|
||||
except_data_len,
|
||||
checksum,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Codec<TactileAFrame> for TactileACodec {
|
||||
fn decode(
|
||||
&mut self,
|
||||
input: &[u8],
|
||||
session_started_at: std::time::Instant,
|
||||
) -> Result<Vec<TactileAFrame>, CodecError> {
|
||||
self.buffer.extend_from_slice(input);
|
||||
let mut frames: Vec<TactileAFrame> = Vec::new();
|
||||
|
||||
loop {
|
||||
if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH {
|
||||
break;
|
||||
}
|
||||
|
||||
// Search for response header: [0xAA, 0x55]
|
||||
let header_pos = self.buffer.windows(2).position(|w| w == [0xAA, 0x55]);
|
||||
|
||||
let Some(pos) = header_pos else {
|
||||
self.buffer.clear();
|
||||
break;
|
||||
};
|
||||
if pos > 0 {
|
||||
self.buffer.drain(0..pos);
|
||||
}
|
||||
|
||||
if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH {
|
||||
break;
|
||||
}
|
||||
|
||||
let header = [self.buffer[0], self.buffer[1]];
|
||||
let payload_len = u16::from_le_bytes([self.buffer[2], self.buffer[3]]) as usize;
|
||||
let device_addr = self.buffer[4];
|
||||
let extend_code = self.buffer[5];
|
||||
let func_code = self.buffer[6];
|
||||
let start_addr = u32::from_le_bytes([
|
||||
self.buffer[7],
|
||||
self.buffer[8],
|
||||
self.buffer[9],
|
||||
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,
|
||||
};
|
||||
|
||||
if except_data_len != self.expected_data_len {
|
||||
log::debug!(
|
||||
"unexpected payload length: expected {}, got {}, buffer_len={}",
|
||||
self.expected_data_len,
|
||||
except_data_len,
|
||||
self.buffer.len()
|
||||
);
|
||||
self.buffer.drain(0..1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let frame_length = except_data_len + FRAME_BUFFER_MIN_LENGTH;
|
||||
if self.buffer.len() < frame_length {
|
||||
break;
|
||||
}
|
||||
|
||||
let need_check_data = self.buffer[0..14 + except_data_len].to_vec();
|
||||
let payload = self.buffer[14..14 + except_data_len].to_vec();
|
||||
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
|
||||
let checksum = crc8_itu_alg.checksum(&need_check_data.as_slice());
|
||||
if self.buffer[frame_length - 1] != checksum {
|
||||
log::debug!(
|
||||
"checksum mismatch: expected {:02X}, got {:02X}, frame_len={}",
|
||||
checksum,
|
||||
self.buffer[frame_length - 1],
|
||||
frame_length
|
||||
);
|
||||
self.buffer.drain(0..1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let dts_ms = elapsed_millis(session_started_at);
|
||||
let meta = TactileAFrameMetaData {
|
||||
header,
|
||||
payload_len,
|
||||
device_addr,
|
||||
extend_code,
|
||||
func_code,
|
||||
start_addr,
|
||||
except_data_len,
|
||||
checksum,
|
||||
};
|
||||
frames.push(TactileAFrame::Rep(TactileARepFrame {
|
||||
meta,
|
||||
status,
|
||||
payload,
|
||||
dts_ms,
|
||||
}));
|
||||
|
||||
self.buffer.drain(0..frame_length);
|
||||
}
|
||||
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn encode(&self, frame: &TactileAFrame) -> Result<Vec<u8>, CodecError> {
|
||||
match frame {
|
||||
TactileAFrame::Req(f) => {
|
||||
let mut req_bytes: Vec<u8> = Vec::new();
|
||||
req_bytes.extend_from_slice(f.meta.header.as_slice());
|
||||
req_bytes.extend_from_slice((f.meta.payload_len as u16).to_le_bytes().as_slice());
|
||||
req_bytes.push(f.meta.device_addr);
|
||||
req_bytes.push(f.meta.extend_code);
|
||||
req_bytes.push(f.meta.func_code);
|
||||
req_bytes.extend_from_slice(f.meta.start_addr.to_le_bytes().as_slice());
|
||||
req_bytes
|
||||
.extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice());
|
||||
let checksum = calc_crc8_itu(req_bytes.as_slice());
|
||||
req_bytes.push(checksum);
|
||||
Ok(req_bytes)
|
||||
}
|
||||
_ => Err(CodecError::InvalidFrameType),
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/serial_core/error.rs
Normal file
51
src/serial_core/error.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SerialError {
|
||||
OpenError,
|
||||
CloseError,
|
||||
ScanError,
|
||||
InvalidConfig,
|
||||
AlreadyConnected,
|
||||
StateError,
|
||||
NoRecordedData,
|
||||
}
|
||||
|
||||
impl fmt::Display for SerialError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
SerialError::OpenError => write!(f, "Opening Error"),
|
||||
SerialError::CloseError => write!(f, "Closing Error"),
|
||||
SerialError::ScanError => write!(f, "Scan Error"),
|
||||
SerialError::InvalidConfig => write!(f, "Invalid Config"),
|
||||
SerialError::AlreadyConnected => write!(f, "Already Connected"),
|
||||
SerialError::StateError => write!(f, "State Error"),
|
||||
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SerialError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CodecError {
|
||||
InvalidHeader,
|
||||
InvalidTail,
|
||||
InvalidLength,
|
||||
InvalidFrameType,
|
||||
PayloadTooLarge,
|
||||
}
|
||||
|
||||
impl fmt::Display for CodecError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
CodecError::InvalidHeader => write!(f, "Invalid Header"),
|
||||
CodecError::InvalidTail => write!(f, "Invalid Tail"),
|
||||
CodecError::InvalidLength => write!(f, "Invalid Length"),
|
||||
CodecError::InvalidFrameType => write!(f, "Invalid Frame Type"),
|
||||
CodecError::PayloadTooLarge => write!(f, "Payload too large"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CodecError {}
|
||||
36
src/serial_core/frame.rs
Normal file
36
src/serial_core/frame.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TactileAFrameMetaData {
|
||||
pub header: [u8; 2],
|
||||
pub payload_len: usize,
|
||||
pub device_addr: u8,
|
||||
pub extend_code: u8,
|
||||
pub func_code: u8,
|
||||
pub start_addr: u32,
|
||||
pub except_data_len: usize,
|
||||
pub checksum: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TactileAReqFrame {
|
||||
pub meta: TactileAFrameMetaData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TactileARepFrame {
|
||||
pub meta: TactileAFrameMetaData,
|
||||
pub status: TactileAFrameStatusCode,
|
||||
pub payload: Vec<u8>,
|
||||
pub dts_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TactileAFrameStatusCode {
|
||||
Success,
|
||||
Failure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TactileAFrame {
|
||||
Req(TactileAReqFrame),
|
||||
Rep(TactileARepFrame),
|
||||
}
|
||||
6
src/serial_core/mod.rs
Normal file
6
src/serial_core/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod codec;
|
||||
pub mod codecs;
|
||||
pub mod error;
|
||||
pub mod frame;
|
||||
pub mod serial;
|
||||
pub mod utils;
|
||||
96
src/serial_core/serial.rs
Normal file
96
src/serial_core/serial.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use crate::serial_core::codec::Codec;
|
||||
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 std::io::{Read, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const POLL_INTERVAL_MS: u64 = 10;
|
||||
|
||||
/// Runs the serial polling loop on the calling (background) thread.
|
||||
/// Sends decoded pressure matrix data (Vec<i32>) to the output channel.
|
||||
pub fn run_serial_loop(
|
||||
port: &mut dyn ReadWrite,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
cancel_rx: &Receiver<()>,
|
||||
sample_tx: &Sender<Vec<i32>>,
|
||||
) {
|
||||
let session_started_at = Instant::now();
|
||||
let mut codec = TactileACodec::new(cols, rows);
|
||||
let req_frame = TactileACodec::build_req_frame(cols, rows);
|
||||
let mut buffer = [0u8; 1024];
|
||||
let mut poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
|
||||
|
||||
loop {
|
||||
// Check cancel
|
||||
if cancel_rx.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Send poll request
|
||||
if let Ok(req_bytes) = codec.encode(&req_frame) {
|
||||
let _ = port.write_all(&req_bytes);
|
||||
}
|
||||
|
||||
// Read response with poll interval
|
||||
let deadline = Instant::now() + poll_interval;
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
match port.read(&mut buffer) {
|
||||
Ok(n) if n > 0 => {
|
||||
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) {
|
||||
let _ = sample_tx.try_send(vals);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[serial] read error: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait abstracting read+write for the serial port
|
||||
pub trait ReadWrite: Send {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
|
||||
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()>;
|
||||
}
|
||||
|
||||
/// Wrapper for serialport's Box<dyn SerialPort>
|
||||
pub struct SerialPortReadWrite {
|
||||
inner: Box<dyn serialport::SerialPort>,
|
||||
}
|
||||
|
||||
impl SerialPortReadWrite {
|
||||
pub fn new(port: Box<dyn serialport::SerialPort>) -> Self {
|
||||
Self { inner: port }
|
||||
}
|
||||
}
|
||||
|
||||
impl ReadWrite for SerialPortReadWrite {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.inner.read(buf)
|
||||
}
|
||||
|
||||
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
|
||||
self.inner.write_all(buf)
|
||||
}
|
||||
}
|
||||
10
src/serial_core/utils.rs
Normal file
10
src/serial_core/utils.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use std::time::Instant;
|
||||
|
||||
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 elapsed_millis(start_at: Instant) -> u64 {
|
||||
start_at.elapsed().as_millis() as u64
|
||||
}
|
||||
282
src/shader.wgsl
282
src/shader.wgsl
@@ -1,52 +1,258 @@
|
||||
// 顶点着色器
|
||||
|
||||
struct Camera {
|
||||
struct MatrixUniform {
|
||||
view_proj: mat4x4f,
|
||||
}
|
||||
@group(1) @binding(0)
|
||||
var<uniform> camera: Camera;
|
||||
|
||||
struct VertexInput {
|
||||
@location(0) position: vec3f,
|
||||
@location(1) tex_coords: vec2f,
|
||||
}
|
||||
struct InstanceInput {
|
||||
@location(5) model_matrix_0: vec4f,
|
||||
@location(6) model_matrix_1: vec4f,
|
||||
@location(7) model_matrix_2: vec4f,
|
||||
@location(8) model_matrix_3: vec4f,
|
||||
viewport: vec4f,
|
||||
glyph: vec4f,
|
||||
color: vec4f,
|
||||
}
|
||||
|
||||
struct VertexOutput {
|
||||
@group(0) @binding(0)
|
||||
var<uniform> u: MatrixUniform;
|
||||
|
||||
struct BackgroundVertexOutput {
|
||||
@builtin(position) clip_position: vec4f,
|
||||
@location(0) tex_coords: vec2f,
|
||||
}
|
||||
|
||||
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_main(
|
||||
model: VertexInput,
|
||||
instance: InstanceInput,
|
||||
) -> VertexOutput {
|
||||
let model_matrix = mat4x4f(
|
||||
instance.model_matrix_0,
|
||||
instance.model_matrix_1,
|
||||
instance.model_matrix_2,
|
||||
instance.model_matrix_3,
|
||||
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: VertexOutput;
|
||||
out.tex_coords = model.tex_coords;
|
||||
out.clip_position = camera.view_proj * model_matrix * vec4f(model.position, 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)));
|
||||
|
||||
@group(0) @binding(0)
|
||||
var t_diffuse: texture_2d<f32>;
|
||||
@group(0)@binding(1)
|
||||
var s_diffuse: sampler;
|
||||
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_main(in: VertexOutput) -> @location(0) vec4f {
|
||||
return textureSample(t_diffuse, s_diffuse, in.tex_coords);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
168
src/theme.rs
Normal file
168
src/theme.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use eframe::egui;
|
||||
|
||||
#[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 accent: egui::Color32,
|
||||
pub accent_hot: egui::Color32,
|
||||
pub radius: u8,
|
||||
}
|
||||
|
||||
pub const ONE_DARK_PRO: AppTheme = AppTheme {
|
||||
bg: egui::Color32::from_rgb(40, 44, 52), // #282C34 editor background
|
||||
panel: egui::Color32::from_rgb(33, 37, 43), // #21252B sidebar/darker
|
||||
panel_strong: egui::Color32::from_rgb(44, 49, 58), // #2C313A slightly lighter
|
||||
panel_deep: egui::Color32::from_rgb(27, 31, 39), // #1B1F27 darkest
|
||||
border: egui::Color32::from_rgb(62, 68, 81), // #3E4451 border
|
||||
border_soft: egui::Color32::from_rgb(44, 49, 58), // #2C313A soft border
|
||||
text: egui::Color32::from_rgb(171, 178, 191), // #ABB2BF default text
|
||||
text_dim: egui::Color32::from_rgb(92, 99, 112), // #5C6370 dimmed text
|
||||
accent: egui::Color32::from_rgb(198, 120, 221), // #C678DD purple (signature)
|
||||
accent_hot: egui::Color32::from_rgb(229, 192, 123), // #E5C07B yellow/gold
|
||||
radius: 3,
|
||||
};
|
||||
|
||||
// Additional One Dark Pro accent colors (standalone constants)
|
||||
pub const ACCENT_GREEN: egui::Color32 = egui::Color32::from_rgb(152, 195, 121); // #98C379
|
||||
pub const ACCENT_RED: egui::Color32 = egui::Color32::from_rgb(224, 108, 117); // #E06C75
|
||||
pub const ACCENT_BLUE: egui::Color32 = egui::Color32::from_rgb(97, 175, 239); // #61AFEF
|
||||
pub const ACCENT_CYAN: egui::Color32 = egui::Color32::from_rgb(86, 182, 194); // #56B6C2
|
||||
pub const ACCENT_ORANGE: egui::Color32 = egui::Color32::from_rgb(209, 154, 102); // #D19A66
|
||||
|
||||
pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
|
||||
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(55, 61, 72);
|
||||
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, theme.border);
|
||||
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
||||
visuals.widgets.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(50, 56, 66);
|
||||
visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, theme.border);
|
||||
visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
||||
|
||||
visuals.selection.bg_fill = theme.accent;
|
||||
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(7.0, 5.0);
|
||||
style.spacing.window_margin = egui::Margin::same(7);
|
||||
style.visuals.window_corner_radius = egui::CornerRadius::same(theme.radius);
|
||||
style.visuals.menu_corner_radius = egui::CornerRadius::same(2);
|
||||
style.visuals.window_shadow = egui::epaint::Shadow {
|
||||
offset: [0, 12],
|
||||
blur: 24,
|
||||
spread: 0,
|
||||
color: egui::Color32::from_black_alpha(110),
|
||||
};
|
||||
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))
|
||||
.shadow(egui::epaint::Shadow {
|
||||
offset: [0, 12],
|
||||
blur: 26,
|
||||
spread: 0,
|
||||
color: egui::Color32::from_black_alpha(120),
|
||||
})
|
||||
}
|
||||
|
||||
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(2))
|
||||
.inner_margin(egui::Margin::symmetric(6, 5))
|
||||
}
|
||||
|
||||
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(2))
|
||||
}
|
||||
|
||||
pub fn dim_text() -> egui::Color32 {
|
||||
ONE_DARK_PRO.text_dim
|
||||
}
|
||||
|
||||
pub fn accent_text() -> egui::Color32 {
|
||||
ONE_DARK_PRO.accent_hot
|
||||
}
|
||||
11
src/utils.rs
Normal file
11
src/utils.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use anyhow;
|
||||
use serialport::available_ports;
|
||||
|
||||
pub fn serial_enum() -> anyhow::Result<Vec<String>> {
|
||||
let ports = available_ports()
|
||||
.map_err(|e| anyhow::anyhow!("available_ports failed: {}", e))?
|
||||
.into_iter()
|
||||
.map(|info| info.port_name)
|
||||
.collect();
|
||||
Ok(ports)
|
||||
}
|
||||
BIN
static/Hack-Bold.ttf
Normal file
BIN
static/Hack-Bold.ttf
Normal file
Binary file not shown.
BIN
static/cpu.png
Normal file
BIN
static/cpu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Reference in New Issue
Block a user