Files
lhns--steam-frame-nix/modules/vr-keyboard-controllers/tests/geometry.test.mjs
T
Pierre Kisters af32cf72ce VR keyboard swipe with two controllers: the path from the controller bridge
With both lasers on the keyboard SteamVR forwards per poll only one laser's
movement, so the pressing one may get no touchmoves and its swipe became a
tap. keyboard.vr.swipe.twoHanded (default on; turns on the controller
bridge): a press is attributed to the hand whose laser hit is nearest to it
(within 30 px, the trigger as a tiebreak; without recent frames the first
frame after the press decides, within 60 px). The path comes from runs of
one source: the touch's own moves while they flow, that hand's laser hit
per bridge frame once they pause for 60 ms, touchmoves again if frames
pause. A touchend disagreeing with the laser during a bridge run ends at
the laser. Touchmoves on the other hand's laser move the attribution.
Without fresh frames: touch events only, as before. A second press during
a swipe stays Steam's tap (one text field, one word at a time).

The bridge sends full rate only on demand now: hub.js demand(ms) goes
back through the relay (CDP binding __sfuiCtlIn) to systemui's
__sfuiCtl.demand; the swipe patch asks from the press to the release.

Tests: gesture-input with the two-controller recording and synthetic bridge
frames (a pressing hand without touchmoves, attribution with both lasers on
the keyboard, a stale bridge, frames stopping mid-swipe, re-attribution,
interleaved hover and presses); hub demand.
2026-10-01 03:45:47 +02:00

90 lines
5.3 KiB
JavaScript

