Wire wrist placement settings through config and installer

This commit is contained in:
baketnk committed 2026-09-24 14:46:24 -04:00
1 parent 569ba13830
commit e61bf733f9
9 files changed
+80 -6

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
{ {
"font": "", "font": "",
"input_priority": "normal", "input_priority": "normal",
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
"theme": { "theme": {
"background": "#0c101b", "background": "#0c101b",
"card": "#141c2b", "card": "#141c2b",
+17 -2
View File
@@ -74,6 +74,7 @@ installer creates one with defaults on first install. Copy the shipped
{ {
"font": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "font": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"input_priority": "normal", "input_priority": "normal",
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
"theme": { "theme": {
"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "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. 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. 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 World/head width is 0.85 m; wrist width defaults to 0.30 m. Both wrists use
offsets. A missing/untracked selected wrist temporarily falls back to world space, 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. 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 The saved preference is not replaced by the fallback. These offsets and sizes are
initial choices, **not headset-comfort acceptance**. initial choices, **not headset-comfort acceptance**.
+8
View File
@@ -34,6 +34,7 @@ DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
CONFIG_DEFAULTS = { CONFIG_DEFAULTS = {
"font": "", "font": "",
"input_priority": "normal", "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", "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
"frame_start": "#1fff91", "frame_end": "#1f70ff"}, "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 ""} fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
priority = data.get("input_priority", "normal") priority = data.get("input_priority", "normal")
fixed["input_priority"] = priority if priority in ("normal", "experimental") else "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"): for section in ("theme", "buttons"):
source = data.get(section) source = data.get(section)
source = source if isinstance(source, dict) else {} source = source if isinstance(source, dict) else {}
+8
View File
@@ -23,6 +23,7 @@ DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
CONFIG_DEFAULTS = { CONFIG_DEFAULTS = {
"font": "", "font": "",
"input_priority": "normal", "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", "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
"frame_start": "#1fff91", "frame_end": "#1f70ff"}, "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 ""} fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
priority = data.get("input_priority", "normal") priority = data.get("input_priority", "normal")
fixed["input_priority"] = priority if priority in ("normal", "experimental") else "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"): for section in ("theme", "buttons"):
source = data.get(section) source = data.get(section)
source = source if isinstance(source, dict) else {} source = source if isinstance(source, dict) else {}
+23 -1
View File
@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <chrono> #include <chrono>
#include <cmath>
#include <cstdlib> #include <cstdlib>
#include <fstream> #include <fstream>
#include <stdexcept> #include <stdexcept>
@@ -12,7 +13,7 @@ namespace {
struct Json { struct Json {
std::string value; std::string value;
std::map<std::string, Json> object; std::map<std::string, Json> object;
bool is_string = false, is_object = false; bool is_string = false, is_object = false, is_number = false;
}; };
struct Parser { struct Parser {
std::string_view s; std::string_view s;
@@ -104,6 +105,10 @@ struct Parser {
} }
} }
if (pos == start) fail(); 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; return result;
} }
}; };
@@ -124,6 +129,13 @@ Rgba color(const Json& json) {
} }
return c; 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) { std::filesystem::path xdg_root(const char* variable, const char* fallback) {
const char* env = std::getenv(variable); const char* env = std::getenv(variable);
if (env && *env && std::filesystem::path(env).is_absolute()) return env; 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")) if (!value.is_string || (value.value != "normal" && value.value != "experimental"))
throw std::runtime_error("Config input_priority must be normal or experimental"); throw std::runtime_error("Config input_priority must be normal or experimental");
config.experimental_input_priority = value.value == "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") { } else if (key == "theme") {
if (!value.is_object) throw std::runtime_error("Config theme must be an object"); if (!value.is_object) throw std::runtime_error("Config theme must be an object");
for (const auto& [name, v] : value.object) { for (const auto& [name, v] : value.object) {
+2
View File
@@ -1,4 +1,5 @@
#pragma once #pragma once
#include "mount.hpp"
#include <array> #include <array>
#include <filesystem> #include <filesystem>
#include <map> #include <map>
@@ -17,6 +18,7 @@ struct Config {
std::string font; // absolute TTF/OTF path; empty uses the bundled face std::string font; // absolute TTF/OTF path; empty uses the bundled face
// Requests OpenVR's experimental global action priority; SteamVR must allow it too. // Requests OpenVR's experimental global action priority; SteamVR must allow it too.
bool experimental_input_priority = false; bool experimental_input_priority = false;
WristPlacement wrist;
// OpenVR action name -> physical Frame controller input path; empty disables it. // OpenVR action name -> physical Frame controller input path; empty disables it.
std::map<std::string, std::string> buttons; std::map<std::string, std::string> buttons;
}; };
+2 -2
View File
@@ -215,13 +215,13 @@ struct Overlay::Impl {
overlay_check(overlay->SetOverlayTransformAbsolute(handle, vr::TrackingUniverseStanding, &world_transform), overlay, overlay_check(overlay->SetOverlayTransformAbsolute(handle, vr::TrackingUniverseStanding, &world_transform), overlay,
"SetOverlayTransformAbsolute"); "SetOverlayTransformAbsolute");
} else { } else {
const auto pose = relative_mount_pose(effective); const auto pose = relative_mount_pose(effective, config.wrist);
vr::HmdMatrix34_t transform{}; 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]; 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, overlay_check(overlay->SetOverlayTransformTrackedDeviceRelative(handle, target, &transform), overlay,
"SetOverlayTransformTrackedDeviceRelative"); "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; applied_mount = effective;
anchor = target; anchor = target;
} }
+12
View File
@@ -28,10 +28,12 @@ int main(int argc, char** argv) {
::setenv("XDG_CONFIG_HOME", dir.c_str(), 1); ::setenv("XDG_CONFIG_HOME", dir.c_str(), 1);
assert(load_config(path).buttons.empty()); assert(load_config(path).buttons.empty());
assert(!load_config(path).experimental_input_priority); 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"); auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json");
assert(example.buttons.at("ptt") == "/user/hand/right/input/x"); assert(example.buttons.at("ptt") == "/user/hand/right/input/x");
assert(example.font.empty()); assert(example.font.empty());
assert(!example.experimental_input_priority); assert(!example.experimental_input_priority);
assert(example.wrist.y == .18f && example.wrist.z == .089f);
std::filesystem::create_directories(path.parent_path()); std::filesystem::create_directories(path.parent_path());
put(path, R"({"input_priority":"experimental"})"); put(path, R"({"input_priority":"experimental"})");
auto experimental = load_config(path); auto experimental = load_config(path);
@@ -45,6 +47,16 @@ int main(int argc, char** argv) {
put(path, invalid); put(path, invalid);
fails([&] { load_config(path); }); 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":""}})"); 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); auto config = load_config(path);
assert((config.theme.background == Rgba{0x12, 0x3a, 0xbc, 255})); assert((config.theme.background == Rgba{0x12, 0x3a, 0xbc, 255}));
+7 -1
View File
@@ -125,16 +125,18 @@ class InstallTests(unittest.TestCase):
self.assertEqual(fixed["theme"]["card"], installer.CONFIG_DEFAULTS["theme"]["card"]) self.assertEqual(fixed["theme"]["card"], installer.CONFIG_DEFAULTS["theme"]["card"])
self.assertEqual(fixed["buttons"]["cancel"], "") self.assertEqual(fixed["buttons"]["cancel"], "")
self.assertEqual(fixed["input_priority"], "normal") self.assertEqual(fixed["input_priority"], "normal")
self.assertEqual(fixed["wrist"], installer.CONFIG_DEFAULTS["wrist"])
backups = list(config.parent.glob("config.json.backup-*")) backups = list(config.parent.glob("config.json.backup-*"))
self.assertEqual(len(backups), 1) self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_bytes(), original) self.assertEqual(backups[0].read_bytes(), original)
fixed["input_priority"] = "experimental" fixed["input_priority"] = "experimental"
fixed["wrist"]["y"] = 0.2
compact = json.dumps(fixed, separators=(",", ":")).encode() compact = json.dumps(fixed, separators=(",", ":")).encode()
config.write_bytes(compact) config.write_bytes(compact)
self.install("v1", archive, digest) self.install("v1", archive, digest)
self.assertEqual(config.read_bytes(), compact) self.assertEqual(config.read_bytes(), compact)
self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1) 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) config.write_bytes(original)
self.install("v1", archive, digest) self.install("v1", archive, digest)
fixed = json.loads(config.read_text()) 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["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"])
self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset
self.assertEqual(fixed["input_priority"], "normal") 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.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])) 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"' original = b'{"font":"one","font":"two"'