17 Commits

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

View File

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

45
Cargo.lock generated
View File

@@ -1304,7 +1304,7 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]] [[package]]
name = "eskin-model-player" name = "eskin-model-player"
version = "0.5.0" version = "5.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytemuck", "bytemuck",
@@ -1318,8 +1318,11 @@ dependencies = [
"gltf", "gltf",
"image", "image",
"log", "log",
"serde",
"serialport", "serialport",
"tobj", "tobj",
"toml",
"winresource",
] ]
[[package]] [[package]]
@@ -3645,6 +3648,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "serialport" name = "serialport"
version = "4.9.0" version = "4.9.0"
@@ -3989,6 +4001,21 @@ dependencies = [
"ahash", "ahash",
] ]
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@@ -4019,6 +4046,12 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@@ -5017,6 +5050,16 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml",
"version_check",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"

View File

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

View File

@@ -27,6 +27,14 @@ fn main() -> Result<()> {
copy_items(&items, &resource_dir, &copy_options)?; copy_items(&items, &resource_dir, &copy_options)?;
} }
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut resource = winresource::WindowsResource::new();
resource.set_icon("res/icon.ico");
resource.compile().expect("compile windows icon failed");
}
println!("cargo:rustc-env=RESOURCE_DIR={}", resource_dir.display()); println!("cargo:rustc-env=RESOURCE_DIR={}", resource_dir.display());
Ok(()) Ok(())

59
config.toml Normal file
View File

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

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

File diff suppressed because it is too large Load Diff

BIN
res/128x128@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
res/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

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

View File

