From 4b0b29700d3fe645918843a33d6cb33186abad4c Mon Sep 17 00:00:00 2001 From: baketnk Date: Sat, 26 Sep 2026 23:41:13 -0400 Subject: [PATCH] Persist per-mount wrist and head placement after drag --- CMakeLists.txt | 5 +- docs/overlay.md | 27 ++++++--- src/overlay.cpp | 55 ++++++++++++++----- src/placement.cpp | 115 +++++++++++++++++++++++++++++++++++++++ src/placement.hpp | 22 ++++++++ tests/placement_test.cpp | 100 ++++++++++++++++++++++++++++++++++ 6 files changed, 301 insertions(+), 23 deletions(-) create mode 100644 src/placement.cpp create mode 100644 src/placement.hpp create mode 100644 tests/placement_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index df1c6bd..fefb898 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/docs/overlay.md b/docs/overlay.md index c9eb668..f8e7f46 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -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`. diff --git a/src/overlay.cpp b/src/overlay.cpp index b343a62..7bee84f 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -3,6 +3,7 @@ #include "angle_fade.hpp" #include "gestures.hpp" #include "laser_setting.hpp" +#include "placement.hpp" #include "panel_surface.hpp" #include @@ -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, 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(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(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(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, diff --git a/src/placement.cpp b/src/placement.cpp new file mode 100644 index 0000000..89bac31 --- /dev/null +++ b/src/placement.cpp @@ -0,0 +1,115 @@ +#include "placement.hpp" +#include "panel_drag.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace frameyap { +namespace { +constexpr std::size_t max_placement_bytes = 512; +std::atomic 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 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(max_placement_bytes)) return {}; + std::istringstream input(std::string(bytes, static_cast(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::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(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 diff --git a/src/placement.hpp b/src/placement.hpp new file mode 100644 index 0000000..f435227 --- /dev/null +++ b/src/placement.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "mount.hpp" +#include +#include + +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 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 diff --git a/tests/placement_test.cpp b/tests/placement_test.cpp new file mode 100644 index 0000000..70298bc --- /dev/null +++ b/tests/placement_test.cpp @@ -0,0 +1,100 @@ +#include "placement.hpp" +#include "panel_drag.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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::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"; +}