Files
Frametop--frametop/pointer/tests/rayowner-test.cpp
T
DeeJanuzandClaude Opus 5.5 07ce0d9d59 Hand ray: the laser stays with one hand, and a pinch clicks where it pointed
Live test 2026-10-09: taps took several tries and the laser jumped between
hands. The owner's ray dropping for a moment (closing the fingers turns the
hand) handed the laser to the other raised hand at once, for good; a pinch
by the other hand pressed where the old owner's ray pointed; a pinch whose
ray had just dropped became a press at a stale spot, or only woke the
pointer.

- The owner keeps the laser through POINTER_RAY_GRACE (0.5 s) of its ray
  being down; the other hand takes it by pinching, by raising its ray while
  the owner's is down, or after the grace (pointer/helper/rayowner.h).
- A pinch on a ray that dropped within the grace, or is on its way up,
  brings it back (fh_ray.h ShowForPinch) and is a ray pinch.
- Aim lock: the press goes along the ray from just before the fingers
  began to close, after a pose along it has gone out; a tap that ends
  before its press clicks there too.
- Each ray follows one tracked hand through a brief flip of ft-hands'
  left/right call.
- Log: owner changes with the reason, the owner's ray down and back, and
  one line per pinch saying what it clicked or why not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:12:46 -06:00

374 lines
15 KiB
C++