@@ -1,6 +1,7 @@
use crate::breakout::{BreakoutGame, control_from_matrix}; use crate::breakout::{BreakoutGame, control_from_matrix};
use crate::connection::ConnectionManager; use crate::config::{HandForceConfig, try_load_hand_force_config};
use crate::force::{ForceEstimatorState, HudSpatialForce}; use crate::connection::{ConnectionManager, ConnectionState};
use crate::force::{ForceEstimatorState, HAND_FINGERTIP_COUNT, HudSpatialForce};
use crate::recording::Recorder; use crate::recording::Recorder;
use crate::render::{ActiveMode, FingerMode, HandGatewayMode}; use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout}; use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout};
@@ -13,14 +14,30 @@ use crate::{
}, },
ui::{ ui::{
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState, ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
draw_config_panel, draw_export_panel, draw_matrix_config_panel, draw_stats_panel, draw_config_panel, draw_hand_force_panels, panel_restore_item,
panel_restore_item,
}, },
}; };
use eframe::{egui, egui_wgpu}; use eframe::{egui, egui_wgpu};
use std::sync::Arc; use std::collections::BTreeMap;
use std::{
sync::Arc,
time::{Duration, Instant},
};
const SUMMARY_POINTS_PER_SERIES: usize = 42; const SUMMARY_POINTS_PER_SERIES: usize = 42;
const HAND_FORCE_PANEL_COUNT: usize = 7;
const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [84, 84, 84, 84, 84, 70, 44];
const HAND_FORCE_DISPLAY_THRESHOLD_N: f32 = 0.05;
const HAND_IMAGE_SIZE_PX: [f32; 2] = [971.0, 1117.0];
const HAND_FINGERTIP_FORCE_ANCHORS_PX: [[f32; 2]; HAND_FINGERTIP_COUNT] = [
[265.0, 495.0],
[359.0, 260.0],
[482.0, 228.0],
[596.0, 265.0],
[693.0, 370.0],
];
const RATE_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
const DATA_IDLE_PANEL_EXIT_DELAY: Duration = Duration::from_secs(3);
pub struct EskinDesktopApp { pub struct EskinDesktopApp {
connect_panel: FloatingPanelState, connect_panel: FloatingPanelState,
@@ -39,16 +56,66 @@ pub struct EskinDesktopApp {
matrix_config_panel: FloatingPanelState, matrix_config_panel: FloatingPanelState,
matrix_config: MatrixConfigState, matrix_config: MatrixConfigState,
signal_history: Vec<f32>, signal_history: Vec<f32>,
hand_signal_histories: [Vec<f32>; HAND_FORCE_PANEL_COUNT],
force_estimator: ForceEstimatorState, force_estimator: ForceEstimatorState,
latest_spatial_force: Option<HudSpatialForce>, latest_spatial_force: Option<HudSpatialForce>,
latest_hand_spatial_forces: [Option<HudSpatialForce>; HAND_FINGERTIP_COUNT],
spatial_force_panel_force: Option<HudSpatialForce>,
latest_raw_matrix: Vec<u32>, latest_raw_matrix: Vec<u32>,
latest_matrix_rows: u32, latest_matrix_rows: u32,
latest_matrix_cols: u32, latest_matrix_cols: u32,
last_sample_at: Option<Instant>,
last_color_debug_at: Option<Instant>,
breakout_visible: bool, breakout_visible: bool,
breakout_game: BreakoutGame, breakout_game: BreakoutGame,
live_rates: LiveRateMeters,
context_menu_open: bool, context_menu_open: bool,
context_menu_pos: egui::Pos2, context_menu_pos: egui::Pos2,
active_mode: ActiveMode, active_mode: ActiveMode,
hand_force_config: Option<HandForceConfig>,
}
struct LiveRateMeters {
window_started_at: Instant,
window_rx_frames: u64,
window_render_frames: u32,
sample_rate_hz: f32,
render_rate_hz: f32,
}
impl LiveRateMeters {
fn new() -> Self {
Self {
window_started_at: Instant::now(),
window_rx_frames: 0,
window_render_frames: 0,
sample_rate_hz: 0.0,
render_rate_hz: 0.0,
}
}
fn tick_render(&mut self, total_rx_frames: u64) {
if total_rx_frames < self.window_rx_frames {
self.window_rx_frames = total_rx_frames;
self.sample_rate_hz = 0.0;
}
self.window_render_frames = self.window_render_frames.saturating_add(1);
let now = Instant::now();
let elapsed = now.duration_since(self.window_started_at);
if elapsed < RATE_UPDATE_INTERVAL {
return;
}
let seconds = elapsed.as_secs_f32().max(0.001);
let rx_delta = total_rx_frames.saturating_sub(self.window_rx_frames);
self.sample_rate_hz = rx_delta as f32 / seconds;
self.render_rate_hz = self.window_render_frames as f32 / seconds;
self.window_started_at = now;
self.window_rx_frames = total_rx_frames;
self.window_render_frames = 0;
}
} }
impl EskinDesktopApp { impl EskinDesktopApp {
@@ -73,6 +140,7 @@ impl EskinDesktopApp {
MATRIX_COLS, MATRIX_COLS,
)); ));
let hand_force_config = try_load_hand_force_config();
Self { Self {
connect_panel: FloatingPanelState::new([0.0, 0.0], [layout::RIGHT_TAG_X, 48.0]), connect_panel: FloatingPanelState::new([0.0, 0.0], [layout::RIGHT_TAG_X, 48.0]),
connect_state: ConnectPanelState::default(), connect_state: ConnectPanelState::default(),
@@ -104,21 +172,23 @@ impl EskinDesktopApp {
), ),
matrix_config: MatrixConfigState::default(), matrix_config: MatrixConfigState::default(),
signal_history: Vec::with_capacity(128), signal_history: Vec::with_capacity(128),
hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)),
force_estimator: ForceEstimatorState::new(), force_estimator: ForceEstimatorState::new(),
latest_spatial_force: None, latest_spatial_force: None,
latest_hand_spatial_forces: [None; HAND_FINGERTIP_COUNT],
spatial_force_panel_force: None,
latest_raw_matrix: Vec::new(), latest_raw_matrix: Vec::new(),
latest_matrix_rows: MATRIX_ROWS, latest_matrix_rows: MATRIX_ROWS,
latest_matrix_cols: MATRIX_COLS, latest_matrix_cols: MATRIX_COLS,
last_sample_at: None,
last_color_debug_at: None,
breakout_visible: false, breakout_visible: false,
breakout_game: BreakoutGame::default(), breakout_game: BreakoutGame::default(),
live_rates: LiveRateMeters::new(),
context_menu_open: false, context_menu_open: false,
context_menu_pos: egui::pos2(24.0, 48.0), context_menu_pos: egui::pos2(24.0, 48.0),
active_mode: ActiveMode::Finger(FingerMode { active_mode: ActiveMode::Hand(HandGatewayMode { range: 0..7000 }),
rows: 12, hand_force_config,
cols: 7,
range: 0..7000,
dot: true,
}),
} }
} }
@@ -140,40 +210,97 @@ impl EskinDesktopApp {
} }
fn paint_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) { fn paint_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) {
if !self.recent_sample_is_fresh() {
return;
}
if matches!(self.active_mode, ActiveMode::Hand(_)) {
self.paint_hand_spatial_force_overlay(ui, rect);
return;
}
let Some(force) = self.latest_spatial_force else { let Some(force) = self.latest_spatial_force else {
return; return;
}; };
let painter = ui.painter();
let center = rect.center(); let center = rect.center();
let magnitude = force.magnitude.clamp(0.0, 1.8); paint_spatial_force_arrow(ui.painter(), center, force, 34.0, 54.0);
let length = 34.0 + magnitude * 54.0; }
let angle = force.angle_deg.to_radians();
let direction = egui::vec2(angle.cos(), angle.sin());
let end = center + direction * length;
let side = egui::vec2(-direction.y, direction.x);
let color = egui::Color32::from_rgb(255, 196, 54);
let glow = egui::Color32::from_rgba_unmultiplied(255, 196, 54, 58);
painter.line_segment([center, end], egui::Stroke::new(9.0, glow)); fn paint_hand_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) {
painter.line_segment([center, end], egui::Stroke::new(2.4, color)); for (force, anchor_px) in self
painter.line_segment( .latest_hand_spatial_forces
[end, end - direction * 14.0 + side * 7.0], .iter()
egui::Stroke::new(2.4, color), .zip(HAND_FINGERTIP_FORCE_ANCHORS_PX)
); {
painter.line_segment( let Some(force) = force else {
[end, end - direction * 14.0 - side * 7.0], continue;
egui::Stroke::new(2.4, color), };
);
painter.circle_filled(center, 4.2, color); let center = hand_image_pixel_to_screen(rect, anchor_px);
paint_spatial_force_arrow(ui.painter(), center, *force, 20.0, 34.0);
}
}
fn strongest_hand_spatial_force(&self) -> Option<HudSpatialForce> {
self.latest_hand_spatial_forces
.iter()
.flatten()
.copied()
.max_by(|a, b| a.magnitude.total_cmp(&b.magnitude))
}
fn analyze_spatial_force(&mut self, values: &[u32]) {
if matches!(self.config_state.mode, SerialMode::Hand) {
let enabled = std::array::from_fn(|tip_index| {
hand_segment_force(values, tip_index, self.hand_force_config.as_ref())
> HAND_FORCE_DISPLAY_THRESHOLD_N
});
self.latest_hand_spatial_forces = self
.force_estimator
.analyze_fingertips_masked(values, &enabled);
self.latest_spatial_force = self.strongest_hand_spatial_force();
} else {
self.latest_spatial_force = self.force_estimator.analyze(values);
self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT];
}
}
fn connection_has_live_data(&self) -> bool {
matches!(
self.connection.state(),
ConnectionState::Connected | ConnectionState::Streaming
)
}
fn recent_sample_is_fresh(&self) -> bool {
self.last_sample_at
.is_some_and(|sample_at| sample_at.elapsed() <= DATA_IDLE_PANEL_EXIT_DELAY)
}
fn hand_force_panels_visible(&self) -> bool {
self.connection_has_live_data() && self.recent_sample_is_fresh()
}
fn sync_disconnected_live_state(&mut self) {
if self.connection_has_live_data() {
return;
}
self.last_sample_at = None;
self.latest_spatial_force = None;
self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT];
self.spatial_force_panel_force = None;
} }
fn draw_workspace(&mut self, ui: &mut egui::Ui) { fn draw_workspace(&mut self, ui: &mut egui::Ui) {
if self.breakout_visible { let screen = ui.max_rect();
self.draw_split_workspace(ui); let workspace = egui::Rect::from_min_max(
} else { screen.left_top()
self.draw_wgpu_background_rect(ui, ui.max_rect()); + egui::vec2(0.0, layout::TITLE_BAR_HEIGHT + layout::CONFIG_BAR_HEIGHT),
} screen.right_bottom(),
);
self.draw_wgpu_background_rect(ui, workspace);
} }
fn draw_split_workspace(&mut self, ui: &mut egui::Ui) { fn draw_split_workspace(&mut self, ui: &mut egui::Ui) {
@@ -214,6 +341,7 @@ impl EskinDesktopApp {
fn update_pressure_matrix(&mut self) { fn update_pressure_matrix(&mut self) {
if let Some(sample) = self.connection.take_latest_sample() { if let Some(sample) = self.connection.take_latest_sample() {
self.last_sample_at = Some(Instant::now());
normalize_pressure_sample( normalize_pressure_sample(
&sample.matrix, &sample.matrix,
sample.rows, sample.rows,
@@ -225,20 +353,16 @@ impl EskinDesktopApp {
self.latest_raw_matrix.extend_from_slice(&sample.matrix); self.latest_raw_matrix.extend_from_slice(&sample.matrix);
self.latest_matrix_rows = sample.rows; self.latest_matrix_rows = sample.rows;
self.latest_matrix_cols = sample.cols; self.latest_matrix_cols = sample.cols;
self.log_hand_color_mapping_debug(&sample.matrix);
// Feed data to recorder self.analyze_spatial_force(&sample.matrix);
self.recorder.add_frame(&sample.matrix);
self.latest_spatial_force = self.force_estimator.analyze(&sample.matrix); let force = update_hand_signal_histories(
&mut self.hand_signal_histories,
// Keep JE-Skin's summary path separate from the optional spatial-force vector. &sample.matrix,
let raw_total = sample self.hand_force_config.as_ref(),
.matrix )
.iter() .min(25.6);
.fold(0_u64, |sum, value| sum + *value as u64)
.min(u32::MAX as u64) as u32;
let force = raw_to_g1(raw_total).min(25.6);
let force = if force <= 0.1 { 0.0 } else { force };
self.signal_history.push(force); self.signal_history.push(force);
if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES { if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES {
self.signal_history.remove(0); self.signal_history.remove(0);
@@ -357,29 +481,36 @@ impl EskinDesktopApp {
} }
} }
fn draw_floating_panels(&mut self, ctx: &egui::Context) { fn draw_floating_panels(
// draw_scene_panel(ctx, &mut self.scene_panel); &mut self,
ctx: &egui::Context,
stats: crate::serial_core::serial::SerialIoStats,
) {
self.scene_panel.visible = false;
self.connect_panel.visible = false;
self.export_panel.visible = false;
self.matrix_config_panel.visible = false;
self.context_menu_open = false;
if let Some(next_mode) = draw_config_panel( if let Some(next_mode) = draw_config_panel(
ctx, ctx,
&mut self.config_panel, &mut self.config_panel,
&mut self.config_state, &mut self.config_state,
&self.connection, &self.connection,
&self.recorder,
stats,
self.live_rates.sample_rate_hz,
self.live_rates.render_rate_hz,
) { ) {
self.switch_mode(next_mode); self.switch_mode(next_mode);
} }
draw_stats_panel(
self.stats_panel.visible = false;
draw_hand_force_panels(
ctx, ctx,
&mut self.stats_panel, self.hand_force_panels_visible(),
&self.signal_history, &self.hand_signal_histories,
self.latest_spatial_force,
); );
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 draw_panel_context_menu(&mut self, ctx: &egui::Context) { fn draw_panel_context_menu(&mut self, ctx: &egui::Context) {
@@ -415,20 +546,33 @@ impl EskinDesktopApp {
panel_restore_item(ui, "配置", &mut self.config_panel); panel_restore_item(ui, "配置", &mut self.config_panel);
panel_restore_item(ui, "录制", &mut self.export_panel); panel_restore_item(ui, "录制", &mut self.export_panel);
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel); panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
panel_restore_item(ui, "统计", &mut self.stats_panel); if self.stats_panel.visible {
if ui.button("打砖块").clicked() { if ui
self.breakout_visible = true; .add_sized(
close_menu = true; egui::vec2(ui.available_width(), 0.0),
egui::Button::new("隐藏 统计"),
)
.clicked()
{
self.stats_panel.visible = false;
close_menu = true;
}
} else {
panel_restore_item(ui, "统计", &mut self.stats_panel);
} }
ui.separator(); ui.separator();
if ui.button("全部显示").clicked() { if ui
.add_sized(
egui::vec2(ui.available_width(), 0.0),
egui::Button::new("全部显示"),
)
.clicked()
{
self.config_panel.visible = true; self.config_panel.visible = true;
self.export_panel.visible = true; self.export_panel.visible = true;
self.matrix_config_panel.visible = true; self.matrix_config_panel.visible = true;
self.stats_panel.visible = true; self.stats_panel.visible = true;
self.breakout_visible = true;
close_menu = true; close_menu = true;
} }
}); });
@@ -453,9 +597,16 @@ impl EskinDesktopApp {
self.connection.disconnect(); self.connection.disconnect();
self.pressure_matrix.fill([0.0, 0.0]); self.pressure_matrix.fill([0.0, 0.0]);
self.hand_pressure.clear(); self.hand_pressure.clear();
self.last_sample_at = None;
self.last_color_debug_at = None;
self.force_estimator.reset(); self.force_estimator.reset();
self.latest_spatial_force = None; self.latest_spatial_force = None;
self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT];
self.spatial_force_panel_force = None;
self.signal_history.clear(); self.signal_history.clear();
self.hand_signal_histories
.iter_mut()
.for_each(|history| history.clear());
self.active_mode = match next { self.active_mode = match next {
SerialMode::Finger => ActiveMode::Finger(FingerMode { SerialMode::Finger => ActiveMode::Finger(FingerMode {
@@ -467,14 +618,347 @@ impl EskinDesktopApp {
SerialMode::Hand => ActiveMode::Hand(HandGatewayMode { range: 0..7000 }), SerialMode::Hand => ActiveMode::Hand(HandGatewayMode { range: 0..7000 }),
} }
} }
fn log_hand_color_mapping_debug(&mut self, raw: &[u32]) {
let now = Instant::now();
if self
.last_color_debug_at
.is_some_and(|last| now.duration_since(last) < Duration::from_secs(1))
{
return;
}
self.last_color_debug_at = Some(now);
let mut parts = Vec::with_capacity(HAND_FORCE_PANEL_COUNT);
let mut start = 0;
for (panel_index, (range, cell_count)) in HAND_SENSOR_PANEL_COLOR_RANGES
.iter()
.copied()
.zip(HAND_SENSOR_PANEL_CELL_COUNTS)
.enumerate()
{
let end = start + cell_count;
let peak = raw
.get(start..end)
.unwrap_or(&[])
.iter()
.copied()
.max()
.unwrap_or(0);
let cell_range = range.per_cell(cell_count);
let [intensity, _] = cell_range.normalize(peak);
parts.push(format!(
"{} raw_peak={} range_total_max={:.0} cell_range_max={:.0} intensity={:.3}",
match panel_index {
0..=4 => format!("F{}", panel_index + 1),
5 => "Palm-H".to_owned(),
_ => "Palm-V".to_owned(),
},
peak,
range.max,
cell_range.max,
intensity
));
start = end;
}
}
} }
fn raw_to_g1(raw: u32) -> f32 { fn hand_image_pixel_to_screen(rect: egui::Rect, point_px: [f32; 2]) -> egui::Pos2 {
const RAW: [u32; 12] = [ let image_uv = egui::vec2(
0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444, point_px[0] / HAND_IMAGE_SIZE_PX[0].max(1.0),
point_px[1] / HAND_IMAGE_SIZE_PX[1].max(1.0),
);
let viewport_aspect = rect.width() / rect.height().max(1.0);
let image_aspect = HAND_IMAGE_SIZE_PX[0] / HAND_IMAGE_SIZE_PX[1].max(1.0);
let mut screen_uv = image_uv;
if viewport_aspect > image_aspect {
let image_width = image_aspect / viewport_aspect;
screen_uv.x = image_uv.x * image_width + (1.0 - image_width) * 0.5;
} else {
let image_height = viewport_aspect / image_aspect;
screen_uv.y = image_uv.y * image_height + (1.0 - image_height) * 0.5;
}
egui::pos2(
rect.left() + screen_uv.x * rect.width(),
rect.top() + screen_uv.y * rect.height(),
)
}
fn paint_spatial_force_arrow(
painter: &egui::Painter,
center: egui::Pos2,
force: HudSpatialForce,
base_length: f32,
magnitude_scale: f32,
) {
let magnitude = force.magnitude.clamp(0.0, 1.8);
let length = base_length + magnitude * magnitude_scale;
let angle = force.angle_deg.to_radians();
let direction = egui::vec2(angle.cos(), angle.sin());
let end = center + direction * length;
let side = egui::vec2(-direction.y, direction.x);
let head_length = (length * 0.24).clamp(8.0, 14.0);
let head_width = head_length * 0.5;
let color = egui::Color32::from_rgb(255, 196, 54);
let glow = egui::Color32::from_rgba_unmultiplied(255, 196, 54, 58);
painter.line_segment([center, end], egui::Stroke::new(8.0, glow));
painter.line_segment([center, end], egui::Stroke::new(2.4, color));
painter.line_segment(
[end, end - direction * head_length + side * head_width],
egui::Stroke::new(2.4, color),
);
painter.line_segment(
[end, end - direction * head_length - side * head_width],
egui::Stroke::new(2.4, color),
);
painter.circle_filled(center, 3.8, color);
}
fn update_hand_signal_histories(
histories: &mut [Vec<f32>; HAND_FORCE_PANEL_COUNT],
raw_values: &[u32],
config: Option<&HandForceConfig>,
) -> f32 {
let mut total_force = 0.0;
for (index, history) in histories.iter_mut().enumerate() {
let force = hand_segment_force(raw_values, index, config);
let force = if force <= HAND_FORCE_DISPLAY_THRESHOLD_N {
0.0
} else {
force
};
total_force += force;
history.push(force);
if history.len() > SUMMARY_POINTS_PER_SERIES {
history.remove(0);
}
}
total_force
}
fn hand_segment_force(raw_values: &[u32], index: usize, config: Option<&HandForceConfig>) -> f32 {
let offset: usize = HAND_FORCE_SEGMENT_COUNTS[..index.min(HAND_FORCE_PANEL_COUNT)]
.iter()
.sum();
let sample_count = HAND_FORCE_SEGMENT_COUNTS
.get(index)
.copied()
.unwrap_or_default();
let raw_total = raw_values
.get(offset..offset + sample_count)
.unwrap_or(&[])
.iter()
.fold(0_u64, |sum, value| sum + *value as u64)
.min(u32::MAX as u64) as u32;
raw_to_hand_segment_g(index, raw_total, config).min(20.6)
}
fn raw_to_hand_segment_g(index: usize, raw: u32, config: Option<&HandForceConfig>) -> f32 {
match index {
0 => raw_to_g1(raw, config),
1 => raw_to_g2(raw, config),
2 => raw_to_g3(raw, config),
3 => raw_to_g4(raw, config),
4 => raw_to_g5(raw, config),
5 => raw_to_g6(raw),
6 => raw_to_g7(raw),
_ => raw_to_gd(raw),
}
}
fn to_force_table(values: &BTreeMap<String, u32>) -> Vec<(u32, f32)> {
let mut table = values
.iter()
.filter_map(|(force_n, raw)| force_n.parse::<f32>().ok().map(|force| (*raw, force)))
.collect::<Vec<_>>();
table.sort_by(|a, b| a.1.total_cmp(&b.1));
table.insert(0, (0, 0.0));
table
}
fn interpolate_force(raw: u32, table: &[(u32, f32)]) -> f32 {
let Some(&(first_raw, first_force)) = table.first() else {
return 0.0;
};
let Some(&(last_raw, last_force)) = table.last() else {
return 0.0;
};
if raw <= first_raw {
return first_force;
}
if raw >= last_raw {
return last_force;
}
let right = table.partition_point(|(raw_point, _)| *raw_point <= raw);
let (raw_left, force_left) = table[right - 1];
let (raw_right, force_right) = table[right];
let ratio = (raw - raw_left) as f32 / (raw_right - raw_left).max(1) as f32;
force_left + ratio * (force_right - force_left)
}
fn force_from_config_or_fallback(
raw: u32,
config_values: Option<&BTreeMap<String, u32>>,
fallback_raw: &[u32],
fallback_force_centi_n: &[f32],
) -> f32 {
if let Some(config_values) = config_values {
let table = to_force_table(config_values);
if table.len() >= 2 && table.windows(2).all(|pair| pair[0].0 <= pair[1].0) {
return interpolate_force(raw, &table);
}
log::warn!(
"Hand force config raw values are not monotonically increasing; using fallback table"
);
}
let fallback = fallback_raw
.iter()
.copied()
.zip(fallback_force_centi_n.iter().map(|force| force / 100.0))
.collect::<Vec<_>>();
interpolate_force(raw, &fallback)
}
fn raw_to_gd(raw: u32) -> f32 {
const RAW: [u32; 11] = [
0, 486, 5310, 8130, 10005, 12883, 14700, 18141, 19877, 26270, 32648,
]; ];
const FORCE_CENTI_N: [f32; 12] = [ const FORCE_CENTI_N: [f32; 11] = [
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0, 2557.0, 0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
];
if raw <= RAW[0] {
return FORCE_CENTI_N[0] / 100.0;
}
if raw >= RAW[RAW.len() - 1] {
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
}
let mut left = 0usize;
let mut right = RAW.len() - 1;
while left + 1 < right {
let mid = (left + right) / 2;
if RAW[mid] <= raw {
left = mid;
} else {
right = mid;
}
}
let raw_left = RAW[left] as f32;
let raw_right = RAW[right] as f32;
let span = (raw_right - raw_left).max(1.0);
let ratio = (raw as f32 - raw_left) / span;
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
}
fn raw_to_g1(raw: u32, config: Option<&HandForceConfig>) -> f32 {
const RAW: [u32; 11] = [
0, 35694, 226640, 319868, 398021, 450448, 510506, 592075, 655760, 771671, 813967,
];
const FORCE_CENTI_N: [f32; 11] = [
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
];
force_from_config_or_fallback(raw, config.map(|cfg| &cfg.thumb), &RAW, &FORCE_CENTI_N)
}
fn raw_to_g2(raw: u32, config: Option<&HandForceConfig>) -> f32 {
const RAW: [u32; 11] = [
0, 45490, 267579, 352066, 431681, 457605, 550875, 624327, 732735, 883397, 899338,
];
const FORCE_CENTI_N: [f32; 11] = [
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
];
force_from_config_or_fallback(raw, config.map(|cfg| &cfg.index), &RAW, &FORCE_CENTI_N)
}
fn raw_to_g3(raw: u32, config: Option<&HandForceConfig>) -> f32 {
const RAW: [u32; 11] = [
0, 51562, 252840, 326531, 386571, 415819, 514679, 631745, 678945, 810847, 900422,
];
const FORCE_CENTI_N: [f32; 11] = [
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
];
force_from_config_or_fallback(raw, config.map(|cfg| &cfg.middle), &RAW, &FORCE_CENTI_N)
}
fn raw_to_g4(raw: u32, config: Option<&HandForceConfig>) -> f32 {
const RAW: [u32; 11] = [
0, 33213, 220611, 307383, 369637, 430462, 489659, 584789, 664960, 802749, 926907,
];
const FORCE_CENTI_N: [f32; 11] = [
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
];
force_from_config_or_fallback(raw, config.map(|cfg| &cfg.ring), &RAW, &FORCE_CENTI_N)
}
fn raw_to_g5(raw: u32, config: Option<&HandForceConfig>) -> f32 {
const RAW: [u32; 11] = [
0, 44291, 216657, 301950, 366186, 384791, 496521, 566189, 701459, 845608, 940796,
];
const FORCE_CENTI_N: [f32; 11] = [
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
];
force_from_config_or_fallback(raw, config.map(|cfg| &cfg.little), &RAW, &FORCE_CENTI_N)
}
fn raw_to_g6(raw: u32) -> f32 {
const RAW: [u32; 11] = [
0, 0, 2532, 5122, 7256, 11525, 13544, 16044, 18504, 29778, 41845,
];
const FORCE_CENTI_N: [f32; 11] = [
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
];
if raw <= RAW[0] {
return FORCE_CENTI_N[0] / 100.0;
}
if raw >= RAW[RAW.len() - 1] {
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
}
let mut left = 0usize;
let mut right = RAW.len() - 1;
while left + 1 < right {
let mid = (left + right) / 2;
if RAW[mid] <= raw {
left = mid;
} else {
right = mid;
}
}
let raw_left = RAW[left] as f32;
let raw_right = RAW[right] as f32;
let span = (raw_right - raw_left).max(1.0);
let ratio = (raw as f32 - raw_left) / span;
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
}
fn raw_to_g7(raw: u32) -> f32 {
const RAW: [u32; 11] = [
0, 233, 9000, 12067, 14311, 16297, 18113, 27918, 32559, 50861, 65857,
];
const FORCE_CENTI_N: [f32; 11] = [
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
]; ];
if raw <= RAW[0] { if raw <= RAW[0] {
@@ -552,6 +1036,79 @@ fn split_viewport_body(rect: egui::Rect) -> egui::Rect {
) )
} }
#[derive(Clone, Copy)]
struct PressureColorRange {
min: f32,
max: f32,
gamma: f32,
}
// Controls the pressure-to-color response curve. Higher values reserve warmer
// colors for larger fractions of each panel's configured raw range.
const PRESSURE_COLOR_GAMMA: f32 = 1.5;
impl PressureColorRange {
const fn new(min: u32, max: u32) -> Self {
Self {
min: min as f32,
max: max as f32,
gamma: PRESSURE_COLOR_GAMMA,
}
}
fn normalize(self, value: u32) -> [f32; 2] {
let raw = value as f32;
let span = (self.max - self.min).max(1.0);
let mapped = ((raw - self.min) / span).clamp(0.0, 1.0);
let intensity = if raw <= self.min + 4.0 {
0.0
} else {
mapped.powf(self.gamma)
};
let display_value = if raw <= self.min + 4.0 {
0.0
} else {
raw.round().min(9999.0)
};
[intensity, display_value]
}
fn per_cell(self, cell_count: usize) -> Self {
let cells = cell_count.max(1) as f32;
Self {
min: self.min / cells,
max: self.max / cells,
gamma: self.gamma,
}
}
}
const DEFAULT_COLOR_RANGE: PressureColorRange = PressureColorRange::new(0, 7000);
const HAND_SENSOR_PANEL_COLOR_RANGES: [PressureColorRange; HAND_FORCE_PANEL_COUNT] = [
PressureColorRange::new(0, 813967),
PressureColorRange::new(0, 899338),
PressureColorRange::new(0, 900422),
PressureColorRange::new(0, 926907),
PressureColorRange::new(0, 940796),
PressureColorRange::new(0, 41845),
PressureColorRange::new(0, 65857),
];
const HAND_FINGER_SENSOR_CELLS: usize = 12 * 7;
const HAND_SENSOR_PANEL_CELL_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [
HAND_FINGER_SENSOR_CELLS,
HAND_FINGER_SENSOR_CELLS,
HAND_FINGER_SENSOR_CELLS,
HAND_FINGER_SENSOR_CELLS,
HAND_FINGER_SENSOR_CELLS,
5 * 14,
11 * 4,
];
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) { fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
normalized.fill([0.0, 0.0]); normalized.fill([0.0, 0.0]);
@@ -571,33 +1128,132 @@ fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut
} }
fn normalize_pressure_samples(raw: &[u32]) -> PressureSamples { fn normalize_pressure_samples(raw: &[u32]) -> PressureSamples {
raw.iter().copied().map(normalize_pressure_value).collect() raw.iter()
.copied()
.enumerate()
.map(|(index, value)| {
hand_sensor_panel_for_index(index)
.map(|(range, cell_count)| range.per_cell(cell_count).normalize(value))
.unwrap_or_else(|| DEFAULT_COLOR_RANGE.normalize(value))
})
.collect()
}
fn hand_sensor_panel_for_index(index: usize) -> Option<(PressureColorRange, usize)> {
let mut start = 0;
for (range, cell_count) in HAND_SENSOR_PANEL_COLOR_RANGES
.iter()
.copied()
.zip(HAND_SENSOR_PANEL_CELL_COUNTS)
{
let end = start + cell_count;
if index < end {
return Some((range, cell_count));
}
start = end;
}
None
} }
fn normalize_pressure_value(value: u32) -> [f32; 2] { fn normalize_pressure_value(value: u32) -> [f32; 2] {
const RANGE_MIN: f32 = 0.0; DEFAULT_COLOR_RANGE.normalize(value)
const RANGE_MAX: f32 = 7000.0; }
let raw_value = value as f32; #[cfg(test)]
let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0); mod tests {
let display_value = if raw_value <= RANGE_MIN + 4.0 { use super::*;
0.0
} else {
raw_value.round().min(9999.0)
};
[mapped, display_value] #[test]
fn raw_to_g1_uses_loaded_thumb_config() {
let config: HandForceConfig = toml::from_str(
r#"
[thumb]
"0.57" = 10
"2.57" = 20
[index]
"0.57" = 1
[middle]
"0.57" = 1
[ring]
"0.57" = 1
[little]
"0.57" = 1
"#,
)
.expect("test config should parse");
assert_eq!(raw_to_g1(20, Some(&config)), 2.57);
assert_ne!(raw_to_g1(20, None), 2.57);
}
#[test]
fn pressure_normalization_makes_low_hand_values_visible() {
let [zero_intensity, zero_label] = normalize_pressure_value(4);
assert_eq!(zero_intensity, 0.0);
assert_eq!(zero_label, 0.0);
let [low_intensity, low_label] = normalize_pressure_value(100);
assert!(
low_intensity > 0.0,
"low hand-gateway values should retain a non-zero response"
);
assert_eq!(low_label, 100.0);
let [max_intensity, max_label] = normalize_pressure_value(7000);
assert_eq!(max_intensity, 1.0);
assert_eq!(max_label, 7000.0);
}
#[test]
fn pressure_normalization_applies_configured_gamma() {
let range = PressureColorRange::new(0, 10_000);
let [intensity, _] = range.normalize(8_000);
let expected = 0.8_f32.powf(PRESSURE_COLOR_GAMMA);
assert!(
(intensity - expected).abs() < 0.002,
"the normalized response should follow the configured gamma"
);
}
#[test]
fn hand_panel_ranges_cover_both_palm_boards() {
let total_cells: usize = HAND_SENSOR_PANEL_CELL_COUNTS.iter().sum();
let mut raw = vec![0; total_cells];
let mut start = 0;
for (range, cell_count) in HAND_SENSOR_PANEL_COLOR_RANGES
.iter()
.copied()
.zip(HAND_SENSOR_PANEL_CELL_COUNTS)
{
raw[start] = (range.max / cell_count as f32).ceil() as u32;
start += cell_count;
}
let normalized = normalize_pressure_samples(&raw);
let palm_horizontal_start = HAND_SENSOR_PANEL_CELL_COUNTS[..5].iter().sum::<usize>();
let palm_vertical_start = palm_horizontal_start + HAND_SENSOR_PANEL_CELL_COUNTS[5];
assert_eq!(normalized[palm_horizontal_start][0], 1.0);
assert_eq!(normalized[palm_vertical_start][0], 1.0);
}
} }
impl eframe::App for EskinDesktopApp { impl eframe::App for EskinDesktopApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let stats = self.connection.stats();
self.live_rates.tick_render(stats.rx_frames);
self.update_pressure_matrix(); self.update_pressure_matrix();
self.sync_disconnected_live_state();
self.draw_workspace(ui); self.draw_workspace(ui);
self.draw_title_bar(ui, frame); self.draw_title_bar(ui, frame);
self.draw_floating_panels(&ctx); self.draw_floating_panels(&ctx, stats);
self.draw_panel_context_menu(&ctx);
// Keep repainting while the wgpu background is a realtime viewport. // Keep repainting while the wgpu background is a realtime viewport.
ctx.request_repaint(); ctx.request_repaint();

View File

@@ -20,6 +20,7 @@ enum BreakoutPhase {
Idle, Idle,
Running, Running,
Paused, Paused,
Won,
Over, Over,
} }
@@ -46,7 +47,6 @@ pub struct BreakoutGame {
score: u32, score: u32,
combo: u32, combo: u32,
lives: u32, lives: u32,
level: u32,
last_time: Option<f64>, last_time: Option<f64>,
previous_pause_gesture: bool, previous_pause_gesture: bool,
pause_locked_until: f64, pause_locked_until: f64,
@@ -63,7 +63,6 @@ impl Default for BreakoutGame {
score: 0, score: 0,
combo: 0, combo: 0,
lives: 3, lives: 3,
level: 1,
last_time: None, last_time: None,
previous_pause_gesture: false, previous_pause_gesture: false,
pause_locked_until: 0.0, pause_locked_until: 0.0,
@@ -150,7 +149,6 @@ impl BreakoutGame {
status_chip(ui, "分数", self.score.to_string(), ACCENT_GREEN); status_chip(ui, "分数", self.score.to_string(), ACCENT_GREEN);
status_chip(ui, "连击", self.combo.to_string(), ACCENT_ORANGE); status_chip(ui, "连击", self.combo.to_string(), ACCENT_ORANGE);
status_chip(ui, "生命", self.lives.to_string(), ACCENT_RED); status_chip(ui, "生命", self.lives.to_string(), ACCENT_RED);
status_chip(ui, "关卡", self.level.to_string(), ACCENT_BLUE);
}); });
ui.add_space(7.0); ui.add_space(7.0);
@@ -191,12 +189,11 @@ impl BreakoutGame {
self.score = 0; self.score = 0;
self.combo = 0; self.combo = 0;
self.lives = 3; self.lives = 3;
self.level = 1;
self.rebuild_bricks(); self.rebuild_bricks();
} }
fn start(&mut self) { fn start(&mut self) {
if self.phase == BreakoutPhase::Over { if self.phase == BreakoutPhase::Over || self.phase == BreakoutPhase::Won {
self.reset(); self.reset();
} }
if self.phase != BreakoutPhase::Running { if self.phase != BreakoutPhase::Running {
@@ -219,7 +216,10 @@ impl BreakoutGame {
let threshold = pressure_pause_threshold(); let threshold = pressure_pause_threshold();
let active = top_force >= threshold; let active = top_force >= threshold;
if active && !self.previous_pause_gesture && now >= self.pause_locked_until { if active && !self.previous_pause_gesture && now >= self.pause_locked_until {
if self.phase == BreakoutPhase::Idle || self.phase == BreakoutPhase::Over { if matches!(
self.phase,
BreakoutPhase::Idle | BreakoutPhase::Over | BreakoutPhase::Won
) {
self.start(); self.start();
} else { } else {
self.toggle_pause(); self.toggle_pause();
@@ -249,7 +249,7 @@ impl BreakoutGame {
} }
fn launch_ball(&mut self) { fn launch_ball(&mut self) {
let direction = if self.level % 2 == 0 { -0.24 } else { 0.24 }; let direction = 0.24;
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS); self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
self.ball_vel = egui::vec2(direction, -1.0).normalized() * BALL_SPEED; self.ball_vel = egui::vec2(direction, -1.0).normalized() * BALL_SPEED;
} }
@@ -324,9 +324,8 @@ impl BreakoutGame {
} }
if self.bricks.iter().all(|brick| !brick.alive) { if self.bricks.iter().all(|brick| !brick.alive) {
self.level += 1; self.phase = BreakoutPhase::Won;
self.rebuild_bricks(); self.ball_vel = egui::Vec2::ZERO;
self.launch_ball();
} }
} }
@@ -358,6 +357,7 @@ impl BreakoutGame {
BreakoutPhase::Idle => "待机", BreakoutPhase::Idle => "待机",
BreakoutPhase::Running => "运行", BreakoutPhase::Running => "运行",
BreakoutPhase::Paused => "暂停", BreakoutPhase::Paused => "暂停",
BreakoutPhase::Won => "过关",
BreakoutPhase::Over => "结束", BreakoutPhase::Over => "结束",
} }
} }
@@ -387,6 +387,7 @@ impl BreakoutGame {
if self.phase == BreakoutPhase::Idle if self.phase == BreakoutPhase::Idle
|| self.phase == BreakoutPhase::Paused || self.phase == BreakoutPhase::Paused
|| self.phase == BreakoutPhase::Won
|| self.phase == BreakoutPhase::Over || self.phase == BreakoutPhase::Over
{ {
paint_center_overlay(&painter, rect, self.phase); paint_center_overlay(&painter, rect, self.phase);
@@ -403,6 +404,10 @@ pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl
let cols = cols.max(1) as usize; let cols = cols.max(1) as usize;
let sample_rows = rows.min(2); let sample_rows = rows.min(2);
let sample_cols = cols.min(2); let sample_cols = cols.min(2);
let top_gesture_rows = rows.min(1);
let top_gesture_cols = (cols / 3).clamp(1, cols);
let top_gesture_col_start = (cols - top_gesture_cols) / 2;
let top_gesture_col_end = top_gesture_col_start + top_gesture_cols;
let avg = |row_start: usize, row_end: usize, col_start: usize, col_end: usize| -> f32 { let avg = |row_start: usize, row_end: usize, col_start: usize, col_end: usize| -> f32 {
let mut sum = 0.0; let mut sum = 0.0;
let mut count = 0.0; let mut count = 0.0;
@@ -430,7 +435,12 @@ pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl
let left_force = tl + bl; let left_force = tl + bl;
let right_force = tr + br; let right_force = tr + br;
let top_force = tl + tr; let top_force = avg(
0,
top_gesture_rows,
top_gesture_col_start,
top_gesture_col_end,
);
let span = 1200.0_f32.max((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN) * 0.22); let span = 1200.0_f32.max((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN) * 0.22);
let raw_axis = ((right_force - left_force) / span).clamp(-1.0, 1.0); let raw_axis = ((right_force - left_force) / span).clamp(-1.0, 1.0);
let axis = if raw_axis.abs() < 0.045 { let axis = if raw_axis.abs() < 0.045 {
@@ -485,6 +495,7 @@ fn status_color(phase: BreakoutPhase) -> egui::Color32 {
BreakoutPhase::Idle => ONE_DARK_PRO.text_dim, BreakoutPhase::Idle => ONE_DARK_PRO.text_dim,
BreakoutPhase::Running => ACCENT_GREEN, BreakoutPhase::Running => ACCENT_GREEN,
BreakoutPhase::Paused => ACCENT_ORANGE, BreakoutPhase::Paused => ACCENT_ORANGE,
BreakoutPhase::Won => ACCENT_GREEN,
BreakoutPhase::Over => ACCENT_RED, BreakoutPhase::Over => ACCENT_RED,
} }
} }
@@ -607,6 +618,7 @@ fn paint_center_overlay(painter: &egui::Painter, rect: egui::Rect, phase: Breako
let (title, detail) = match phase { let (title, detail) = match phase {
BreakoutPhase::Idle => ("按压顶部或点击开始", "左右分区压力控制挡板"), BreakoutPhase::Idle => ("按压顶部或点击开始", "左右分区压力控制挡板"),
BreakoutPhase::Paused => ("已暂停", "再次按压顶部、P 或点击暂停继续"), BreakoutPhase::Paused => ("已暂停", "再次按压顶部、P 或点击暂停继续"),
BreakoutPhase::Won => ("恭喜过关", "按压顶部、点击或空格重新开始"),
BreakoutPhase::Over => ("游戏结束", "点击或空格重开"), BreakoutPhase::Over => ("游戏结束", "点击或空格重开"),
BreakoutPhase::Running => ("", ""), BreakoutPhase::Running => ("", ""),
}; };

70
src/config.rs Normal file
View File

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

View File

@@ -4,6 +4,7 @@ use std::time::Duration;
use crossbeam_channel::{self, Receiver, Sender, TryRecvError}; use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
use crate::recording::Recorder;
use crate::serial_core::serial::{ use crate::serial_core::serial::{
SerialIoStats, SerialPortReadWrite, SerialProtocol, run_serial_loop, SerialIoStats, SerialPortReadWrite, SerialProtocol, run_serial_loop,
}; };
@@ -83,6 +84,7 @@ impl ConnectionManager {
cols: u32, cols: u32,
baud_rate: u32, baud_rate: u32,
protocol: SerialProtocol, protocol: SerialProtocol,
recorder: Recorder,
) { ) {
self.disconnect(); self.disconnect();
self.set_state(ConnectionState::Connecting); self.set_state(ConnectionState::Connecting);
@@ -108,6 +110,7 @@ impl ConnectionManager {
&sample_tx, &sample_tx,
&stats_tx, &stats_tx,
&latest_sample, &latest_sample,
recorder,
); );
if let Err(e) = result { if let Err(e) = result {
eprintln!("[connection] device loop error: {e}"); eprintln!("[connection] device loop error: {e}");
@@ -187,9 +190,10 @@ fn run_device_loop(
sample_tx: &Sender<Vec<i32>>, sample_tx: &Sender<Vec<i32>>,
stats_tx: &Sender<SerialIoStats>, stats_tx: &Sender<SerialIoStats>,
latest_sample: &Arc<Mutex<Option<PressureSample>>>, latest_sample: &Arc<Mutex<Option<PressureSample>>>,
recorder: Recorder,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let port = serialport::new(port_name, baud_rate) let port = serialport::new(port_name, baud_rate)
.timeout(Duration::from_millis(100)) .timeout(Duration::from_millis(1))
.open()?; .open()?;
*state.lock().unwrap() = ConnectionState::Connected; *state.lock().unwrap() = ConnectionState::Connected;
@@ -205,6 +209,7 @@ fn run_device_loop(
cancel_rx, cancel_rx,
sample_tx, sample_tx,
Some(stats_tx), Some(stats_tx),
Some(&recorder),
); );
if let Ok(mut latest) = latest_sample.lock() { if let Ok(mut latest) = latest_sample.lock() {

View File

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

View File

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

View File

@@ -44,43 +44,43 @@ pub struct HandGatewayMode {
const HAND_TIP_MATRICES: [HandTipMatrix; 5] = [ const HAND_TIP_MATRICES: [HandTipMatrix; 5] = [
HandTipMatrix { HandTipMatrix {
center_px: [260.0, 490.0], center_px: [263.5, 495.0],
size_px: [70.0, 120.0], size_px: [70.0, 120.0],
angle_rad: -0.45, angle_rad: -0.40,
}, },
HandTipMatrix { HandTipMatrix {
center_px: [360.0, 255.0], center_px: [362.0, 260.0],
size_px: [70.0, 120.0], size_px: [70.0, 120.0],
angle_rad: -0.05, angle_rad: -0.158,
}, },
HandTipMatrix { HandTipMatrix {
center_px: [485.0, 225.0], center_px: [480.5, 228.0],
size_px: [70.0, 120.0], size_px: [70.0, 120.0],
angle_rad: 0.0, angle_rad: -0.02,
}, },
HandTipMatrix { HandTipMatrix {
center_px: [595.0, 260.0], center_px: [594.0, 265.0],
size_px: [70.0, 120.0], size_px: [70.0, 120.0],
angle_rad: 0.12, angle_rad: 0.10,
}, },
HandTipMatrix { HandTipMatrix {
center_px: [705.0, 385.0], center_px: [693.0, 370.0],
size_px: [70.0, 120.0], size_px: [70.0, 120.0],
angle_rad: 0.28, angle_rad: 0.22,
}, },
]; ];
const HAND_PALM_CHIPS: [HandPalmChip; 2] = [ const HAND_PALM_CHIPS: [HandPalmChip; 2] = [
HandPalmChip { HandPalmChip {
center_px: [538.0, 608.0], center_px: [530.0, 593.0],
size_px: [248.0, 82.0], size_px: [258.0, 88.0],
angle_rad: 0.06, angle_rad: 0.12,
rows: 5, rows: 5,
cols: 14, cols: 14,
}, },
HandPalmChip { HandPalmChip {
center_px: [606.0, 780.0], center_px: [610.0, 778.0],
size_px: [72.0, 214.0], size_px: [82.0, 228.0],
angle_rad: 0.05, angle_rad: 0.05,
rows: 11, rows: 11,
cols: 4, cols: 4,
@@ -90,6 +90,7 @@ const HAND_PALM_CHIPS: [HandPalmChip; 2] = [
const HAND_FINGER_SENSOR_CELLS: usize = 12 * 7; const HAND_FINGER_SENSOR_CELLS: usize = 12 * 7;
const HAND_PALM_HORIZONTAL_OFFSET: usize = HAND_FINGER_SENSOR_CELLS * 5; const HAND_PALM_HORIZONTAL_OFFSET: usize = HAND_FINGER_SENSOR_CELLS * 5;
const HAND_PALM_VERTICAL_OFFSET: usize = HAND_PALM_HORIZONTAL_OFFSET + 5 * 14; const HAND_PALM_VERTICAL_OFFSET: usize = HAND_PALM_HORIZONTAL_OFFSET + 5 * 14;
const HAND_TIP_DOT_LOCAL_Y_OFFSET_PX: f32 = 14.0;
// Each entry pins one miniature matrix to a fingertip in hand.png. // Each entry pins one miniature matrix to a fingertip in hand.png.
// Coordinates are authored in source-image pixels so they are easy to tune by eye. // Coordinates are authored in source-image pixels so they are easy to tune by eye.
@@ -531,12 +532,13 @@ impl BackgroundRenderResources {
let hand_membrane_instances = build_hand_membrane_instances( let hand_membrane_instances = build_hand_membrane_instances(
hand_image_texture.width as f32, hand_image_texture.width as f32,
hand_image_texture.height as f32, hand_image_texture.height as f32,
&[],
); );
let hand_membrane_instance_buffer = let hand_membrane_instance_buffer =
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Hand Fingertip Membrane Instance Buffer"), label: Some("Hand Fingertip Membrane Instance Buffer"),
contents: bytemuck::cast_slice(&hand_membrane_instances), contents: bytemuck::cast_slice(&hand_membrane_instances),
usage: wgpu::BufferUsages::VERTEX, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
}); });
let hand_dot_instances = build_hand_dot_instances( let hand_dot_instances = build_hand_dot_instances(
rows, rows,
@@ -653,6 +655,17 @@ impl BackgroundRenderResources {
hand_pressure hand_pressure
}; };
self.hand_membrane_instances = build_hand_membrane_instances(
self.hand_image_texture.width as f32,
self.hand_image_texture.height as f32,
hand_pressure,
);
queue.write_buffer(
&self.hand_membrane_instance_buffer,
0,
bytemuck::cast_slice(&self.hand_membrane_instances),
);
// Hand mode uses UV-anchored fingertip matrices over hand.png. // Hand mode uses UV-anchored fingertip matrices over hand.png.
// Rebuild their instance positions here so pressure colors update every frame. // Rebuild their instance positions here so pressure colors update every frame.
self.hand_dot_instances = build_hand_dot_instances( self.hand_dot_instances = build_hand_dot_instances(
@@ -1138,24 +1151,48 @@ fn create_hand_palm_dot_pipeline(
}) })
} }
fn build_hand_membrane_instances(image_width: f32, image_height: f32) -> Vec<GlyphInstance> { fn build_hand_membrane_instances(
image_width: f32,
image_height: f32,
pressure: &[[f32; 2]],
) -> Vec<GlyphInstance> {
HAND_TIP_MATRICES HAND_TIP_MATRICES
.iter() .iter()
.map(|tip| { .enumerate()
.map(|(tip_index, tip)| {
let uv_x = tip.center_px[0] / image_width.max(1.0); let uv_x = tip.center_px[0] / image_width.max(1.0);
let uv_y = tip.center_px[1] / image_height.max(1.0); let uv_y = tip.center_px[1] / image_height.max(1.0);
let membrane_size = [tip.size_px[0] * 1.62, tip.size_px[1] * 1.56]; let membrane_size = [tip.size_px[0] * 1.44, tip.size_px[1] * 1.36];
let tip_intensity = hand_tip_peak_intensity(pressure, tip_index);
GlyphInstance { GlyphInstance {
// Membrane shaders read xy as hand.png UV. // Membrane shaders read xy as hand.png UV.
world_position: [uv_x, uv_y, 0.0, 1.0], world_position: [uv_x, uv_y, 0.0, 1.0],
// Store angle and an oversized source-image pixel size for the floating sensor film. // Store angle, source-image pixel size, and the live fingertip peak intensity.
style: [tip.angle_rad, membrane_size[0], membrane_size[1], 0.0], style: [
tip.angle_rad,
membrane_size[0],
membrane_size[1],
tip_intensity,
],
} }
}) })
.collect() .collect()
} }
fn hand_tip_peak_intensity(pressure: &[[f32; 2]], tip_index: usize) -> f32 {
let start = tip_index * HAND_FINGER_SENSOR_CELLS;
let end = start + HAND_FINGER_SENSOR_CELLS;
pressure
.get(start..end)
.unwrap_or(&[])
.iter()
.map(|sample| sample[0])
.fold(0.0, f32::max)
.clamp(0.0, 1.0)
}
fn build_hand_palm_chip_instances(image_width: f32, image_height: f32) -> Vec<GlyphInstance> { fn build_hand_palm_chip_instances(image_width: f32, image_height: f32) -> Vec<GlyphInstance> {
HAND_PALM_CHIPS HAND_PALM_CHIPS
.iter() .iter()
@@ -1202,7 +1239,8 @@ fn build_hand_dot_instances(
// Lay out a rows x cols matrix in fingertip-local pixel space. // Lay out a rows x cols matrix in fingertip-local pixel space.
let local_x = (col as f32 - cols as f32 / 2.0 + 0.5) / cols as f32 * tip.size_px[0]; let local_x = (col as f32 - cols as f32 / 2.0 + 0.5) / cols as f32 * tip.size_px[0];
let local_y = (row as f32 - rows as f32 / 2.0 + 0.5) / rows as f32 * tip.size_px[1]; let local_y = (row as f32 - rows as f32 / 2.0 + 0.5) / rows as f32 * tip.size_px[1]
+ HAND_TIP_DOT_LOCAL_Y_OFFSET_PX;
// Rotate the local matrix so it follows the direction of the finger. // Rotate the local matrix so it follows the direction of the finger.
let x = tip.center_px[0] + local_x * cos - local_y * sin; let x = tip.center_px[0] + local_x * cos - local_y * sin;
@@ -1238,16 +1276,16 @@ fn build_hand_palm_dot_instances(
for (chip_index, chip) in HAND_PALM_CHIPS.into_iter().enumerate() { for (chip_index, chip) in HAND_PALM_CHIPS.into_iter().enumerate() {
let cos = chip.angle_rad.cos(); let cos = chip.angle_rad.cos();
let sin = chip.angle_rad.sin(); let sin = chip.angle_rad.sin();
// Leave a bevel around the chip so the matrix reads as embedded pixels. // Palm films can float slightly beyond the hand artwork, similar to fingertip membranes.
let active_size = [chip.size_px[0] * 0.72, chip.size_px[1] * 0.76]; let active_size = [chip.size_px[0] * 0.86, chip.size_px[1] * 0.86];
for row in 0..chip.rows { for row in 0..chip.rows {
for col in 0..chip.cols { for col in 0..chip.cols {
let index = (row * chip.cols + col) as usize;
let offset = match chip_index { let offset = match chip_index {
0 => HAND_PALM_HORIZONTAL_OFFSET, 0 => HAND_PALM_HORIZONTAL_OFFSET,
_ => HAND_PALM_VERTICAL_OFFSET, _ => HAND_PALM_VERTICAL_OFFSET,
}; };
let index = hand_palm_pressure_index(chip_index, row, col, chip.rows, chip.cols);
let [normalized, display_value] = let [normalized, display_value] =
sample_pressure_at(pressure, offset + index, index); sample_pressure_at(pressure, offset + index, index);
@@ -1275,6 +1313,14 @@ fn build_hand_palm_dot_instances(
instances instances
} }
fn hand_palm_pressure_index(chip_index: usize, row: u32, col: u32, rows: u32, cols: u32) -> usize {
if chip_index == 0 {
(col * rows + (rows - 1 - row)) as usize
} else {
(row * cols + col) as usize
}
}
fn sample_pressure_at(pressure: &[[f32; 2]], index: usize, fallback_index: usize) -> [f32; 2] { fn sample_pressure_at(pressure: &[[f32; 2]], index: usize, fallback_index: usize) -> [f32; 2] {
pressure pressure
.get(index) .get(index)
@@ -1406,3 +1452,24 @@ impl GlyphInstance {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hand_palm_horizontal_uses_column_major_pressure_order() {
assert_eq!(hand_palm_pressure_index(0, 0, 0, 5, 14), 4);
assert_eq!(hand_palm_pressure_index(0, 1, 0, 5, 14), 3);
assert_eq!(hand_palm_pressure_index(0, 0, 1, 5, 14), 9);
assert_eq!(hand_palm_pressure_index(0, 4, 13, 5, 14), 65);
}
#[test]
fn hand_palm_vertical_keeps_row_major_pressure_order() {
assert_eq!(hand_palm_pressure_index(1, 0, 0, 11, 4), 0);
assert_eq!(hand_palm_pressure_index(1, 0, 1, 11, 4), 1);
assert_eq!(hand_palm_pressure_index(1, 1, 0, 11, 4), 4);
assert_eq!(hand_palm_pressure_index(1, 10, 3, 11, 4), 43);
}
}

View File

@@ -38,8 +38,9 @@ pub fn load_texture(
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
) -> anyhow::Result<texture::Texture> { ) -> anyhow::Result<texture::Texture> {
let data = load_binary(file_name)?; // let data = load_binary(file_name)?;
texture::Texture::from_bytes(device, queue, &data, file_name) let data = include_bytes!("../res/hand.png");
texture::Texture::from_bytes(device, queue, data, file_name)
} }
pub fn load_model( pub fn load_model(

View File

@@ -5,7 +5,7 @@ const SENSOR_COUNT: usize = SENSOR_ROWS * SENSOR_COLS;
const TOTAL_PRESSURE_LOW_THRESHOLD: f32 = 500.0; const TOTAL_PRESSURE_LOW_THRESHOLD: f32 = 500.0;
const COP_STABILITY_FRAMES_REQUIRED: usize = 15; const COP_STABILITY_FRAMES_REQUIRED: usize = 15;
const POST_INIT_WINDOW_CNT: usize = 100; const POST_INIT_WINDOW_CNT: usize = 50;
const POST_INIT_STABLE_CNT: usize = 50; const POST_INIT_STABLE_CNT: usize = 50;
const POST_INIT_STABLE_THRESH: f32 = 0.1; const POST_INIT_STABLE_THRESH: f32 = 0.1;

View File

@@ -1,3 +1,4 @@
use crate::recording::Recorder;
use crate::serial_core::codec::Codec; use crate::serial_core::codec::Codec;
use crate::serial_core::codecs::hand_gateway::{HandGatewayCodec, HandGatewayFrame}; use crate::serial_core::codecs::hand_gateway::{HandGatewayCodec, HandGatewayFrame};
use crate::serial_core::codecs::tactile_a::TactileACodec; use crate::serial_core::codecs::tactile_a::TactileACodec;
@@ -6,13 +7,14 @@ use crossbeam_channel::{Receiver, Sender};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
const POLL_INTERVAL_MS: u64 = 10; const POLL_INTERVAL_MS: u64 = 5;
const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84, 84, 84, 84, 84, 70, 44]; const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84, 84, 84, 84, 84, 70, 44];
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SerialIoStats { pub struct SerialIoStats {
pub rx_bytes: u64, pub rx_bytes: u64,
pub tx_bytes: u64, pub tx_bytes: u64,
pub rx_frames: u64,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -31,12 +33,15 @@ pub fn run_serial_loop(
cancel_rx: &Receiver<()>, cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>, sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>, stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) { ) {
match protocol { match protocol {
SerialProtocol::TactileA => { SerialProtocol::TactileA => {
run_tactile_a_loop(port, rows, cols, cancel_rx, sample_tx, stats_tx) run_tactile_a_loop(port, rows, cols, cancel_rx, sample_tx, stats_tx, recorder)
}
SerialProtocol::HandGateway => {
run_hand_gateway_loop(port, cancel_rx, sample_tx, stats_tx, recorder)
} }
SerialProtocol::HandGateway => run_hand_gateway_loop(port, cancel_rx, sample_tx, stats_tx),
} }
} }
@@ -47,6 +52,7 @@ fn run_tactile_a_loop(
cancel_rx: &Receiver<()>, cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>, sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>, stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) { ) {
let session_started_at = Instant::now(); let session_started_at = Instant::now();
let mut codec = TactileACodec::new(cols, rows); let mut codec = TactileACodec::new(cols, rows);
@@ -90,7 +96,14 @@ fn run_tactile_a_loop(
for frame in frames { for frame in frames {
if let TactileAFrame::Rep(rep) = frame { if let TactileAFrame::Rep(rep) = frame {
if let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload) { if let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload) {
if let Some(recorder) = recorder {
let pressures: Vec<u32> =
vals.iter().map(|v| (*v).max(0) as u32).collect();
recorder.add_frame(&pressures);
}
let _ = sample_tx.try_send(vals); let _ = sample_tx.try_send(vals);
io_stats.rx_frames += 1;
publish_stats(stats_tx, io_stats);
} }
} }
} }
@@ -116,6 +129,7 @@ fn run_hand_gateway_loop(
cancel_rx: &Receiver<()>, cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>, sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>, stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) { ) {
let session_started_at = Instant::now(); let session_started_at = Instant::now();
let mut codec = HandGatewayCodec::new(DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS); let mut codec = HandGatewayCodec::new(DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS);
@@ -174,7 +188,14 @@ fn run_hand_gateway_loop(
if parse_ok { if parse_ok {
// println!("[hand-packet-values] samples={}", vals.len()); // println!("[hand-packet-values] samples={}", vals.len());
if let Some(recorder) = recorder {
let pressures: Vec<u32> =
vals.iter().map(|v| (*v).max(0) as u32).collect();
recorder.add_frame(&pressures);
}
let _ = sample_tx.try_send(vals); let _ = sample_tx.try_send(vals);
io_stats.rx_frames += 1;
publish_stats(stats_tx, io_stats);
} }
} }
} }

View File

@@ -1,4 +1,4 @@
use eframe::egui; use eframe::egui::{self, Color32};
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct AppTheme { pub struct AppTheme {
@@ -29,7 +29,7 @@ pub struct DesignMetrics {
} }
pub const ONE_DARK_PRO: AppTheme = AppTheme { pub const ONE_DARK_PRO: AppTheme = AppTheme {
bg: egui::Color32::from_rgb(30, 40, 50), bg: egui::Color32::BLACK,
panel: egui::Color32::from_rgb(22, 28, 35), panel: egui::Color32::from_rgb(22, 28, 35),
panel_strong: egui::Color32::from_rgb(34, 43, 54), panel_strong: egui::Color32::from_rgb(34, 43, 54),
panel_deep: egui::Color32::from_rgb(15, 20, 27), panel_deep: egui::Color32::from_rgb(15, 20, 27),
@@ -61,6 +61,7 @@ pub const METRICS: DesignMetrics = DesignMetrics {
pub mod layout { pub mod layout {
pub const TITLE_BAR_HEIGHT: f32 = 36.0; pub const TITLE_BAR_HEIGHT: f32 = 36.0;
pub const CONFIG_BAR_HEIGHT: f32 = 38.0;
pub const CENTER_PANEL_TOP: f32 = 48.0; pub const CENTER_PANEL_TOP: f32 = 48.0;
pub const LEFT_X: f32 = 24.0; pub const LEFT_X: f32 = 24.0;
pub const RIGHT_X: f32 = 1328.0; pub const RIGHT_X: f32 = 1328.0;
@@ -213,6 +214,19 @@ pub fn tag_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
.min_size(egui::vec2(0.0, METRICS.button_height)) .min_size(egui::vec2(0.0, METRICS.button_height))
} }
pub fn rich_tag_button(
label: impl Into<String>,
color: impl Into<Color32>,
) -> egui::Button<'static> {
let text = egui::RichText::new(label.into()).color(color);
egui::Button::new(text)
.fill(ONE_DARK_PRO.panel_strong)
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
.corner_radius(egui::CornerRadius::same(4))
.min_size(egui::vec2(0.0, METRICS.button_height))
}
pub fn primary_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> { pub fn primary_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
egui::Button::new(label) egui::Button::new(label)
.fill(ONE_DARK_PRO.accent) .fill(ONE_DARK_PRO.accent)

1163
src/ui.rs

File diff suppressed because it is too large Load Diff

View File

@@ -63,7 +63,7 @@ fn linear_to_srgb(linear: vec3f) -> vec3f {
fn output_color(linear_rgb: vec3f, alpha: f32) -> vec4f { fn output_color(linear_rgb: vec3f, alpha: f32) -> vec4f {
let clamped = clamp(linear_rgb, vec3f(0.0), vec3f(1.0)); let clamped = clamp(linear_rgb, vec3f(0.0), vec3f(1.0));
if (u.color.w > 0.5) { if u.color.w > 0.5 {
return vec4f(clamped, alpha); return vec4f(clamped, alpha);
} }
@@ -91,21 +91,20 @@ fn range_stop_color(index: u32) -> vec3f {
fn sample_range_color(value: f32) -> vec3f { fn sample_range_color(value: f32) -> vec3f {
let t = saturate(value); let t = saturate(value);
if (t <= 0.33) { if t <= 0.33 {
let local = smoothstep(0.0, 0.33, t); let local = smoothstep(0.0, 0.33, t);
return mix(range_stop_color(0u), range_stop_color(1u), local); return mix(range_stop_color(0u), range_stop_color(1u), local);
} }
if (t <= 0.66) { if t <= 0.66 {
let local = smoothstep(0.33, 0.66, t); let local = smoothstep(0.33, 0.66, t);
return mix(range_stop_color(1u), range_stop_color(2u), local); return mix(range_stop_color(1u), range_stop_color(2u), local);
} }
let local = smoothstep(0.66, 1.0, t); let local = smoothstep(0.66, 0.92, t);
return mix(range_stop_color(2u), range_stop_color(3u), local); return mix(range_stop_color(2u), range_stop_color(3u), local);
} }
// background // background
struct BackgroundVertexOutput { struct BackgroundVertexOutput {
@builtin(position) clip_position: vec4f, @builtin(position) clip_position: vec4f,
@@ -126,18 +125,9 @@ fn vs_background(@builtin(vertex_index) vertex_index: u32) -> BackgroundVertexOu
@fragment @fragment
fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f { fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f {
let pixel = frag_coord.xy; return output_color(vec3f(0.0, 0.0, 0.0), 1.0);
let viewport = u.viewport.xy;
let uv = pixel / max(viewport, vec2f(1.0, 1.0));
var color = mix(vec3f(0.018, 0.019, 0.022), vec3f(0.038, 0.040, 0.046), 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.22;
color += vec3f(0.010, 0.010, 0.012) * (1.0 - smoothstep(0.0, 0.85, abs(uv.y - 0.50)));
return output_color(color, 1.0);
} }
// hand image background // hand image background
struct HandImageVertexOutput { struct HandImageVertexOutput {
@builtin(position) clip_position: vec4f, @builtin(position) clip_position: vec4f,
@@ -175,7 +165,7 @@ fn fs_hand_image(in: HandImageVertexOutput) -> @location(0) vec4f {
let image_aspect = u.image.x / max(u.image.y, 1.0); let image_aspect = u.image.x / max(u.image.y, 1.0);
var uv = in.screen_uv; var uv = in.screen_uv;
if (viewport_aspect > image_aspect) { if viewport_aspect > image_aspect {
let image_width = image_aspect / viewport_aspect; let image_width = image_aspect / viewport_aspect;
uv.x = (uv.x - (1.0 - image_width) * 0.5) / image_width; uv.x = (uv.x - (1.0 - image_width) * 0.5) / image_width;
} else { } else {
@@ -183,7 +173,7 @@ fn fs_hand_image(in: HandImageVertexOutput) -> @location(0) vec4f {
uv.y = (uv.y - (1.0 - image_height) * 0.5) / image_height; uv.y = (uv.y - (1.0 - image_height) * 0.5) / image_height;
} }
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { if uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 {
discard; discard;
} }
@@ -191,7 +181,6 @@ fn fs_hand_image(in: HandImageVertexOutput) -> @location(0) vec4f {
return output_color(color.rgb, color.a); return output_color(color.rgb, color.a);
} }
// glyph // glyph
struct GlyphVertexInput { struct GlyphVertexInput {
@location(0) local: vec2f, @location(0) local: vec2f,
@@ -234,45 +223,45 @@ fn digit_segment_on(digit: u32, segment: u32) -> bool {
fn seven_segment_digit_alpha(local: vec2f, digit: u32) -> f32 { fn seven_segment_digit_alpha(local: vec2f, digit: u32) -> f32 {
var alpha = 0.0; var alpha = 0.0;
if (digit_segment_on(digit, 0u)) { if digit_segment_on(digit, 0u) {
alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.70), vec2f(0.38, 0.078))); alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.70), vec2f(0.38, 0.078)));
} }
if (digit_segment_on(digit, 1u)) { if digit_segment_on(digit, 1u) {
alpha = max(alpha, rect_alpha(local, vec2f(0.39, 0.36), vec2f(0.078, 0.335))); alpha = max(alpha, rect_alpha(local, vec2f(0.39, 0.36), vec2f(0.078, 0.335)));
} }
if (digit_segment_on(digit, 2u)) { if digit_segment_on(digit, 2u) {
alpha = max(alpha, rect_alpha(local, vec2f(0.39, -0.36), vec2f(0.078, 0.335))); alpha = max(alpha, rect_alpha(local, vec2f(0.39, -0.36), vec2f(0.078, 0.335)));
} }
if (digit_segment_on(digit, 3u)) { if digit_segment_on(digit, 3u) {
alpha = max(alpha, rect_alpha(local, vec2f(0.0, -0.70), vec2f(0.38, 0.078))); alpha = max(alpha, rect_alpha(local, vec2f(0.0, -0.70), vec2f(0.38, 0.078)));
} }
if (digit_segment_on(digit, 4u)) { if digit_segment_on(digit, 4u) {
alpha = max(alpha, rect_alpha(local, vec2f(-0.39, -0.36), vec2f(0.078, 0.335))); alpha = max(alpha, rect_alpha(local, vec2f(-0.39, -0.36), vec2f(0.078, 0.335)));
} }
if (digit_segment_on(digit, 5u)) { if digit_segment_on(digit, 5u) {
alpha = max(alpha, rect_alpha(local, vec2f(-0.39, 0.36), vec2f(0.078, 0.335))); alpha = max(alpha, rect_alpha(local, vec2f(-0.39, 0.36), vec2f(0.078, 0.335)));
} }
if (digit_segment_on(digit, 6u)) { if digit_segment_on(digit, 6u) {
alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.0), vec2f(0.35, 0.075))); alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.0), vec2f(0.35, 0.075)));
} }
return alpha; return alpha;
} }
fn digit_count(value: u32) -> u32 { fn digit_count(value: u32) -> u32 {
if (value >= 1000u) { if value >= 1000u {
return 4u; return 4u;
} }
if (value >= 100u) { if value >= 100u {
return 3u; return 3u;
} }
if (value >= 10u) { if value >= 10u {
return 2u; return 2u;
} }
return 1u; return 1u;
} }
fn digit_at(value: u32, slot: u32, count: u32) -> u32 { fn digit_at(value: u32, slot: u32, count: u32) -> u32 {
if (count == 4u) { if count == 4u {
switch slot { switch slot {
case 0u: { return (value / 1000u) % 10u; } case 0u: { return (value / 1000u) % 10u; }
case 1u: { return (value / 100u) % 10u; } case 1u: { return (value / 100u) % 10u; }
@@ -280,14 +269,14 @@ fn digit_at(value: u32, slot: u32, count: u32) -> u32 {
default: { return value % 10u; } default: { return value % 10u; }
} }
} }
if (count == 3u) { if count == 3u {
switch slot { switch slot {
case 0u: { return (value / 100u) % 10u; } case 0u: { return (value / 100u) % 10u; }
case 1u: { return (value / 10u) % 10u; } case 1u: { return (value / 10u) % 10u; }
default: { return value % 10u; } default: { return value % 10u; }
} }
} }
if (count == 2u) { if count == 2u {
return select(value % 10u, (value / 10u) % 10u, slot == 0u); return select(value % 10u, (value / 10u) % 10u, slot == 0u);
} }
return value % 10u; return value % 10u;
@@ -302,7 +291,7 @@ fn number_alpha(local: vec2f, display_value: f32) -> f32 {
var alpha = 0.0; var alpha = 0.0;
for (var slot = 0u; slot < 4u; slot = slot + 1u) { for (var slot = 0u; slot < 4u; slot = slot + 1u) {
if (slot < count) { if slot < count {
let center_x = start_x + f32(slot) * slot_width; 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_local = vec2f((local.x - center_x) / (slot_width * 0.78), local.y / 0.92);
let digit = digit_at(value, slot, count); let digit = digit_at(value, slot, count);
@@ -343,8 +332,7 @@ struct DotVertexInput {
struct DotInstanceInput { struct DotInstanceInput {
@location(1) world_position: vec4f, @location(1) world_position: vec4f,
@location(2) style: vec4f @location(2) style: vec4f}
}
struct DotVertexOutput { struct DotVertexOutput {
@builtin(position) clip_position: vec4f, @builtin(position) clip_position: vec4f,
@@ -366,7 +354,7 @@ fn hand_image_uv_to_clip(image_uv: vec2f) -> vec2f {
var screen_uv = image_uv; var screen_uv = image_uv;
if (viewport_aspect > image_aspect) { if viewport_aspect > image_aspect {
let image_width = image_aspect / viewport_aspect; let image_width = image_aspect / viewport_aspect;
screen_uv.x = image_uv.x * image_width + (1.0 - image_width) * 0.5; screen_uv.x = image_uv.x * image_width + (1.0 - image_width) * 0.5;
} else { } else {
@@ -380,6 +368,7 @@ fn hand_image_uv_to_clip(image_uv: vec2f) -> vec2f {
struct HandMembraneVertexOutput { struct HandMembraneVertexOutput {
@builtin(position) clip_position: vec4f, @builtin(position) clip_position: vec4f,
@location(0) local: vec2f, @location(0) local: vec2f,
@location(1) intensity: f32,
} }
fn rotate_2d(point: vec2f, angle: f32) -> vec2f { fn rotate_2d(point: vec2f, angle: f32) -> vec2f {
@@ -394,12 +383,15 @@ fn rounded_rect_alpha(local: vec2f, radius: f32, softness: f32) -> f32 {
return 1.0 - smoothstep(0.0, softness, dist); return 1.0 - smoothstep(0.0, softness, dist);
} }
fn dot_matrix(uv: vec2f, grid: vec2f, dot_radius: f32, dot_softness: f32) -> f32 {
let cell = fract(uv * grid) - vec2f(0.5, 0.5);
return 1.0 - smoothstep(dot_radius, dot_radius + dot_softness, length(cell));
}
fn fingertip_film_alpha( fn fingertip_film_alpha(
local: vec2f, local: vec2f,
half_width: f32, half_width: f32,
cap_center_y: f32, cap_center_y: f32,
rear_edge_y: f32,
rear_bulge: f32,
softness: f32, softness: f32,
) -> f32 { ) -> f32 {
// Top/front of the film is a closed round fingertip cap. // Top/front of the film is a closed round fingertip cap.
@@ -407,14 +399,10 @@ fn fingertip_film_alpha(
let cap = (1.0 - smoothstep(half_width, half_width + softness, cap_dist)) let cap = (1.0 - smoothstep(half_width, half_width + softness, cap_dist))
* (1.0 - smoothstep(cap_center_y - softness, cap_center_y + softness, local.y)); * (1.0 - smoothstep(cap_center_y - softness, cap_center_y + softness, local.y));
// The rear half keeps nearly parallel sides and ends with a shallow arc; // The rear remains open; the carrier quad clips the membrane at its end.
// it deliberately does not converge back into another capsule end.
let x_norm = clamp(abs(local.x) / max(half_width, 0.001), 0.0, 1.0);
let rear_curve_y = rear_edge_y + rear_bulge * (1.0 - x_norm * x_norm);
let side = 1.0 - smoothstep(half_width, half_width + softness, abs(local.x)); let side = 1.0 - smoothstep(half_width, half_width + softness, abs(local.x));
let rear = 1.0 - smoothstep(rear_curve_y, rear_curve_y + softness, local.y);
let body_gate = smoothstep(cap_center_y - softness, cap_center_y + softness, local.y); let body_gate = smoothstep(cap_center_y - softness, cap_center_y + softness, local.y);
let body = side * rear * body_gate; let body = side * body_gate;
return max(cap, body); return max(cap, body);
} }
@@ -430,45 +418,110 @@ fn vs_hand_membrane(vertex: DotVertexInput, instance: DotInstanceInput) -> HandM
var out: HandMembraneVertexOutput; var out: HandMembraneVertexOutput;
out.clip_position = vec4f(hand_image_uv_to_clip(image_uv), 0.0, 1.0); out.clip_position = vec4f(hand_image_uv_to_clip(image_uv), 0.0, 1.0);
out.local = vertex.local; out.local = vertex.local;
out.intensity = saturate(instance.style.w);
return out; return out;
} }
@fragment @fragment
fn fs_hand_membrane(in: HandMembraneVertexOutput) -> @location(0) vec4f { fn fs_hand_membrane(in: HandMembraneVertexOutput) -> @location(0) vec4f {
// The film shape matches the fingertip: closed round front, parallel rear sides, let p = in.local;
// and a shallow rear arc instead of a second capsule end. let live = saturate(in.intensity);
let panel = fingertip_film_alpha(in.local, 0.66, -0.38, 0.72, 0.18, 0.045); let response = smoothstep(0.03, 0.96, live);
let inner = fingertip_film_alpha(in.local, 0.54, -0.36, 0.64, 0.12, 0.060); let hot = smoothstep(0.72, 0.96, live);
let border = clamp(panel - inner * 0.52, 0.0, 1.0); let live_color = sample_range_color(live);
// Dense mesh: the live 12x7 pressure dots sit over this finer sensor lattice. let halo_shape = fingertip_film_alpha(p, 0.72, -0.20, 0.055);
let uv = clamp(in.local * 0.5 + vec2f(0.5, 0.5), vec2f(0.0, 0.0), vec2f(1.0, 1.0)); let panel = fingertip_film_alpha(p, 0.66, -0.22, 0.035);
let grid = vec2f(18.0, 34.0); let inner = fingertip_film_alpha(p, 0.58, -0.25, 0.045);
let cell = uv * grid - vec2f(0.5, 0.5); let clear_inner = fingertip_film_alpha(p, 0.48, -0.24, 0.160);
let nearest = abs(fract(cell + vec2f(0.5, 0.5)) - vec2f(0.5, 0.5));
let grid_line = max( let halo = clamp(halo_shape - panel, 0.0, 1.0);
1.0 - smoothstep(0.014, 0.038, nearest.x), let rim = clamp(panel - inner, 0.0, 1.0);
1.0 - smoothstep(0.014, 0.038, nearest.y), let edge_fade = pow(clamp(1.0 - clear_inner, 0.0, 1.0), 1.20) * panel;
// Strong pseudo extrusion / bevel.
let depth_offset = vec2f(0.045, 0.055);
let back_shape_1 = fingertip_film_alpha(p - depth_offset * 0.55, 0.66, -0.22, 0.045);
let back_shape_2 = fingertip_film_alpha(p - depth_offset, 0.66, -0.22, 0.060);
let extrusion = clamp(max(back_shape_1, back_shape_2) - panel, 0.0, 1.0);
let bevel_offset = vec2f(0.028, 0.034);
let shifted_down_right = fingertip_film_alpha(p - bevel_offset, 0.66, -0.22, 0.035);
let bevel_light = clamp(panel - shifted_down_right, 0.0, 1.0);
let shifted_up_left = fingertip_film_alpha(p + bevel_offset, 0.66, -0.22, 0.035);
let bevel_dark = clamp(panel - shifted_up_left, 0.0, 1.0);
let broad_bevel = edge_fade * clamp(0.58 - p.x * 0.20 - p.y * 0.18, 0.0, 1.0);
// Micro lattice.
let uv = clamp(
p * vec2f(0.52, 0.46) + vec2f(0.5, 0.46),
vec2f(0.0, 0.0),
vec2f(1.0, 1.0),
); );
let joint = 1.0 - smoothstep(0.070, 0.150, length(nearest * vec2f(1.18, 1.0)));
let center_shadow = 1.0 - smoothstep(0.10, 0.76, length(in.local * vec2f(0.92, 0.66)));
let top_light = smoothstep(-0.52, 0.16, -in.local.y) * 0.20; let grid = vec2f(18.0, 34.0);
let side_glow = smoothstep(0.30, 0.70, abs(in.local.x)) * 0.26; let cell_uv = fract(uv * grid) - vec2f(0.5, 0.5);
let lift_shadow = smoothstep(0.48, 0.86, in.local.y) * (1.0 - smoothstep(0.50, 0.86, abs(in.local.x))) * 0.13; let dot_dist = length(cell_uv * vec2f(1.0, 1.06));
let scan = (0.5 + 0.5 * sin((uv.y * 76.0 + uv.x * 9.0) * 6.28318)) * 0.030; let dot_aa = max(fwidth(dot_dist) * 1.35, 0.006);
let dots = (1.0 - smoothstep(0.105 - dot_aa, 0.105 + dot_aa, dot_dist)) * inner;
let membrane_color = vec3f(0.006, 0.28, 0.38); let sheen_axis = p.x * 0.88 + p.y * 0.22 + 0.16;
let line_color = vec3f(0.12, 0.72, 0.88); let sheen = (1.0 - smoothstep(0.018, 0.105, abs(sheen_axis))) * panel;
let rim_color = vec3f(0.18, 0.98, 1.0); let shell_sheen = sheen * (0.26 + edge_fade * 0.74);
let color = membrane_color * (0.68 + top_light + side_glow + scan)
+ line_color * (grid_line * 0.20 + joint * 0.42)
+ rim_color * (border * 0.92 + side_glow * 0.18)
- vec3f(0.0, 0.16, 0.24) * center_shadow * 0.30
- vec3f(0.0, 0.10, 0.16) * lift_shadow;
let alpha = panel * (0.24 + grid_line * 0.10 + joint * 0.23 + border * 0.42);
return output_color(color, alpha); // Temporary synthetic pressure hotspot. Real hand pressure dots are drawn above this pass.
let pressure_center = vec2f(-0.08, -0.12);
let pressure_dist = length((p - pressure_center) * vec2f(1.0, 0.74));
let pressure = (1.0 - smoothstep(0.05, 0.36, pressure_dist)) * inner;
let pressure_hot = (1.0 - smoothstep(0.02, 0.13, pressure_dist)) * inner;
let glass_base = vec3f(0.006, 0.055, 0.105);
let glass_cyan = vec3f(0.025, 0.42, 0.58);
let glass_live = mix(glass_cyan, live_color * 0.58, response);
let rim_color = mix(vec3f(0.10, 0.88, 1.00), live_color, response);
let extrusion_color = vec3f(0.004, 0.080, 0.110);
let extrusion_edge_color = mix(vec3f(0.015, 0.30, 0.38), live_color * 0.72, response);
let bevel_highlight = mix(vec3f(0.48, 1.00, 1.00), live_color, response * 0.82);
let bevel_dark_color = vec3f(0.004, 0.035, 0.060);
let dot_color = mix(vec3f(0.08, 0.48, 0.58), live_color, response);
let pressure_color = mix(dot_color, live_color, response);
let pressure_hot_color = live_color;
let membrane_color = extrusion_color * extrusion * 0.90
+ extrusion_edge_color * extrusion * halo_shape * 0.32
+ glass_base * panel * 0.26
+ glass_live * edge_fade * 0.46
+ glass_live * broad_bevel * 0.24
+ rim_color * rim * 0.88
+ bevel_highlight * bevel_light * 1.35
+ bevel_dark_color * bevel_dark * 0.95
+ bevel_highlight * shell_sheen * 0.28
+ rim_color * halo * 0.16
+ dot_color * dots * 0.68
+ pressure_color * dots * pressure * 0.82
+ pressure_hot_color * dots * pressure_hot * 0.90;
let live_wash = live_color * response * (inner * 0.12 + dots * 0.72 + rim * 0.30)
+ live_color * hot * (inner * 0.08 + dots * 0.22);
let color = membrane_color + live_wash;
let alpha = clamp(
extrusion * 0.44
+ panel * 0.055
+ edge_fade * 0.30
+ rim * 0.34
+ bevel_light * 0.34
+ bevel_dark * 0.20
+ shell_sheen * 0.09
+ halo * 0.045
+ dots * 0.09
+ dots * pressure * 0.10,
0.0,
0.90,
) + response * (dots * 0.07 + rim * 0.035) + hot * dots * 0.04;
return output_color(color, clamp(alpha, 0.0, 0.96));
} }
@vertex @vertex
@@ -540,33 +593,9 @@ fn vs_hand_palm_chip(vertex: DotVertexInput, instance: DotInstanceInput) -> Hand
@fragment @fragment
fn fs_hand_palm_chip(in: HandPalmChipVertexOutput) -> @location(0) vec4f { fn fs_hand_palm_chip(in: HandPalmChipVertexOutput) -> @location(0) vec4f {
// Dark rounded tile: this is the inset chip body sitting inside the palm surface. // The live palm-dot pass draws every chip cell, including the idle dots.
let panel = rounded_rect_alpha(in.local, 0.10, 0.040); // Keeping this background pass transparent prevents a second offset dot grid.
let inset = rounded_rect_alpha(in.local * vec2f(1.10, 1.08), 0.08, 0.052); return output_color(vec3f(0.0, 0.0, 0.0), 0.0);
let rim = clamp(panel - inset * 0.72, 0.0, 1.0);
// Inactive chip pixels use the chip's real hand layout:
// horizontal 14 columns x 5 rows, or vertical 4 columns x 11 rows.
let uv = clamp(in.local * 0.5 + vec2f(0.5, 0.5), vec2f(0.0, 0.0), vec2f(1.0, 1.0));
let cell = abs(fract(uv * in.grid) - vec2f(0.5, 0.5));
let micro_pixel = 1.0 - smoothstep(0.105, 0.178, length(cell * vec2f(1.04, 0.94)));
let top_bevel = smoothstep(-0.96, -0.18, -in.local.y) * 0.16;
let lower_shadow = smoothstep(0.20, 0.92, in.local.y) * 0.22;
let side_bevel = smoothstep(0.58, 0.96, abs(in.local.x)) * 0.12;
let scan = (0.5 + 0.5 * sin((uv.y * 36.0 + uv.x * 7.0) * 6.28318)) * 0.026;
let base = vec3f(0.004, 0.012, 0.018);
let glass = vec3f(0.012, 0.048, 0.064);
let pixel_color = vec3f(0.075, 0.300, 0.360);
let rim_color = vec3f(0.060, 0.560, 0.670);
let color = base * (0.92 - lower_shadow)
+ glass * (0.52 + top_bevel + side_bevel + scan)
+ pixel_color * micro_pixel * 0.70
+ rim_color * rim * 0.60;
let alpha = panel * (0.64 + micro_pixel * 0.18 + rim * 0.20);
return output_color(color, alpha);
} }
@vertex @vertex
@@ -574,9 +603,10 @@ fn vs_hand_palm_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVe
let intensity = saturate(instance.style.x); let intensity = saturate(instance.style.x);
let shaped = smoothstep(0.0, 1.0, intensity); let shaped = smoothstep(0.0, 1.0, intensity);
// Palm chip pixels are deliberately smaller than fingertip beads so they read as a chip matrix.
let center = hand_image_uv_to_clip(instance.world_position.xy); let center = hand_image_uv_to_clip(instance.world_position.xy);
let pixel_size = u.glyph.x * mix(0.13, 0.25, shaped); // Palm boards have more tightly packed cells than the fingertips, so use
// a larger circular mask to keep each sensor visibly readable.
let pixel_size = u.glyph.x * mix(0.40, 0.54, shaped);
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0; let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
var out: DotVertexOutput; var out: DotVertexOutput;
@@ -589,17 +619,16 @@ fn vs_hand_palm_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVe
@fragment @fragment
fn fs_hand_palm_dot(in: DotVertexOutput) -> @location(0) vec4f { fn fs_hand_palm_dot(in: DotVertexOutput) -> @location(0) vec4f {
let intensity = saturate(in.intensity); let intensity = saturate(in.intensity);
let pixel = chip_pixel_alpha(in.local, 0.52, 0.070);
let glow = circle_alpha(in.local, 0.95, 0.22) * intensity * 0.36;
let cold = vec3f(0.070, 0.340, 0.360); let core = circle_alpha(in.local, 0.48, 0.07);
let halo = circle_alpha(in.local, 0.74, 0.14) * (0.10 + intensity * 0.26);
let idle = vec3f(0.060, 0.250, 0.320);
let gradient = sample_range_color(intensity); let gradient = sample_range_color(intensity);
let color = mix(cold, gradient, smoothstep(0.0, 0.20, intensity)) let color = mix(idle, gradient, smoothstep(0.0, 0.18, intensity))
* (0.58 + intensity * 1.04) * mix(0.78, 1.18, intensity);
+ gradient * glow * 0.72;
let alpha = max(pixel * (0.20 + intensity * 0.76), glow); return output_color(color, max(core, halo));
return output_color(color, alpha);
} }
@vertex @vertex
@@ -629,7 +658,6 @@ fn fs_dot(in: DotVertexOutput) -> @location(0) vec4f {
return output_color(color, alpha); return output_color(color, alpha);
} }
// model // model
struct ModelVertexInput { struct ModelVertexInput {
@location(0) position: vec3f, @location(0) position: vec3f,
@@ -748,11 +776,11 @@ fn aces_tonemap(x: vec3f) -> vec3f {
fn material_normal(in: ModelVertexOutput, front_facing: bool) -> vec3f { fn material_normal(in: ModelVertexOutput, front_facing: bool) -> vec3f {
var n = normalize(in.world_normal); var n = normalize(in.world_normal);
if (!front_facing && material.flags.w > 0.5) { if !front_facing && material.flags.w > 0.5 {
n = -n; n = -n;
} }
if (material.flags.z < 0.5) { if material.flags.z < 0.5 {
return n; return n;
} }
@@ -772,17 +800,17 @@ fn fs_model(in: ModelVertexOutput, @builtin(front_facing) front_facing: bool) ->
let base_color = base_sample * material.base_color * in.color; let base_color = base_sample * material.base_color * in.color;
let alpha_mode = material.emissive_alpha.w; let alpha_mode = material.emissive_alpha.w;
var alpha = base_color.a; var alpha = base_color.a;
if (alpha_mode < 0.5) { if alpha_mode < 0.5 {
alpha = 1.0; alpha = 1.0;
} else if (alpha_mode < 1.5) { } else if alpha_mode < 1.5 {
if (alpha < material.flags.x) { if alpha < material.flags.x {
discard; discard;
} }
alpha = 1.0; alpha = 1.0;
} }
let debug_mode = u32(u.render_options.x + 0.5); let debug_mode = u32(u.render_options.x + 0.5);
if (debug_mode == 1u) { if debug_mode == 1u {
return output_color(base_color.rgb, alpha); return output_color(base_color.rgb, alpha);
} }
@@ -848,7 +876,7 @@ fn fs_model(in: ModelVertexOutput, @builtin(front_facing) front_facing: bool) ->
let ambient_strength = 0.08; let ambient_strength = 0.08;
// Temporary neutral linear ambient term until a real IBL/HDR environment is added. // Temporary neutral linear ambient term until a real IBL/HDR environment is added.
var ambient = vec3f(0.0); var ambient = vec3f(0.0);
if (u.render_options.y > 0.5) { if u.render_options.y > 0.5 {
let ambient_diffuse = albedo * (1.0 - metallic) * ambient_color * ambient_strength; let ambient_diffuse = albedo * (1.0 - metallic) * ambient_color * ambient_strength;
let ambient_specular = f_ambient * ambient_color * ambient_strength * (1.0 - roughness * 0.55); let ambient_specular = f_ambient * ambient_color * ambient_strength * (1.0 - roughness * 0.55);
ambient = (ambient_diffuse + ambient_specular) * ao; ambient = (ambient_diffuse + ambient_specular) * ao;
@@ -856,7 +884,7 @@ fn fs_model(in: ModelVertexOutput, @builtin(front_facing) front_facing: bool) ->
let exposed_color = (ambient + key + fill + emissive) * max(u.render_options.w, 0.0); let exposed_color = (ambient + key + fill + emissive) * max(u.render_options.w, 0.0);
var color = exposed_color; var color = exposed_color;
if (u.render_options.z > 0.5) { if u.render_options.z > 0.5 {
color = aces_tonemap(exposed_color); color = aces_tonemap(exposed_color);
} }

254
wix/main.wxs Normal file
View File

@@ -0,0 +1,254 @@
<?xml version='1.0' encoding='windows-1252'?>
<!--
Copyright (C) 2017 Christopher R. Field.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!--
The "cargo wix" subcommand provides a variety of predefined variables available
for customization of this template. The values for each variable are set at
installer creation time. The following variables are available:
TargetTriple = The rustc target triple name.
TargetEnv = The rustc target environment. This is typically either
"msvc" or "gnu" depending on the toolchain downloaded and
installed.
TargetVendor = The rustc target vendor. This is typically "pc", but Rust
does support other vendors, like "uwp".
CargoTargetBinDir = The complete path to the directory containing the
binaries (exes) to include. The default would be
"target\release\". If an explicit rustc target triple is
used, i.e. cross-compiling, then the default path would
be "target\<CARGO_TARGET>\<CARGO_PROFILE>",
where "<CARGO_TARGET>" is replaced with the "CargoTarget"
variable value and "<CARGO_PROFILE>" is replaced with the
value from the "CargoProfile" variable. This can also
be overridden manually with the "target-bin-dir" flag.
CargoTargetDir = The path to the directory for the build artifacts, i.e.
"target".
CargoProfile = The cargo profile used to build the binaries
(usually "debug" or "release").
Version = The version for the installer. The default is the
"Major.Minor.Fix" semantic versioning number of the Rust
package.
-->
<!--
Please do not remove these pre-processor If-Else blocks. These are used with
the `cargo wix` subcommand to automatically determine the installation
destination for 32-bit versus 64-bit installers. Removal of these lines will
cause installation errors.
-->
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
<Product
Id='*'
Name='eskin-model-player'
UpgradeCode='7CEF316B-BE23-4533-B4B2-87D06D2230B5'
Manufacturer='JOYSONQUIN'
Language='1033'
Codepage='1252'
Version='$(var.Version)'>
<Package Id='*'
Keywords='Installer'
Description='Desktop pressure sensor visualization and playback application.'
Manufacturer='JOYSONQUIN'
InstallerVersion='450'
Languages='1033'
Compressed='yes'
InstallScope='perUser'
InstallPrivileges='limited'
SummaryCodepage='1252'
/>
<MajorUpgrade
Schedule='afterInstallInitialize'
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
<Property Id='DiskPrompt' Value='eskin-model-player Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='LocalAppDataFolder'>
<Directory Id='APPLICATIONFOLDER' Name='eskin-model-player'>
<!--
Enabling the license sidecar file in the installer is a four step process:
1. Uncomment the `Component` tag and its contents.
2. Change the value for the `Source` attribute in the `File` tag to a path
to the file that should be included as the license sidecar file. The path
can, and probably should be, relative to this file.
3. Change the value for the `Name` attribute in the `File` tag to the
desired name for the file when it is installed alongside the `bin` folder
in the installation directory. This can be omitted if the desired name is
the same as the file name.
4. Uncomment the `ComponentRef` tag with the Id attribute value of "License"
further down in this file.
-->
<!--
<Component Id='License' Guid='*'>
<File Id='LicenseFile' Name='ChangeMe' DiskId='1' Source='C:\Path\To\File' KeyPath='yes'/>
</Component>
-->
<Directory Id='Bin' Name='bin'>
<Component Id='Path' Guid='FD230ABD-75B9-47D4-870B-CF2EA72B76BE'>
<RegistryValue
Root='HKCU'
Key='Software\JOYSONQUIN\eskin-model-player'
Name='PathComponent'
Type='integer'
Value='1'
KeyPath='yes'/>
<RemoveFolder Id='RemoveBinFolder' Directory='Bin' On='uninstall'/>
<RemoveFolder Id='RemoveApplicationFolder' Directory='APPLICATIONFOLDER' On='uninstall'/>
<Environment
Id='PATH'
Name='PATH'
Value='[Bin]'
Permanent='no'
Part='last'
Action='set'
System='no'/>
</Component>
<Component Id='binary0' Guid='302CAFB5-6951-426B-BC5A-988C351A2CF2'>
<RegistryValue
Root='HKCU'
Key='Software\JOYSONQUIN\eskin-model-player'
Name='BinaryComponent'
Type='integer'
Value='1'
KeyPath='yes'/>
<File
Id='exe0'
Name='ESkinPlayer.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\ESkinPlayer.exe'/>
</Component>
</Directory>
</Directory>
</Directory>
</Directory>
<Feature
Id='Binaries'
Title='Application'
Description='Installs all binaries and the license.'
Level='1'
ConfigurableDirectory='APPLICATIONFOLDER'
AllowAdvertise='no'
Display='expand'
Absent='disallow'>
<!--
Uncomment the following `ComponentRef` tag to add the license
sidecar file to the installer.
-->
<!--<ComponentRef Id='License'/>-->
<ComponentRef Id='binary0'/>
<Feature
Id='Environment'
Title='PATH Environment Variable'
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
Level='1'
Absent='allow'>
<ComponentRef Id='Path'/>
</Feature>
</Feature>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
<!--
Uncomment the following `Icon` and `Property` tags to change the product icon.
The product icon is the graphic that appears in the Add/Remove
Programs control panel for the application.
-->
<!--<Icon Id='ProductICO' SourceFile='wix\Product.ico'/>-->
<!--<Property Id='ARPPRODUCTICON' Value='ProductICO' />-->
<!--
Adding a URL to Add/Remove Programs control panel listing for the
application is a two step process:
1. Uncomment the following `Property` tag with the "ARPHELPLINK" Id
attribute value.
2. Change the value for `Value` attribute of the following
`Property` tag to a valid URL.
-->
<!--<Property Id='ARPHELPLINK' Value='ChangeMe'/>-->
<UI>
<UIRef Id='WixUI_FeatureTree'/>
<!--
Enabling the EULA dialog in the installer is a three step process:
1. Comment out or remove the two `Publish` tags that follow the
`WixVariable` tag.
2. Uncomment the `<WixVariable Id='WixUILicenseRtf' Value='Path\to\Eula.rft'>` tag further down
3. Replace the `Value` attribute of the `WixVariable` tag with
the path to a RTF file that will be used as the EULA and
displayed in the license agreement dialog.
-->
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
</UI>
<!--
Enabling the EULA dialog in the installer requires uncommenting
the following `WixUILicenseRTF` tag and changing the `Value`
attribute.
-->
<!-- <WixVariable Id='WixUILicenseRtf' Value='Relative\Path\to\Eula.rtf'/> -->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom banner image across
the top of each screen. See the WiX Toolset documentation for details
about customization.
The banner BMP dimensions are 493 x 58 pixels.
-->
<!--<WixVariable Id='WixUIBannerBmp' Value='wix\Banner.bmp'/>-->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom image to the first
dialog, or screen. See the WiX Toolset documentation for details about
customization.
The dialog BMP dimensions are 493 x 312 pixels.
-->
<!--<WixVariable Id='WixUIDialogBmp' Value='wix\Dialog.bmp'/>-->
</Product>
</Wix>