mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 08:00:23 +02:00
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.
90 lines
5.3 KiB
JavaScript
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`);
|