Add rigid controller grab and layout lock; fix scale hit mask

This commit is contained in:
baketnk committed 2026-09-24 17:01:33 -04:00
1 parent 9c7fdae264
commit 0d4cfbfdb7
15 files changed
+387 -162

No files matched your search

+6 -4
View File
@@ -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.
+1
View File
@@ -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},
+30 -14
View File
@@ -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
+3
View File
@@ -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")
+3
View File
@@ -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")
+6
View File
@@ -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);
}
+2
View File
@@ -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);
+55 -46
View File
@@ -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<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)
@@ -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<Mount> 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<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");
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<vr::VROverlayIntersectionMaskPrimitive_t> 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);
+53 -15
View File
@@ -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<float>(value.x);
result[1][c] = static_cast<float>(value.y);
result[2][c] = static_cast<float>(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<float>(value.x);
result[1][c] = static_cast<float>(value.y);
result[2][c] = static_cast<float>(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<PanelDragUpdate> 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<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)};
const double factor = dot(hit - anchor_, down_hit_ - anchor_) / baseline_sq_;
if (!finite(hit) || !std::isfinite(factor) ||
!std::isfinite(static_cast<float>(factor))) return std::nullopt;
return PanelDragUpdate{panel_pose_, static_cast<float>(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;
};
+39 -23
View File
@@ -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<Button, 20> buttons{{
constexpr std::array<Button, 21> 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<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)) {
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<DateFormat>((static_cast<int>(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::Bounds> PanelSurface::input_regions() const {
std::vector<Bounds> 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; }
}
+4
View File
@@ -15,6 +15,7 @@ struct SurfaceEvent {
std::optional<bool> lasers_anytime;
std::optional<bool> advanced_debug;
std::optional<bool> auto_insert;
std::optional<bool> lock_layout;
std::optional<bool> clock_24h;
std::optional<DateFormat> date_format;
bool recenter = false;
@@ -39,6 +40,9 @@ public:
std::optional<PanelDragKind> 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<Bounds> 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);
+27
View File
@@ -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})",
+116 -59
View File
@@ -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<float>::quiet_NaN();
const float inf = std::numeric_limits<float>::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<float>::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));
}
+24 -1
View File
@@ -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));
+18
View File
@@ -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)