Files
Frametop--frametop/pointer/tests/smooth-test.cpp
T
DeeJanuzandClaude Opus 5.5 0d6b0c93b5 Hand ray: drags move smoothly and hold steady, and a quick tap stays a click
The ray's One Euro filters (new hands/include/fh_smooth.h) run in continuous time and the
helper reads the ray every loop, so the pose sent to ft_pointer moves at about 100 Hz
instead of jumping at the tracker's 15-30 Hz (the dashboard's dragger made those jumps
jerky). The direction is filtered on the sphere. On the probe-moves replay the pose's
second difference per 90 Hz tick went from 0.90-0.96 deg rms to 0.24-0.26 at the same lag.

While a ray pinch is held the ray pivots on the shoulder as it was at the press, so looking
around doesn't move what's dragged; the reach for push and pull is filtered. A pinch's
first 0.3 s takes 6 deg of turn before it drags. One failed read of the gestures file no
longer ends a drag, and the log says why a drag ended or the ray went off.

Settings: POINTER_RAY_SMOOTH_MINCUTOFF, POINTER_RAY_SMOOTH_BETA, POINTER_RAY_SMOOTH_HOLD,
POINTER_RAY_STEADY, POINTER_RAY_TAP, POINTER_RAY_TAP_DEADZONE. rayeval reads publish dumps
and reports the smoothness at the display's rate.

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

266 lines
11 KiB
C++

