diff --git a/CMakeLists.txt b/CMakeLists.txt index d231354..5d262dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -131,6 +131,10 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) target_include_directories(frameyap_lock_test PRIVATE src) add_test(NAME frameyap.instance_lock COMMAND frameyap_lock_test) endif() + add_executable(frameyap_panel_drag_test tests/panel_drag_test.cpp) + target_include_directories(frameyap_panel_drag_test PRIVATE src) + target_compile_options(frameyap_panel_drag_test PRIVATE -UNDEBUG) + add_test(NAME frameyap.panel_drag COMMAND frameyap_panel_drag_test) add_executable(frameyap_gestures_test tests/gestures_test.cpp) target_include_directories(frameyap_gestures_test PRIVATE src) target_compile_options(frameyap_gestures_test PRIVATE -UNDEBUG) diff --git a/README.md b/README.md index bf7441f..f0149e2 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,10 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet Nothing submits automatically. Auto Insert is opt-in and off by default. - **Overlay:** Record/Stop, Cancel, paginated preview, Insert, Enter and Quit. Review and settings share one Inconsolata/neon-framed surface. - Drag the inset lower-right grip to resize the panel (world, head or wrist); - the upper-left corner stays anchored. Size changes last for this run only. + 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. **Bindings** requests SteamVR's binding editor directly. Existing SteamVR overrides may supersede defaults. World-space by default; settings offer left wrist, right wrist and head mounting, plus recenter. Dashboard lasers provide clickable controls. Settings → Lasers anytime is an diff --git a/docs/overlay.md b/docs/overlay.md index 5e49956..faa31ac 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -18,8 +18,9 @@ A missing selected font falls back to bundled Inconsolata, then a system DejaVu Sans face if present. Glyph coverage depends on the selected face; full CJK coverage is not claimed. -`src/panel_surface.*` renders **one 1000×680 RGBA canvas** for review, settings, -status and controls. `src/overlay_texture.*` uploads this CPU canvas into one +`src/panel_surface.*` renders **one 1080×780 RGBA canvas** containing a 1000×680 +main panel for review, settings, status and controls, plus transparent right/bottom +margins for a thin grab underline and an external L-shaped scale handle. `src/overlay_texture.*` uploads this CPU canvas into one persistent Vulkan RGBA8 image and submits it with `SetOverlayTexture`. The image, staging allocation and command buffer are reused; tabs do not create extra overlays or render targets. The rounded mint-to-blue perimeter, @@ -203,12 +204,33 @@ a fresh placement. Settings → Recenter in front deliberately resamples the pos Settings offers World space, Left wrist, Right wrist and Head on that same canvas. World/head width is 0.85 m; wrist width defaults to 0.30 m. All mounts have -the same lower-right resize grip: drag it to scale the panel -between half and twice its configured width. The original upper-left corner -remains fixed relative to the chosen mount, rather than scaling about the -center. The physical resize is session-only; switching mounts retains the -scale factor, while restart restores configured defaults. Grip behavior and -comfort still need an opt-in on-headset check. The left wrist uses +a thin grab bar below the main panel and an L-shaped scale bracket outside its +lower-right corner, visually modeled on the user's Steam terminal-window screenshot. +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. + +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. + +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. +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 +ray geometry or a 15-second safety limit cancels the drag. If legacy trigger state is +observable on press, release is also checked through that state, including outside +the texture. Otherwise the drag cancels as soon as this overlay stops being the +hover target, rather than waiting for a potentially missing outside MouseButtonUp. Other pointer approvals and bound actions cannot fire during a drag. +Source-device reporting, out-of-bounds release, mask behavior and comfort still need +on-headset acceptance. The left wrist uses VR Workspace's fallback watch-face axes: panel-right points toward the fingers (controller -Z), panel-up points out of the back of the hand (controller +Y), and panel-front points toward controller +X. The right wrist reverses panel-right @@ -258,8 +280,10 @@ coexistence remains unverified. ### Hardware-free UI checks -The default build tests mount parsing, laser preference persistence and pose -geometry without any native dependencies. A FreeType-only opt-in build exercises the actual renderer, +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, pointer gating, tab switches, pagination, recording state and redraw invalidation: ```sh diff --git a/src/overlay.cpp b/src/overlay.cpp index 9afdeea..6d187ba 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -34,6 +34,19 @@ void configure_registry() { ::setenv("VR_PATHREG_OVERRIDE", registry.c_str(), 0); } constexpr int W = PanelSurface::width, H = PanelSurface::height; +constexpr int CW = PanelSurface::body.w, CH = PanelSurface::body.h; +Matrix34 matrix(const vr::HmdMatrix34_t& value) { + Matrix34 result{}; + 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) @@ -78,6 +91,15 @@ struct Overlay::Impl { bool world_ready = false, placed = false, has_texture = false, shown = false; float size_scale = 1.f; bool size_changed = false; + float move_x = 0.f, move_y = 0.f; + Matrix34 canvas_pose{}; + 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; + std::chrono::steady_clock::time_point drag_started{}; vr::HmdMatrix34_t world_transform{}; std::optional applied_mount; vr::TrackedDeviceIndex_t anchor = vr::k_unTrackedDeviceIndexInvalid; @@ -157,6 +179,16 @@ struct Overlay::Impl { 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"); system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses.data(), uint32_t(poses.size())); place(); draw(Panel{"Disabled", "", "Record to start local worker", false, false}); @@ -216,26 +248,29 @@ struct Overlay::Impl { } surface.set_placement_note(note); if (applied_mount != effective || (effective != Mount::World && anchor != target) || size_changed) { - if (!size_changed || applied_mount != effective || anchor != target) - surface.reset_pointers(); // a release on a relocated surface cannot activate an old press + if (applied_mount != effective || anchor != target) { + surface.reset_pointers(); drag.reset(); + move_x = move_y = 0.f; + } const float base_width = mount_width(effective, config.wrist); - if (effective == Mount::World) { - Matrix34 pose{}; - for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) pose[r][c] = world_transform.m[r][c]; - pose = resized_mount_pose(pose, base_width, size_scale, float(H) / W); - 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]; + 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; + 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]; + if (effective == Mount::World) overlay_check(overlay->SetOverlayTransformAbsolute(handle, vr::TrackingUniverseStanding, &transform), overlay, "SetOverlayTransformAbsolute"); - } else { - const auto pose = resized_mount_pose(relative_mount_pose(effective, config.wrist), - base_width, size_scale, float(H) / W); - 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]; + else overlay_check(overlay->SetOverlayTransformTrackedDeviceRelative(handle, target, &transform), overlay, "SetOverlayTransformTrackedDeviceRelative"); - } - overlay_check(overlay->SetOverlayWidthInMeters(handle, base_width * size_scale), overlay, "SetOverlayWidthInMeters"); + overlay_check(overlay->SetOverlayWidthInMeters(handle, base_width * size_scale * W / CW), overlay, "SetOverlayWidthInMeters"); applied_mount = effective; anchor = target; size_changed = false; @@ -243,6 +278,68 @@ struct Overlay::Impl { placed = true; visibility(); } + bool tracked(vr::TrackedDeviceIndex_t device) const { + return device < poses.size() && poses[device].bPoseIsValid && poses[device].bDeviceIsConnected; + } + std::optional drag_source() const { + if (!tracked(drag_device) || !applied_mount) return {}; + auto pose = matrix(poses[drag_device].mDeviceToAbsoluteTracking); + if (*applied_mount != Mount::World) { + if (!tracked(anchor)) return {}; + pose = relative_to(pose, matrix(poses[anchor].mDeviceToAbsoluteTracking)); + } + return pose; + } + void begin_drag(PanelDragKind kind, const vr::VREvent_t& event) { + drag_device = event.trackedDeviceIndex; + drag_cursor = event.data.mouse.cursorIndex; + // Single-cursor overlay: some runtime mouse events omit the source. + // The dashboard's primary device is the only supported fallback, never + // a guessed left/right hand or a source chosen by proximity. + if (drag_cursor == 0 && (drag_device == vr::k_unTrackedDeviceIndexInvalid || + drag_device == vr::k_unTrackedDeviceIndex_Hmd)) + drag_device = overlay->GetPrimaryDashboardDevice(); + drag_kind = kind; + const auto source = drag_source(); + const float w = applied_mount ? mount_width(*applied_mount, config.wrist) * size_scale * W / CW : 0.f; + if (!source || system->GetTrackedDeviceClass(drag_device) != vr::TrackedDeviceClass_Controller || + !drag.begin(kind, canvas_pose, w, w * H / W, event.data.mouse.x / W, + 1.f - event.data.mouse.y / H, *source)) { + surface.reset_pointers(); drag.reset(); + last_pointer_event = "drag source unavailable"; + return; + } + drag_scale = size_scale; drag_x = move_x; drag_y = move_y; + drag_started = std::chrono::steady_clock::now(); + vr::VRControllerState_t state{}; + drag_trigger_observed = system->GetControllerState(drag_device, &state, sizeof(state)) && + (state.ulButtonPressed & vr::ButtonMaskFromId(vr::k_EButton_SteamVR_Trigger)); + last_pointer_event = std::string(kind == PanelDragKind::Grab ? "grab" : "scale") + + " device=" + std::to_string(drag_device) + " trigger-watch=" + (drag_trigger_observed ? "Y" : "N"); + } + void update_drag() { + if (!drag.active()) return; + const auto source = drag_source(); + vr::VRControllerState_t state{}; + const bool trigger_released = drag_trigger_observed && + (!system->GetControllerState(drag_device, &state, sizeof(state)) || + !(state.ulButtonPressed & vr::ButtonMaskFromId(vr::k_EButton_SteamVR_Trigger))); + // 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 = !drag_trigger_observed && !overlay->IsHoverTargetOverlay(handle); + if (!surface.dragging(drag_cursor) || !shown || !focus || !source || trigger_released || lost_unwatched_pointer || + std::chrono::steady_clock::now() - drag_started > std::chrono::seconds(15)) { + surface.reset_pointers(); drag.reset(); return; + } + 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; + } + } bool available(UiAction action) const { return surface.available(action); } void draw(const Panel& p) { panel = p; @@ -344,17 +441,15 @@ struct Overlay::Impl { // a release must still hit the same enabled control. last_pointer_event = "overlay focus changed"; break; case vr::VREvent_MouseMove: - if (auto factor = surface.pointer_move(event.data.mouse.cursorIndex, event.data.mouse.x, - H - event.data.mouse.y)) { - const float next = std::clamp(size_scale * *factor, .5f, 2.f); - if (next != size_scale) { size_scale = next; size_changed = true; } - } + // Manipulation uses the captured controller ray, not coordinates + // fed back from a changing overlay or a batch of stale mouse hits. break; case vr::VREvent_MouseButtonDown: ++pointer_downs; last_pointer_event = "down button=" + std::to_string(event.data.mouse.button); if (event.data.mouse.button == vr::VRMouseButton_Left) - surface.pointer_down(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y); + if (auto kind = surface.pointer_down(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y)) + begin_drag(*kind, event); break; case vr::VREvent_MouseButtonUp: ++pointer_ups; @@ -412,7 +507,17 @@ struct Overlay::Impl { default: break; } } + update_drag(); place(); + if (drag.active()) { + // A controller used to manipulate the panel must not also authorize + // Record/Insert/Enter. Require neutral rearm after the drag ends. + left.reset(); right.reset(); + if (grip_capture || ptt_capture) result.push_back(UiAction::Cancel); + grip_capture = ptt_capture = false; + for (auto& edge : edges) edge.reset(); + return result; + } vr::VRActiveActionSet_t set{}; set.ulActionSet = action_set; set.ulRestrictedToDevice = vr::k_ulInvalidInputValueHandle; diff --git a/src/panel_drag.hpp b/src/panel_drag.hpp new file mode 100644 index 0000000..bf3b104 --- /dev/null +++ b/src/panel_drag.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include "mount.hpp" + +#include +#include + +namespace frameyap { + +// Inputs are rigid poses in the SAME coordinate frame (world or the same +// 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; }; + +namespace panel_drag_detail { +struct Vec3 { double x, y, z; }; +inline Vec3 operator+(Vec3 a, Vec3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; } +inline Vec3 operator-(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; } +inline Vec3 operator*(Vec3 a, double s) { return {a.x * s, a.y * s, a.z * s}; } +inline double dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +inline Vec3 cross(Vec3 a, Vec3 b) { + return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; +} +inline bool finite(Vec3 a) { return std::isfinite(a.x) && std::isfinite(a.y) && std::isfinite(a.z); } +inline Vec3 column(const Matrix34& m, int c) { return {m[0][c], m[1][c], m[2][c]}; } +inline Vec3 translation(const Matrix34& m) { return column(m, 3); } +inline Vec3 rotate(const Matrix34& m, Vec3 v) { + return column(m, 0) * v.x + column(m, 1) * v.y + column(m, 2) * v.z; +} +inline Vec3 inverse_rotate(const Matrix34& m, Vec3 v) { + return {dot(column(m, 0), v), dot(column(m, 1), v), dot(column(m, 2), v)}; +} +inline bool rigid(const Matrix34& m) { + for (const auto& row : m) + for (float value : row) + if (!std::isfinite(value)) return false; + const Vec3 x = column(m, 0), y = column(m, 1), z = column(m, 2); + constexpr double tolerance = 1e-3; + return std::abs(dot(x, x) - 1) < tolerance && + std::abs(dot(y, y) - 1) < tolerance && + std::abs(dot(z, z) - 1) < tolerance && + std::abs(dot(x, y)) < tolerance && + std::abs(dot(x, z)) < tolerance && + std::abs(dot(y, z)) < tolerance && + std::abs(dot(cross(x, y), z) - 1) < tolerance; +} +} // namespace panel_drag_detail + +class PanelDrag { +public: + bool begin(PanelDragKind kind, const Matrix34& panel_pose, float width, float height, + float hit_x, float hit_y, const Matrix34& controller_pose) { + using namespace panel_drag_detail; + reset(); + if ((kind != PanelDragKind::Grab && kind != PanelDragKind::Scale) || + !rigid(panel_pose) || !rigid(controller_pose) || + !std::isfinite(width) || !std::isfinite(height) || width <= 0 || height <= 0 || + !std::isfinite(hit_x) || !std::isfinite(hit_y) || + hit_x < 0 || hit_x > 1 || hit_y < 0 || hit_y > 1) return false; + + 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); + const Vec3 anchor = center - right * (static_cast(width) * .5) + + up * (static_cast(height) * .5); + const Vec3 down_hit = anchor + right * (static_cast(width) * hit_x) - + up * (static_cast(height) * hit_y); + const Vec3 down_vector = down_hit - translation(controller_pose); + const double length = std::sqrt(dot(down_vector, down_vector)); + if (!std::isfinite(length) || length < 1e-6 || length > 10) return false; + const Vec3 direction = down_vector * (1.0 / length); + const Vec3 baseline = down_hit - anchor; + 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))) + return false; + + kind_ = kind; + center_ = center; + right_ = right; + up_ = up; + normal_ = normal; + anchor_ = anchor; + down_hit_ = down_hit; + baseline_sq_ = baseline_sq; + local_ray_ = inverse_rotate(controller_pose, direction); + active_ = true; + return true; + } + + std::optional update(const Matrix34& controller_pose) const { + using namespace panel_drag_detail; + if (!active_ || !rigid(controller_pose)) return std::nullopt; + const Vec3 origin = translation(controller_pose); + const Vec3 ray = rotate(controller_pose, local_ray_); + const double denominator = dot(ray, normal_); + if (!finite(ray) || !std::isfinite(denominator) || std::abs(denominator) < 1e-5) + return std::nullopt; + 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)}; + } + + void reset() { active_ = false; } + bool active() const { return active_; } + +private: + using Vec3 = panel_drag_detail::Vec3; + bool active_ = false; + PanelDragKind kind_ = PanelDragKind::Grab; + Vec3 center_{}, right_{}, up_{}, normal_{}, anchor_{}, down_hit_{}, local_ray_{}; + double baseline_sq_ = 0; +}; + +} // namespace frameyap diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index 3fc6692..ed6f732 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -13,7 +13,7 @@ namespace frameyap { namespace { constexpr int W = PanelSurface::width, H = PanelSurface::height; using Color = Rgba; -constexpr int grip_x = 927, grip_y = 646, grip_w = 51, grip_h = 29; +constexpr int CW = PanelSurface::body.w, CH = PanelSurface::body.h; float rounded_distance(float px, float py, int x, int y, int w, int h, float radius) { const float dx = std::abs(px - (x + w / 2.f)) - (w / 2.f - radius); const float dy = std::abs(py - (y + h / 2.f)) - (h / 2.f - radius); @@ -111,8 +111,7 @@ struct PanelSurface::Impl { bool dirty = true, lasers_anytime = false, advanced_debug = false, auto_insert = false; std::string placement_note, binding_note; std::array pressed{{-1, -1}}; - int resize_cursor = -1; - float resize_x = 0.f, resize_y = 0.f; + int drag_cursor = -1; std::vector lines; size_t page = 0; static constexpr size_t lines_per_page = 4; @@ -144,8 +143,14 @@ struct PanelSurface::Impl { } void blend(int x, int y, Color color, float amount) { auto* dst = pixels.data() + (size_t(y) * W + x) * 4; + // Straight-alpha source-over, including strokes in the transparent margin. + const float alpha = amount * color[3] / 255.f; + const float old_alpha = dst[3] / 255.f; + const float out_alpha = alpha + old_alpha * (1.f - alpha); + if (out_alpha <= 0.f) return; for (int k = 0; k < 3; ++k) - dst[k] = static_cast(dst[k] * (1.f - amount) + color[k] * amount); + dst[k] = static_cast((dst[k] * old_alpha * (1.f - alpha) + color[k] * alpha) / out_alpha); + dst[3] = static_cast(255.f * out_alpha); } void rounded(Rect r, int radius, Color fill, Color edge, float glow = 0.f, int stroke = 1) { // Distance-field antialiasing and restrained baked glow. No second texture or GPU pass. @@ -166,12 +171,12 @@ struct PanelSurface::Impl { void frame() { // Independently rasterized version of kouseki's HUD visual language: // rounded mint-to-blue perimeter and a second shallow curved accent. - for (int y = 0; y < H; ++y) for (int x = 0; x < W; ++x) { - if (x > 34 && x < W - 34 && y > 34 && y < H - 34) continue; - const float distance = rounded_distance(x, y, 10, 10, W - 20, H - 20, 18.f); + for (int y = 0; y < CH; ++y) for (int x = 0; x < CW; ++x) { + if (x > 34 && x < CW - 34 && y > 34 && y < CH - 34) continue; + const float distance = rounded_distance(x, y, 10, 10, CW - 20, CH - 20, 18.f); const float edge = std::abs(distance); const float strength = edge <= 1.f ? 1.f : .30f * std::max(0.f, 1.f - (edge - 1.f) / 6.f); - const float t = float(x) / W; + const float t = float(x) / CW; Color gradient{}; for (int k = 0; k < 3; ++k) gradient[k] = static_cast(theme.frame_start[k] * (1.f - t) + theme.frame_end[k] * t); @@ -180,9 +185,9 @@ struct PanelSurface::Impl { for (int k = 0; k < 3; ++k) dst[k] = static_cast(background[k] + (gradient[k] - background[k]) * strength); dst[3] = distance <= 1.f ? 255 : static_cast(255 * std::max(0.f, 1.f - (distance - 1.f) / 6.f)); } - for (int x = 32; x < W - 32; ++x) { - const float t = float(x - 32) / (W - 64); - const int y = H - 21 - int(5 * std::sin(t * 3.14159265f)); + for (int x = 32; x < CW - 32; ++x) { + const float t = float(x - 32) / (CW - 64); + const int y = CH - 21 - int(5 * std::sin(t * 3.14159265f)); Color gradient{}; for (int k = 0; k < 3; ++k) gradient[k] = static_cast(theme.frame_start[k] * (1.f - t) + theme.frame_end[k] * t); @@ -266,7 +271,7 @@ struct PanelSurface::Impl { } void reset() { pressed.fill(-1); - resize_cursor = -1; + drag_cursor = -1; } bool render(const Panel& p) { if (p.recording != panel.recording || p.enabled != panel.enabled || @@ -282,7 +287,8 @@ struct PanelSurface::Impl { dirty = true; panel = p; if (!dirty) return false; - rect({0, 0, W, H}, background); + std::fill(pixels.begin(), pixels.end(), 0); + rect({0, 0, CW, CH}, background); frame(); text("FrameYap", 32, 61, 40, ink, 300); text(auto_insert ? "ON-DEVICE / AUTO INSERT OPT-IN" : "ON-DEVICE / REVIEW FIRST", 280, 58, 22, muted, 720); @@ -341,11 +347,12 @@ struct PanelSurface::Impl { active ? cyan : muted, b.r.x + b.r.w - 12); } } - // Separate from the Quit hitbox. The three diagonal marks remain - // visible on both tabs, slightly inset from the lower-right corner. - for (int n = 0; n < 3; ++n) - for (int i = 0; i < 13 + 5 * n; ++i) - rect({956 - i, 657 + 5 * n + i / 3, 2, 2}, cyan); + // 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); dirty = false; return true; } @@ -356,30 +363,27 @@ PanelSurface::~PanelSurface() = default; bool PanelSurface::render(const Panel& p) { return impl_->render(p); } const std::vector& PanelSurface::pixels() const { return impl_->pixels; } bool PanelSurface::available(UiAction a) const { return impl_->available(a); } -std::optional PanelSurface::pointer_move(unsigned cursor, float x, float y) { - if (cursor >= impl_->pressed.size() || int(cursor) != impl_->resize_cursor || - !std::isfinite(x) || !std::isfinite(y)) return {}; - // OpenVR supplies coordinates on the *current* panel. Work incrementally - // so resizing never depends on a stale down-position after a width change. - const float delta = (x - impl_->resize_x) / W - (y - impl_->resize_y) / H; - impl_->resize_x = x; impl_->resize_y = y; - return std::clamp(1.f + delta, .9f, 1.1f); +bool PanelSurface::dragging(unsigned cursor) const { + return cursor < impl_->pressed.size() && int(cursor) == impl_->drag_cursor; } -void PanelSurface::pointer_down(unsigned cursor, float x, float y) { - if (cursor >= impl_->pressed.size()) return; - if (impl_->resize_cursor < 0 && std::isfinite(x) && std::isfinite(y) && - x >= grip_x && x < grip_x + grip_w && y >= grip_y && y < grip_y + grip_h) { - impl_->resize_cursor = int(cursor); - impl_->resize_x = x; impl_->resize_y = y; - impl_->pressed[cursor] = -1; - return; +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)) { + impl_->reset(); // other cursor's prior approval cannot survive relocation + impl_->drag_cursor = int(cursor); + return contains(grab) ? PanelDragKind::Grab : PanelDragKind::Scale; } impl_->pressed[cursor] = impl_->hit(x, y); + return {}; } SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y) { SurfaceEvent result; if (cursor >= impl_->pressed.size()) return result; - if (impl_->resize_cursor == int(cursor)) { impl_->resize_cursor = -1; return result; } + if (impl_->drag_cursor >= 0) { + if (impl_->drag_cursor == int(cursor)) impl_->reset(); + return result; + } int index = std::exchange(impl_->pressed[cursor], -1); if (index < 0 || impl_->hit(x, y) != index) return result; auto c = buttons[index].id; diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index a682f19..6ccabb6 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -1,6 +1,7 @@ #pragma once #include "overlay.hpp" #include "config.hpp" +#include "panel_drag.hpp" #include #include #include @@ -20,17 +21,21 @@ struct SurfaceEvent { // status and safety controls remain on the same surface. class PanelSurface { public: - static constexpr int width = 1000, height = 680; + struct Bounds { int x, y, w, h; }; + static constexpr int width = 1080, height = 780; + static constexpr Bounds body{0, 0, 1000, 680}; + static constexpr Bounds grab{370, 698, 260, 52}; + static constexpr Bounds scale{996, 680, 68, 68}; PanelSurface(const std::string& font, Mount mount, Theme theme = {}); ~PanelSurface(); PanelSurface(const PanelSurface&) = delete; PanelSurface& operator=(const PanelSurface&) = delete; bool render(const Panel& panel); const std::vector& pixels() const; - void pointer_down(unsigned cursor, float x, float y); + // Handles capture one cursor and never authorize a UI action on release. + std::optional pointer_down(unsigned cursor, float x, float y); SurfaceEvent pointer_up(unsigned cursor, float x, float y); - // Returns a relative physical-width change while dragging the resize grip. - std::optional pointer_move(unsigned cursor, float x, float y); + bool dragging(unsigned cursor) const; void reset_pointers(); void set_placement_note(std::string note); void set_lasers_anytime(bool enabled); diff --git a/tests/panel_drag_test.cpp b/tests/panel_drag_test.cpp new file mode 100644 index 0000000..0f64452 --- /dev/null +++ b/tests/panel_drag_test.cpp @@ -0,0 +1,156 @@ +#include "panel_drag.hpp" + +#include +#include +#include + +using namespace frameyap; +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 near(float actual, float expected) { assert(std::abs(actual - expected) < 1e-4f); } +Matrix34 translated(Matrix34 pose, float x, float y, float z) { + pose[0][3] += x; pose[1][3] += y; pose[2][3] += z; + return pose; +} +Matrix34 controller_at(float x, float y, float z = 1.f) { + return translated(identity, x, y, z); +} +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. + const auto down = controller_at(x - .5f, .5f - y); + 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); + 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); +} +// 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}}}}; +} +} // namespace + +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)); + 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); + drag.reset(); assert(!drag.active()); assert(!drag.update(down)); + + // Independent horizontal, vertical, and diagonal anchor-based scaling; + // both contraction and growth, including intersections outside the panel. + for (float factor : {.4f, 1.f, 1.6f, 3.f}) { + scale_case(.5f, 0.f, factor); + 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(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 + assert(moved); near(moved->factor, 1); + drag.reset(); + assert(!drag.begin(PanelDragKind::Scale, identity, 1, 1, 0, 0, + controller_at(-.5f, .5f))); // zero anchor baseline + 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. + auto angled_down = controller_at(0, 0); + assert(drag.begin(PanelDragKind::Grab, 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); + + 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)); + moved = drag.update(translated(corner_ray, 0, -.25f, -.25f)); + assert(moved); near(moved->factor, 2.f); + + // 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); + 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); + 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); + + const float nan = std::numeric_limits::quiet_NaN(); + const float inf = std::numeric_limits::infinity(); + auto bad = identity; bad[2][3] = nan; + assert(!drag.begin(PanelDragKind::Grab, bad, 1, 1, .5f, .5f, down)); + assert(!drag.active()); assert(!drag.update(down)); + bad = identity; bad[0][0] = 2; // not rigid + assert(!drag.begin(PanelDragKind::Grab, bad, 1, 1, .5f, .5f, down)); + bad = down; bad[0][3] = inf; + assert(!drag.begin(PanelDragKind::Grab, identity, 1, 1, .5f, .5f, bad)); + bad = down; bad[0][0] = -1; // reflection, not a proper rotation + assert(!drag.begin(PanelDragKind::Grab, identity, 1, 1, .5f, .5f, bad)); + assert(!drag.begin(PanelDragKind::Grab, identity, 0, 1, .5f, .5f, down)); + 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, + controller_at(1.f, 0.f, 1e-7f))); // near parallel at down + assert(!drag.begin(PanelDragKind::Grab, 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)); + 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.active()); // invalid update leaves original calibration intact + assert(drag.update(down)); + drag.reset(); assert(!drag.active()); +} diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index 890ce0c..ade6df5 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -61,19 +61,34 @@ int main(int argc, char** argv) { no_action(click(surface, std::numeric_limits::quiet_NaN(), 610)); no_action(click(surface, -1, 610)); no_action(click(surface, 1000, 680)); - // Lower-right grip scales without activating the nearby Quit button or - // uploading new pixels. Other cursors cannot hijack an active drag. - surface.pointer_down(0, 951, 656); - assert(!surface.pointer_move(1, 970, 650)); - auto factor = surface.pointer_move(0, 971, 641); - assert(factor && *factor > 1.f); + // Outside handles capture one cursor, invalidate all previous approvals, + // and never activate Quit/Record on release. No repaint during manipulation. + assert(surface.pointer_down(0, 1028, 712) == PanelDragKind::Scale); + assert(surface.dragging(0) && !surface.dragging(1)); + assert(!surface.pointer_down(1, 500, 724)); + no_action(surface.pointer_up(1, 1028, 712)); + assert(surface.dragging(0)); no_action(surface.pointer_up(0, 900, 610)); - assert(!surface.pointer_move(0, 975, 640)); + assert(!surface.dragging(0)); assert(!surface.render(p)); - surface.pointer_down(0, 951, 656); + surface.pointer_down(1, 100, 610); + assert(surface.pointer_down(0, 500, 724) == PanelDragKind::Grab); + no_action(surface.pointer_up(1, 100, 610)); + no_action(surface.pointer_up(0, 100, 610)); + no_action(surface.pointer_up(1, 100, 610)); + assert(surface.pointer_down(0, 1028, 712) == PanelDragKind::Scale); surface.reset_pointers(); - assert(!surface.pointer_move(0, 971, 641)); - no_action(surface.pointer_up(0, 951, 656)); + assert(!surface.dragging(0)); + no_action(surface.pointer_up(0, 1028, 712)); + no_action(click(surface, 951, 656)); // retired inset grip + no_action(click(surface, 800, 724)); // empty transparent margin + assert(!surface.pointer_down(0, 996, 680)); // clipped handle corner + const auto alpha = [&](int x, int y) { return surface.pixels()[(y * PanelSurface::width + x) * 4 + 3]; }; + assert(alpha(500, 723) == 255 && alpha(1040, 705) == 255); + assert(alpha(800, 724) == 0 && alpha(1050, 760) == 0); + assert(alpha(500, 690) == 0 && alpha(1030, 704) == 0); + // Antialiased handle edges carry real alpha, not RGB hidden at alpha zero. + assert(alpha(401, 721) > 0 && alpha(401, 721) < 255); p.enabled = true; p.record_available = false; @@ -207,10 +222,9 @@ int main(int argc, char** argv) { assert(surface.pixels() == replaced); p.status = std::string(4096, 's'); p.detail = std::string(4096, 'd'); assert(surface.render(p)); assert(!surface.render(p)); - // Laser motion and a press do not re-upload raw pixels. A completed - // action still reaches the caller; meaningful panel changes redraw. - surface.pointer_move(0, 900, 610); assert(!surface.render(p)); - surface.pointer_move(0, 900, 610); assert(!surface.render(p)); + // A press does not re-upload pixels. A completed action still reaches the + // caller; meaningful panel changes redraw. + assert(!surface.render(p)); surface.pointer_down(0, 900, 610); assert(!surface.render(p)); assert(surface.pointer_up(0, 900, 610).action == UiAction::Quit); assert(!surface.render(p));