Persist per-mount wrist and head placement after drag

This commit is contained in:
baketnk committed 2026-09-26 23:41:13 -04:00
1 parent 8f4c1877f7
commit 4b0b29700d
6 files changed
+301 -23

No files matched your search

+4 -1
View File
@@ -58,7 +58,7 @@ target_compile_definitions(frameyap PRIVATE FRAMEYAP_VERSION="${FRAMEYAP_VERSION
FRAMEYAP_BUILD_ROOT="${CMAKE_CURRENT_BINARY_DIR}"
FRAMEYAP_SOURCE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}")
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_library(frameyap_mount src/mount.cpp src/laser_setting.cpp)
add_library(frameyap_mount src/mount.cpp src/laser_setting.cpp src/placement.cpp)
target_include_directories(frameyap_mount PUBLIC src)
add_library(frameyap_worker src/worker.cpp)
target_include_directories(frameyap_worker PUBLIC src)
@@ -167,6 +167,9 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
add_executable(frameyap_mount_test tests/mount_test.cpp)
target_link_libraries(frameyap_mount_test PRIVATE frameyap_mount)
add_test(NAME frameyap.mount COMMAND frameyap_mount_test)
add_executable(frameyap_placement_test tests/placement_test.cpp)
target_link_libraries(frameyap_placement_test PRIVATE frameyap_mount)
add_test(NAME frameyap.placement COMMAND frameyap_placement_test)
add_executable(frameyap_angle_fade_test tests/angle_fade_test.cpp)
target_include_directories(frameyap_angle_fade_test PRIVATE src)
target_compile_options(frameyap_angle_fade_test PRIVATE -UNDEBUG)
+18 -9
View File
@@ -359,8 +359,12 @@ panel with the controller, including depth, pitch, yaw and roll. Grab captures
`inverse(controller_down) * panel_down` and applies that unchanged relative pose
to each controller pose, so grabbing does not snap or reset the panel orientation.
Release leaves the last pose in the chosen mount frame; head/wrist mounts continue
following that anchor afterward. Mount changes/recenter reset the pose, while
retaining the size factor. Restart restores configured position/size defaults.
following that anchor afterward. A completed grab or scale on Head, Left wrist or
Right wrist saves the full device-relative canvas position, rotation and scale
separately for that mount. Mount changes restore each saved relative placement;
restarting does too. A cancelled drag does not update the saved placement.
World-space placement is resampled on startup/recenter rather than restored
against a possibly different tracking origin.
Drag the corner bracket to scale between half and twice the configured width.
Scaling freezes its initial plane and calibrates a ray from the source controller
@@ -402,9 +406,10 @@ fade feel and interaction at the threshold still need live acceptance.
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.
(-180 to 180) before applying the offset. These settings are initial defaults
for both wrists; a saved per-wrist pose takes precedence. They 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.
Use Settings → **Wrist world fallback** to switch between the default
@@ -416,10 +421,14 @@ pointer controls cannot be used.
The saved mount preference is not replaced by the fallback. These offsets and sizes are
initial choices, **not headset-comfort acceptance**.
A selection saves only the mount token to `$XDG_CONFIG_HOME/frameyap/mount`
(or `$HOME/.config/frameyap/mount`), using an atomic replacement. No pose, audio
or transcript is saved. Missing/invalid settings default to world; write failure
keeps the selection for the session and displays a warning. `--mount
A selection saves the mount token to `$XDG_CONFIG_HOME/frameyap/mount`
(or `$HOME/.config/frameyap/mount`). Completed relative adjustments save to
`placement-left-wrist`, `placement-right-wrist`, or `placement-head` beside that
file. Remove one placement file while FrameYap is closed to restore that mount's
default. These are bounded, versioned device-relative transforms with owner-only
atomic writes; invalid files are ignored. World-space poses, audio and transcripts
are not saved. Missing/invalid mount settings default to World; write failure
keeps the placement for the session and displays a warning. `--mount
world|left-wrist|right-wrist|head` overrides the saved choice for one launch without
writing it; `--head` remains an alias for `--mount head`.
+42 -13
View File
@@ -3,6 +3,7 @@
#include "angle_fade.hpp"
#include "gestures.hpp"
#include "laser_setting.hpp"
#include "placement.hpp"
#include "panel_surface.hpp"
#include <openvr.h>
@@ -85,6 +86,7 @@ struct Overlay::Impl {
std::filesystem::path settings_path, laser_settings_path;
bool save_failed = false, debug_save_failed = false, auto_save_failed = false;
bool layout_save_failed = false, mic_save_failed = false, laser_change_failed = false;
bool placement_save_failed = false;
bool persist_mount = true;
} persistence;
Mount mount;
@@ -100,6 +102,7 @@ struct Overlay::Impl {
float size_scale = 1.f;
bool placement_dirty = false;
Matrix34 canvas_pose{};
std::array<std::optional<RelativePlacement>, 4> saved_placements{};
struct DragState {
Matrix34 canvas{};
PanelDrag panel;
@@ -136,6 +139,9 @@ struct Overlay::Impl {
config(load_config(default_config_path())),
surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme, config.gradient) {
persistence.persist_mount = persist;
for (auto selected : {Mount::LeftWrist, Mount::RightWrist, Mount::Head})
saved_placements[static_cast<size_t>(selected)] =
load_relative_placement(default_placement_path(selected), selected);
const auto action_path = absolute_file(action_manifest(assets, config));
absolute_file(std::filesystem::path(assets) / "bindings_knuckles.json");
try {
@@ -273,7 +279,8 @@ struct Overlay::Impl {
const Mount effective = *selected;
if (effective == Mount::World && applied_mount && *applied_mount != Mount::World)
world_ready = false; // a fresh world fallback near the wearer, not an old room location
std::string note = persistence.mic_save_failed ? "Mic preference not saved; using it only for this session." :
std::string note = persistence.placement_save_failed ? "Placement not saved; using it only for this session." :
persistence.mic_save_failed ? "Mic preference not saved; using it only for this session." :
persistence.layout_save_failed ? "Layout lock not saved; using it only for this session." :
persistence.auto_save_failed ? "Auto insert preference not saved; using it only for this session." :
persistence.debug_save_failed ? "Debug preference not saved; using it only for this session." :
@@ -302,14 +309,22 @@ struct Overlay::Impl {
const float base_width = mount_width(effective, config.wrist);
if (relocated) {
surface.reset_pointers(); dragging.panel.reset();
auto pose = effective == Mount::World ? matrix(world_transform) : relative_mount_pose(effective, config.wrist);
pose = resized_mount_pose(pose, base_width, size_scale, float(CH) / CW);
// Preserve main-panel dimensions when adding transparent margins.
const float meters_per_pixel = base_width * size_scale / CW;
const float dx = (W - CW) * .5f * meters_per_pixel;
const float dy = -(H - CH) * .5f * meters_per_pixel;
for (int r = 0; r < 3; ++r) pose[r][3] += pose[r][0] * dx + pose[r][1] * dy;
canvas_pose = pose;
const auto& remembered = saved_placements[static_cast<size_t>(effective)];
if (effective != Mount::World && remembered) {
// Saved canvas coordinates already include transparent margins.
size_scale = remembered->scale;
canvas_pose = remembered->canvas_pose;
} else {
if (effective != Mount::World) size_scale = 1.f;
auto pose = effective == Mount::World ? matrix(world_transform) : relative_mount_pose(effective, config.wrist);
pose = resized_mount_pose(pose, base_width, size_scale, float(CH) / CW);
// Preserve main-panel dimensions when adding transparent margins.
const float meters_per_pixel = base_width * size_scale / CW;
const float dx = (W - CW) * .5f * meters_per_pixel;
const float dy = -(H - CH) * .5f * meters_per_pixel;
for (int r = 0; r < 3; ++r) pose[r][3] += pose[r][0] * dx + pose[r][1] * dy;
canvas_pose = pose;
}
}
// After a grab, keep the full released pose. Never rebuild it from
// a planar offset or configured orientation on the next poll.
@@ -369,6 +384,18 @@ struct Overlay::Impl {
diagnostics.last_pointer_event = std::string(kind == PanelDragKind::Grab ? "grab" : "scale") +
" device=" + std::to_string(dragging.device) + " trigger-watch=" + (dragging.trigger_observed ? "Y" : "N");
}
void finish_drag(bool released) {
if (released && applied_mount && *applied_mount == mount && mount != Mount::World &&
(size_scale != dragging.scale || canvas_pose != dragging.canvas)) {
const RelativePlacement current{canvas_pose, size_scale};
if (valid_relative_placement(current)) {
saved_placements[static_cast<size_t>(mount)] = current;
persistence.placement_save_failed = persistence.persist_mount &&
!save_relative_placement(default_placement_path(mount), mount, current);
} else persistence.placement_save_failed = true;
}
surface.reset_pointers(); dragging.panel.reset();
}
void update_drag() {
if (!dragging.panel.active()) return;
const auto source = drag_source();
@@ -379,12 +406,14 @@ struct Overlay::Impl {
// Without a readable release watchdog, never keep manipulating after
// the pointer leaves our hit region: an outside MouseUp is not assured.
const bool lost_unwatched_pointer = !dragging.trigger_observed && !overlay->IsHoverTargetOverlay(handle);
if (!surface.dragging(dragging.cursor) || !shown || !focus || !source || trigger_released || lost_unwatched_pointer ||
std::chrono::steady_clock::now() - dragging.started > std::chrono::seconds(15)) {
surface.reset_pointers(); dragging.panel.reset(); return;
const bool timed_out = std::chrono::steady_clock::now() - dragging.started > std::chrono::seconds(15);
if (!surface.dragging(dragging.cursor) || !shown || !focus || !source || trigger_released || lost_unwatched_pointer || timed_out) {
finish_drag((!surface.dragging(dragging.cursor) || trigger_released) &&
shown && focus && bool(source) && !lost_unwatched_pointer && !timed_out);
return;
}
const auto change = dragging.panel.update(*source);
if (!change) { surface.reset_pointers(); dragging.panel.reset(); return; }
if (!change) { finish_drag(false); return; }
const float scale = dragging.kind == PanelDragKind::Scale ? std::clamp(dragging.scale * change->factor, .5f, 2.f) : size_scale;
const auto pose = dragging.kind == PanelDragKind::Grab ? change->pose :
resized_mount_pose(dragging.canvas, mount_width(*applied_mount, config.wrist) * dragging.scale * W / CW,
+115
View File
@@ -0,0 +1,115 @@
#include "placement.hpp"
#include "panel_drag.hpp"
#include <atomic>
#include <cerrno>
#include <cmath>
#include <fstream>
#include <iomanip>
#include <limits>
#include <locale>
#include <sstream>
#include <string>
#include <fcntl.h>
#include <unistd.h>
namespace frameyap {
namespace {
constexpr std::size_t max_placement_bytes = 512;
std::atomic<unsigned long> next_temp{0};
bool relative_mount(Mount mount) {
return mount == Mount::LeftWrist || mount == Mount::RightWrist || mount == Mount::Head;
}
} // namespace
std::filesystem::path default_placement_path(Mount mount) {
if (!relative_mount(mount)) return {};
const auto preference = default_mount_settings_path();
if (preference.empty()) return {};
return preference.parent_path() / ("placement-" + std::string(mount_name(mount)));
}
bool valid_relative_placement(const RelativePlacement& placement) {
if (!panel_drag_detail::rigid(placement.canvas_pose) ||
!std::isfinite(placement.scale) || placement.scale < .5f || placement.scale > 2.f) return false;
for (int row = 0; row < 3; ++row)
if (std::abs(placement.canvas_pose[row][3]) > 5.f) return false;
return true;
}
std::optional<RelativePlacement> load_relative_placement(const std::filesystem::path& path, Mount mount) {
try {
if (!relative_mount(mount) || path.empty() || std::filesystem::is_symlink(path) ||
!std::filesystem::is_regular_file(path)) return {};
std::ifstream file(path, std::ios::binary);
if (!file) return {};
char bytes[max_placement_bytes + 1];
file.read(bytes, sizeof(bytes));
const auto count = file.gcount();
if (count <= 0 || count > static_cast<std::streamsize>(max_placement_bytes)) return {};
std::istringstream input(std::string(bytes, static_cast<std::size_t>(count)));
input.imbue(std::locale::classic());
std::string magic, name;
RelativePlacement placement{};
if (!(input >> magic >> name >> placement.scale) || magic != "frameyap-placement-v1" ||
name != mount_name(mount)) return {};
for (auto& row : placement.canvas_pose)
for (auto& value : row)
if (!(input >> value)) return {};
input >> std::ws;
if (!input.eof() || !valid_relative_placement(placement)) return {};
return placement;
} catch (...) { return {}; }
}
bool save_relative_placement(const std::filesystem::path& path, Mount mount,
const RelativePlacement& placement) {
if (!relative_mount(mount) || path.empty() || !path.is_absolute() ||
!valid_relative_placement(placement)) return false;
std::filesystem::path temporary;
int fd = -1;
try {
const auto dir = path.parent_path();
if (std::filesystem::is_symlink(dir) || std::filesystem::is_symlink(path) ||
(std::filesystem::exists(path) && !std::filesystem::is_regular_file(path))) return false;
std::ostringstream out;
out.imbue(std::locale::classic());
out << "frameyap-placement-v1 " << mount_name(mount) << ' ' <<
std::setprecision(std::numeric_limits<float>::max_digits10) << placement.scale;
for (const auto& row : placement.canvas_pose)
for (float value : row) out << ' ' << value;
out << '\n';
const auto content = out.str();
if (content.size() > max_placement_bytes) return false;
std::filesystem::create_directories(dir);
if (std::filesystem::is_symlink(dir)) return false;
for (int attempt = 0; attempt < 16; ++attempt) {
temporary = dir / (path.filename().string() + ".tmp." + std::to_string(::getpid()) +
"." + std::to_string(next_temp.fetch_add(1)));
fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
if (fd >= 0) break;
if (errno != EEXIST) return false;
}
if (fd < 0) return false;
std::size_t written = 0;
while (written < content.size()) {
const auto n = ::write(fd, content.data() + written, content.size() - written);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) { ::close(fd); fd = -1; std::filesystem::remove(temporary); return false; }
written += static_cast<std::size_t>(n);
}
const bool synced = ::fsync(fd) == 0;
const bool closed = ::close(fd) == 0;
fd = -1;
if (!synced || !closed) { std::filesystem::remove(temporary); return false; }
std::filesystem::rename(temporary, path);
return true;
} catch (...) {
if (fd >= 0) ::close(fd);
std::error_code ignored;
if (!temporary.empty()) std::filesystem::remove(temporary, ignored);
return false;
}
}
} // namespace frameyap
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "mount.hpp"
#include <filesystem>
#include <optional>
namespace frameyap {
// The exact OpenVR canvas transform in the selected tracked device's local
// coordinate frame, including the transparent grab/scale margins.
struct RelativePlacement {
Matrix34 canvas_pose;
float scale = 1.f;
};
// World-space placement is deliberately not persisted across tracking origins.
std::filesystem::path default_placement_path(Mount mount);
bool valid_relative_placement(const RelativePlacement& placement);
std::optional<RelativePlacement> load_relative_placement(const std::filesystem::path& path, Mount mount);
// Atomic owner-only write; does not follow a symlink at path or its parent.
bool save_relative_placement(const std::filesystem::path& path, Mount mount,
const RelativePlacement& placement);
} // namespace frameyap
+100
View File
@@ -0,0 +1,100 @@
#include "placement.hpp"
#include "panel_drag.hpp"
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <limits>
#include <string>
#include <unistd.h>
using namespace frameyap;
namespace fs = std::filesystem;
#define CHECK(x) do { if (!(x)) { std::cerr << "line " << __LINE__ << ": " #x "\n"; std::exit(1); } } while (0)
namespace {
constexpr Matrix34 identity{{{{1.f, 0.f, 0.f, 0.f}},
{{0.f, 1.f, 0.f, 0.f}},
{{0.f, 0.f, 1.f, 0.f}}}};
void put(const fs::path& path, const std::string& bytes) {
std::ofstream file(path, std::ios::binary | std::ios::trunc);
CHECK(bool(file)); file << bytes; CHECK(bool(file));
}
std::string get(const fs::path& path) {
std::ifstream file(path, std::ios::binary);
return {std::istreambuf_iterator<char>(file), {}};
}
bool near(float a, float b) { return std::abs(a - b) < 1e-6f; }
} // namespace
int main() {
std::string pattern = (fs::temp_directory_path() / "frameyap-placement-test-XXXXXX").string();
CHECK(::mkdtemp(pattern.data()) != nullptr);
const fs::path dir = pattern;
::setenv("XDG_CONFIG_HOME", dir.c_str(), 1);
const auto left = default_placement_path(Mount::LeftWrist);
const auto right = default_placement_path(Mount::RightWrist);
const auto head = default_placement_path(Mount::Head);
CHECK(left == dir / "frameyap/placement-left-wrist");
CHECK(right == dir / "frameyap/placement-right-wrist");
CHECK(head == dir / "frameyap/placement-head");
CHECK(default_placement_path(Mount::World).empty());
CHECK(!load_relative_placement(left, Mount::LeftWrist));
RelativePlacement placed{relative_mount_pose(Mount::LeftWrist), 1.37f};
// A freely rotated canvas with both depth and lateral offsets, rather than
// only the editable roll angle in config.json.
const auto turn = relative_mount_pose(Mount::RightWrist);
placed.canvas_pose = compose_pose(placed.canvas_pose, turn);
placed.canvas_pose[0][3] += .31f;
placed.canvas_pose[1][3] -= .14f;
placed.canvas_pose[2][3] += .22f;
CHECK(valid_relative_placement(placed));
CHECK(save_relative_placement(left, Mount::LeftWrist, placed));
CHECK((fs::status(left).permissions() & fs::perms::group_read) == fs::perms::none);
auto restored = load_relative_placement(left, Mount::LeftWrist);
CHECK(restored && near(restored->scale, placed.scale));
for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c)
CHECK(near(restored->canvas_pose[r][c], placed.canvas_pose[r][c]));
CHECK(!load_relative_placement(left, Mount::RightWrist));
CHECK(!load_relative_placement(right, Mount::RightWrist));
CHECK(save_relative_placement(right, Mount::RightWrist, RelativePlacement{relative_mount_pose(Mount::RightWrist), .7f}));
CHECK(load_relative_placement(right, Mount::RightWrist)->scale == .7f);
CHECK(load_relative_placement(left, Mount::LeftWrist)->scale == placed.scale);
CHECK(save_relative_placement(head, Mount::Head, RelativePlacement{relative_mount_pose(Mount::Head), 2.f}));
CHECK(load_relative_placement(head, Mount::Head)->scale == 2.f);
CHECK(!save_relative_placement(left, Mount::World, placed));
const auto good = get(left);
for (const auto& bad : {std::string("frameyap-placement-v2 left-wrist 1\n"),
good + "junk", std::string(513, 'x')}) {
put(left, bad);
CHECK(!load_relative_placement(left, Mount::LeftWrist));
}
put(left, good);
auto invalid = placed;
invalid.scale = 0.f;
CHECK(!save_relative_placement(left, Mount::LeftWrist, invalid));
invalid = placed; invalid.scale = std::numeric_limits<float>::quiet_NaN();
CHECK(!save_relative_placement(left, Mount::LeftWrist, invalid));
invalid = placed; invalid.canvas_pose[0][0] *= 1.1f;
CHECK(!save_relative_placement(left, Mount::LeftWrist, invalid));
invalid = placed; invalid.canvas_pose[2][3] = 6.f;
CHECK(!save_relative_placement(left, Mount::LeftWrist, invalid));
CHECK(get(left) == good); // invalid writes cannot corrupt a prior placement
const auto linked = dir / "linked-placement";
fs::create_symlink(left, linked);
CHECK(!load_relative_placement(linked, Mount::LeftWrist));
CHECK(!save_relative_placement(linked, Mount::LeftWrist, placed));
CHECK(get(left) == good);
fs::create_directory(dir / "directory-placement");
CHECK(!save_relative_placement(dir / "directory-placement", Mount::Head, placed));
fs::create_directory_symlink(left.parent_path(), dir / "linked-directory");
CHECK(!save_relative_placement(dir / "linked-directory/placement-left-wrist", Mount::LeftWrist, placed));
fs::remove_all(dir);
std::cout << "relative placement persistence checks passed\n";
}