From e61bf733f9b95d2d7e7d9257c607741e36eb2efd Mon Sep 17 00:00:00 2001 From: baketnk Date: Thu, 24 Sep 2026 14:46:24 -0400 Subject: [PATCH] Wire wrist placement settings through config and installer --- assets/config.example.json | 1 + docs/overlay.md | 19 +++++++++++++++++-- install.sh | 8 ++++++++ scripts/install_payload.py | 8 ++++++++ src/config.cpp | 24 +++++++++++++++++++++++- src/config.hpp | 2 ++ src/overlay.cpp | 4 ++-- tests/config_test.cpp | 12 ++++++++++++ tests/test_installer.py | 8 +++++++- 9 files changed, 80 insertions(+), 6 deletions(-) diff --git a/assets/config.example.json b/assets/config.example.json index 3f81063..894826d 100644 --- a/assets/config.example.json +++ b/assets/config.example.json @@ -1,6 +1,7 @@ { "font": "", "input_priority": "normal", + "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, "theme": { "background": "#0c101b", "card": "#141c2b", diff --git a/docs/overlay.md b/docs/overlay.md index 9d6f95a..297883b 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -74,6 +74,7 @@ installer creates one with defaults on first install. Copy the shipped { "font": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "input_priority": "normal", + "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, "theme": { "background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", @@ -142,8 +143,22 @@ rather than placing a menu at the world origin. A tracking-origin reset requests a fresh placement. Settings → Recenter in front deliberately resamples the pose. Settings offers World space, Left wrist, Right wrist and Head on that same canvas. -World/head width is 0.85 m; wrist width is 0.42 m with mirrored controller-relative -offsets. A missing/untracked selected wrist temporarily falls back to world space, +World/head width is 0.85 m; wrist width defaults to 0.30 m. Both wrists use +VR Workspace's fallback watch-face axes: panel-right points toward the fingers +(controller -Z), panel-up points out of the back of the hand (controller +Y), +and panel-front points toward controller +X. Its controller-relative center is +(0, 0.18, 0.089) m, approximating the compact HUD's surface center: its +0.12 m wrist lift, 0.09 m bottom anchor and ~0.03 m panel-center correction; +Z combines the fallback 0.054 m wrist calibration and 0.035 m finger-back offset. This copies placement geometry, not +VR Workspace's avatar-dependent wrist calibration or its head-facing fade. + +To tune the selected wrist, set `wrist` in `config.json` as in the example above: +`x`, `y`, `z` are controller-local meters (each -0.3 to 0.3), `width` is panel +width in meters (0.15 to 0.6), and `roll_degrees` rotates about controller -Z +(-180 to 180) before applying the offset. These settings apply to both wrists, +are read at startup, and do not alter world/head placement. Invalid values +fail direct native startup; the installer backs up and repairs invalid entries. +A missing/untracked selected wrist temporarily falls back to world space, with a visible explanation in Settings, then reattaches when tracking returns. The saved preference is not replaced by the fallback. These offsets and sizes are initial choices, **not headset-comfort acceptance**. diff --git a/install.sh b/install.sh index 2cb04ba..13742dd 100755 --- a/install.sh +++ b/install.sh @@ -34,6 +34,7 @@ DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z") CONFIG_DEFAULTS = { "font": "", "input_priority": "normal", + "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "frame_start": "#1fff91", "frame_end": "#1f70ff"}, @@ -90,6 +91,13 @@ def normalized_config(data): fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""} priority = data.get("input_priority", "normal") fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal" + source = data.get("wrist") + source = source if isinstance(source, dict) else {} + fixed["wrist"] = {} + for name, default in CONFIG_DEFAULTS["wrist"].items(): + value = source.get(name, default) + limit = (.15, .6) if name == "width" else (-180, 180) if name == "roll_degrees" else (-.3, .3) + fixed["wrist"][name] = value if type(value) in (int, float) and limit[0] <= value <= limit[1] else default for section in ("theme", "buttons"): source = data.get(section) source = source if isinstance(source, dict) else {} diff --git a/scripts/install_payload.py b/scripts/install_payload.py index 09c94c4..4643f78 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -23,6 +23,7 @@ DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z") CONFIG_DEFAULTS = { "font": "", "input_priority": "normal", + "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "frame_start": "#1fff91", "frame_end": "#1f70ff"}, @@ -79,6 +80,13 @@ def normalized_config(data): fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""} priority = data.get("input_priority", "normal") fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal" + source = data.get("wrist") + source = source if isinstance(source, dict) else {} + fixed["wrist"] = {} + for name, default in CONFIG_DEFAULTS["wrist"].items(): + value = source.get(name, default) + limit = (.15, .6) if name == "width" else (-180, 180) if name == "roll_degrees" else (-.3, .3) + fixed["wrist"][name] = value if type(value) in (int, float) and limit[0] <= value <= limit[1] else default for section in ("theme", "buttons"): source = data.get(section) source = source if isinstance(source, dict) else {} diff --git a/src/config.cpp b/src/config.cpp index 96cc5d6..536345a 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -12,7 +13,7 @@ namespace { struct Json { std::string value; std::map object; - bool is_string = false, is_object = false; + bool is_string = false, is_object = false, is_number = false; }; struct Parser { std::string_view s; @@ -104,6 +105,10 @@ struct Parser { } } if (pos == start) fail(); + if (s[start] == '-' || (s[start] >= '0' && s[start] <= '9')) { + result.is_number = true; + result.value = s.substr(start, pos - start); + } return result; } }; @@ -124,6 +129,13 @@ Rgba color(const Json& json) { } return c; } +float bounded_number(const Json& json, std::string_view name, float lower, float upper) { + if (!json.is_number) throw std::runtime_error("Config wrist " + std::string(name) + " must be a number"); + const float value = std::strtof(json.value.c_str(), nullptr); + if (!std::isfinite(value) || value < lower || value > upper) + throw std::runtime_error("Config wrist " + std::string(name) + " out of range"); + return value; +} std::filesystem::path xdg_root(const char* variable, const char* fallback) { const char* env = std::getenv(variable); if (env && *env && std::filesystem::path(env).is_absolute()) return env; @@ -177,6 +189,16 @@ Config load_config(const std::filesystem::path& path) { if (!value.is_string || (value.value != "normal" && value.value != "experimental")) throw std::runtime_error("Config input_priority must be normal or experimental"); config.experimental_input_priority = value.value == "experimental"; + } else if (key == "wrist") { + if (!value.is_object) throw std::runtime_error("Config wrist must be an object"); + for (const auto& [name, v] : value.object) { + if (name == "x") config.wrist.x = bounded_number(v, name, -.3f, .3f); + else if (name == "y") config.wrist.y = bounded_number(v, name, -.3f, .3f); + else if (name == "z") config.wrist.z = bounded_number(v, name, -.3f, .3f); + else if (name == "width") config.wrist.width = bounded_number(v, name, .15f, .6f); + else if (name == "roll_degrees") config.wrist.roll_degrees = bounded_number(v, name, -180.f, 180.f); + else throw std::runtime_error("Unknown wrist placement key: " + name); + } } else if (key == "theme") { if (!value.is_object) throw std::runtime_error("Config theme must be an object"); for (const auto& [name, v] : value.object) { diff --git a/src/config.hpp b/src/config.hpp index 57fd437..ba3a5f2 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -1,4 +1,5 @@ #pragma once +#include "mount.hpp" #include #include #include @@ -17,6 +18,7 @@ struct Config { std::string font; // absolute TTF/OTF path; empty uses the bundled face // Requests OpenVR's experimental global action priority; SteamVR must allow it too. bool experimental_input_priority = false; + WristPlacement wrist; // OpenVR action name -> physical Frame controller input path; empty disables it. std::map buttons; }; diff --git a/src/overlay.cpp b/src/overlay.cpp index fa8f3b0..ab74fac 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -215,13 +215,13 @@ struct Overlay::Impl { overlay_check(overlay->SetOverlayTransformAbsolute(handle, vr::TrackingUniverseStanding, &world_transform), overlay, "SetOverlayTransformAbsolute"); } else { - const auto pose = relative_mount_pose(effective); + const auto pose = relative_mount_pose(effective, config.wrist); vr::HmdMatrix34_t transform{}; for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) transform.m[r][c] = pose[r][c]; overlay_check(overlay->SetOverlayTransformTrackedDeviceRelative(handle, target, &transform), overlay, "SetOverlayTransformTrackedDeviceRelative"); } - overlay_check(overlay->SetOverlayWidthInMeters(handle, mount_width(effective)), overlay, "SetOverlayWidthInMeters"); + overlay_check(overlay->SetOverlayWidthInMeters(handle, mount_width(effective, config.wrist)), overlay, "SetOverlayWidthInMeters"); applied_mount = effective; anchor = target; } diff --git a/tests/config_test.cpp b/tests/config_test.cpp index 2841ce0..8604668 100644 --- a/tests/config_test.cpp +++ b/tests/config_test.cpp @@ -28,10 +28,12 @@ int main(int argc, char** argv) { ::setenv("XDG_CONFIG_HOME", dir.c_str(), 1); assert(load_config(path).buttons.empty()); assert(!load_config(path).experimental_input_priority); + assert(load_config(path).wrist.width == .30f); auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json"); assert(example.buttons.at("ptt") == "/user/hand/right/input/x"); assert(example.font.empty()); assert(!example.experimental_input_priority); + assert(example.wrist.y == .18f && example.wrist.z == .089f); std::filesystem::create_directories(path.parent_path()); put(path, R"({"input_priority":"experimental"})"); auto experimental = load_config(path); @@ -45,6 +47,16 @@ int main(int argc, char** argv) { put(path, invalid); fails([&] { load_config(path); }); } + put(path, R"({"wrist":{"x":-0.02,"y":0.15,"z":0.1,"width":0.35,"roll_degrees":-35}})"); + auto tuned = load_config(path).wrist; + assert(tuned.x == -.02f && tuned.y == .15f && tuned.z == .1f); + assert(tuned.width == .35f && tuned.roll_degrees == -35.f); + for (const auto* invalid : {R"({"wrist":{"width":0}})", R"({"wrist":{"x":true}})", + R"({"wrist":{"z":"0.1"}})", R"({"wrist":{"roll_degrees":181}})", + R"({"wrist":{"extra":1}})", R"({"wrist":null})", + R"({"wrist":{"x":1e999}})"}) { + put(path, invalid); fails([&] { load_config(path); }); + } put(path, R"({"theme":{"background":"#123ABC","accent":"#abcdef","frame_end":"#010203"},"font":"/nonexistent/face.ttf","buttons":{"ptt":"/user/hand/left/input/y","cancel":"/user/hand/right/input/b","right_grip":""}})"); auto config = load_config(path); assert((config.theme.background == Rgba{0x12, 0x3a, 0xbc, 255})); diff --git a/tests/test_installer.py b/tests/test_installer.py index 83ad235..f488c74 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -125,16 +125,18 @@ class InstallTests(unittest.TestCase): self.assertEqual(fixed["theme"]["card"], installer.CONFIG_DEFAULTS["theme"]["card"]) self.assertEqual(fixed["buttons"]["cancel"], "") self.assertEqual(fixed["input_priority"], "normal") + self.assertEqual(fixed["wrist"], installer.CONFIG_DEFAULTS["wrist"]) backups = list(config.parent.glob("config.json.backup-*")) self.assertEqual(len(backups), 1) self.assertEqual(backups[0].read_bytes(), original) fixed["input_priority"] = "experimental" + fixed["wrist"]["y"] = 0.2 compact = json.dumps(fixed, separators=(",", ":")).encode() config.write_bytes(compact) self.install("v1", archive, digest) self.assertEqual(config.read_bytes(), compact) self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1) - original = b'{"font":"/system/face.ttf","input_priority":"highest","theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}' + original = b'{"font":"/system/face.ttf","input_priority":"highest","wrist":{"x":0.04,"y":true,"width":100,"obsolete":4},"theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}' config.write_bytes(original) self.install("v1", archive, digest) fixed = json.loads(config.read_text()) @@ -142,6 +144,10 @@ class InstallTests(unittest.TestCase): self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"]) self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset self.assertEqual(fixed["input_priority"], "normal") + self.assertEqual(fixed["wrist"]["x"], 0.04) + self.assertEqual(fixed["wrist"]["y"], 0.18) + self.assertEqual(fixed["wrist"]["width"], 0.30) + self.assertNotIn("obsolete", fixed["wrist"]) self.assertNotIn("old_option", fixed) self.assertEqual(sorted(p.read_bytes() for p in config.parent.glob("config.json.backup-*")), sorted([backups[0].read_bytes(), original])) original = b'{"font":"one","font":"two"'