// Unit tests for the hand ray's owner, slots and aim lock (pointer/helper/rayowner.h), and a pinch
// bringing a ray back (hands/include/fh_ray.h's ShowForPinch): made-up timelines at the helper's
// 8 ms frame and ft-hands' 30 Hz.
// Build and run: just test-c (or g++ -std=c++17 -I. pointer/tests/rayowner-test.cpp).
#include "pointer/helper/rayowner.h"
#include <cmath>
#include <cstdio>
#include <functional>
#include <string>
#include <vector>
using namespace rayown;
using fhray::kDeg;
static int failures = 0;
#define CHECK(cond, ...) \
do { \
if (!(cond)) { \
std::printf("FAIL %s:%d: ", __FILE__, __LINE__); \
std::printf(__VA_ARGS__); \
std::printf("\n"); \
++failures; \
} \
} while (0)
namespace {
constexpr double kDt = 0.008;
constexpr double kFrame = 1 / 30.0;
// The owner, stepped with each side's ray up or down (and allowed to point).
struct World {
Owner o;
double t = 0;
bool shown[2] = {false, false}, may[2] = {true, true};
std::string why[2] = {"palm", "palm"};
std::vector<Change> changes;
std::vector<std::string> log;
explicit World(Params p = {}) : o(p) {}
void Step() {
o.Update(t, shown, may, why);
for (const Change &c : o.changes) changes.push_back(c);
for (const std::string &l : o.log) log.push_back(l);
o.changes.clear(), o.log.clear();
t += kDt;
}
void Run(double secs, const std::function<void()> &each = {}) {
const double end = t + secs - 1e-9;
while (t < end) {
if (each) each();
Step();
}
}
};
void TestFirstRaised() {
World w;
w.Run(0.2);
CHECK(w.o.Side() == -1 && w.changes.empty(), "no ray: no owner");
w.shown[1] = true;
w.Step();
CHECK(w.o.Side() == 1, "the first ray up takes it: %d", w.o.Side());
CHECK(w.changes.size() == 1 && w.changes[0].from == -1 && w.changes[0].to == 1 && w.changes[0].why == "raised",
"one change, none -> right (raised)");
// the other one showing later doesn't take it
w.shown[0] = true;
w.Run(2);
CHECK(w.o.Side() == 1 && w.changes.size() == 1, "a second ray up leaves the owner: %d, %zu changes", w.o.Side(),
w.changes.size());
}
// The live complaint: both hands up, the owner's ray dropping for a moment as the fingers close.
void TestBriefDropKeepsTheOwner() {
World w;
w.shown[1] = true;
w.Step();
w.shown[0] = true; // the left hand up too, all along
w.Run(0.5);
w.changes.clear();
// ten drops of 0.3 s, one a second
for (int i = 0; i < 10; ++i) {
w.Run(0.7);
w.shown[1] = false;
w.Step();
w.Run(0.29, [&] { CHECK(w.o.Side() == 1 && w.o.Waiting(), "right keeps it while its ray is down"); });
w.shown[1] = true;
}
w.Run(0.1);
CHECK(w.changes.empty(), "brief drops: no owner change (%zu)", w.changes.size());
CHECK(!w.o.Waiting(), "back up: not waiting");
int downs = 0, backs = 0;
for (const std::string &l : w.log) {
downs += l.find(": down (palm)") != std::string::npos;
backs += l.find(": back after") != std::string::npos;
}
CHECK(downs == 10 && backs == 10, "each drop logs once down, once back: %d %d", downs, backs);
// as it was (no grace): every drop handed the laser to the other hand, and it stayed there
Params none;
none.grace = 0;
World old(none);
old.shown[1] = true;
old.Step();
old.shown[0] = true;
old.Run(0.5);
old.shown[1] = false;
old.Run(0.3);
old.shown[1] = true;
old.Run(0.5);
CHECK(old.o.Side() == 0, "without the grace a drop gives the other hand the ray: %d", old.o.Side());
}
void TestGraceRunsOut() {
World w;
w.shown[0] = w.shown[1] = true;
w.Step();
CHECK(w.o.Side() == 0 || w.o.Side() == 1, "an owner");
const int o = w.o.Side(), other = 1 - o;
w.Run(0.5);
w.changes.clear();
w.shown[o] = false;
const double from = w.t;
double took = -1;
w.Run(1, [&] {
if (took < 0 && w.o.Side() == other) took = w.t;
});
CHECK(took >= from + 0.5 - 1e-9 && took < from + 0.5 + 2 * kDt, "the other hand's ray takes it after the grace: %.3f",
took - from);
CHECK(w.changes.size() == 1 && w.changes[0].why.find("down 0.5 s (palm)") != std::string::npos &&
w.changes[0].why.find("up: ") != std::string::npos,
"why: %s", w.changes.empty() ? "-" : w.changes[0].why.c_str());
// with no other ray up, the owner goes after the grace
World v;
v.shown[1] = true;
v.Run(0.3);
v.shown[1] = false;
v.Run(0.45);
CHECK(v.o.Side() == 1, "within the grace: still right");
v.Run(0.1);
CHECK(v.o.Side() == -1 && v.changes.back().to == -1 && !v.changes.back().off, "after it: none, not off");
}
void TestFreshRaiseTakesAtOnce() {
World w;
w.shown[1] = true;
w.Run(0.3);
w.shown[1] = false; // the right hand comes down
w.Run(0.1);
w.shown[0] = true; // and the left goes up (its ray shows, after the right's went)
w.Step();
CHECK(w.o.Side() == 0, "a ray raised while the owner's is down takes it at once: %d", w.o.Side());
CHECK(w.changes.back().why.find("left raised") != std::string::npos, "why: %s", w.changes.back().why.c_str());
}
void TestOffLeavesAtOnce() {
World w;
w.shown[0] = w.shown[1] = true;
w.Run(0.2);
const int o = w.o.Side();
w.may[o] = false, w.shown[o] = false; // its controller in use: fh_ray hides it ("off")
w.Step();
CHECK(w.changes.back().from == o && w.changes.back().to == -1 && w.changes.back().off, "off: leaves at once, off");
w.Step();
CHECK(w.o.Side() == 1 - o, "then the other ray up takes it: %d", w.o.Side());
// near a panel (ft-screens' poke): at once too, not off
World v;
v.shown[1] = true;
v.Run(0.2);
v.shown[1] = false, v.why[1] = "near";
v.Step();
CHECK(v.o.Side() == -1 && !v.changes.back().off && v.changes.back().why == "near a panel", "near: leaves at once");
}
void TestPinches() {
World w;
w.shown[0] = w.shown[1] = true;
w.Step();
const int o = w.o.Side(), other = 1 - o;
w.changes.clear();
CHECK(w.o.Pinched(o, true) == Owner::Pinch::Ray, "the owner's pinch: a ray pinch");
CHECK(w.o.Pinched(other, false) == Owner::Pinch::None, "the other hand without a ray: not a ray pinch");
CHECK(w.o.Side() == o, "and it leaves the owner");
CHECK(w.o.Pinched(other, true) == Owner::Pinch::Take, "the other hand with its ray up: takes it");
CHECK(w.o.Side() == other && w.o.changes.back().why.find("pinched") != std::string::npos, "taken by the pinch");
w.o.changes.clear();
// the owner's ray down, a pinch on it within the grace: back, a ray pinch, no change
w.shown[other] = false;
w.Run(0.2);
CHECK(w.o.Waiting(), "waiting");
CHECK(w.o.Pinched(other, true) == Owner::Pinch::Ray && !w.o.Waiting(), "the owner's pinch brings it back");
w.shown[other] = true;
w.Run(1);
CHECK(w.o.Side() == other && w.changes.empty(), "still the owner, no change");
// no owner at all: a pinch with a ray that can come up takes it
World v;
CHECK(v.o.Pinched(1, true) == Owner::Pinch::Take && v.o.Side() == 1, "no owner: the pinch takes it");
v.o.Drop("the mouse");
CHECK(v.o.Side() == -1 && v.o.changes.back().why == "the mouse", "dropped");
}
void TestSlots() {
Params p;
Slots sl(p);
int pick[2];
double t = 0;
std::vector<SeenHand> hands = {{3, 1}, {4, 0}};
sl.Update(t, hands, pick);
CHECK(pick[0] == 1 && pick[1] == 0, "by ft-hands' call: %d %d", pick[0], pick[1]);
CHECK(sl.SideOf(3) == 1 && sl.SideOf(4) == 0 && sl.SideOf(9) == -1, "SideOf");
// the calls swap for 0.2 s: each ray keeps its hand
for (int i = 0; i < 6; ++i) {
t += kFrame;
sl.Update(t, {{3, 0}, {4, 1}}, pick);
CHECK(pick[1] == 0 && pick[0] == 1, "a brief swap: the rays keep their hands (%d %d)", pick[0], pick[1]);
}
t += kFrame;
sl.Update(t, hands, pick);
CHECK(pick[0] == 1 && pick[1] == 0, "back: unchanged");
// ft-hands settles on the other call: after 0.3 s the rays move
double moved = -1;
const double from = t + kFrame;
for (int i = 0; i < 20 && moved < 0; ++i) {
t += kFrame;
sl.Update(t, {{3, 0}, {4, 1}}, pick);
if (pick[0] == 0) moved = t;
}
CHECK(moved >= from + 0.3 - 1e-9 && moved < from + 0.3 + kFrame + 1e-9, "a held call moves the rays after 0.3 s: %.3f",
moved - from);
CHECK(pick[1] == 1 && sl.SideOf(3) == 0 && sl.SideOf(4) == 1, "and both follow the call (%d %d)", pick[0], pick[1]);
// both called right: the right ray keeps its hand, the left keeps its own for the hold
Slots d(p);
t = 0;
d.Update(t, {{7, 0}, {8, 1}}, pick);
t += kFrame;
d.Update(t, {{7, 1}, {8, 1}}, pick);
CHECK(pick[0] == 0 && pick[1] == 1, "both called right: no hand changes ray (%d %d)", pick[0], pick[1]);
// one hand gone, a new one called that side
t += kFrame;
d.Update(t, {{8, 1}, {9, 0}}, pick);
CHECK(pick[0] == 1 && pick[1] == 0 && d.SideOf(9) == 0, "a new hand takes the free ray (%d %d)", pick[0], pick[1]);
// a lone hand's call flips for a frame: no ray changes hand
Slots one(p);
t = 0;
one.Update(t, {{5, 1}}, pick);
t += kFrame;
one.Update(t, {{5, 0}}, pick);
CHECK(pick[1] == 0 && pick[0] == -1, "a lone hand's flip: still the right ray's (%d %d)", pick[0], pick[1]);
}
fhray::Ray RayAt(double yawDeg) {
fhray::Ray r;
r.dir = fhray::Dir(yawDeg * kDeg, 0);
r.origin = {0.2, 1.3, -0.4};
return r;
}
double YawDeg(const fhray::Ray &r) { return fhray::YawOf(r.dir) / kDeg; }
void TestAimLock() {
// pointing at yaw 0 with the fingers open (6 cm), then closing over 150 ms, turning the ray 3 deg
// down to the begin (2 cm, when ft-hands sees the pinch)
AimHistory h;
const double begin = 2.0;
for (double t = 1.0; t <= begin + 1e-9; t += kFrame) {
const double closing = std::clamp((t - (begin - 0.15)) / 0.15, 0.0, 1.0);
h.Add(t, RayAt(-3 * closing), true, 0.06 - 0.04 * closing);
}
fhray::Ray r;
double age = -1;
CHECK(h.Lock(begin, 0.035, r, age), "a lock");
CHECK(std::fabs(YawDeg(r)) < 1.3, "the aim before the fingers closed far: %.2f deg", YawDeg(r));
CHECK(age > 0.05 && age < 0.15, "from the frame the tips were last apart: %.3f s", age);
// without the distance (no skeleton): the frame 0.1 s before
AimHistory n;
for (double t = 1.0; t <= begin + 1e-9; t += kFrame) n.Add(t, RayAt(-20 * (t - 1)), true, -1);
CHECK(n.Lock(begin, 0.035, r, age), "a lock without distances");
CHECK(age >= 0.1 - 1e-9 && age < 0.1 + kFrame, "0.1 s back: %.3f", age);
// fingers resting nearly closed (never past end_m in the window): 0.1 s back too
AimHistory c;
for (double t = 1.0; t <= begin + 1e-9; t += kFrame) c.Add(t, RayAt(0), true, 0.025);
CHECK(c.Lock(begin, 0.035, r, age) && age >= 0.1 - 1e-9 && age < 0.1 + kFrame, "resting closed: %.3f", age);
// frames with the ray down don't count; none up in the window: no lock
AimHistory d;
for (double t = 1.0; t <= begin + 1e-9; t += kFrame) d.Add(t, RayAt(t > 1.8 ? 10 : 0), t > 1.8, 0.06);
CHECK(d.Lock(begin, 0.035, r, age) && std::fabs(YawDeg(r) - 10) < 1e-6, "only frames with the ray up");
AimHistory e;
for (double t = 1.0; t <= begin + 1e-9; t += kFrame) e.Add(t, RayAt(0), t < 1.5, 0.06);
CHECK(!e.Lock(begin, 0.035, r, age), "nothing up within 0.35 s: no lock");
// the ray just showed (less than 0.1 s of it, fingers closed): its first frame
AimHistory f;
for (double t = 1.0; t <= begin + 1e-9; t += kFrame) f.Add(t, RayAt(t), t > begin - 0.07, 0.01);
CHECK(f.Lock(begin, 0.035, r, age) && age <= 0.07 + 1e-9 && age > 0.03, "a ray just up: its oldest frame (%.3f)", age);
}
// fh_ray's ShowForPinch: a pinch brings back a ray that just dropped, not one sent away.
fhray::HandFrame Pointing(double t) {
fhray::HandFrame f;
f.t = t, f.stereo = true;
f.knuckle = {0.18, 1.35, -0.45};
f.wrist = {0.17, 1.33, -0.36};
f.palmNormal = fhray::Norm({0, -0.5, -1});
f.elbow = {0.2, 1.15, -0.1};
return f;
}
void TestShowForPinch() {
const fhray::V3 eye{0, 1.6, 0}, fwd{0, 0, -1};
fhray::Body b;
fhray::HandRay r(1);
double t = 0;
auto run = [&](double secs, const std::function<fhray::HandFrame(double)> &make, bool may = true) {
const double end = t + secs - 1e-9;
while (t < end) {
b.Update(t, eye, fwd);
r.Frame(b, make(t), may);
t += kFrame;
}
};
auto palmIn = [](double tt) {
fhray::HandFrame f = Pointing(tt);
f.palmNormal = {0, 0, 1};
return f;
};
run(0.5, Pointing);
CHECK(r.Shown(), "pointing: shown");
run(0.4, palmIn);
CHECK(!r.Shown() && r.event == "hide palm", "palm toward you: hidden (%s)", r.event.c_str());
CHECK(r.CanShowForPinch(t, 0.5), "just hid: a pinch may bring it back");
r.ShowForPinch();
r.Hold(true, t);
CHECK(r.Shown(), "brought back");
run(1, palmIn);
CHECK(r.Shown(), "held by the pinch whatever the pose");
r.Hold(false, t);
run(0.8, palmIn);
CHECK(!r.Shown(), "after the pinch: hides again");
run(0.6, palmIn);
CHECK(!r.CanShowForPinch(t, 0.5), "hid more than the grace ago: no");
// hidden for "off" (its controller in use): never
fhray::HandRay q(1);
t = 0;
while (t < 0.5) b.Update(t, eye, fwd), q.Frame(b, Pointing(t), true), t += kFrame;
b.Update(t, eye, fwd), q.Frame(b, Pointing(t), false);
CHECK(!q.Shown() && !q.CanShowForPinch(t, 0.5), "hidden off: a pinch can't bring it back");
// not shown yet but every show test passing (raised and pinched within 120 ms): yes
fhray::HandRay s(1);
t = 0;
b.Update(t, eye, fwd), s.Frame(b, Pointing(t), true);
CHECK(!s.Shown() && s.CanShowForPinch(t, 0.5), "on its way up: a pinch may show it");
fhray::HandRay lap(1);
lap.Frame(b, palmIn(0), true);
CHECK(!lap.CanShowForPinch(0, 0.5), "never up, failing a test: no");
}
} // namespace
int main() {
TestFirstRaised();
TestBriefDropKeepsTheOwner();
TestGraceRunsOut();
TestFreshRaiseTakesAtOnce();
TestOffLeavesAtOnce();
TestPinches();
TestSlots();
TestAimLock();
TestShowForPinch();
if (failures) {
std::printf("%d failures\n", failures);
return 1;
}
std::printf("rayowner-test: ok\n");
return 0;
}