Files
Frametop--frametop/screens/tests/handview_test.cpp
T
DeeJanuzandClaude Opus 5.5 9029a2d7a5 hands: drawn hands blend between frames, hold still when lost, refuse jumps
User, 2026-10-13: "implement interpolation for the rendered hands so that they
feel smoother", and "if the hand is lost it's preferable the rendered hand
display freeze instead of make impossible movements, spaz out, or vanish until a
few seconds have passed".

screens/handhold.h, per side in ft-handview:
- each tracking frame blends in joint by joint from the drawn pose over about
  the frame interval (15-70 ms), drawn every 15 ms meanwhile;
- a lost hand stays frozen where it was drawn for HANDS_VIEW_HOLD s (3);
- a frame 15 cm / 6 m/s off the last one taken, or with the palm turned over,
  waits for two agreeing frames (or 0.25 s) while the drawn hand holds still;
  a first-seen or returning hand waits for two agreeing frames too.
HANDS_VIEW_SMOOTH=0 gives the old behaviour. Tests in handview_test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:30:41 -06:00

682 lines
32 KiB
C++

// Offline tests for ft-handview's CPU side: Room View's detector (roomview.h) against a fake
// shared state, the rendered hand's mesh and square (handmesh.cpp), the hand model's posing
// (handskin.cpp), the robot hand's (handrobot.cpp), and the drawn hand between and past the
// tracking's frames (handhold.h). No OpenVR, GPU or headset.
//
// Build and run: the justfile's test-c.
#include "../handmesh.h"
#include "../handasset.h"
#include "../handhold.h"
#include "../handrobot.h"
#include "../handskin.h"
#include "../roomview.h"
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
namespace {
int g_fail = 0, g_checks = 0;
void Check(bool ok, int line, const char *what) {
++g_checks;
if (!ok) ++g_fail, std::printf("handview_test.cpp:%d: FAILED: %s\n", line, what);
}
#define CHECK(cond) Check((cond), __LINE__, #cond)
#define NEAR(a, b, tol) CHECK(std::fabs(double(a) - double(b)) <= (tol))
// A fake /dev/shm: the passthrough state with `enabled`, and a decoy of another size.
std::string FakeShm(int enabled) {
static std::string dir;
if (dir.empty()) {
char tmpl[] = "/tmp/handview-test-XXXXXX";
dir = mkdtemp(tmpl);
}
const std::string prefix = dir + "/u" + std::to_string(getuid()) + "-Shm_";
std::string data(roomview::kStateSize, '\0');
data[0] = 1, data[2] = char(enabled), data[3] = 1, data[4] = 1, data[6] = 1;
FILE *f = std::fopen((prefix + "abc").c_str(), "wb");
std::fwrite(data.data(), 1, data.size(), f);
std::fclose(f);
f = std::fopen((prefix + "decoy").c_str(), "wb");
std::fwrite(data.data(), 1, 100, f);
std::fclose(f);
return dir;
}
void TestRoomView() {
const int64_t s = 1'000'000'000;
roomview::Detector d;
d.SetDir(FakeShm(1));
// no game: the construct's setting
CHECK(d.Update(1 * s, false, true));
CHECK(d.sharedEnabled() == 1);
CHECK(!d.Update(2 * s, false, false));
// a game: off until it's shown, then the events
CHECK(!d.Update(3 * s, true, true));
CHECK(!std::strcmp(d.why(), "VR game"));
d.Event(roomview::kEventShown);
CHECK(d.Update(4 * s, true, true));
d.Event(roomview::kEventHidden);
CHECK(!d.Update(5 * s, true, true));
d.Event(12345); // not ours
CHECK(!d.Update(6 * s, true, true));
// the game ends: its events are old
d.Event(roomview::kEventHidden);
CHECK(d.Update(7 * s, false, true));
// passthrough disabled: off whatever else; enabled again: start over
FakeShm(0);
CHECK(!d.Update(8 * s, false, true));
CHECK(d.sharedEnabled() == 0);
CHECK(!std::strcmp(d.why(), "passthrough disabled"));
FakeShm(1);
CHECK(!d.Update(8 * s + 100, false, true)); // read at most twice a second
CHECK(d.Update(9 * s, false, true));
// forced
d.SetForce(roomview::Force::Off);
CHECK(!d.Update(10 * s, false, true));
d.SetForce(roomview::Force::On);
CHECK(d.Update(11 * s, true, false));
roomview::Force f;
CHECK(roomview::ParseForce("off", &f) && f == roomview::Force::Off);
CHECK(!roomview::ParseForce("maybe", &f));
// no state at all: the rest decides
roomview::Detector none;
none.SetDir("/nonexistent");
CHECK(none.Update(1 * s, false, true));
CHECK(none.sharedEnabled() == -1);
}
// An open hand, fingers up, palm toward a head at the origin, `z` metres ahead (OpenVR: -z).
handcut::Hand OpenHand(double x, double y, double z) {
handcut::Hand h;
for (int j = 0; j < 21; ++j) {
const int finger = j == 0 ? 0 : (j - 1) / 4, k = j == 0 ? 0 : (j - 1) % 4 + 1;
h.p[j][0] = float(x + (finger - 2) * 0.02);
h.p[j][1] = float(y + (j == 0 ? 0 : 0.06 + 0.03 * k));
h.p[j][2] = float(-z);
h.radius[j] = 0.008f;
}
for (int b = 0; b < 20; ++b) h.bone[b] = 0.03f;
h.elbow[0] = float(x), h.elbow[1] = float(y - 0.25), h.elbow[2] = float(-z + 0.05);
h.hasElbow = true;
h.v[0] = h.v[1] = h.v[2] = 0;
return h;
}
void Apply(const float m[16], const float p[3], double out[4]) {
for (int r = 0; r < 4; ++r) out[r] = m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]; // column-major
}
void TestMesh() {
// a hull: a unit cube's 8 corners make 12 faces' worth of triangles, all outward
handmesh::Mesh m;
std::vector<std::array<float, 3>> cube;
for (int i = 0; i < 8; ++i) cube.push_back({float(i & 1), float(i >> 1 & 1), float(i >> 2 & 1)});
handmesh::AddHull(cube, m);
CHECK(m.idx.size() >= 36 && m.idx.size() % 3 == 0);
bool outward = true;
for (const handmesh::Vertex &v : m.v) {
const double d = (v.p[0] - 0.5) * v.n[0] + (v.p[1] - 0.5) * v.n[1] + (v.p[2] - 0.5) * v.n[2];
outward &= d > 0.4;
}
CHECK(outward);
// a capsule: every vertex at its radius from the axis' ends or side
m.clear();
handmesh::AddCapsule({{0, 0, 0}, {0, 0.1f, 0}, 0.01f, 0.01f}, m);
bool round = !m.v.empty();
for (const handmesh::Vertex &v : m.v) {
const double y = std::clamp(double(v.p[1]), 0.0, 0.1);
const double r = std::sqrt(v.p[0] * v.p[0] + (v.p[1] - y) * (v.p[1] - y) + v.p[2] * v.p[2]);
round &= std::fabs(r - 0.01) < 1e-5;
}
CHECK(round);
for (uint16_t i : m.idx) CHECK(i < m.v.size());
// the hand: a mesh, the forearm behind the wrist
const handcut::Hand h = OpenHand(0.1, -0.2, 0.45);
const double head[3] = {0, 0, 0};
handmesh::Build(h, head, m);
CHECK(m.v.size() > 500);
float maxBack = -1, minBack = 1;
for (const handmesh::Vertex &v : m.v) maxBack = std::max(maxBack, v.back), minBack = std::min(minBack, v.back);
NEAR(maxBack, 0.11, 1e-4); // the forearm's stub, to where it has faded out
CHECK(minBack < -0.15); // the fingertips
// its square: at the hand, facing the head, holding what each eye sees of it
const double eyes[2][3] = {{-0.032, 0, 0}, {0.032, 0, 0}};
handmesh::Quad q;
CHECK(handmesh::Place(m, eyes, 0.04, q));
NEAR(q.distance, std::sqrt(0.1 * 0.1 + 0.2 * 0.2 + 0.45 * 0.45), 0.08);
CHECK(q.size > 0.15 && q.size < 0.45);
const double toHead[3] = {-q.pose.m[0][3], -q.pose.m[1][3], -q.pose.m[2][3]};
const double n = std::sqrt(toHead[0] * toHead[0] + toHead[1] * toHead[1] + toHead[2] * toHead[2]);
NEAR((q.pose.m[0][2] * toHead[0] + q.pose.m[1][2] * toHead[1] + q.pose.m[2][2] * toHead[2]) / n, 1, 1e-3); // +z to the head
CHECK(std::fabs(q.pose.m[0][1]) < 1e-6 || q.pose.m[1][1] > 0.8f); // +y up (level square)
for (int e = 0; e < 2; ++e) {
float mvp[16];
handmesh::EyeMatrix(q, eyes[e], 0.05, q.distance + 1, mvp);
// every vertex inside the viewport, in front of the near plane
bool inside = true;
for (const handmesh::Vertex &v : m.v) {
double c[4];
Apply(mvp, v.p, c);
inside &= c[3] > 0.05 && std::fabs(c[0] / c[3]) <= 1 && std::fabs(c[1] / c[3]) <= 1 && std::fabs(c[2] / c[3]) <= 1;
}
CHECK(inside);
// a point on the square lands where it is on it: its top at the buffer's top (y -1)
const double lx = 0.3 * q.size / 2, ly = 0.6 * q.size / 2;
float p[3];
for (int i = 0; i < 3; ++i) p[i] = float(q.pose.m[i][3] + q.pose.m[i][0] * lx + q.pose.m[i][1] * ly);
double c[4];
Apply(mvp, p, c);
NEAR(c[0] / c[3], 0.3, 1e-3);
NEAR(c[1] / c[3], -0.6, 1e-3);
}
// the eyes see a near point at different places: depth (the right eye sees it further left)
float mvpL[16], mvpR[16];
handmesh::EyeMatrix(q, eyes[0], 0.05, 2, mvpL), handmesh::EyeMatrix(q, eyes[1], 0.05, 2, mvpR);
float nearPt[3];
for (int i = 0; i < 3; ++i) nearPt[i] = float(q.pose.m[i][3] + q.pose.m[i][2] * 0.1);
double cl[4], cr[4];
Apply(mvpL, nearPt, cl), Apply(mvpR, nearPt, cr);
CHECK(cr[0] / cr[3] < cl[0] / cl[3] - 0.01);
// further depth is further in the depth buffer
float farPt[3];
for (int i = 0; i < 3; ++i) farPt[i] = float(q.pose.m[i][3] - q.pose.m[i][2] * 0.1);
double cf[4];
Apply(mvpL, farPt, cf);
CHECK(cf[2] / cf[3] > cl[2] / cl[3]);
// too close to the eyes: not drawn
const handcut::Hand close = OpenHand(0, -0.05, 0.03);
handmesh::Build(close, head, m);
CHECK(!handmesh::Place(m, eyes, 0.04, q));
}
} // namespace
// Turns p by `rad` about the unit axis through `at` (Rodrigues).
void Turn(float p[3], const float at[3], const double axis[3], double rad) {
const double v[3] = {p[0] - at[0], p[1] - at[1], p[2] - at[2]};
const double c = std::cos(rad), s = std::sin(rad), d = axis[0] * v[0] + axis[1] * v[1] + axis[2] * v[2];
const double x[3] = {axis[1] * v[2] - axis[2] * v[1], axis[2] * v[0] - axis[0] * v[2], axis[0] * v[1] - axis[1] * v[0]};
for (int i = 0; i < 3; ++i) p[i] = float(at[i] + v[i] * c + x[i] * s + axis[i] * d * (1 - c));
}
// How far joint j's matrix turns things (degrees), and how far it moves the point q (metres).
double Angle(const handskin::Bones &b, int j) {
const float *m = b.m[j];
const double s = std::cbrt(m[0] * (m[5] * m[10] - m[6] * m[9]) - m[1] * (m[4] * m[10] - m[6] * m[8]) + m[2] * (m[4] * m[9] - m[5] * m[8]));
const double tr = (m[0] + m[5] + m[10]) / s;
return std::acos(std::clamp((tr - 1) / 2, -1.0, 1.0)) * 180 / M_PI;
}
void TestModels() {
// the default first (HANDS_VIEW_MODEL=asset), each found by its name, nothing else
CHECK(handasset::kModels >= 2 && !std::strcmp(handasset::kModel[0].name, "asset"));
for (int k = 0; k < handasset::kModels; ++k) CHECK(handskin::FindModel(handasset::kModel[k].name) == k);
CHECK(handskin::FindModel("capsules") == -1 && handskin::FindModel("") == -1);
for (int model = 0; model < handasset::kModels; ++model)
for (bool right : {false, true}) {
const handasset::Hand &a = handasset::kModel[model].hands[right];
// every vertex's weights add up, on WebXR's joints; every triangle's corners exist
bool ok = a.verts > 0 && a.tris > 0;
for (int i = 0; i < a.verts && ok; ++i) {
int sum = 0;
for (int k = 0; k < 4; ++k) sum += a.weights[4 * i + k], ok = ok && a.joints[4 * i + k] < handskin::kJoints;
ok = ok && sum == 255;
}
for (int i = 0; i < 3 * a.tris && ok; ++i) ok = a.idx[i] < a.verts;
CHECK(ok);
// each fingertip's joint at the end of its finger: the mesh reaches at most 14 mm
// past it (generic-hand's sit about a centimetre in) and not 3 mm short of it
for (int f = 0; f < 5; ++f) {
const int tip = f ? 4 + 5 * f : 4, distal = tip - 1;
double d[3], n = 0, reach = -1;
for (int i = 0; i < 3; ++i) d[i] = a.bind[tip][i] - a.bind[distal][i], n += d[i] * d[i];
for (int i = 0; i < a.verts; ++i) {
int w = 0;
for (int k = 0; k < 4; ++k) w += a.joints[4 * i + k] == distal || a.joints[4 * i + k] == tip ? a.weights[4 * i + k] : 0;
if (w <= 127) continue;
float p[3];
handskin::BindVertex(model, right, i, p);
double along = 0;
for (int k = 0; k < 3; ++k) along += (p[k] - a.bind[tip][k]) * d[k] / std::sqrt(n);
reach = std::max(reach, along);
}
CHECK(reach > -0.003 && reach < 0.014);
}
// the side's own hand, not the other mirrored: (index knuckle - wrist) x (little knuckle
// - wrist) points out of a right hand's palm and a left one's back, and the thumb's tip
// sits in front of the palm (it opposes the fingers; 5-7 cm in every model's bind pose)
const float *w = a.bind[0], *ix = a.bind[6], *lt = a.bind[21], *th = a.bind[4];
const double u[3] = {ix[0] - w[0], ix[1] - w[1], ix[2] - w[2]}, v[3] = {lt[0] - w[0], lt[1] - w[1], lt[2] - w[2]};
const double nrm[3] = {u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]};
const double len = std::sqrt(nrm[0] * nrm[0] + nrm[1] * nrm[1] + nrm[2] * nrm[2]);
const double front = ((th[0] - w[0]) * nrm[0] + (th[1] - w[1]) * nrm[1] + (th[2] - w[2]) * nrm[2]) / len;
CHECK((right ? front : -front) > 0.02);
}
}
void TestSkin(int model) {
for (bool right : {false, true}) {
// the model's own pose: every matrix the identity, every vertex where it was
handskin::Bones b;
const handcut::Hand bind = handskin::BindHand(model, right);
CHECK(handskin::Pose(model, bind, b));
CHECK(b.model == model);
double worst = 0;
for (int j = 0; j < handskin::kJoints; ++j)
for (int r = 0; r < 3; ++r)
for (int c = 0; c < 4; ++c) worst = std::max(worst, std::fabs(b.m[j][r * 4 + c] - (r == c ? 1.0 : 0.0)));
CHECK(worst < 1e-4);
NEAR(b.scale, 1, 1e-4);
double moved = 0;
for (int i = 0; i < handskin::Vertices(model, right); ++i) {
float p[3], q[3];
handskin::BindVertex(model, right, i, p);
handskin::Skin(right, b, i, q);
moved = std::max(moved, double(std::hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2])));
}
CHECK(moved < 1e-4);
// moved and turned whole, and 20% bigger: the model follows, scaled
handcut::Hand big = bind;
const double axis[3] = {0.48, 0.6, 0.64};
const float origin[3] = {0, 0, 0};
for (auto &p : big.p) {
Turn(p, origin, axis, 1.1);
for (int i = 0; i < 3; ++i) p[i] = p[i] * 1.2f + (i == 2 ? -0.5f : 0.1f);
}
CHECK(handskin::Pose(model, big, b));
NEAR(b.scale, 1.2, 1e-3);
double off = 0;
for (int i = 0; i < handskin::Vertices(model, right); i += 7) {
float p[3], q[3];
handskin::BindVertex(model, right, i, p);
Turn(p, origin, axis, 1.1);
for (int k = 0; k < 3; ++k) p[k] = p[k] * 1.2f + (k == 2 ? -0.5f : 0.1f);
handskin::Skin(right, b, i, q);
off = std::max(off, double(std::hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2])));
}
CHECK(off < 1e-3);
// the index finger bent 60 degrees at its middle joint (MediaPipe 6): its last two bones
// (the model's index intermediate, distal and tip joints, 7-9) turn that much, nothing else
handcut::Hand bent = bind;
const float *pip = bind.p[6];
double ax[3];
{
const double d[3] = {bind.p[7][0] - pip[0], bind.p[7][1] - pip[1], bind.p[7][2] - pip[2]};
const double a[3] = {bind.p[17][0] - bind.p[5][0], bind.p[17][1] - bind.p[5][1], bind.p[17][2] - bind.p[5][2]};
// the bending axis: across the palm, square to the bone
const double k = (a[0] * d[0] + a[1] * d[1] + a[2] * d[2]) / (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
for (int i = 0; i < 3; ++i) ax[i] = a[i] - k * d[i];
const double n = std::sqrt(ax[0] * ax[0] + ax[1] * ax[1] + ax[2] * ax[2]);
for (double &v : ax) v /= n;
}
for (int j : {7, 8}) Turn(bent.p[j], pip, ax, 60 * M_PI / 180);
CHECK(handskin::Pose(model, bent, b));
for (int j = 0; j < handskin::kJoints; ++j) {
const double a = Angle(b, j);
if (j >= 7 && j <= 9) NEAR(a, 60, 2); // a bind pose's twisted finger rolls a little
else CHECK(a < 0.5);
}
// a vertex of the index fingertip went with it (most of its weight on the last bones: MRTK2's
// share some with the one before); one of the little finger's stayed
const handasset::Hand &a = handasset::kModel[model].hands[right];
int tipV = -1, littleV = -1;
for (int i = 0; i < a.verts; ++i) { // its weight on the index's last bones, or all on the little finger's
int index = 0, little = 0;
for (int k = 0; k < 4; ++k) {
const int j = a.joints[4 * i + k], w = a.weights[4 * i + k];
index += j >= 8 && j <= 9 ? w : 0, little += j >= 22 && j <= 24 ? w : 0;
}
if (index >= 200 && tipV < 0) tipV = i;
if (little == 255 && littleV < 0) littleV = i;
}
CHECK(tipV >= 0 && littleV >= 0);
if (tipV >= 0 && littleV >= 0) {
float p[3], q[3];
handskin::BindVertex(model, right, tipV, p), handskin::Skin(right, b, tipV, q);
CHECK(std::hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]) > 0.01);
handskin::BindVertex(model, right, littleV, p), handskin::Skin(right, b, littleV, q);
CHECK(std::hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]) < 1e-4);
}
}
// left and right: the same pose mirrored gives the mirrored hand (the model's two hands
// aren't one mirrored mesh, so compare their extents and middles)
for (const char *pose : {"open", "fist", "pinch"}) {
handskin::Bones b[2];
double lo[2][3], hi[2][3], mid[2][3];
for (int right = 0; right < 2; ++right) {
const double side = right ? 1 : -1, at[3] = {0.1 * side, -0.2, -0.4}, palm[3] = {-0.3 * side, 0.2, 1},
up[3] = {0.2 * side, 1, -0.2};
handcut::Hand h;
CHECK(handskin::DemoPose(pose, right, at, palm, up, 0, h) && h.right == bool(right));
CHECK(handskin::Pose(model, h, b[right]));
const int n = handskin::Vertices(model, right);
for (int i = 0; i < 3; ++i) lo[right][i] = 1e9, hi[right][i] = -1e9, mid[right][i] = 0;
for (int v = 0; v < n; ++v) {
float q[3];
handskin::Skin(right, b[right], v, q);
for (int i = 0; i < 3; ++i) {
const double x = i == 0 ? q[i] * side : q[i]; // the left hand flipped across x = 0
lo[right][i] = std::min(lo[right][i], x), hi[right][i] = std::max(hi[right][i], x), mid[right][i] += x / n;
}
}
// the model's thumb and index tips where the tracked ones are
for (int k : {4, 8}) {
const int j = handskin::kFromMediaPipe[k];
const float *m = b[right].m[j];
float tip[3];
const handcut::Hand bh = handskin::BindHand(model, right);
for (int r = 0; r < 3; ++r)
tip[r] = m[r * 4] * bh.p[k][0] + m[r * 4 + 1] * bh.p[k][1] + m[r * 4 + 2] * bh.p[k][2] + m[r * 4 + 3];
NEAR(std::hypot(tip[0] - h.p[k][0], tip[1] - h.p[k][1], tip[2] - h.p[k][2]), 0, 1e-4);
}
}
for (int i = 0; i < 3; ++i) {
NEAR(lo[0][i], lo[1][i], 0.008), NEAR(hi[0][i], hi[1][i], 0.008);
NEAR(mid[0][i], mid[1][i], 0.005);
}
}
// a degenerate hand: no pose
handcut::Hand flat;
for (auto &p : flat.p) p[0] = p[1] = p[2] = 0;
handskin::Bones b;
CHECK(!handskin::Pose(model, flat, b));
}
// A demo pose of that side, mirrored across x = 0 for the left (as --snapshot draws them).
handcut::Hand Demo(const char *pose, bool right) {
const double side = right ? 1 : -1, at[3] = {0.1 * side, -0.2, -0.4}, palm[3] = {-0.3 * side, 0.2, 1}, up[3] = {0.2 * side, 1, -0.2};
handcut::Hand h;
CHECK(handskin::DemoPose(pose, right, at, palm, up, 0, h));
return h;
}
double Dist(const float a[3], const float b[3]) { return std::hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); }
void TestRobot() {
const handrobot::Mesh &m = handrobot::Model();
// the mesh: every triangle's corners exist, every vertex on a segment, fixed to an end, with a
// material and a unit normal; about as many triangles as the models (it draws once, they twice)
CHECK(!m.v.empty() && m.idx.size() % 3 == 0 && m.v.size() < 65536);
bool ok = true;
for (uint16_t i : m.idx) ok = ok && i < m.v.size();
int mats[4] = {};
for (const handrobot::Vertex &v : m.v) {
ok = ok && v.seg >= 0 && v.seg < handrobot::kSegs && v.seg == std::floor(v.seg) && (v.end == 0 || v.end == 1);
ok = ok && v.mat >= 0 && v.mat <= 3 && std::fabs(std::hypot(v.n[0], v.n[1], v.n[2]) - 1) < 1e-3;
if (v.mat >= 0 && v.mat <= 3) ++mats[int(v.mat)];
}
CHECK(ok);
for (int k = 0; k < 4; ++k) CHECK(mats[k] > 0);
std::printf("robot: %zu vertices, %zu triangles\n", m.v.size(), m.idx.size() / 3);
CHECK(m.idx.size() / 3 < 4500);
// its normals point out: away from its segment's bone line (shells, joints) for most vertices
// of the fingers
int out = 0, fingers = 0;
for (const handrobot::Vertex &v : m.v)
if (v.seg >= 4 && v.mat == handrobot::kShell) ++fingers, out += v.p[0] * v.n[0] + v.p[1] * v.n[1] > -1e-6;
CHECK(out > 0.97 * fingers);
for (const char *pose : {"open", "fist", "pinch", "point", "spread"}) {
handrobot::Bones b[2];
handcut::Hand h[2];
for (int right = 0; right < 2; ++right) {
h[right] = Demo(pose, right);
CHECK(handrobot::Pose(h[right], b[right]));
const handcut::Hand &hh = h[right];
// each segment starts at its joint, along its bone
for (int k = 0; k < handrobot::kSegs; ++k) {
const float *s = b[right].seg[k];
const float o[3] = {s[3], s[7], s[11]};
NEAR(Dist(o, hh.p[handrobot::kFrom[k]]), 0, 1e-5);
const float L = Dist(hh.p[handrobot::kTo[k]], hh.p[handrobot::kFrom[k]]);
NEAR(s[12] * b[right].scale, L, 1e-4);
// its z axis toward its end joint
double along = 0;
for (int i = 0; i < 3; ++i) along += s[i * 4 + 2] / b[right].scale * (hh.p[handrobot::kTo[k]][i] - o[i]) / L;
NEAR(along, 1, 1e-4);
}
// every vertex within its bone's length along it (a ball pokes out behind its joint),
// and the fingertips' furthest reach at the tracked tips
double worstPast = -1, worstBehind = -1, tipOff[5] = {1, 1, 1, 1, 1};
for (const handrobot::Vertex &v : m.v) {
const int k = int(v.seg);
if (k == 0) continue;
float p[3];
handrobot::Place(b[right], v, p);
const float *a = hh.p[handrobot::kFrom[k]], *e = hh.p[handrobot::kTo[k]];
const double L = Dist(a, e);
double along = 0;
for (int i = 0; i < 3; ++i) along += (p[i] - a[i]) * (e[i] - a[i]) / L;
worstPast = std::max(worstPast, along - L);
worstBehind = std::max(worstBehind, -along);
if (k == 3 || (k >= 6 && (k - 6) % 3 == 0)) {
const int f = k == 3 ? 0 : 1 + (k - 6) / 3;
tipOff[f] = std::min(tipOff[f], Dist(p, e));
}
}
CHECK(worstPast < 1e-4);
CHECK(worstBehind < 0.0115); // the thumb's base ball, 10.8 mm
for (double d : tipOff) CHECK(d < 0.0012);
}
// the left hand is the right one mirrored, vertex for vertex
double off = 0;
for (const handrobot::Vertex &v : m.v) {
float l[3], r[3];
handrobot::Place(b[0], v, l), handrobot::Place(b[1], v, r);
off = std::max(off, double(std::hypot(l[0] + r[0], l[1] - r[1], l[2] - r[2])));
}
CHECK(off < 1e-5);
// rigid: in a pose with the same bone lengths as the open hand, each segment's vertices keep
// their distances to each other
if (!std::strcmp(pose, "fist")) {
handrobot::Bones open;
CHECK(handrobot::Pose(Demo("open", true), open));
double worst = 0;
for (size_t i = 0; i < m.v.size(); i += 5)
for (size_t j = i + 1; j < m.v.size(); j += 37) {
if (m.v[i].seg != m.v[j].seg || m.v[i].seg < 4) continue; // the fingers (the thumb's lengths differ)
float a0[3], a1[3], c0[3], c1[3];
handrobot::Place(open, m.v[i], a0), handrobot::Place(open, m.v[j], a1);
handrobot::Place(b[1], m.v[i], c0), handrobot::Place(b[1], m.v[j], c1);
worst = std::max(worst, std::fabs(Dist(a0, a1) - Dist(c0, c1)));
}
CHECK(worst < 1e-5);
}
// the palm's side: its glowing dot toward the palm, the knuckles' line on the back; the
// fingertips' pads on the palm side of the open hand, the thumb's facing the index
for (int right = 0; right < 2; ++right) {
const handcut::Hand &hh = h[right];
double palm[3];
{
const double side = right ? 1 : -1, raw[3] = {-0.3 * side, 0.2, 1};
const double n = std::sqrt(raw[0] * raw[0] + raw[1] * raw[1] + raw[2] * raw[2]);
for (int i = 0; i < 3; ++i) palm[i] = raw[i] / n;
}
for (const handrobot::Vertex &v : m.v) {
if (v.mat != handrobot::kAccent) continue;
float p[3];
handrobot::Place(b[right], v, p);
const double toward = (p[0] - hh.p[0][0]) * palm[0] + (p[1] - hh.p[0][1]) * palm[1] + (p[2] - hh.p[0][2]) * palm[2];
const double z = v.p[2] + v.end * handrobot::ReferenceLength(0); // on the reference hand
if (v.seg == 0 && z > 0.035 && z < 0.08 && std::fabs(v.p[1]) > 0.01) CHECK(v.p[1] > 0 ? toward > 0.008 : toward < -0.008);
if (!std::strcmp(pose, "open") && v.seg >= 6 && (int(v.seg) - 6) % 3 == 0) {
const float *tip = hh.p[handrobot::kTo[int(v.seg)]];
CHECK((p[0] - tip[0]) * palm[0] + (p[1] - tip[1]) * palm[1] + (p[2] - tip[2]) * palm[2] > 0);
}
}
}
if (!std::strcmp(pose, "pinch")) {
// the thumb's pad and the index's: their middles within 15 mm (they meet)
for (int right = 0; right < 2; ++right) {
double c[2][3] = {};
int n[2] = {};
for (const handrobot::Vertex &v : m.v) {
if (v.mat != handrobot::kAccent || (v.seg != 3 && v.seg != 6)) continue;
const int w = v.seg == 6;
float p[3];
handrobot::Place(b[right], v, p);
for (int i = 0; i < 3; ++i) c[w][i] += p[i];
++n[w];
}
CHECK(n[0] > 0 && n[1] > 0);
if (n[0] && n[1]) NEAR(std::hypot(c[0][0] / n[0] - c[1][0] / n[1], c[0][1] / n[0] - c[1][1] / n[1], c[0][2] / n[0] - c[1][2] / n[1]), 0, 0.015);
}
}
}
// a longer finger: its shells' ends stay as they are (only the middle stretches), the tip at the tip
{
handcut::Hand h = Demo("open", true), longer = h;
for (int j : {7, 8})
for (int i = 0; i < 3; ++i) longer.p[j][i] += 0.2f * (h.p[7][i] - h.p[6][i]);
handrobot::Bones a, b;
CHECK(handrobot::Pose(h, a) && handrobot::Pose(longer, b));
const double stretch = 0.2 * Dist(h.p[7], h.p[6]);
double startMoved = 0, endOff = 0;
for (const handrobot::Vertex &v : m.v)
if (v.seg == 5) {
float p[3], q[3];
handrobot::Place(a, v, p), handrobot::Place(b, v, q);
if (v.end == 0) startMoved = std::max(startMoved, Dist(p, q));
else endOff = std::max(endOff, std::fabs(Dist(p, q) - stretch));
}
CHECK(startMoved < 1e-5);
CHECK(endOff < 1e-5);
}
// a degenerate hand: no pose
handcut::Hand flat;
for (auto &p : flat.p) p[0] = p[1] = p[2] = 0;
handrobot::Bones b;
CHECK(!handrobot::Pose(flat, b));
}
// handhold.h: blending between frames, holding a lost hand, refusing a jump
handcut::Hand HeldHand(double x, double y = 0, double z = -0.4, bool flipped = false) {
handcut::Hand h{};
h.right = true;
for (int j = 0; j < 21; ++j) h.p[j][0] = float(x), h.p[j][1] = float(y + 0.01 * j), h.p[j][2] = float(z);
// a palm: the wrist, the index knuckle up, the little knuckle beside it (flipped: the other side)
h.p[5][1] = float(y + 0.08), h.p[17][1] = float(y + 0.07);
h.p[17][0] = float(x + (flipped ? -0.06 : 0.06));
for (int j : {0, 9, 13}) h.p[j][1] = float(y + (j ? 0.08 : 0));
return h;
}
double PalmX(const handcut::Hand *h) {
double p[3];
h->Palm(p);
return p[0];
}
void TestHold() {
handhold::Params k;
handhold::Side s;
const int64_t ms = 1'000'000;
int64_t t = 1000 * ms;
uint64_t frame = 1;
// first seen: drawn on the second frame that agrees
handcut::Hand a = HeldHand(0.0);
CHECK(!s.Update(&a, frame, t, k));
a = HeldHand(0.01), t += 33 * ms, ++frame;
const handcut::Hand *d = s.Update(&a, frame, t, k);
CHECK(d && std::fabs(PalmX(d) - PalmX(&a)) < 1e-6);
// a new frame 3 cm on: blended in over about the frame's interval
handcut::Hand b = HeldHand(0.04);
t += 33 * ms, ++frame;
d = s.Update(&b, frame, t, k);
NEAR(PalmX(d), PalmX(&a), 1e-6); // starts where it was drawn
CHECK(s.blending(t + 10 * ms));
d = s.Update(&b, frame, t + 16 * ms, k);
CHECK(PalmX(d) > PalmX(&a) + 0.01 && PalmX(d) < PalmX(&b) - 0.01);
d = s.Update(&b, frame, t + 40 * ms, k);
NEAR(PalmX(d), PalmX(&b), 1e-6);
CHECK(!s.blending(t + 40 * ms));
t += 40 * ms;
// one misread frame 60 cm off: not drawn; the next one back on the track is
handcut::Hand wild = HeldHand(0.64), c = HeldHand(0.05);
t += 33 * ms, ++frame;
d = s.Update(&wild, frame, t, k);
NEAR(PalmX(d), PalmX(&b), 1e-6);
CHECK(s.frozen() && s.jumps == 1);
t += 33 * ms, ++frame;
s.Update(&c, frame, t, k);
CHECK(!s.frozen());
t += 80 * ms;
d = s.Update(&c, frame, t, k);
NEAR(PalmX(d), PalmX(&c), 1e-6);
// the palm turned over in one frame: a misreading too
handcut::Hand over = HeldHand(0.05, 0, -0.4, true);
t += 33 * ms, ++frame;
d = s.Update(&over, frame, t, k);
CHECK(s.frozen() && s.jumps == 2);
// a real move far away (two frames agreeing): drawn there on the second, straight there
handcut::Hand far1 = HeldHand(1.2), far2 = HeldHand(1.21); // (146 ms since the last frame taken)
t += 33 * ms, ++frame;
s.Update(&far1, frame, t, k);
t += 33 * ms, ++frame;
d = s.Update(&far2, frame, t, k);
CHECK(!s.frozen());
NEAR(PalmX(d), PalmX(&far2), 1e-6);
// fast but steady (5 m/s, 16.5 cm a frame): followed frame by frame
handcut::Hand fast = far2;
bool kept = true;
for (int i = 0; i < 5; ++i) {
fast = HeldHand(1.21 - 0.165 * (i + 1));
t += 33 * ms, ++frame;
s.Update(&fast, frame, t, k);
kept = kept && !s.frozen();
}
CHECK(kept);
// lost: held still for holdS, then gone
t += 100 * ms;
const double lastX = PalmX(s.Update(&fast, frame, t, k));
t += 33 * ms, ++frame;
d = s.Update(nullptr, frame, t, k);
CHECK(d && s.frozen() && s.holds == 1);
NEAR(PalmX(d), lastX, 1e-6);
d = s.Update(nullptr, frame, t + int64_t(k.holdS * 1000 - 50) * ms, k);
CHECK(d);
// back within the hold, 30 cm away: drawn there on the second frame, without sweeping across
handcut::Hand back1 = HeldHand(lastX + 0.3), back2 = HeldHand(lastX + 0.31);
t += 500 * ms, ++frame;
d = s.Update(&back1, frame, t, k);
NEAR(PalmX(d), lastX, 1e-6);
t += 33 * ms, ++frame;
d = s.Update(&back2, frame, t, k);
NEAR(PalmX(d), PalmX(&back2), 1e-6);
// lost past the hold: nothing
t += 33 * ms, ++frame;
s.Update(nullptr, frame, t, k);
CHECK(!s.Update(nullptr, frame, t + int64_t(k.holdS * 1000 + 50) * ms, k));
// a reset (a controller taken up): gone at once, and a hand seen after starts afresh
s.Update(&back2, ++frame, t + 4000 * ms, k);
s.Reset();
CHECK(!s.Update(nullptr, frame, t + 4010 * ms, k));
}
int main() {
TestRoomView();
TestHold();
TestRobot();
TestMesh();
TestModels();
for (int model = 0; model < handasset::kModels; ++model) {
const int before = g_fail;
TestSkin(model);
if (g_fail > before) std::printf(" (model %s)\n", handasset::kModel[model].name);
}
std::printf("%d checks, %d failed\n", g_checks, g_fail);
return g_fail ? 1 : 0;
}