From 0d4cfbfdb74aaaa8e56be844122ac51eeb8fe365 Mon Sep 17 00:00:00 2001 From: baketnk Date: Thu, 24 Sep 2026 17:01:33 -0400 Subject: [PATCH] Add rigid controller grab and layout lock; fix scale hit mask --- README.md | 10 ++- assets/config.example.json | 1 + docs/overlay.md | 44 +++++++--- install.sh | 3 + scripts/install_payload.py | 3 + src/config.cpp | 6 ++ src/config.hpp | 2 + src/overlay.cpp | 101 +++++++++++---------- src/panel_drag.hpp | 68 ++++++++++---- src/panel_surface.cpp | 62 ++++++++----- src/panel_surface.hpp | 4 + tests/config_test.cpp | 27 ++++++ tests/panel_drag_test.cpp | 175 ++++++++++++++++++++++++------------- tests/panel_test.cpp | 25 +++++- tests/test_installer.py | 18 ++++ 15 files changed, 387 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index ef42e99..75658db 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,12 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet - **Overlay:** Record/Stop, Cancel, paginated preview, Insert, Enter and Quit. Review and settings share one Inconsolata/neon-framed surface. The header shows local time/date; Settings selects 12/24-hour time and date format/off. - Drag the thin bar below the panel to move it in its plane; drag the external - lower-right corner bracket to scale it (world, head or wrist). Both sit in - transparent margins, like Steam's window handles. Scaling keeps the upper-left - corner anchored. Position/size changes last for this run only. + Hold the thin bar below the panel to freely move and rotate it with your + controller; release to leave it at that pose. Drag the external lower-right + bracket to scale it (world, head or wrist), keeping the upper-left anchored. + Both handles use the app's gradient in transparent margins, like Steam's + window handles. Settings → **Lock grab/scale** hides and disables both handles; + the lock is saved. Position/size changes last for this run only. Wrist mounting fades the panel as its full orientation turns away from an upright viewer-facing pose (60°–75°), hiding interaction past that angle; world and head mounting do not fade. diff --git a/assets/config.example.json b/assets/config.example.json index fb8520b..a98b658 100644 --- a/assets/config.example.json +++ b/assets/config.example.json @@ -3,6 +3,7 @@ "input_priority": "normal", "advanced_debug": false, "auto_insert": false, + "lock_layout": false, "clock_24h": false, "date_format": "mdy", "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, diff --git a/docs/overlay.md b/docs/overlay.md index 22569d6..41ce0c2 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -105,6 +105,7 @@ installer creates one with defaults on first install. Copy the shipped "input_priority": "normal", "advanced_debug": false, "auto_insert": false, + "lock_layout": false, "clock_24h": false, "date_format": "mdy", "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, @@ -223,19 +224,32 @@ lower-right corner, visually modeled on the user's Steam terminal-window screens These are original FrameYap controls, not Steam private UI components. RGBA alpha leaves their surrounding area transparent; an explicit OpenVR intersection mask excludes empty margins from laser hit testing (the slender strokes have larger hit -targets). The extra canvas area does not shrink the main panel's physical width. +targets). Both handles use the configured mint-to-blue frame gradient. The extra +canvas area does not shrink the main panel's physical width. Intersection rectangles +use top-left pixel coordinates; only mouse events need the bottom-left GL Y flip. +The first deployed pass incorrectly flipped the mask, leaving the scale corner +outside its input region; this version corrects that mapping. -Hold the laser's primary click on the bar and move to translate the panel **in its -plane**; this does not change depth or orientation. Drag the corner bracket to scale -between half and twice the configured width. The original upper-left corner remains -fixed relative to the chosen mount. Both work on either tab and all mounts. Movement -is bounded to two meters per axis; mount changes/recenter clear movement, while mount -changes retain the size factor. Restart restores configured position/size defaults. +Hold the laser's primary click on the bar to **freely position and rotate** the +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. -A drag freezes its initial plane and calibrates a ray from the source controller to -the initial hit. Subsequent tracked poses determine translation/scale, even beyond -the original texture bounds; changing overlay coordinates never feed back into the -drag. For head/wrist mounts, geometry stays in the anchor-device coordinate frame. +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 +to the initial hit. The top-left corner of the current, potentially rotated panel +stays fixed, including after repeated grab/scale operations. Changing overlay +coordinates never feed back into the calculation. Both controls work on either tab; +for head/wrist mounts, geometry stays in the anchor-device coordinate frame. + +Settings → **Lock grab/scale** (`"lock_layout": true`, default false) hides both +handles and removes their hit regions, cancels an active drag and prevents new +manipulation. Unlock remains accessible in Settings. The boolean is saved across +restarts/upgrades; failed saves leave a visible session-only warning. It does not +disable explicit mount/recenter choices or freeze normal head/wrist tracking. The event's controller is used, with the primary dashboard device as the single-cursor fallback when the event omits it. No guessed controller or desktop pointer fallback. Release, changed UI authorization, tracking loss, hidden overlay, relocation, invalid @@ -304,9 +318,11 @@ coexistence remains unverified. ### Hardware-free UI checks The default build tests mount parsing, laser preference persistence, pose geometry -and captured-ray grab/scale math without any native dependencies. Drag tests cover -stationary stability, independent axes, rotated/relative mounts, out-of-bounds hits, -invalid rays and no feedback from prior updates. A FreeType-only opt-in build exercises the actual renderer, +and controller-relative grab/captured-ray scale math without any native dependencies. +Drag tests cover XYZ translation, pitch/yaw/roll and lever-arm rotation, re-grabbing +a moved panel, stationary stability, relative mounts, out-of-bounds scale hits, +invalid poses/rays and no feedback from prior updates. UI/config/installer tests +cover hidden/disabled handles, top-left mask regions, lock persistence and repair. A FreeType-only opt-in build exercises the actual renderer, pointer gating, tab switches, pagination, recording state and redraw invalidation: ```sh diff --git a/install.sh b/install.sh index 8b4b87a..287633c 100755 --- a/install.sh +++ b/install.sh @@ -36,6 +36,7 @@ CONFIG_DEFAULTS = { "input_priority": "normal", "advanced_debug": False, "auto_insert": False, + "lock_layout": False, "clock_24h": False, "date_format": "mdy", "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, @@ -100,6 +101,8 @@ def normalized_config(data): fixed["advanced_debug"] = debug if type(debug) is bool else False automatic = data.get("auto_insert", False) fixed["auto_insert"] = automatic if type(automatic) is bool else False + layout = data.get("lock_layout", False) + fixed["lock_layout"] = layout if type(layout) is bool else False clock = data.get("clock_24h", False) fixed["clock_24h"] = clock if type(clock) is bool else False date = data.get("date_format", "mdy") diff --git a/scripts/install_payload.py b/scripts/install_payload.py index 2b2fcf0..390a60d 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -25,6 +25,7 @@ CONFIG_DEFAULTS = { "input_priority": "normal", "advanced_debug": False, "auto_insert": False, + "lock_layout": False, "clock_24h": False, "date_format": "mdy", "wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0}, @@ -89,6 +90,8 @@ def normalized_config(data): fixed["advanced_debug"] = debug if type(debug) is bool else False automatic = data.get("auto_insert", False) fixed["auto_insert"] = automatic if type(automatic) is bool else False + layout = data.get("lock_layout", False) + fixed["lock_layout"] = layout if type(layout) is bool else False clock = data.get("clock_24h", False) fixed["clock_24h"] = clock if type(clock) is bool else False date = data.get("date_format", "mdy") diff --git a/src/config.cpp b/src/config.cpp index e4f1103..14274b3 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -200,6 +200,9 @@ Config load_config(const std::filesystem::path& path) { } else if (key == "auto_insert") { if (!value.is_bool) throw std::runtime_error("Config auto_insert must be a boolean"); config.auto_insert = value.value == "true"; + } else if (key == "lock_layout") { + if (!value.is_bool) throw std::runtime_error("Config lock_layout must be a boolean"); + config.lock_layout = value.value == "true"; } else if (key == "clock_24h") { if (!value.is_bool) throw std::runtime_error("Config clock_24h must be a boolean"); config.clock_24h = value.value == "true"; @@ -306,6 +309,9 @@ bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexce bool save_auto_insert(const std::filesystem::path& path, bool enabled) noexcept { return save_bool_option(path, "auto_insert", enabled); } +bool save_lock_layout(const std::filesystem::path& path, bool enabled) noexcept { + return save_bool_option(path, "lock_layout", enabled); +} bool save_clock_24h(const std::filesystem::path& path, bool enabled) noexcept { return save_bool_option(path, "clock_24h", enabled); } diff --git a/src/config.hpp b/src/config.hpp index 8cd6022..202f78e 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -21,6 +21,7 @@ struct Config { bool experimental_input_priority = false; bool advanced_debug = false; // opt-in full diagnostic logging; never raw audio recording bool auto_insert = false; // opt-in; runtime also requires uninterrupted verified Xwayland focus + bool lock_layout = false; // hide the grab and scale handles when enabled bool clock_24h = false; DateFormat date_format = DateFormat::MonthDayYear; WristPlacement wrist; @@ -33,6 +34,7 @@ Config load_config(const std::filesystem::path& path); // Other user customizations and formatting are retained; creates a minimal config if absent. bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexcept; bool save_auto_insert(const std::filesystem::path& path, bool enabled) noexcept; +bool save_lock_layout(const std::filesystem::path& path, bool enabled) noexcept; bool save_clock_24h(const std::filesystem::path& path, bool enabled) noexcept; bool save_date_format(const std::filesystem::path& path, DateFormat format) noexcept; std::string resolve_font(const std::string& assets, const std::string& requested); diff --git a/src/overlay.cpp b/src/overlay.cpp index e2f7be2..a5271e0 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -43,13 +43,6 @@ Matrix34 matrix(const vr::HmdMatrix34_t& value) { for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) result[r][c] = value.m[r][c]; return result; } -Matrix34 relative_to(const Matrix34& pose, const Matrix34& parent) { - Matrix34 result{}; // inverse rigid parent * pose - for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) - for (int k = 0; k < 3; ++k) - result[r][c] += parent[k][r] * (pose[k][c] - (c == 3 ? parent[k][3] : 0.f)); - return result; -} constexpr std::array action_names{{"left_grip", "right_grip", "ptt", "cancel", "insert", "enter"}}; void overlay_check(vr::EVROverlayError err, vr::IVROverlay* api, const char* op) { if (err != vr::VROverlayError_None) @@ -90,19 +83,18 @@ struct Overlay::Impl { Config config; PanelSurface surface; Panel panel; - bool save_failed = false, debug_save_failed = false, auto_save_failed = false; + bool save_failed = false, debug_save_failed = false, auto_save_failed = false, layout_save_failed = false; bool world_ready = false, placed = false, has_texture = false, shown = false; float published_alpha = -1.f; float size_scale = 1.f; - bool size_changed = false; - float move_x = 0.f, move_y = 0.f; - Matrix34 canvas_pose{}; + bool placement_dirty = false; + Matrix34 canvas_pose{}, drag_canvas{}; PanelDrag drag; PanelDragKind drag_kind = PanelDragKind::Grab; unsigned drag_cursor = 0; vr::TrackedDeviceIndex_t drag_device = vr::k_unTrackedDeviceIndexInvalid; bool drag_trigger_observed = false; - float drag_scale = 1.f, drag_x = 0.f, drag_y = 0.f; + float drag_scale = 1.f; std::chrono::steady_clock::time_point drag_started{}; vr::HmdMatrix34_t world_transform{}; std::optional applied_mount; @@ -180,21 +172,13 @@ struct Overlay::Impl { surface.set_lasers_anytime(lasers_anytime); surface.set_advanced_debug(config.advanced_debug); surface.set_auto_insert(config.auto_insert); + surface.set_layout_locked(config.lock_layout); surface.set_clock_24h(config.clock_24h); surface.set_date_format(config.date_format); overlay_check(overlay->SetOverlayFlag(handle, vr::VROverlayFlags_VisibleInDashboard, true), overlay, "VisibleInDashboard"); vr::HmdVector2_t mouse_scale{{float(W), float(H)}}; overlay_check(overlay->SetOverlayMouseScale(handle, &mouse_scale), overlay, "SetOverlayMouseScale"); - // Alpha is visual, not an input mask. Exclude the empty margins from - // laser intersection rather than blocking neighboring windows there. - std::array mask{}; - const std::array regions{{{10, 10, CW - 20, CH - 20}, PanelSurface::grab, PanelSurface::scale}}; - for (size_t i = 0; i < mask.size(); ++i) { - const auto b = regions[i]; - mask[i].m_nPrimitiveType = vr::OverlayIntersectionPrimitiveType_Rectangle; - mask[i].m_Primitive.m_Rectangle = {float(b.x), float(H - b.y - b.h), float(b.w), float(b.h)}; - } - overlay_check(overlay->SetOverlayIntersectionMask(handle, mask.data(), uint32_t(mask.size())), overlay, "SetOverlayIntersectionMask"); + update_intersection_mask(); system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses.data(), uint32_t(poses.size())); place(); draw(Panel{"Disabled", "", "Record to start local worker", false, false}); @@ -202,6 +186,19 @@ struct Overlay::Impl { } catch (...) { cleanup(); throw; } } ~Impl() { cleanup(); } + void update_intersection_mask() { + const auto regions = surface.input_regions(); + std::vector mask(regions.size()); + for (size_t i = 0; i < mask.size(); ++i) { + const auto b = regions[i]; + mask[i].m_nPrimitiveType = vr::OverlayIntersectionPrimitiveType_Rectangle; + // Mask rectangles are TOP-left pixel coordinates. Mouse events are + // bottom-left GL coordinates; flipping this mask makes the outside + // scale corner unclickable while the body happens to cover Grab. + mask[i].m_Primitive.m_Rectangle = {float(b.x), float(b.y), float(b.w), float(b.h)}; + } + overlay_check(overlay->SetOverlayIntersectionMask(handle, mask.data(), uint32_t(mask.size())), overlay, "SetOverlayIntersectionMask"); + } void cleanup() { if (overlay && handle != vr::k_ulOverlayHandleInvalid) { overlay->HideOverlay(handle); @@ -245,7 +242,8 @@ struct Overlay::Impl { } 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 = auto_save_failed ? "Auto insert preference not saved; using it only for this session." : + std::string note = layout_save_failed ? "Layout lock not saved; using it only for this session." : + auto_save_failed ? "Auto insert preference not saved; using it only for this session." : debug_save_failed ? "Debug preference not saved; using it only for this session." : laser_change_failed ? "SteamVR declined the laser mode change." : save_failed ? "Preference could not be saved; using it for this session." : ""; @@ -267,23 +265,24 @@ struct Overlay::Impl { applied_mount.reset(); } surface.set_placement_note(note); - if (applied_mount != effective || (effective != Mount::World && anchor != target) || size_changed) { - if (applied_mount != effective || anchor != target) { - surface.reset_pointers(); drag.reset(); - move_x = move_y = 0.f; - } + const bool relocated = applied_mount != effective || anchor != target; + if (relocated || placement_dirty) { const float base_width = mount_width(effective, config.wrist); - 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); - // Keep the original content width/center; transparent right/bottom - // margins extend the canvas, not the main panel's physical size. - const float meters_per_pixel = base_width * size_scale / CW; - const float dx = move_x + (W - CW) * .5f * meters_per_pixel; - const float dy = move_y - (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; + if (relocated) { + surface.reset_pointers(); drag.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; + } + // After a grab, keep the full released pose. Never rebuild it from + // a planar offset or configured orientation on the next poll. 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] = canvas_pose[r][c]; if (effective == Mount::World) overlay_check(overlay->SetOverlayTransformAbsolute(handle, vr::TrackingUniverseStanding, &transform), overlay, "SetOverlayTransformAbsolute"); @@ -293,7 +292,7 @@ struct Overlay::Impl { overlay_check(overlay->SetOverlayWidthInMeters(handle, base_width * size_scale * W / CW), overlay, "SetOverlayWidthInMeters"); applied_mount = effective; anchor = target; - size_changed = false; + placement_dirty = false; } placed = true; visibility(); @@ -306,11 +305,12 @@ struct Overlay::Impl { auto pose = matrix(poses[drag_device].mDeviceToAbsoluteTracking); if (*applied_mount != Mount::World) { if (!tracked(anchor)) return {}; - pose = relative_to(pose, matrix(poses[anchor].mDeviceToAbsoluteTracking)); + pose = relative_pose(matrix(poses[anchor].mDeviceToAbsoluteTracking), pose); } return pose; } void begin_drag(PanelDragKind kind, const vr::VREvent_t& event) { + if (config.lock_layout) { surface.reset_pointers(); drag.reset(); return; } drag_device = event.trackedDeviceIndex; drag_cursor = event.data.mouse.cursorIndex; // Single-cursor overlay: some runtime mouse events omit the source. @@ -329,7 +329,7 @@ struct Overlay::Impl { last_pointer_event = "drag source unavailable"; return; } - drag_scale = size_scale; drag_x = move_x; drag_y = move_y; + drag_scale = size_scale; drag_canvas = canvas_pose; drag_started = std::chrono::steady_clock::now(); vr::VRControllerState_t state{}; drag_trigger_observed = system->GetControllerState(drag_device, &state, sizeof(state)) && @@ -354,10 +354,11 @@ struct Overlay::Impl { const auto change = drag.update(*source); if (!change) { surface.reset_pointers(); drag.reset(); return; } const float scale = drag_kind == PanelDragKind::Scale ? std::clamp(drag_scale * change->factor, .5f, 2.f) : size_scale; - const float x = drag_kind == PanelDragKind::Grab ? std::clamp(drag_x + change->dx, -2.f, 2.f) : move_x; - const float y = drag_kind == PanelDragKind::Grab ? std::clamp(drag_y + change->dy, -2.f, 2.f) : move_y; - if (scale != size_scale || x != move_x || y != move_y) { - size_scale = scale; move_x = x; move_y = y; size_changed = true; + const auto pose = drag_kind == PanelDragKind::Grab ? change->pose : + resized_mount_pose(drag_canvas, mount_width(*applied_mount, config.wrist) * drag_scale * W / CW, + scale / drag_scale, float(H) / W); + if (scale != size_scale || pose != canvas_pose) { + size_scale = scale; canvas_pose = pose; placement_dirty = true; } } bool available(UiAction action) const { return surface.available(action); } @@ -477,7 +478,7 @@ struct Overlay::Impl { last_pointer_event = "up button=" + std::to_string(event.data.mouse.button); if (event.data.mouse.button == vr::VRMouseButton_Left) { auto event_result = surface.pointer_up(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y); - if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.advanced_debug || event_result.auto_insert || event_result.clock_24h || event_result.date_format) ++pointer_actions; + if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.advanced_debug || event_result.auto_insert || event_result.lock_layout || event_result.clock_24h || event_result.date_format) ++pointer_actions; if (event_result.action) result.push_back(*event_result.action); if (event_result.clock_24h) { config.clock_24h = *event_result.clock_24h; @@ -493,6 +494,14 @@ struct Overlay::Impl { reset_input(result); return result; } + if (event_result.lock_layout) { + config.lock_layout = *event_result.lock_layout; + surface.set_layout_locked(config.lock_layout); + update_intersection_mask(); + layout_save_failed = persist_mount && !save_lock_layout(default_config_path(), config.lock_layout); + reset_input(result); drag.reset(); + return result; + } if (event_result.auto_insert) { config.auto_insert = *event_result.auto_insert; auto_save_failed = persist_mount && !save_auto_insert(default_config_path(), config.auto_insert); diff --git a/src/panel_drag.hpp b/src/panel_drag.hpp index bf3b104..57d0375 100644 --- a/src/panel_drag.hpp +++ b/src/panel_drag.hpp @@ -11,7 +11,7 @@ namespace frameyap { // mounting device); the 3x3 blocks are proper orthonormal rotations. +X is // panel right, +Y panel up, and the translation is the panel center. enum class PanelDragKind { Grab, Scale }; -struct PanelDragUpdate { float dx, dy, factor; }; +struct PanelDragUpdate { Matrix34 pose; float factor; }; namespace panel_drag_detail { struct Vec3 { double x, y, z; }; @@ -47,6 +47,36 @@ inline bool rigid(const Matrix34& m) { } } // namespace panel_drag_detail +// Rigid transforms only (proper orthonormal rotation, finite translation). +// compose_pose(a, b) maps a pose in a's coordinates into a's parent frame; +// relative_pose(a, b) expresses b in a's coordinates. Inputs must be valid +// rigid poses in the same frame where applicable. +inline Matrix34 compose_pose(const Matrix34& a, const Matrix34& b) { + using namespace panel_drag_detail; + Matrix34 result{}; + for (int c = 0; c < 4; ++c) { + const Vec3 value = c == 3 ? rotate(a, translation(b)) + translation(a) + : rotate(a, column(b, c)); + result[0][c] = static_cast(value.x); + result[1][c] = static_cast(value.y); + result[2][c] = static_cast(value.z); + } + return result; +} + +inline Matrix34 relative_pose(const Matrix34& a, const Matrix34& b) { + using namespace panel_drag_detail; + Matrix34 result{}; + for (int c = 0; c < 4; ++c) { + const Vec3 value = inverse_rotate(a, c == 3 ? translation(b) - translation(a) + : column(b, c)); + result[0][c] = static_cast(value.x); + result[1][c] = static_cast(value.y); + result[2][c] = static_cast(value.z); + } + return result; +} + class PanelDrag { public: bool begin(PanelDragKind kind, const Matrix34& panel_pose, float width, float height, @@ -59,6 +89,15 @@ public: !std::isfinite(hit_x) || !std::isfinite(hit_y) || hit_x < 0 || hit_x > 1 || hit_y < 0 || hit_y > 1) return false; + if (kind == PanelDragKind::Grab) { + relative_panel_ = relative_pose(controller_pose, panel_pose); + if (!rigid(relative_panel_)) return false; + panel_pose_ = panel_pose; + kind_ = kind; + active_ = true; + return true; + } + const Vec3 center = translation(panel_pose); const Vec3 right = column(panel_pose, 0), up = column(panel_pose, 1); const Vec3 normal = column(panel_pose, 2); @@ -74,13 +113,12 @@ public: const double baseline_sq = dot(baseline, baseline); if (!finite(anchor) || !finite(down_hit) || !finite(direction) || std::abs(dot(direction, normal)) < 1e-5 || - (kind == PanelDragKind::Scale && (!std::isfinite(baseline_sq) || baseline_sq < 1e-16))) + !std::isfinite(baseline_sq) || baseline_sq < 1e-16) return false; kind_ = kind; + panel_pose_ = panel_pose; center_ = center; - right_ = right; - up_ = up; normal_ = normal; anchor_ = anchor; down_hit_ = down_hit; @@ -93,6 +131,11 @@ public: std::optional update(const Matrix34& controller_pose) const { using namespace panel_drag_detail; if (!active_ || !rigid(controller_pose)) return std::nullopt; + if (kind_ == PanelDragKind::Grab) { + const Matrix34 pose = compose_pose(controller_pose, relative_panel_); + if (!rigid(pose)) return std::nullopt; + return PanelDragUpdate{pose, 1.f}; + } const Vec3 origin = translation(controller_pose); const Vec3 ray = rotate(controller_pose, local_ray_); const double denominator = dot(ray, normal_); @@ -101,16 +144,10 @@ public: const double distance = dot(center_ - origin, normal_) / denominator; if (!std::isfinite(distance) || distance <= 0 || distance > 10) return std::nullopt; const Vec3 hit = origin + ray * distance; - const Vec3 delta = hit - down_hit_; - const double dx = kind_ == PanelDragKind::Grab ? dot(delta, right_) : 0; - const double dy = kind_ == PanelDragKind::Grab ? dot(delta, up_) : 0; - const double factor = kind_ == PanelDragKind::Scale ? - dot(hit - anchor_, down_hit_ - anchor_) / baseline_sq_ : 1; - if (!finite(hit) || !std::isfinite(dx) || !std::isfinite(dy) || - !std::isfinite(factor) || !std::isfinite(static_cast(dx)) || - !std::isfinite(static_cast(dy)) || !std::isfinite(static_cast(factor))) - return std::nullopt; - return PanelDragUpdate{static_cast(dx), static_cast(dy), static_cast(factor)}; + const double factor = dot(hit - anchor_, down_hit_ - anchor_) / baseline_sq_; + if (!finite(hit) || !std::isfinite(factor) || + !std::isfinite(static_cast(factor))) return std::nullopt; + return PanelDragUpdate{panel_pose_, static_cast(factor)}; } void reset() { active_ = false; } @@ -120,7 +157,8 @@ private: using Vec3 = panel_drag_detail::Vec3; bool active_ = false; PanelDragKind kind_ = PanelDragKind::Grab; - Vec3 center_{}, right_{}, up_{}, normal_{}, anchor_{}, down_hit_{}, local_ray_{}; + Matrix34 panel_pose_{}, relative_panel_{}; + Vec3 center_{}, normal_{}, anchor_{}, down_hit_{}, local_ray_{}; double baseline_sq_ = 0; }; diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index 1efcd88..2b50bdc 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -30,13 +30,14 @@ struct Rect { }; enum class Control { Review, Settings, Bindings, Prev, Next, Record, Cancel, Insert, Enter, Quit, World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug, AutoInsert, - Clock24h, Date }; + Clock24h, Date, LockLayout }; enum class Tab { Review, Settings }; struct Button { Rect r; Control id; const char* label; }; -constexpr std::array buttons{{ +constexpr std::array buttons{{ {{32, 138, 180, 46}, Control::Review, "Review"}, {{226, 138, 180, 46}, Control::Settings, "Settings"}, {{420, 138, 180, 46}, Control::Bindings, "Bindings"}, + {{620, 138, 348, 46}, Control::LockLayout, "Lock grab/scale"}, {{32, 406, 154, 44}, Control::Prev, "Previous"}, {{838, 406, 130, 44}, Control::Next, "Next"}, {{32, 574, 176, 68}, Control::Record, "Record"}, @@ -104,7 +105,7 @@ struct PanelSurface::Impl { Color background, card, ink, muted, cyan, pink; Tab tab = Tab::Review; bool dirty = true, lasers_anytime = false, advanced_debug = false, auto_insert = false; - bool clock_24h = false; + bool clock_24h = false, layout_locked = false; DateFormat date_format = DateFormat::MonthDayYear; std::time_t clock_time = std::time(nullptr); ClockLabel displayed_clock; @@ -254,7 +255,7 @@ struct PanelSurface::Impl { if (c == Control::Prev || c == Control::Next) return tab == Tab::Review; if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime || c == Control::AdvancedDebug || c == Control::AutoInsert || - c == Control::Clock24h || c == Control::Date) return tab == Tab::Settings; + c == Control::Clock24h || c == Control::Date || c == Control::LockLayout) return tab == Tab::Settings; return true; } bool enabled(Control c) const { @@ -330,7 +331,8 @@ struct PanelSurface::Impl { (mounting(b.id) && *mounting(b.id) == mount) || (b.id == Control::LasersAnytime && lasers_anytime) || (b.id == Control::AdvancedDebug && advanced_debug) || - (b.id == Control::AutoInsert && auto_insert); + (b.id == Control::AutoInsert && auto_insert) || + (b.id == Control::LockLayout && layout_locked); const Color fill = !on ? mix(background, card, .40f) : selected ? mix(card, cyan, .14f) : card; const Color accent = b.id == Control::Record && panel.recording ? pink : cyan; @@ -347,30 +349,33 @@ struct PanelSurface::Impl { text(label, b.r.x + 16, b.r.y + b.r.h / 2 + 9, b.id == Control::Date ? 23 : 27, on ? ink : mix(background, muted, .48f), b.r.x + b.r.w - 8); if (mounting(b.id) && selected) text("ON", b.r.x + b.r.w - 56, b.r.y + 38, 23, cyan, b.r.x + b.r.w - 12); - if (b.id == Control::LasersAnytime || b.id == Control::AdvancedDebug || b.id == Control::AutoInsert) { + if (b.id == Control::LasersAnytime || b.id == Control::AdvancedDebug || b.id == Control::AutoInsert || + b.id == Control::LockLayout) { bool active = b.id == Control::LasersAnytime ? lasers_anytime : - b.id == Control::AutoInsert ? auto_insert : advanced_debug; + b.id == Control::AutoInsert ? auto_insert : + b.id == Control::LockLayout ? layout_locked : advanced_debug; text(active ? "ON" : "OFF", b.r.x + b.r.w - 66, b.r.y + b.r.h / 2 + 9, 22, active ? cyan : muted, b.r.x + b.r.w - 12); } } // Screenshot-inspired grab underline and outside corner bracket. No // opaque toolbar backing; broad hit targets surround the slender strokes. - const Color handle = theme.frame_end; - rounded({400, 721, 200, 6}, 3, handle, handle); - rounded({1008, 721, 36, 6}, 3, handle, handle); - rounded({1038, 691, 6, 36}, 3, handle, handle); - // Give each handle the same full mint-to-blue theme gradient as the - // main frame, without changing its antialiased alpha coverage. - for (Rect bounds : {Rect{400, 721, 200, 6}, Rect{1008, 691, 36, 36}}) - for (int y = bounds.y; y < bounds.y + bounds.h; ++y) - for (int x = bounds.x; x < bounds.x + bounds.w; ++x) { - auto* dst = pixels.data() + (size_t(y) * W + x) * 4; - if (!dst[3]) continue; - const auto color = mix(theme.frame_start, theme.frame_end, - float(x - bounds.x) / (bounds.w - 1)); - std::copy_n(color.begin(), 3, dst); - } + if (!layout_locked) { + const Color handle = theme.frame_end; + rounded({400, 721, 200, 6}, 3, handle, handle); + rounded({1008, 721, 36, 6}, 3, handle, handle); + rounded({1038, 691, 6, 36}, 3, handle, handle); + // The frame gradient, without changing antialiased alpha coverage. + for (Rect bounds : {Rect{400, 721, 200, 6}, Rect{1008, 691, 36, 36}}) + for (int y = bounds.y; y < bounds.y + bounds.h; ++y) + for (int x = bounds.x; x < bounds.x + bounds.w; ++x) { + auto* dst = pixels.data() + (size_t(y) * W + x) * 4; + if (!dst[3]) continue; + const auto color = mix(theme.frame_start, theme.frame_end, + float(x - bounds.x) / (bounds.w - 1)); + std::copy_n(color.begin(), 3, dst); + } + } dirty = false; return true; } @@ -387,7 +392,7 @@ bool PanelSurface::dragging(unsigned cursor) const { std::optional PanelSurface::pointer_down(unsigned cursor, float x, float y) { if (cursor >= impl_->pressed.size() || impl_->drag_cursor >= 0) return {}; const auto contains = [&](Bounds b) { return Rect{b.x, b.y, b.w, b.h}.contains(x, y); }; - if (contains(grab) || contains(scale)) { + if (!impl_->layout_locked && (contains(grab) || contains(scale))) { impl_->reset(); // other cursor's prior approval cannot survive relocation impl_->drag_cursor = int(cursor); return contains(grab) ? PanelDragKind::Grab : PanelDragKind::Scale; @@ -412,6 +417,7 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y) { else if (c == Control::LasersAnytime) result.lasers_anytime = !impl_->lasers_anytime; else if (c == Control::AdvancedDebug) result.advanced_debug = !impl_->advanced_debug; else if (c == Control::AutoInsert) result.auto_insert = !impl_->auto_insert; + else if (c == Control::LockLayout) result.lock_layout = !impl_->layout_locked; else if (c == Control::Clock24h) result.clock_24h = !impl_->clock_24h; else if (c == Control::Date) result.date_format = static_cast((static_cast(impl_->date_format) + 1) % 4); else if (c == Control::Bindings) { result.open_bindings = true; impl_->reset(); } @@ -427,6 +433,16 @@ void PanelSurface::set_binding_note(std::string note) { if (impl_->binding_note != note) { impl_->binding_note = std::move(note); impl_->dirty = true; } } void PanelSurface::reset_pointers() { impl_->reset(); } +std::vector PanelSurface::input_regions() const { + std::vector result{{10, 10, CW - 20, CH - 20}}; + if (!impl_->layout_locked) { result.push_back(grab); result.push_back(scale); } + return result; +} +void PanelSurface::set_layout_locked(bool locked) { + if (impl_->layout_locked != locked) { + impl_->layout_locked = locked; impl_->reset(); impl_->dirty = true; + } +} void PanelSurface::set_placement_note(std::string note) { if (impl_->placement_note != note) { impl_->placement_note = std::move(note); impl_->dirty = true; } } diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index 5d57dfd..fca6978 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -15,6 +15,7 @@ struct SurfaceEvent { std::optional lasers_anytime; std::optional advanced_debug; std::optional auto_insert; + std::optional lock_layout; std::optional clock_24h; std::optional date_format; bool recenter = false; @@ -39,6 +40,9 @@ public: std::optional pointer_down(unsigned cursor, float x, float y); SurfaceEvent pointer_up(unsigned cursor, float x, float y); bool dragging(unsigned cursor) const; + // OpenVR intersection masks use top-left coordinates, unlike mouse events. + std::vector input_regions() const; + void set_layout_locked(bool locked); void reset_pointers(); void set_placement_note(std::string note); void set_lasers_anytime(bool enabled); diff --git a/tests/config_test.cpp b/tests/config_test.cpp index cb3f2be..8917f76 100644 --- a/tests/config_test.cpp +++ b/tests/config_test.cpp @@ -42,6 +42,7 @@ int main(int argc, char** argv) { assert(!load_config(path).experimental_input_priority); assert(!load_config(path).advanced_debug); assert(!load_config(path).auto_insert); + assert(!load_config(path).lock_layout); assert(!load_config(path).clock_24h); assert(load_config(path).date_format == DateFormat::MonthDayYear); assert(load_config(path).wrist.width == .30f); @@ -51,6 +52,7 @@ int main(int argc, char** argv) { assert(!example.experimental_input_priority); assert(!example.advanced_debug); assert(!example.auto_insert); + assert(!example.lock_layout); assert(!example.clock_24h && example.date_format == DateFormat::MonthDayYear); assert(example.wrist.y == .18f && example.wrist.z == .089f); std::filesystem::create_directories(path.parent_path()); @@ -63,6 +65,10 @@ int main(int argc, char** argv) { assert(load_config(path).auto_insert); assert(save_auto_insert(path, false)); assert(!load_config(path).auto_insert); + assert(save_lock_layout(path, true)); + assert(load_config(path).lock_layout); + assert(save_lock_layout(path, false)); + assert(!load_config(path).lock_layout); assert(save_clock_24h(path, true) && load_config(path).clock_24h); assert(save_date_format(path, DateFormat::Iso) && load_config(path).date_format == DateFormat::Iso); assert(save_date_format(path, DateFormat::Off) && load_config(path).date_format == DateFormat::Off); @@ -97,6 +103,7 @@ int main(int argc, char** argv) { std::filesystem::create_symlink(path, link); assert(!save_advanced_debug(link, false)); assert(!save_auto_insert(link, true)); + assert(!save_lock_layout(link, true)); assert(get(path) == before); std::filesystem::remove(link); put(path, R"({"font":")" + std::string(4090, 'x') + R"("})"); @@ -124,6 +131,26 @@ int main(int argc, char** argv) { assert(save_auto_insert(path, true)); assert(load_config(path).auto_insert); assert(get(path) == R"({"auto_insert":true,"font":"kept"})"); + for (const auto* invalid : {R"({"lock_layout":"true"})", R"({"lock_layout":1})", + R"({"lock_layout":null})", R"({"lock_layout":[]})", + R"({"lock_layout":{}})"}) { + put(path, invalid); + fails([&] { load_config(path); }); + assert(!save_lock_layout(path, true)); + assert(get(path) == invalid); + } + put(path, R"({"lock_layout":false,"font":"kept","date_format":"iso"})"); + assert(save_lock_layout(path, true)); + assert(load_config(path).lock_layout); + assert(get(path) == R"({"lock_layout":true,"font":"kept","date_format":"iso"})"); + before = get(path); + assert(save_lock_layout(path, true) && get(path) == before); + assert(save_lock_layout(path, false)); + assert(get(path) == R"({"lock_layout":false,"font":"kept","date_format":"iso"})"); + put(path, R"({"font":"escaped \u0061","theme":{"ink":"#123ABC"},"auto_insert":true})"); + assert(save_lock_layout(path, true)); + assert(get(path) == R"({"font":"escaped \u0061","theme":{"ink":"#123ABC"},"auto_insert":true,"lock_layout":true})"); + assert(load_config(path).lock_layout && load_config(path).auto_insert); put(path, R"({"input_priority":"normal"})"); assert(!load_config(path).experimental_input_priority); for (const auto* invalid : {R"({"input_priority":true})", R"({"input_priority":16777216})", diff --git a/tests/panel_drag_test.cpp b/tests/panel_drag_test.cpp index 0f64452..d04ce91 100644 --- a/tests/panel_drag_test.cpp +++ b/tests/panel_drag_test.cpp @@ -10,6 +10,11 @@ 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 near(float actual, float expected) { assert(std::abs(actual - expected) < 1e-4f); } +void near_pose(const Matrix34& actual, const Matrix34& expected) { + for (int row = 0; row < 3; ++row) + for (int col = 0; col < 4; ++col) + near(actual[row][col], expected[row][col]); +} Matrix34 translated(Matrix34 pose, float x, float y, float z) { pose[0][3] += x; pose[1][3] += y; pose[2][3] += z; return pose; @@ -17,6 +22,22 @@ Matrix34 translated(Matrix34 pose, float x, float y, float z) { Matrix34 controller_at(float x, float y, float z = 1.f) { return translated(identity, x, y, z); } +// Right-handed +90 degree rotations around each world axis. +Matrix34 pitch() { + return Matrix34{{{{1.f, 0.f, 0.f, 0.f}}, + {{0.f, 0.f, -1.f, 0.f}}, + {{0.f, 1.f, 0.f, 0.f}}}}; +} +Matrix34 yaw() { + return Matrix34{{{{0.f, 0.f, 1.f, 0.f}}, + {{0.f, 1.f, 0.f, 0.f}}, + {{-1.f, 0.f, 0.f, 0.f}}}}; +} +Matrix34 roll() { + return Matrix34{{{{0.f, -1.f, 0.f, 0.f}}, + {{1.f, 0.f, 0.f, 0.f}}, + {{0.f, 0.f, 1.f, 0.f}}}}; +} void scale_case(float x, float y, float factor) { // 1 m square, top-left (-.5,+.5); shift controller and hence its // intersection by (factor - 1) times the original anchor-to-hit vector. @@ -24,16 +45,30 @@ void scale_case(float x, float y, float factor) { PanelDrag drag; assert(drag.begin(PanelDragKind::Scale, identity, 1.f, 1.f, x, y, down)); const auto same = drag.update(down); - assert(same); near(same->factor, 1.f); near(same->dx, 0); near(same->dy, 0); + assert(same); near(same->factor, 1.f); near_pose(same->pose, identity); const auto changed = drag.update(translated(down, (factor - 1.f) * x, (1.f - factor) * y, 0)); - assert(changed); near(changed->factor, factor); - near(changed->dx, 0); near(changed->dy, 0); + assert(changed); near(changed->factor, factor); near_pose(changed->pose, identity); } -// Yaw +90 degrees: panel right=-Z, up=+Y, normal=+X. -Matrix34 yawed_panel() { - return Matrix34{{{{0.f, 0.f, 1.f, 2.f}}, - {{0.f, 1.f, 0.f, 3.f}}, - {{-1.f, 0.f, 0.f, 4.f}}}}; +void grab_rotation_case(const Matrix34& rotation, const Matrix34& expected) { + PanelDrag drag; + // Nonzero lever arm: rotation must turn the panel position as well as its axes. + const auto down = controller_at(0, 0); + const auto panel = translated(identity, .25f, .4f, -.3f); + assert(drag.begin(PanelDragKind::Grab, panel, 1, 1, .7f, .3f, down)); + auto turned = rotation; + turned[2][3] = 1.f; + const auto update = drag.update(turned); + assert(update); near(update->factor, 1.f); near_pose(update->pose, expected); + // No accumulation: moving back to down restores the exact initial pose. + drag.reset(); + assert(!drag.update(turned)); + // Release while turned, then pick that rotated panel up again elsewhere. + const auto second_down = translated(turned, .12f, -.08f, .2f); + assert(drag.begin(PanelDragKind::Grab, update->pose, 1, 1, .4f, .6f, second_down)); + const auto restored = drag.update(second_down); + assert(restored); near_pose(restored->pose, update->pose); + const auto shifted = drag.update(translated(second_down, 0.f, 0.f, -.4f)); + assert(shifted); near_pose(shifted->pose, translated(update->pose, 0.f, 0.f, -.4f)); } } // namespace @@ -41,16 +76,37 @@ int main() { PanelDrag drag; assert(!drag.active()); assert(!drag.update(controller_at(0, 0))); const auto down = controller_at(0, 0); - assert(drag.begin(PanelDragKind::Grab, identity, 1.f, 1.f, .5f, .5f, down)); + auto panel = translated(identity, .25f, .4f, -.3f); + assert(drag.begin(PanelDragKind::Grab, panel, 1.f, 1.f, .5f, .5f, down)); assert(drag.active()); - const auto still = drag.update(down); - assert(still); near(still->dx, 0); near(still->dy, 0); near(still->factor, 1); - auto moved = drag.update(controller_at(.23f, -.16f)); - assert(moved); near(moved->dx, .23f); near(moved->dy, -.16f); near(moved->factor, 1); - // The original plane is fixed: the ray may hit outside the initial canvas. - moved = drag.update(controller_at(2.f, 1.f)); - assert(moved); near(moved->dx, 2.f); near(moved->dy, 1.f); + for (int i = 0; i < 10; ++i) { + const auto still = drag.update(down); + assert(still); near_pose(still->pose, panel); near(still->factor, 1.f); + } + auto moved = drag.update(controller_at(.23f, -.16f, 1.45f)); + assert(moved); near_pose(moved->pose, translated(panel, .23f, -.16f, .45f)); + const auto released_pose = moved->pose; drag.reset(); assert(!drag.active()); assert(!drag.update(down)); + // A subsequent grab begins at the dropped position and orientation, not the + // old calibration; releasing leaves the caller's last pose unchanged. + const auto rotated_pose = compose_pose(translated(yaw(), 0.f, .1f, 0.f), released_pose); + const auto second_down = controller_at(-.2f, .1f, .9f); + assert(drag.begin(PanelDragKind::Grab, rotated_pose, 1, 1, .5f, .5f, second_down)); + moved = drag.update(second_down); + assert(moved); near_pose(moved->pose, rotated_pose); + moved = drag.update(translated(second_down, -.1f, .2f, -.3f)); + assert(moved); near_pose(moved->pose, translated(rotated_pose, -.1f, .2f, -.3f)); + drag.reset(); assert(!drag.update(second_down)); + + grab_rotation_case(pitch(), Matrix34{{{{1.f, 0.f, 0.f, .25f}}, + {{0.f, 0.f, -1.f, 1.3f}}, + {{0.f, 1.f, 0.f, 1.4f}}}}); + grab_rotation_case(yaw(), Matrix34{{{{0.f, 0.f, 1.f, -1.3f}}, + {{0.f, 1.f, 0.f, .4f}}, + {{-1.f, 0.f, 0.f, .75f}}}}); + grab_rotation_case(roll(), Matrix34{{{{0.f, -1.f, 0.f, -.4f}}, + {{1.f, 0.f, 0.f, .25f}}, + {{0.f, 0.f, 1.f, -.3f}}}}); // Independent horizontal, vertical, and diagonal anchor-based scaling; // both contraction and growth, including intersections outside the panel. @@ -59,14 +115,12 @@ int main() { scale_case(0.f, .5f, factor); scale_case(.5f, .5f, factor); } - assert(drag.begin(PanelDragKind::Scale, identity, 1, 1, .5f, .5f, - controller_at(0, 0))); - // A perpendicular offset does not alter projected scale. - moved = drag.update(controller_at(.25f, .25f)); + assert(drag.begin(PanelDragKind::Scale, identity, 1, 1, .5f, .5f, down)); + moved = drag.update(controller_at(.25f, .25f)); // perpendicular to anchor vector assert(moved); near(moved->factor, 1); moved = drag.update(controller_at(.5f, -.5f)); assert(moved); near(moved->factor, 2); - moved = drag.update(controller_at(0, 0)); // no feedback from the previous size + moved = drag.update(down); // no feedback from previous size assert(moved); near(moved->factor, 1); drag.reset(); assert(!drag.begin(PanelDragKind::Scale, identity, 1, 1, 0, 0, @@ -74,49 +128,40 @@ int main() { assert(drag.begin(PanelDragKind::Grab, identity, 1, 1, 0, 0, controller_at(-.5f, .5f))); - // Rotating the controller changes the calibrated ray even without moving - // its origin. The initial ray is (.25,0,-1), rotated 90 degrees about Z. + // Scaling still uses the captured controller-local ray, not the grab pose. auto angled_down = controller_at(0, 0); - assert(drag.begin(PanelDragKind::Grab, identity, 1, 1, .75f, .5f, angled_down)); + assert(drag.begin(PanelDragKind::Scale, identity, 1, 1, .75f, .5f, angled_down)); auto turned = angled_down; turned[0][0] = 0; turned[0][1] = -1; turned[1][0] = 1; turned[1][1] = 0; moved = drag.update(turned); - assert(moved); near(moved->dx, -.25f); near(moved->dy, .25f); + assert(moved); near(moved->factor, .5f / .8125f); near_pose(moved->pose, identity); - const auto panel = yawed_panel(); - // Use the same rotated basis for both controller and panel; a 0.2 m shift - // along panel right (-Z) yields panel-right grab displacement. - auto rotated_down = panel; - rotated_down[0][3] += 1.f; - assert(drag.begin(PanelDragKind::Grab, panel, 1, 1, .5f, .5f, rotated_down)); - moved = drag.update(translated(rotated_down, 0, -.15f, -.2f)); - assert(moved); near(moved->dx, .2f); near(moved->dy, -.15f); - auto corner_ray = rotated_down; - corner_ray[1][3] += .25f; - corner_ray[2][3] += .25f; // initial hit at x=.25, y=.25 - assert(drag.begin(PanelDragKind::Scale, panel, 1, 1, .25f, .25f, corner_ray)); + const auto yawed_panel = translated(yaw(), 2.f, 3.f, 4.f); + auto corner_ray = translated(yawed_panel, 1.f, .25f, .25f); + assert(drag.begin(PanelDragKind::Scale, yawed_panel, 1, 1, .25f, .25f, corner_ray)); moved = drag.update(translated(corner_ray, 0, -.25f, -.25f)); - assert(moved); near(moved->factor, 2.f); + assert(moved); near(moved->factor, 2.f); near_pose(moved->pose, yawed_panel); - // Relative poses are valid without converting them to world space: both - // inputs here share a rotated/translated parent coordinate system. - auto relative_panel = identity; - relative_panel[0][3] = -.3f; relative_panel[1][3] = .1f; - auto relative_controller = translated(relative_panel, 0, 0, .8f); + // The identical grab in a device-relative frame and under an arbitrary + // rigid parent in world space must produce equivalent world panel poses. + const auto parent = translated(compose_pose(yaw(), pitch()), 2.f, 3.f, 4.f); + const auto relative_panel = translated(roll(), -.3f, .1f, -.1f); + const auto relative_controller = controller_at(.1f, -.2f, .8f); + const auto relative_next = compose_pose(translated(yaw(), .08f, -.04f, .2f), relative_controller); assert(drag.begin(PanelDragKind::Grab, relative_panel, .4f, .2f, .5f, .5f, relative_controller)); - moved = drag.update(translated(relative_controller, .08f, -.04f, 0)); - assert(moved); near(moved->dx, .08f); near(moved->dy, -.04f); - // Express the same poses in world coordinates under a yawed parent: - // (x, y, z) -> (2+z, 3+y, 4-x). The update is invariant. - auto world_panel = yawed_panel(); - world_panel[1][3] += .1f; world_panel[2][3] += .3f; - auto world_controller = translated(world_panel, .8f, 0, 0); + moved = drag.update(relative_next); + assert(moved); + const auto relative_result = moved->pose; + const auto world_panel = compose_pose(parent, relative_panel); + const auto world_controller = compose_pose(parent, relative_controller); assert(drag.begin(PanelDragKind::Grab, world_panel, .4f, .2f, .5f, .5f, world_controller)); - moved = drag.update(translated(world_controller, 0, -.04f, -.08f)); - assert(moved); near(moved->dx, .08f); near(moved->dy, -.04f); + moved = drag.update(compose_pose(parent, relative_next)); + assert(moved); near_pose(moved->pose, compose_pose(parent, relative_result)); + near_pose(relative_pose(parent, moved->pose), relative_result); + near_pose(compose_pose(world_controller, relative_pose(world_controller, world_panel)), world_panel); const float nan = std::numeric_limits::quiet_NaN(); const float inf = std::numeric_limits::infinity(); @@ -133,24 +178,36 @@ int main() { assert(!drag.begin(PanelDragKind::Grab, identity, inf, 1, .5f, .5f, down)); assert(!drag.begin(PanelDragKind::Grab, identity, 1, 1, nan, .5f, down)); assert(!drag.begin(PanelDragKind::Grab, identity, 1, 1, -1, .5f, down)); - assert(!drag.begin(PanelDragKind::Grab, identity, 1, 1, .5f, .5f, identity)); // zero ray - assert(!drag.begin(PanelDragKind::Grab, identity, 1, 1, .5f, .5f, + // Both inputs are finite but their relative translation cannot fit in Matrix34. + const float max = std::numeric_limits::max(); + assert(!drag.begin(PanelDragKind::Grab, translated(identity, -max, 0, 0), + 1, 1, .5f, .5f, translated(identity, max, 0, 0))); + assert(!drag.active()); assert(!drag.update(down)); + assert(drag.begin(PanelDragKind::Grab, identity, 1, 1, .5f, .5f, identity)); + assert(drag.update(identity)); // grab does not require a ray to the panel + drag.reset(); + assert(!drag.begin(PanelDragKind::Scale, identity, 1, 1, .5f, .5f, identity)); // zero ray + assert(!drag.begin(PanelDragKind::Scale, identity, 1, 1, .5f, .5f, controller_at(1.f, 0.f, 1e-7f))); // near parallel at down - assert(!drag.begin(PanelDragKind::Grab, identity, 1, 1, .5f, .5f, + assert(!drag.begin(PanelDragKind::Scale, identity, 1, 1, .5f, .5f, controller_at(0, 0, 11.f))); // beyond 10 m - assert(drag.begin(PanelDragKind::Grab, identity, 1, 1, .5f, .5f, down)); + assert(drag.begin(PanelDragKind::Scale, identity, 1, 1, .5f, .5f, down)); bad = down; bad[1][2] = inf; assert(!drag.update(bad)); bad = down; bad[0][0] = 0; assert(!drag.update(bad)); assert(!drag.update(controller_at(0, 0, -1.f))); // behind ray source assert(!drag.update(controller_at(0, 0, 11.f))); // beyond 10 m - // Rotate -Z to -X: ray parallel to initial panel plane. bad = down; bad[0][0] = 0; bad[0][2] = 1; bad[2][0] = -1; bad[2][2] = 0; - assert(!drag.update(bad)); + assert(!drag.update(bad)); // ray parallel to initial panel plane assert(drag.active()); // invalid update leaves original calibration intact assert(drag.update(down)); - drag.reset(); assert(!drag.active()); + drag.reset(); assert(!drag.active()); assert(!drag.update(down)); + assert(drag.begin(PanelDragKind::Grab, identity, 1, 1, .5f, .5f, down)); + bad = down; bad[2][3] = nan; + assert(!drag.update(bad)); + assert(drag.update(down)); + drag.reset(); assert(!drag.update(down)); } diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index d482e35..71ac247 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -14,7 +14,7 @@ SurfaceEvent click(PanelSurface& surface, float x, float y, unsigned cursor = 0) } void no_action(const SurfaceEvent& event) { assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug && !event.auto_insert && - !event.clock_24h && !event.date_format && !event.recenter && !event.open_bindings); + !event.lock_layout && !event.clock_24h && !event.date_format && !event.recenter && !event.open_bindings); } void snapshot(PanelSurface& surface, const std::string& path) { std::ofstream out(path, std::ios::binary); @@ -109,6 +109,21 @@ int main(int argc, char** argv) { assert(channel(1012, 723, 0) > 220 && channel(1040, 705, 2) > 220); assert(channel(1030, 704, 3) == 0 && channel(800, 724, 3) == 0); assert(channel(401, 721, 3) == alpha(401, 721)); + auto regions = surface.input_regions(); + assert(regions.size() == 3); + assert(regions[2].x == 996 && regions[2].y == 680); // masks use TOP-left, not GL mouse Y + surface.pointer_down(0, 500, 724); + surface.set_layout_locked(true); + assert(!surface.dragging(0)); + assert(surface.render(p)); + assert(alpha(500, 723) == 0 && alpha(1040, 705) == 0); + assert(surface.input_regions().size() == 1); + assert(!surface.pointer_down(0, 500, 724)); + assert(!surface.pointer_down(0, 1028, 712)); + no_action(surface.pointer_up(0, 900, 610)); + assert(!surface.render(p)); + surface.set_layout_locked(false); assert(surface.render(p)); + assert(alpha(500, 723) == 255 && alpha(1040, 705) == 255); p.enabled = true; p.record_available = false; @@ -148,6 +163,13 @@ int main(int argc, char** argv) { surface.render(p); surface.reset_pointers(); surface.render(p); if (argc >= 3) snapshot(surface, std::string(argv[2]) + "-settings.ppm"); + auto lock = click(surface, 790, 160); + assert(lock.lock_layout == true && !lock.action); + assert(!surface.render(p)); // caller applies/persists the requested lock + surface.set_layout_locked(true); assert(surface.render(p)); + lock = click(surface, 790, 160); + assert(lock.lock_layout == false); // unlock stays accessible in Settings + surface.set_layout_locked(false); assert(surface.render(p)); assert(click(surface, 200, 420).recenter); auto event = click(surface, 180, 332); assert(event.mount == Mount::LeftWrist && !event.action); @@ -206,6 +228,7 @@ int main(int argc, char** argv) { no_action(click(surface, 680, 420)); // laser toggle only exists on Settings no_action(click(surface, 680, 474)); // debug toggle only exists on Settings no_action(click(surface, 200, 474)); // auto insert only exists on Settings + no_action(click(surface, 790, 160)); // layout lock only exists on Settings no_action(click(surface, 200, 528)); // clock/date controls only exist on Settings no_action(click(surface, 680, 528)); diff --git a/tests/test_installer.py b/tests/test_installer.py index 1a01632..e685d99 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -75,6 +75,7 @@ class InstallTests(unittest.TestCase): self.assertEqual(json.loads(config.read_text()), installer.CONFIG_DEFAULTS) self.assertIs(json.loads(config.read_text())["advanced_debug"], False) self.assertIs(json.loads(config.read_text())["auto_insert"], False) + self.assertIs(json.loads(config.read_text())["lock_layout"], False) self.assertEqual(list(config.parent.glob("config.json.backup-*")), []) self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text()) self.assertTrue(os.access(root / "versions/v1/runtime/bin/helper", os.X_OK)) @@ -129,6 +130,7 @@ class InstallTests(unittest.TestCase): self.assertEqual(fixed["input_priority"], "normal") self.assertIs(fixed["advanced_debug"], False) self.assertIs(fixed["auto_insert"], False) + self.assertIs(fixed["lock_layout"], False) self.assertIs(fixed["clock_24h"], False) self.assertEqual(fixed["date_format"], "mdy") self.assertEqual(fixed["wrist"], installer.CONFIG_DEFAULTS["wrist"]) @@ -138,6 +140,7 @@ class InstallTests(unittest.TestCase): fixed["input_priority"] = "experimental" fixed["advanced_debug"] = True fixed["auto_insert"] = True + fixed["lock_layout"] = True fixed["clock_24h"] = True fixed["date_format"] = "iso" fixed["buttons"]["enter"] = "" # intentional disabling survives upgrades @@ -148,6 +151,7 @@ class InstallTests(unittest.TestCase): self.assertEqual(config.read_bytes(), compact) self.assertIs(json.loads(config.read_text())["advanced_debug"], True) self.assertIs(json.loads(config.read_text())["auto_insert"], True) + self.assertIs(json.loads(config.read_text())["lock_layout"], True) self.assertIs(json.loads(config.read_text())["clock_24h"], True) self.assertEqual(json.loads(config.read_text())["date_format"], "iso") self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1) @@ -167,6 +171,7 @@ class InstallTests(unittest.TestCase): self.assertEqual(fixed["input_priority"], "normal") self.assertIs(fixed["advanced_debug"], False) self.assertIs(fixed["auto_insert"], False) + self.assertIs(fixed["lock_layout"], False) self.assertEqual(fixed["wrist"]["x"], 0.04) self.assertEqual(fixed["wrist"]["y"], 0.18) self.assertEqual(fixed["wrist"]["width"], 0.30) @@ -215,6 +220,19 @@ class InstallTests(unittest.TestCase): self.assertIs(json.loads(config.read_text())["auto_insert"], False) self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) + def test_lock_layout_boolean_repair_backs_up_invalid_values(self): + archive, digest = self.package("v1") + config = self.home / ".config/frameyap/config.json" + config.parent.mkdir(parents=True) + for invalid in ("true", 1, None, [], {}): + original = json.dumps({"lock_layout": invalid, "font": "/custom/font.ttf"}).encode() + config.write_bytes(original) + self.install("v1", archive, digest) + fixed = json.loads(config.read_text()) + self.assertIs(fixed["lock_layout"], False) + self.assertEqual(fixed["font"], "/custom/font.ttf") + self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) + def test_digest_and_same_version_mismatch_leave_previous(self): a, h = self.package("v1") self.install("v1", a, h)