// Unit tests for the smoothing of what hands move (hands/include/fh_smooth.h) and the hand ray's
// use of it (hands/include/fh_ray.h): made-up hands at ft-hands' 30 Hz, read at the display's
// 90 Hz. Jitter of a still noisy hand, lag behind a fast move, no overshoot when it stops, no
// steps between tracker frames, a fresh start for a new grab, a drag's precision, and the steady
// shoulder during a ray pinch.
// Build and run: just test-c (or g++ -std=c++17 -I. pointer/tests/smooth-test.cpp).
#include "hands/include/fh_ray.h"
#include "hands/include/fh_smooth.h"
#include <cmath>
#include <cstdio>
#include <random>
#include <vector>
using namespace fhsmooth;
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 kDeg = M_PI / 180, kFrame = 1 / 30.0, kTick = 1 / 90.0, kLatency = 0.035;
// The ray's defaults (fh_ray.h's Params).
const Params kRay{0.8, 0.06, 1.0};
V3 Yaw(double deg) { return {-std::sin(deg * kDeg), 0, -std::cos(deg * kDeg)}; }
double YawDeg(V3 d) { return std::atan2(-d.x, -d.z) / kDeg; }
double YawDeg(const fhray::Ray &r) { return YawDeg(r.dir); }
double Sd(const std::vector<double> &v) {
double m = 0, q = 0;
for (double x : v) m += x;
m /= double(v.size());
for (double x : v) q += (x - m) * (x - m);
return std::sqrt(q / double(v.size()));
}
// A direction filter fed `yaw(t)` (degrees) at 30 Hz, each sample arriving kLatency after its
// capture, read every 90 Hz tick: the ticks' times and yaws.
struct Run {
std::vector<double> t, out;
};
template <class F>
Run Drive(Direction &f, double from, double to, F yaw, std::mt19937 *rng = nullptr, double noise = 0) {
std::normal_distribution<double> n(0, noise > 0 ? noise : 1);
Run r;
double next = from;
for (double tick = from; tick < to; tick += kTick) {
while (next + kLatency <= tick + 1e-9) {
f.Sample(Yaw(yaw(next) + (rng ? n(*rng) : 0)), next, next + kLatency);
next += kFrame;
}
if (!f.Ready()) continue;
r.t.push_back(tick), r.out.push_back(YawDeg(f.At(tick)));
}
return r;
}
void TestStillJitter() {
// a still hand: 0.25 deg of raw wobble on the ray (2 mm on the knuckles at 0.45 m)
std::mt19937 rng(1);
Direction f(kRay);
std::vector<double> raw;
std::normal_distribution<double> n(0, 0.25);
for (int i = 0; i < 300; ++i) raw.push_back(n(rng));
std::mt19937 rng2(1);
Run r = Drive(f, 0, 10, [](double) { return 0.0; }, &rng2, 0.25);
const std::vector<double> settled(r.out.begin() + 180, r.out.end());
const double a = Sd(raw), c = Sd(settled);
std::printf("still: raw sd %.3f deg, at 90 Hz %.4f deg (%.0fx less)\n", a, c, a / c);
CHECK(c < a / 3, "a still hand's jitter down 3x at least: %.4f of %.3f", c, a);
// a drag's precision (fh_ray's holdMinCutoff 0.5): less still
Direction h({0.5, 0.06, 1.0});
std::mt19937 rng3(1);
Run rh = Drive(h, 0, 10, [](double) { return 0.0; }, &rng3, 0.25);
const double ch = Sd(std::vector<double>(rh.out.begin() + 180, rh.out.end()));
CHECK(ch < c, "held: less jitter still (%.4f against %.4f)", ch, c);
}
void TestFastMove() {
// a deliberate 30 deg turn at 100 deg/s, then still
Direction f(kRay);
auto yaw = [](double t) { return t < 1 ? 0.0 : std::min(30.0, (t - 1) * 100); };
Run r = Drive(f, 0, 3, yaw);
// lag while it turns: the delay behind the hand's yaw at mid-turn, latency included
double atMid = 0;
for (size_t i = 0; i < r.t.size(); ++i)
if (r.t[i] >= 1.15 && atMid == 0) atMid = r.out[i];
const double lagMs = (1.15 - (1 + atMid / 100)) * 1000;
std::printf("fast: 100 deg/s turn, %.0f ms behind at mid-turn (%.0f ms of it the latency)\n", lagMs, kLatency * 1000);
CHECK(lagMs < 120, "under 120 ms behind a fast turn: %.0f", lagMs);
// ...and within 1 deg of where it stopped 150 ms after the hand did
double at = 0;
for (size_t i = 0; i < r.t.size(); ++i)
if (r.t[i] <= 1.3 + 0.15) at = r.out[i];
CHECK(at > 29, "150 ms after the turn ended: at %.2f of 30 deg", at);
// no overshoot: never past 30, never back
double most = -1, back = 0;
for (size_t i = 0; i < r.out.size(); ++i) {
most = std::max(most, r.out[i]);
if (i && r.out[i] < r.out[i - 1] - 1e-9) back = std::max(back, r.out[i - 1] - r.out[i]);
}
CHECK(most <= 30 + 1e-6, "no overshoot: reached %.4f of 30", most);
CHECK(back < 1e-6, "never turns back: %.6f", back);
}
void TestNoSteps() {
// a steady 20 deg/s turn sampled at 30 Hz, read at 90 Hz: the ticks move evenly (the filter
// before this held still for two ticks and jumped on the third)
Direction f(kRay);
Run r = Drive(f, 0, 3, [](double t) { return 20 * t; });
std::vector<double> steps;
for (size_t i = 1; i < r.out.size(); ++i)
if (r.t[i] > 1.5) steps.push_back(r.out[i] - r.out[i - 1]);
double lo = 1e9, hi = 0, m = 0;
for (double s : steps) lo = std::min(lo, s), hi = std::max(hi, s), m += s / double(steps.size());
std::printf("steady turn: ticks move %.3f-%.3f deg (mean %.3f; a held filter: 0 or %.3f)\n", lo, hi, m, 20 * kFrame);
CHECK(lo > 0.7 * m && hi < 1.3 * m, "even steps at the display's rate: %.3f-%.3f around %.3f", lo, hi, m);
}
void TestReset() {
// a new grab starts from the hand as it is, not from where the last one ended
Point3 p({1.0, 2.0, 1.0});
for (int i = 0; i < 30; ++i) p.Sample({0.1, 0, 0}, i * kFrame, i * kFrame);
p.Reset();
p.Sample({0.5, 0.2, -0.3}, 2, 2);
const V3 v = p.At(2.01);
CHECK(Len(v - V3{0.5, 0.2, -0.3}) < 1e-9, "after Reset the first sample is the output: (%.3f %.3f %.3f)", v.x, v.y, v.z);
// a settings change (a drag's precision) doesn't move the output
Point3 q({1.0, 2.0, 1.0});
q.Sample({0, 0, 0}, 0, 0), q.Sample({0.01, 0, 0}, kFrame, kFrame);
const V3 before = q.At(0.05);
q.Set({0.3, 2.0, 1.0});
const V3 after = q.At(0.05);
CHECK(Len(after - before) < 1e-12, "Set doesn't jump the output");
}
void TestPointJitter() {
// a pinch point held still: 3 mm of noise per axis (ft-hands' depth), read at 90 Hz
std::mt19937 rng(2);
std::normal_distribution<double> n(0, 0.003);
Point3 p({1.0, 2.0, 1.0});
std::vector<double> raw, out;
double next = 0;
for (double tick = 0; tick < 10; tick += kTick) {
while (next + kLatency <= tick) {
const V3 v{n(rng), n(rng), n(rng)};
raw.push_back(v.x);
p.Sample(v, next, next + kLatency);
next += kFrame;
}
if (p.Ready() && tick > 2) out.push_back(p.At(tick).x);
}
const double a = Sd(raw), c = Sd(out);
std::printf("pinch point still: raw sd %.2f mm, at 90 Hz %.2f mm\n", a * 1000, c * 1000);
CHECK(c < a / 3, "3 mm of noise down 3x at least: %.2f mm", c * 1000);
}
void TestDirectionWrap() {
// straight behind (yaw +-180) and straight up: no angle to wrap, no pole
Direction f(kRay);
Run r = Drive(f, 0, 2, [](double t) { return 170 + 20 * t; }); // through 180
double worst = 0;
for (size_t i = 1; i < r.out.size(); ++i) worst = std::max(worst, std::fabs(std::remainder(r.out[i] - r.out[i - 1], 360.0)));
CHECK(worst < 1, "through yaw 180 without a jump: %.2f deg in a tick", worst);
Direction up(kRay);
up.Sample({0, 1, 0}, 0, 0);
up.Sample(Norm({0.01, 1, 0}), kFrame, kFrame);
const V3 u = up.At(0.2);
CHECK(std::fabs(Len(u) - 1) < 1e-9 && u.y > 0.999, "straight up stays a unit vector near up");
}
// The hand ray (fh_ray.h): a right hand pointing ahead; the head steps sideways.
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 = Norm({0, -0.5, -1});
f.elbow = {0.2, 1.15, -0.1};
return f;
}
void TestSteadyShoulder() {
// A ray pinch held: the head moves 4 cm sideways (a lean, a glance), the hand stays. Without
// the steady shoulder the ray turns about 5 deg; with it, it holds.
for (int steady = 0; steady < 2; ++steady) {
fhray::Params p;
p.holdShoulder = steady;
fhray::Body b(p);
fhray::HandRay r(1, p);
double t = 0;
for (int i = 0; i < 30; ++i, t += kFrame) b.Update(t, {0, 1.6, 0}, {0, 0, -1}), r.Frame(b, Pointing(t), true);
CHECK(r.Shown(), "the ray shows");
const double y0 = YawDeg(r.At(t));
r.Hold(true, t);
for (int i = 0; i < 45; ++i, t += kFrame) {
const double x = 0.04 * std::min(1.0, i / 15.0);
b.Update(t, {x, 1.6, 0}, {0, 0, -1}), r.Frame(b, Pointing(t), true);
}
const double turned = std::fabs(YawDeg(r.At(t)) - y0);
if (steady) CHECK(turned < 0.2, "steady shoulder: the head's lean leaves the held ray (%.2f deg)", turned);
else CHECK(turned > 3, "without: the lean turns the ray (%.2f deg)", turned);
// released: hideFor (0.25 s) on, it eases to the body model's shoulder over 0.5 s
r.Hold(false, t);
const double y1 = YawDeg(r.At(t));
double worst = 0, last = y1;
for (int i = 0; i < 60; ++i, t += kFrame) {
b.Update(t, {0.04, 1.6, 0}, {0, 0, -1}), r.Frame(b, Pointing(t), true);
for (int k = 0; k < 3; ++k) {
const double y = YawDeg(r.At(t + k * kTick));
worst = std::max(worst, std::fabs(y - last)), last = y;
}
}
fhray::HandRay live(1, p);
fhray::Body lb(p);
double tl = 0;
for (int i = 0; i < 60; ++i, tl += kFrame) lb.Update(tl, {0.04, 1.6, 0}, {0, 0, -1}), live.Frame(lb, Pointing(tl), true);
if (steady) {
CHECK(std::fabs(YawDeg(r.At(t)) - YawDeg(live.At(tl))) < 0.1, "back on the body model after the release: %.2f vs %.2f",
YawDeg(r.At(t)), YawDeg(live.At(tl)));
CHECK(worst < 0.25, "...without a jump: %.3f deg in a tick at most", worst);
}
}
}
void TestRayBetweenFrames() {
// the ray read between tracker frames moves on (the helper draws it at its own rate)
fhray::Body b;
fhray::HandRay r(1);
double t = 0;
for (int i = 0; i < 30; ++i, t += kFrame) b.Update(t, {0, 1.6, 0}, {0, 0, -1}), r.Frame(b, Pointing(t), true);
fhray::HandFrame f = Pointing(t);
f.knuckle = f.knuckle + V3{0.03, 0, 0}; // the hand moved 3 cm right
b.Update(t, {0, 1.6, 0}, {0, 0, -1}), r.Frame(b, f, true, t + kLatency);
const double a = YawDeg(r.At(t + kLatency)), m = YawDeg(r.At(t + kLatency + kTick)), c = YawDeg(r.At(t + kLatency + 2 * kTick));
CHECK(a > m && m > c, "between frames the ray turns toward the new one: %.3f %.3f %.3f", a, m, c);
}
} // namespace
int main() {
TestStillJitter();
TestFastMove();
TestNoSteps();
TestReset();
TestPointJitter();
TestDirectionWrap();
TestSteadyShoulder();
TestRayBetweenFrames();
if (failures) {
std::printf("%d failures\n", failures);
return 1;
}
std::printf("smooth-test: ok\n");
return 0;
}