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": "",
"input_priority": "normal",
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
"theme": {
"background": "#0c101b",
"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",
"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**.
+8
View File
@@ -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 {}
+8
View File
@@ -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 {}
+23 -1
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <fstream>
#include <stdexcept>
@@ -12,7 +13,7 @@ namespace {
struct Json {
std::string value;
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 {
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) {
+2
View File
@@ -1,4 +1,5 @@
#pragma once
#include "mount.hpp"
#include <array>
#include <filesystem>
#include <map>
@@ -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<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,
"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;
}
+12
View File
@@ -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}));
+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["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"'