// geometry.js (tip and laser relative to the keyboard) and hub.js (page px,
// subscribers, demand). usage: node geometry.test.mjs <geometry.js> <hub.js>
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
const GEO = (0, eval)(readFileSync(process.argv[2], 'utf8'));
let passed = 0;
const test = (name, f) => { f(); passed++; console.log(`ok ${name}`); };
const near = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} != ${b}`);
const norm = (q) => { const n = Math.hypot(q.w, q.x, q.y, q.z); return { w: q.w / n, x: q.x / n, y: q.y / n, z: q.z / n }; };
const axis = (deg, x, y, z) => { const h = (deg * Math.PI) / 360; return { w: Math.cos(h), x: x * Math.sin(h), y: y * Math.sin(h), z: z * Math.sin(h) }; };
const ID = { w: 1, x: 0, y: 0, z: 0 };
// A keyboard as measured live (SGQueryService world transform of the
// overlay's scaled frame), 0.738 m wide, page 854 x 280 px.
const KB = { translation: { x: -0.8552, y: 0.4129, z: -3.8837 }, rotation: norm({ w: 0.88649, x: -0.11484, y: -0.44456, z: -0.05758 }), width: 0.738 };
const PAGE = 854;
function world(x, y, front) { // page px, m in front -> tracking space
const r = GEO.rotate(KB.rotation, { x: (x / PAGE - 0.5) * KB.width, y: -(y / PAGE) * KB.width, z: front });
return { x: r.x + KB.translation.x, y: r.y + KB.translation.y, z: r.z + KB.translation.z };
}
// The frame controller's render model "tip" component.
const TIP = { translation: { x: -0.012694, y: -0.02522, z: 0.020687 }, rotation: axis(-40, 1, 0, 0) };
test('toKeyboard: identity keyboard', () => {
const s = GEO.toKeyboard({ x: 0.25, y: -0.1, z: 0.02 }, { translation: { x: 0, y: 0, z: 0 }, rotation: ID, width: 1 });
near(s.u, 0.75); near(s.v, 0.1); near(s.d, -0.02);
});
test('toKeyboard inverts the keyboard transform', () => {
const s = GEO.toKeyboard(world(356, 120, 0.03), KB);
near(s.u * PAGE, 356, 1e-4); near(s.v * PAGE, 120, 1e-4); near(s.d, -0.03);
});
test('tipPose: device pose times the tip component', () => {
const dev = { translation: { x: 1, y: 2, z: 3 }, rotation: axis(90, 0, 1, 0) };
const p = GEO.tipPose(dev, TIP);
// 90 degrees about y: (x, y, z) -> (z, y, -x)
near(p.translation.x, 1 + TIP.translation.z); near(p.translation.y, 2 + TIP.translation.y); near(p.translation.z, 3 - TIP.translation.x);
assert.deepEqual(GEO.tipPose(dev, null).translation, dev.translation);
});
test('rayHit: a laser pointing at a key from 30 cm', () => {
// the tip 30 cm in front of (356, 120), pointing straight at the keyboard:
// tip -z = keyboard -z, i.e. the tip's rotation = the keyboard's
const hit = GEO.rayHit({ translation: world(356, 120, 0.3), rotation: KB.rotation }, KB);
near(hit.u * PAGE, 356, 1e-4); near(hit.v * PAGE, 120, 1e-4); near(hit.dist, 0.3, 1e-9);
// tilted 20 degrees about the keyboard's x axis (pointing down): hits lower
const down = GEO.rayHit({ translation: world(356, 120, 0.3), rotation: GEO.multiply({ translation: { x: 0, y: 0, z: 0 }, rotation: KB.rotation }, { translation: { x: 0, y: 0, z: 0 }, rotation: axis(-20, 1, 0, 0) }).rotation }, KB);
near(down.v * KB.width, 120 / PAGE * KB.width + 0.3 * Math.tan(Math.PI / 9), 1e-6);
near(down.u * PAGE, 356, 1e-4);
});
test('rayHit: none from behind or pointing away', () => {
assert.equal(GEO.rayHit({ translation: world(356, 120, -0.1), rotation: KB.rotation }, KB), null);
const away = GEO.multiply({ translation: { x: 0, y: 0, z: 0 }, rotation: KB.rotation }, { translation: { x: 0, y: 0, z: 0 }, rotation: axis(180, 0, 1, 0) }).rotation;
assert.equal(GEO.rayHit({ translation: world(356, 120, 0.3), rotation: away }, KB), null);
});
test('rotationDegrees: the trigger component angle', () => {
near(GEO.rotationDegrees(axis(12.5, 0.98, 0.076, -0.178)), 12.5, 1e-6);
near(GEO.rotationDegrees(ID), 0);
});
// hub.js with a fake keyboard popup (854 x 280).
globalThis.performance ??= { now: () => Date.now() };
globalThis.g_PopupManager = { GetPopups: () => [{ window: { innerWidth: 854, innerHeight: 280, closed: false, document: { querySelector: () => ({}) } } }] };
const HUB = (0, eval)(readFileSync(process.argv[3], 'utf8'));
test('hub: page px and onKeyboard, subscribers, lost', () => {
const got = [];
const off = HUB.subscribe((f) => got.push(f));
HUB.frame({ seq: 1, t: 5, moving: false, keyboard: { width: 0.738 }, hands: {
left: { tip: { u: 0.5, v: 0.1, d: -0.01 }, ray: { u: 1.2, v: 0.1, dist: 0.2 }, trigger: 0 }, right: null } });
const l = got[0].hands.left;
near(l.tip.x, 427); near(l.tip.y, 85.4, 1e-9); assert.equal(l.tip.onKeyboard, true); assert.equal(l.ray.onKeyboard, false);
assert.equal(HUB.last, got[0]);
HUB.lost();
assert.equal(got[1], null); assert.equal(HUB.last, null);
off();
HUB.frame({ seq: 2, t: 6, keyboard: null, hands: { left: null, right: null } });
assert.equal(got.length, 2);
assert.equal((0, eval)(readFileSync(process.argv[3], 'utf8')), HUB, 'same version: kept');
});
test('hub: demand(ms) through the relay binding, clamped; none without it', () => {
const sent = [];
globalThis.__sfuiCtlIn = (s) => sent.push(JSON.parse(s));
HUB.demand(800); HUB.demand(0); HUB.demand(1e9);
assert.deepEqual(sent, [{ fast: 800 }, { fast: 0 }, { fast: 5000 }]);
delete globalThis.__sfuiCtlIn;
HUB.demand(800); // no relay: nothing, no error
assert.equal(HUB.errors.count, 0);
});
console.log(`${passed} tests passed`);