mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 05:00:24 +02:00
keyboard.vr.touchTyping (off by default): a key is pressed when a controller's tip, where SteamVR's laser starts (/pose/tip: the device pose times the render model's "tip" component), passes through it from the front; released when pulled back 1 cm, re-armed 5 mm in front. Both hands, also at once. Keys go through the keyboard's own HandleTouchStart / HandleTouchEnd, like a laser press (Backspace repeats while held). Options depth (cm) and haptics. The geometry lives in a reusable controller bridge (vr-keyboard-controllers, internal option): a systemui patch reads the keyboard's pose with SteamVR's SGQueryService (an empty transform of ours in the keyboard's mount) and both controllers' poses, and streams per hand the tip, the laser's hit on the keyboard and the trigger (~90 Hz while relevant) through the vr-keyboard-controllers-relay service to __sfuiControllers in Steam's SharedJSContext. Tests: flake checks vr-keyboard-controllers and vr-keyboard-touch.
81 lines
4.9 KiB
JavaScript
81 lines
4.9 KiB
JavaScript
// geometry.js (tip and laser relative to the keyboard) and hub.js (page px,
|
|
// subscribers). 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');
|
|
});
|
|
|
|
console.log(`${passed} tests passed`);
|