Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/DEVICE-TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Run the core checks (A and B) in at least three rooms; the rest once per headset
| A2 | Enter XR to stage placed (panel: `placed +Xs`) | [ ] | |
| A3 | Permission prompts: which appeared, all understandable | [ ] | |
| A4 | Stage on the wall straight ahead (panel: `wall`), or floating screen (`screen`) in R3 | [ ] | |
| A4b | Write down the plane labels from the summary's `placed:` line (for example `wall:4,door frame:1`). If the walls carry a label other than `wall` or `wall face`, the stage will always fall back to the paper screen: tell me the label | [ ] | labels |
| A4c | Facing a window or a door: the stage slides beside it (if it is off to one side), or a paper screen stands just in front of it (if it is straight ahead). Never shadows on the glass | [ ] | |
| A5 | Stage size feels right; not inside furniture | [ ] | |
| A6 | Frame rate holds at 72 (panel line 1 stays cream, not red) | [ ] | fps / p95 |
| A7 | Quality level after 2 minutes (`q0`..`q3`; anything above `q0` means the headset could not hold 72) | [ ] | |
Expand Down Expand Up @@ -101,11 +103,12 @@ and whether anything woke that you did not ask for (panel: `match`).
| E3 | Recenter (hold the menu button): stage re-placed ahead | [ ] | |
| E4 | Stand up mid-story: stage stays put, hands still cast | [ ] | |
| E5 | Sit at 30-45 degrees to the wall: stage placement and shadows | [ ] | |
| E6 | Settings: Re-place stage on the wall ahead works by pinch | [ ] | |
| E6 | Settings: **Move stage here** works by pinch: pinch, look at another wall within about 1.5 s, the stage appears there | [ ] | |
| E7 | One-hand mode: bird and cup with one hand | [ ] | |
| E8 | Tolerance Very forgiving: shapes wake sooner, no false wakes | [ ] | |
| E8b | Change Tolerance while a shape is being asked for: takes effect at once and stays after it wakes | [ ] | |
| E9 | Captions, journal and settings readable without turning the head | [ ] | |
| E9 | Captions and the journal page readable without turning the head (measured in the emulator: within 16.5 degrees either side, 11 up, 13 down) | [ ] | |
| E9b | Settings readable and every row reachable by pinch without straining. It is the tallest thing on the stage: in the emulator it reaches 27 degrees up, 25 down and 33 to the right at a 2 m wall. Note whether the top or bottom rows are uncomfortable | [ ] | |
| E10 | Session ended mid-story: next launch continues from that chapter | [ ] | |

## Optional: silhouette templates
Expand All @@ -128,3 +131,6 @@ a second opinion, it is not required.
dropouts; summarize.mjs counts triggers by shape and what was asked).
- How the capsule hand shadows look on a real wall compared with a real hand.
- Plane detection timing in each room (`placed +Xs`) and the fallback in R3.
- The semantic labels Quest Browser gives walls, doors and windows (summary:
`placed: ... vertical N: labels`). Walls are accepted as `wall`, `wall face`
or no label; doors and windows are recognised by the words door and window.
3 changes: 2 additions & 1 deletion public/ui/settings.uikitml
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,9 @@

<div class="label">Stage</div>
<button id="replace-stage-button" class="toggle">
<span class="toggle-text">Re-place stage on the wall ahead</span>
<span class="toggle-text">Move stage here</span>
</button>
<div class="story-blurb">Pinch, then look where you want the stage.</div>

