mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 01:00:04 +02:00
Wire wrist placement settings through config and installer
This commit is contained in:
1 parent
569ba13830
commit
e61bf733f9
9 files changed
+80
-6
No files matched your search
@@ -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
@@ -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**.
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}));
|
||||||
|
|||||||
@@ -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"'
|
||||||
|
|||||||
Reference in new issue
Block a user