mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 01:00:13 +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.
143 lines
7.5 KiB
JavaScript
143 lines
7.5 KiB
JavaScript
// tracker.js: controller tip -> keyboard surface -> key (through
|
|
// vr-keyboard-controllers' geometry.js, as the bridge computes it),
|
|
// press/release hysteresis, two hands.
|
|
// usage: node tracker.test.mjs <tracker.js> <geometry.js>
|
|
import { readFileSync } from 'node:fs';
|
|
import assert from 'node:assert/strict';
|
|
|
|
const TR = (0, eval)(readFileSync(process.argv[2], 'utf8'));
|
|
const GEO = (0, eval)(readFileSync(process.argv[3], 'utf8'));
|
|
let passed = 0;
|
|
const test = (name, f) => { f(); passed++; console.log(`ok ${name}`); };
|
|
const near = (a, b, eps = 1e-6, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} ${a} != ${b}`);
|
|
|
|
// A keyboard as measured live: world transform of its scaled frame
|
|
// (SGQueryService), 0.738 m wide; page 854 x 280 px.
|
|
const KB = {
|
|
translation: { x: -0.8552, y: 0.4129, z: -3.8837 },
|
|
rotation: { w: 0.88649, x: -0.11484, y: -0.44456, z: -0.05758 },
|
|
width: 0.738,
|
|
};
|
|
const PAGE = { w: 854, h: 280 };
|
|
// Keys of a row (page px), like Steam's German layout.
|
|
const KEYS = { f: [265, 97, 60, 47], g: [325.6, 97, 60.4, 47], h: [386, 97, 60.4, 47], Backspace: [760, 3, 90, 47] };
|
|
const keyAt = (u, v) => {
|
|
const x = u * PAGE.w, y = v * PAGE.w;
|
|
return Object.entries(KEYS).find(([, [kx, ky, w, h]]) => x >= kx && x < kx + w && y >= ky && y < ky + h)?.[0] ?? null;
|
|
};
|
|
// World point of keyboard-local (page px x, y; m in front of the surface).
|
|
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 }; };
|
|
KB.rotation = norm(KB.rotation);
|
|
function world(x, y, front) {
|
|
const l = { x: (x / PAGE.w - 0.5) * KB.width, y: -(y / PAGE.w) * KB.width, z: front };
|
|
const r = GEO.rotate(KB.rotation, l);
|
|
return { x: r.x + KB.translation.x, y: r.y + KB.translation.y, z: r.z + KB.translation.z };
|
|
}
|
|
// The frame controller's tip component (render model "tip").
|
|
const TIP = { translation: { x: -0.012694, y: -0.02522, z: 0.020687 }, rotation: { w: 0.93969, x: -0.34202, y: 0, z: 0 } };
|
|
const HAND_ROT = norm({ w: 0.8294, x: 0.2383, y: -0.3948, z: 0.3154 });
|
|
// The device pose that puts the tip at p.
|
|
function deviceFor(p) {
|
|
const o = GEO.rotate(HAND_ROT, TIP.translation);
|
|
return { translation: { x: p.x - o.x, y: p.y - o.y, z: p.z - o.z }, rotation: HAND_ROT };
|
|
}
|
|
const sampleAt = (x, y, front) => GEO.toKeyboard(GEO.tipPose(deviceFor(world(x, y, front)), TIP).translation, KB);
|
|
|
|
// Feeds a path [x, y, front(m)] at 90 Hz from t0; returns the events.
|
|
function run(tr, path, t0 = 0) {
|
|
const out = [];
|
|
path.forEach((p, i) => {
|
|
const t = t0 + i * 11;
|
|
const ev = tr.update(p && sampleAt(...p), t);
|
|
if (ev) out.push({ ...ev, t, key: ev.u !== undefined ? keyAt(ev.u, ev.v) : undefined });
|
|
});
|
|
return out;
|
|
}
|
|
const poke = (x, y, from, to, back, steps = 6) => {
|
|
const p = [];
|
|
for (let i = 0; i <= steps; i++) p.push([x, y, from + (to - from) * i / steps]);
|
|
for (let i = 1; i <= steps; i++) p.push([x, y, to + (back - to) * i / steps]);
|
|
return p;
|
|
};
|
|
const phases = (evs) => evs.map((e) => (e.phase === 'down' ? `down:${e.key}` : e.phase));
|
|
|
|
test('a poke presses the key under the tip once, released on the way back', () => {
|
|
const evs = run(TR.createTracker(), poke(356, 120, 0.05, -0.015, 0.05));
|
|
assert.deepEqual(phases(evs), ['down:g', 'up']);
|
|
});
|
|
test('pointing (tip in front) never presses', () => {
|
|
const p = [];
|
|
for (let i = 0; i < 200; i++) p.push([100 + i * 3, 50 + (i % 40), 0.02 + 0.01 * Math.sin(i / 7)]);
|
|
assert.deepEqual(run(TR.createTracker(), p), []);
|
|
});
|
|
test('fast poke between two samples: pressed at the crossing point', () => {
|
|
const evs = run(TR.createTracker(), [[356, 120, 0.03], [356, 120, 0.01], [356, 120, -0.03], [356, 120, -0.035], [356, 120, 0.02]]);
|
|
assert.deepEqual(phases(evs), ['down:g', 'up']);
|
|
// moving sideways: the point where the path meets the surface (f | g | h)
|
|
const side = run(TR.createTracker(), [[300, 120, 0.03], [300, 120, 0.01], [400, 120, -0.01], [400, 120, 0.02]]);
|
|
assert.deepEqual(phases(side), ['down:g', 'up']);
|
|
near(side[0].u * PAGE.w, 350, 1e-3);
|
|
});
|
|
test('jitter at the surface while held: one press; release needs 1 cm back', () => {
|
|
const p = [[356, 120, 0.02], [356, 120, 0.004], [356, 120, -0.002]];
|
|
for (let i = 0; i < 60; i++) p.push([356, 120, -0.002 - 0.004 * (i % 2)]);
|
|
assert.deepEqual(phases(run(TR.createTracker(), p)), ['down:g']);
|
|
});
|
|
test('re-armed only in front by 5 mm: jitter after a release does not repeat', () => {
|
|
const p = [...poke(356, 120, 0.02, -0.02, -0.005)]; // released 1.5 cm back, still behind
|
|
for (let i = 0; i < 30; i++) p.push([356, 120, -0.004 + 0.006 * (i % 2)]); // jitter -4..+2 mm
|
|
assert.deepEqual(phases(run(TR.createTracker(), p)), ['down:g', 'up']);
|
|
// ...but out to the front and in again is a second press.
|
|
const q = [...poke(356, 120, 0.02, -0.02, 0.02), ...poke(356, 120, 0.02, -0.02, 0.02)];
|
|
assert.deepEqual(phases(run(TR.createTracker(), q)), ['down:g', 'up', 'down:g', 'up']);
|
|
});
|
|
test('debounce: no new press within minIntervalMs of a release', () => {
|
|
const tr = TR.createTracker({ minIntervalMs: 100 });
|
|
const F = [356, 120, 0.02], B = [356, 120, -0.005];
|
|
// released at 22 ms; pokes at 44 and 66 ms come too soon, the one at 143 ms counts
|
|
const p = [F, B, F, F, B, F, B, F, F, F, F, F, F, B];
|
|
assert.deepEqual(phases(run(tr, p)), ['down:g', 'up', 'down:g']);
|
|
});
|
|
test('from behind or around the edge: no press', () => {
|
|
// starts behind the keyboard, inside
|
|
assert.deepEqual(run(TR.createTracker(), [[356, 120, -0.05], [356, 120, -0.04], [356, 120, -0.03]]), []);
|
|
// in front below the keyboard, behind it there, then up into the keys behind
|
|
const p = [[356, 500, 0.03], [356, 500, 0.01], [356, 500, -0.02], [356, 300, -0.02], [356, 120, -0.02], [356, 120, -0.01]];
|
|
assert.deepEqual(run(TR.createTracker(), p), []);
|
|
});
|
|
test('crossing outside the keyboard: no press', () => {
|
|
assert.deepEqual(run(TR.createTracker(), poke(-100, 120, 0.03, -0.02, 0.03)), []);
|
|
assert.deepEqual(run(TR.createTracker(), poke(356, 600, 0.03, -0.02, 0.03)), []);
|
|
});
|
|
test('a tracking jump through the surface is no press', () => {
|
|
assert.deepEqual(run(TR.createTracker(), [[356, 120, 0.06], [356, 120, -0.06], [356, 120, 0.06]]), []);
|
|
});
|
|
test('lost tracking while pressed: cancel', () => {
|
|
const evs = run(TR.createTracker(), [[356, 120, 0.02], [356, 120, -0.005], null, [356, 120, -0.005]]);
|
|
assert.deepEqual(phases(evs), ['down:g', 'cancel']);
|
|
});
|
|
test('depth: the touch registers that far behind the surface', () => {
|
|
const tr = TR.createTracker({ depth: 0.01 });
|
|
assert.deepEqual(run(tr, poke(356, 120, 0.03, 0.002 - 0.01, 0.03)), []); // 8 mm in: nothing
|
|
assert.deepEqual(phases(run(TR.createTracker({ depth: 0.01 }), poke(356, 120, 0.03, -0.015, 0.03))), ['down:g', 'up']);
|
|
});
|
|
test('two hands: independent, overlapping presses', () => {
|
|
const L = TR.createTracker(), R = TR.createTracker();
|
|
const left = [[300, 120, 0.02], [300, 120, -0.005]]; // f, held
|
|
for (let i = 0; i < 40; i++) left.push([300, 120, -0.006]);
|
|
const right = [[356, 300, 0.02], ...poke(400, 120, 0.02, -0.01, 0.02), ...poke(356, 120, 0.02, -0.01, 0.02)];
|
|
while (right.length < left.length) right.push([400, 120, 0.03]);
|
|
left.push([300, 120, 0.02]);
|
|
right.push([400, 120, 0.03]);
|
|
const evs = [];
|
|
left.forEach((p, i) => {
|
|
for (const [hand, tr, q] of [['L', L, p], ['R', R, right[i]]]) {
|
|
const ev = tr.update(sampleAt(...q), i * 11);
|
|
if (ev) evs.push(`${hand}:${ev.phase}${ev.phase === 'down' ? `:${keyAt(ev.u, ev.v)}` : ''}`);
|
|
}
|
|
});
|
|
assert.deepEqual(evs, ['L:down:f', 'R:down:h', 'R:up', 'R:down:g', 'R:up', 'L:up']);
|
|
});
|
|
|
|
console.log(`${passed} tests passed`);
|