<button id="replay-button" class="replay">Replay story from the beginning</button>
<div id="status-text" class="status">Pinch an option to choose it.</div>
Expand Down
2 changes: 1 addition & 1 deletion scripts/diag/summarize.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ for (const [id, rows] of [...sessions].sort((a, b) => a[1][0].at - b[1][0].at))
const q = ev('quality');
console.log(`quality: ${q.length ? q.map((e) => `-> ${e.level} at ${e.t}s (${e.fps} fps)`).join(', ') : 'stayed at 0'} final shadow map ${snaps.at(-1)?.shadowMap ?? '-'}`);
for (const p of ev('placed')) {
console.log(`placed: ${p.place} wall ${p.wallW} m wide at ${p.wallD} m stage ${p.stageW} m planes ${p.planes} (vertical ${p.vplanes}) meshes ${p.meshes} at ${p.t}s`);
console.log(`placed: ${p.place} wall ${p.wallW} m wide at ${p.wallD} m stage ${p.stageW} m planes ${p.planes} (vertical ${p.vplanes}${p.vlabels ? ': ' + p.vlabels : ''}) meshes ${p.meshes} at ${p.t}s`);
}
const last = snaps.at(-1);
if (last) console.log(`startup: page load to XR ${(last.xrMs / 1000).toFixed(1)}s, XR to stage placed ${(last.placedMs / 1000).toFixed(1)}s heap ${last.heapMB} MB`);
Expand Down
236 changes: 236 additions & 0 deletions scripts/sim/rooms.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Rooms nobody tested: feeds the emulated headset fake room planes (no planes,
// floor and ceiling only, walls too close / too far / at a steep angle, a window
// ahead, room data arriving late), re-places the stage the way "Move stage here"
// does, recenters mid-story, and checks where the stage and its essential UI end up.
//
// node scripts/sim/rooms.mjs [--gpu]
// Needs the dev server (npm run dev) on https://localhost:8082.
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { chromium } from 'playwright';
import { MEDIA_DIR } from './media.mjs';

const APP = process.env.SIM_APP ?? 'https://localhost:8082';
const RENDER = process.argv.includes('--gpu')
? ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']
: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'];
const OUT = join(MEDIA_DIR, `rooms-${new Date().toISOString().replace(/[:.]/g, '-')}`);
mkdirSync(OUT, { recursive: true });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

/** A vertical rectangle in front of a player at (0, eye, 0) looking down -Z (yaw in degrees turns it). */
function wall({ dist = 2, width = 4, height = 2.6, y0 = 0, yaw = 0, cx = 0, label = 'wall' } = {}) {
const a = (yaw * Math.PI) / 180;
const nx = Math.sin(a);
const nz = Math.cos(a);
const center = { x: cx, y: y0 + height / 2, z: -dist };
const corners = [-1, 1, 1, -1].map((u, i) => ({
x: center.x + (nz * u * width) / 2,
y: y0 + (i >= 2 ? height : 0),
z: center.z + (-nx * u * width) / 2,
}));
return { orientation: 'vertical', label, point: center, normal: { x: nx, y: 0, z: nz }, corners };
}
const flat = (y, label) => ({
orientation: 'horizontal',
label,
point: { x: 0, y, z: 0 },
normal: { x: 0, y: 1, z: 0 },
corners: [
{ x: -3, y, z: -3 },
{ x: 3, y, z: -3 },
{ x: 3, y, z: 3 },
{ x: -3, y, z: 3 },
],
});

const CASES = [
{ name: 'ordinary wall 2 m ahead', planes: [flat(0, 'floor'), wall()], expect: { mode: 'wall', distance: [1.99, 2.01] } },
{ name: 'no planes at all', planes: [], expect: { mode: 'screen', distance: [1.99, 2.01] } },
{ name: 'only floor and ceiling', planes: [flat(0, 'floor'), flat(2.6, 'ceiling')], expect: { mode: 'screen', distance: [1.99, 2.01] } },
{ name: 'wall too close (0.45 m)', planes: [wall({ dist: 0.45 })], expect: { mode: 'screen', distance: [0.89, 0.91] } },
{ name: 'wall too far (5 m)', planes: [wall({ dist: 5 })], expect: { mode: 'screen', distance: [1.99, 2.01] } },
{ name: 'wall at a steep angle (55 deg)', planes: [wall({ yaw: 55 })], expect: { mode: 'screen' } },
{ name: 'wall at a usable angle (30 deg)', planes: [wall({ yaw: 30 })], expect: { mode: 'wall' } },
{
name: 'labelled "wall face"',
planes: [wall({ label: 'wall face' })],
expect: { mode: 'wall' },
},
{
name: 'window just right of centre',
planes: [wall({ width: 5 }), wall({ dist: 1.98, width: 0.4, height: 1.2, y0: 0.9, cx: 0.5, label: 'window frame' })],
expect: { mode: 'wall', rightEdgeBelow: Math.atan2(0.3, 2) * (180 / Math.PI) },
},
{
name: 'narrow window straight ahead',
planes: [wall({ width: 5 }), wall({ dist: 1.98, width: 0.5, height: 1.2, y0: 0.9, label: 'window frame' })],
expect: { mode: 'screen', distance: [1.82, 1.84] },
},
{
name: 'wide window straight ahead',
planes: [wall({ width: 5 }), wall({ dist: 1.98, width: 1.2, height: 1.4, y0: 0.8, label: 'window frame' })],
expect: { mode: 'screen', distance: [1.82, 1.84] },
},
{
name: 'door frame only',
planes: [wall({ dist: 2, width: 1, height: 2.1, label: 'door frame' })],
expect: { mode: 'screen', distance: [1.84, 1.86] },
},
];

const browser = await chromium.launch({ args: [...RENDER, '--ignore-certificate-errors', '--autoplay-policy=no-user-gesture-required'] });
const page = await (await browser.newContext({ viewport: { width: 1280, height: 800 }, ignoreHTTPSErrors: true })).newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
await page.goto(APP + '/', { waitUntil: 'load' });
await page.waitForFunction(() => window.__sp, null, { timeout: 60000 });
await page.getByRole('button', { name: /Enter XR/i }).click();
await page.waitForFunction(() => window.__sp.view().ready, null, { timeout: 30000 });

const eye = (await page.evaluate(() => __sp.view())).head[1];
const lift = (planes) =>
// Planes are authored for eyes at 1.2 m; shift them to the emulated head height (floors stay at 0).
planes.map((p) =>
p.orientation === 'vertical'
? { ...p, point: { ...p.point, y: p.point.y + eye - 1.2 }, corners: p.corners.map((c) => ({ ...c, y: c.y + eye - 1.2 })) }
: p,
);

/** Re-place like "Move stage here", then wait for placement (1.5 s turn delay + up to 4 s scan). */
async function place(planes) {
await page.evaluate((p) => {
__sp.room(p);
__sp.set('Stage', 'replaceRequested', true);
}, planes && lift(planes));
await sleep(300);
await page.waitForFunction(() => window.__sp.view().ready, null, { timeout: 15000 });
await sleep(300);
return page.evaluate(() => __sp.view());
}

const results = [];
const check = (name, ok, detail) => {
results.push({ name, ok, ...detail });
console.log(`${ok ? 'PASS' : 'FAIL'} ${name} ${JSON.stringify(detail)}`);
};

for (const c of CASES) {
const t0 = Date.now();
const v = await place(c.planes);
const seconds = +((Date.now() - t0) / 1000).toFixed(1);
let ok = v.mode === c.expect.mode;
if (c.expect.distance) ok &&= v.distance >= c.expect.distance[0] && v.distance <= c.expect.distance[1];
// The stage's right edge stays left of the window.
if (c.expect.rightEdgeBelow) ok &&= v.stage.right < c.expect.rightEdgeBelow;
check(c.name, ok, { mode: v.mode, distance: +(+v.distance).toFixed(2), width: v.width, stage: v.stage, seconds });
await page.screenshot({ path: join(OUT, `${c.name.replace(/[^a-z0-9]+/gi, '-')}.png`) });
}

// Room data arriving late: nothing at first (paper screen), the wall a few seconds later.
{
await place([]);
await page.evaluate((p) => __sp.room(p), lift([wall({ dist: 2.5 })]));
await sleep(1500);
const v = await page.evaluate(() => __sp.view());
check('planes arrive after the timeout: wall takes over', v.mode === 'wall' && Math.abs(v.distance - 2.5) < 0.02, { mode: v.mode, distance: v.distance });
}
// ...but not once the story is under way.
{
await place([]);
await sleep(8000);
await page.evaluate((p) => __sp.room(p), lift([wall({ dist: 2.5 })]));
await sleep(1500);
const v = await page.evaluate(() => __sp.view());
check('planes arrive much later: stage stays put', v.mode === 'screen', { mode: v.mode, distance: v.distance });
}

// Recenter mid-story: turn the head 60 degrees, recenter; the stage comes back ahead and the story carries on.
{
await place([flat(0, 'floor')]);
await page.evaluate(() => {
__sp.set('StoryState', 'script', 'story/the-moth.story.json');
__sp.set('DemoDirector', 'persona', 'perfect');
__sp.set('DemoDirector', 'playing', true);
});
await sleep(12000);
const before = await page.evaluate(() => __sp.get('StoryState', 'beatIndex'));
// Turn the emulated head 60 degrees to the left.
await page.evaluate((y) =>
IWER_DEVICE.remote.dispatch('look_at', { device: 'headset', target: { x: -Math.sin(Math.PI / 3) * 2, y, z: -Math.cos(Math.PI / 3) * 2 } }),
eye);
await sleep(500);
const turned = await page.evaluate(() => __sp.view());
await page.evaluate(() => IWER_DEVICE.recenter());
await sleep(300);
await page.waitForFunction(() => window.__sp.view().ready, null, { timeout: 15000 });
await sleep(1500);
const v = await page.evaluate(() => __sp.view());
const after = await page.evaluate(() => __sp.get('StoryState', 'beatIndex'));
const centred = Math.abs(v.stage.left - v.stage.right) < 2 && turned.stage.right > 40;
check('recenter mid-story: stage ahead again, story continues', centred && after >= before, {
turnedStage: turned.stage,
stage: v.stage,
beatBefore: before,
beatAfter: after,
});
await page.evaluate(() => __sp.set('DemoDirector', 'playing', false));
}

// "Move stage here" by hand: open the journal (Settings appears beside the stage), pinch the
// button with the right hand's ray, then look at the wall on the left; the stage follows.
{
const leftWall = { ...wall({ dist: 2 }), normal: { x: 1, y: 0, z: 0 } };
leftWall.point = { x: -2, y: 1.3, z: 0 };
leftWall.corners = [
{ x: -2, y: 0, z: 2 },
{ x: -2, y: 0, z: -2 },
{ x: -2, y: 2.6, z: -2 },
{ x: -2, y: 2.6, z: 2 },
];
await place([flat(0, 'floor'), wall(), leftWall]);
await page.evaluate(() => __sp.set('ShadowJournal', 'open', true));
await sleep(1000);
const remote = (method, params) => page.evaluate(([m, p]) => IWER_DEVICE.remote.dispatch(m, p), [method, params]);
await remote('set_input_mode', { mode: 'hand' });
await sleep(500);
const button = await page.evaluate(() => __sp.where('replace-stage-button'));
await remote('look_at', { device: 'hand-right', target: { x: button[0], y: button[1], z: button[2] } });
await sleep(300);
await remote('select', { device: 'hand-right', duration: 0.15 });
await sleep(200);
const pending = await page.evaluate(() => __sp.get('Stage', 'mode'));
await remote('look_at', { device: 'headset', target: { x: -2, y: eye, z: 0 } });
await page.waitForFunction(() => window.__sp.view().ready, null, { timeout: 15000 });
await sleep(500);
const v = await page.evaluate(() => __sp.view());
const centred = Math.abs(v.stage.left - v.stage.right) < 2;
check('"Move stage here" by pinch, then looking left: stage on the left wall', pending === 'pending' && v.mode === 'wall' && centred, {
afterPinch: pending,
mode: v.mode,
distance: v.distance,
stage: v.stage,
});
await page.screenshot({ path: join(OUT, 'moved-to-left-wall.png') });
await remote('look_at', { device: 'headset', target: { x: 0, y: eye, z: -2 } });
await remote('set_input_mode', { mode: 'controller' }).catch(() => undefined);
}

// Essential UI inside the comfortable field of view, with the journal (and settings) open.
{
await place([flat(0, 'floor'), wall()]);
await page.evaluate(() => __sp.set('ShadowJournal', 'open', true)).catch(() => undefined);
await sleep(1000);
const v = await page.evaluate(() => __sp.view());
check('field of view with the journal open', true, { stage: v.stage, caption: v.caption, journalPage: v.journalPage, settings: v.settings });
await page.screenshot({ path: join(OUT, 'journal-open.png') });
}

await page.evaluate(() => __sp.room(null));
await browser.close();
writeFileSync(join(OUT, 'report.json'), JSON.stringify({ results, errors }, null, 2));
const failed = results.filter((r) => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} passed, ${errors.length} page errors. Report and screenshots: ${OUT}`);
if (errors.length) console.log(errors.slice(0, 5).join('\n'));
if (failed.length || errors.length) process.exitCode = 1;
Loading
Loading