Fix grab and scale with transparent external handles

This commit is contained in:
baketnk committed 2026-09-24 16:37:33 -04:00
1 parent 468f35622e
commit 33533ba8a2
9 files changed
+527 -86

No files matched your search

+4
View File
@@ -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)
+4 -2
View File
@@ -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
+34 -10
View File
@@ -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
+126 -21
View File
@@ -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<const char*, 6> 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<Mount> 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<vr::VROverlayIntersectionMaskPrimitive_t, 3> mask{};
const std::array<PanelSurface::Bounds, 3> 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<Matrix34> 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;
+127
View File
@@ -0,0 +1,127 @@
#pragma once
#include "mount.hpp"
#include <cmath>
#include <optional>
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<double>(width) * .5) +
up * (static_cast<double>(height) * .5);
const Vec3 down_hit = anchor + right * (static_cast<double>(width) * hit_x) -
up * (static_cast<double>(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<PanelDragUpdate> 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<float>(dx)) ||
!std::isfinite(static_cast<float>(dy)) || !std::isfinite(static_cast<float>(factor)))
return std::nullopt;
return PanelDragUpdate{static_cast<float>(dx), static_cast<float>(dy), static_cast<float>(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
+39 -35
View File
@@ -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<int, 2> pressed{{-1, -1}};
int resize_cursor = -1;
float resize_x = 0.f, resize_y = 0.f;
int drag_cursor = -1;
std::vector<std::string> 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<unsigned char>(dst[k] * (1.f - amount) + color[k] * amount);
dst[k] = static_cast<unsigned char>((dst[k] * old_alpha * (1.f - alpha) + color[k] * alpha) / out_alpha);
dst[3] = static_cast<unsigned char>(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<unsigned char>(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<unsigned char>(background[k] + (gradient[k] - background[k]) * strength);
dst[3] = distance <= 1.f ? 255 : static_cast<unsigned char>(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<unsigned char>(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<unsigned char>& PanelSurface::pixels() const { return impl_->pixels; }
bool PanelSurface::available(UiAction a) const { return impl_->available(a); }
std::optional<float> 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<PanelDragKind> 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;
+9 -4
View File
@@ -1,6 +1,7 @@
#pragma once
#include "overlay.hpp"
#include "config.hpp"
#include "panel_drag.hpp"
#include <memory>
#include <optional>
#include <string>
@@ -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<unsigned char>& 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<PanelDragKind> 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<float> 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);
+156
View File
@@ -0,0 +1,156 @@
#include "panel_drag.hpp"
#include <cassert>
#include <cmath>
#include <limits>
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<float>::quiet_NaN();
const float inf = std::numeric_limits<float>::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());
}
+28 -14
View File
@@ -61,19 +61,34 @@ int main(int argc, char** argv) {
no_action(click(surface, std::numeric_limits<float>::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));