diff --git a/docs/DEVICE-TEST.md b/docs/DEVICE-TEST.md
index 0ca5fe8..69e5f46 100644
--- a/docs/DEVICE-TEST.md
+++ b/docs/DEVICE-TEST.md
@@ -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) | [ ] | |
@@ -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
@@ -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.
diff --git a/public/ui/settings.uikitml b/public/ui/settings.uikitml
index 3b4590d..31666a7 100644
--- a/public/ui/settings.uikitml
+++ b/public/ui/settings.uikitml
@@ -230,8 +230,9 @@
Stage
+
Pinch, then look where you want the stage.
Pinch an option to choose it.
diff --git a/scripts/diag/summarize.mjs b/scripts/diag/summarize.mjs
index bf0199e..cd0a40c 100644
--- a/scripts/diag/summarize.mjs
+++ b/scripts/diag/summarize.mjs
@@ -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`);
diff --git a/scripts/sim/rooms.mjs b/scripts/sim/rooms.mjs
new file mode 100644
index 0000000..d4e1e22
--- /dev/null
+++ b/scripts/sim/rooms.mjs
@@ -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;
diff --git a/src/automation.ts b/src/automation.ts
index bb5c6c4..2b2bccb 100644
--- a/src/automation.ts
+++ b/src/automation.ts
@@ -1,4 +1,4 @@
-import { createSystem, Entity } from '@iwsdk/core';
+import { Box3, createSystem, Entity, Object3D, Vector3 } from '@iwsdk/core';
import {
DemoDirector,
FamilyInbox,
@@ -7,6 +7,7 @@ import {
PerformanceRecorder,
RecognitionLab,
Settings,
+ ShadowJournal,
ShapeRecognizer,
Stage,
StoryState,
@@ -16,7 +17,8 @@ import { loadRecording } from './journal-store.js';
import { voiceStats } from './live/voice.js';
import type { Performance } from './performance/format.js';
import { toWire } from './performance/sharing.js';
-import { handOverride, handRuntime, secondHands } from './stage-runtime.js';
+import { handOverride, handRuntime, secondHands, stageRuntime } from './stage-runtime.js';
+import type { RoomPlane } from './wall-choice.js';
const COMPONENTS = {
DemoDirector,
@@ -26,6 +28,7 @@ const COMPONENTS = {
PerformanceRecorder,
RecognitionLab,
Settings,
+ ShadowJournal,
ShapeRecognizer,
Stage,
StoryState,
@@ -50,6 +53,7 @@ export class AutomationSystem extends createSystem({
PerformanceRecorder: { required: [PerformanceRecorder] },
RecognitionLab: { required: [RecognitionLab] },
Settings: { required: [Settings] },
+ ShadowJournal: { required: [ShadowJournal] },
ShapeRecognizer: { required: [ShapeRecognizer] },
Stage: { required: [Stage] },
StoryState: { required: [StoryState] },
@@ -109,6 +113,73 @@ export class AutomationSystem extends createSystem({
return out;
},
frames: () => this.frames,
+ /** World position of a named object (UI elements are named by their id), or null. */
+ where: (name: string) => {
+ const o = this.world.scene.getObjectByName(name);
+ return o ? o.getWorldPosition(new Vector3()).toArray() : null;
+ },
+ /** Room tests: stage placement sees these planes instead of the headset's (null restores them). */
+ room: (planes: RoomPlane[] | null) => {
+ stageRuntime.testPlanes = planes;
+ },
+ /**
+ * Where the stage is and how far its essential UI sits from straight ahead:
+ * degrees left/right of the horizontal view direction and up/down from eye level.
+ */
+ view: () => {
+ const head = new Vector3();
+ const fwd = new Vector3();
+ const cam = this.player.head;
+ cam.updateWorldMatrix(true, false);
+ head.setFromMatrixPosition(cam.matrixWorld);
+ cam.getWorldDirection(fwd).negate();
+ fwd.y = 0;
+ fwd.normalize();
+ const angles = (points: Vector3[]) => {
+ const out = { left: -Infinity, right: -Infinity, up: -Infinity, down: -Infinity };
+ for (const p of points) {
+ const d = p.clone().sub(head);
+ const ahead = d.x * fwd.x + d.z * fwd.z;
+ const side = d.x * -fwd.z + d.z * fwd.x;
+ const yaw = (Math.atan2(side, ahead) * 180) / Math.PI;
+ const pitch = (Math.atan2(d.y, Math.hypot(ahead, side)) * 180) / Math.PI;
+ out.right = Math.max(out.right, yaw);
+ out.left = Math.max(out.left, -yaw);
+ out.up = Math.max(out.up, pitch);
+ out.down = Math.max(out.down, -pitch);
+ }
+ for (const k of Object.keys(out) as (keyof typeof out)[]) out[k] = Math.round(out[k] * 10) / 10;
+ return out;
+ };
+ const boxOf = (o: Object3D | undefined | null) => {
+ if (!o) return null;
+ let shown = o.visible;
+ o.traverseAncestors((a) => (shown &&= a.visible));
+ if (!shown) return null;
+ const b = new Box3().setFromObject(o);
+ if (b.isEmpty()) return null;
+ const pts: Vector3[] = [];
+ for (const x of [b.min.x, b.max.x]) for (const y of [b.min.y, b.max.y]) for (const z of [b.min.z, b.max.z]) pts.push(new Vector3(x, y, z));
+ return angles(pts);
+ };
+ const r = stageRuntime;
+ const corners = [-1, 1].flatMap((sx) =>
+ [-1, 1].map((sy) => r.center.clone().addScaledVector(r.right, (sx * r.width) / 2).addScaledVector(r.up, (sy * r.height) / 2)),
+ );
+ const scene = this.world.scene;
+ const stage = entity('Stage');
+ return {
+ head: head.toArray().map((v) => Math.round(v * 100) / 100),
+ mode: stage?.getValue(Stage, 'mode'),
+ ready: r.ready,
+ distance: stage?.getValue(Stage, 'distance'),
+ width: Math.round(r.width * 100) / 100,
+ stage: r.ready ? angles(corners) : null,
+ caption: boxOf(scene.getObjectByName('story-caption')),
+ journalPage: boxOf(scene.getObjectByName('journal-overlay')),
+ settings: boxOf(entity('Settings')?.object3D),
+ };
+ },
/** What the GPU draws: shadow casters, transparent layers, shadow map setup (performance audit). */
audit: () => {
const casters: string[] = [];
diff --git a/src/diag/diag-system.ts b/src/diag/diag-system.ts
index 5533aeb..1f7e89b 100644
--- a/src/diag/diag-system.ts
+++ b/src/diag/diag-system.ts
@@ -192,10 +192,23 @@ export class DiagSystem extends createSystem({
stageW: round(stage.getValue(Stage, 'width') ?? 0, 2),
planes: this.queries.planes.entities.size,
vplanes: this.verticalPlanes(),
+ vlabels: this.verticalLabels(),
meshes: this.queries.meshes.entities.size,
};
}
+ /** What the headset calls its vertical planes ("wall:4,door frame:1,-:2"), so label handling can be checked. */
+ private verticalLabels(): string {
+ const counts = new Map();
+ for (const p of this.queries.planes.entities) {
+ const plane = p.getValue(XRPlane, '_plane') as (XRPlane & { semanticLabel?: string }) | undefined;
+ if (plane?.orientation !== 'vertical') continue;
+ const label = (plane.semanticLabel ?? '').toLowerCase().replace(/[^a-z0-9 _-]/g, '').slice(0, 20) || '-';
+ counts.set(label, (counts.get(label) ?? 0) + 1);
+ }
+ return [...counts].map(([l, n]) => `${l}:${n}`).join(',').slice(0, 64);
+ }
+
private verticalPlanes(): number {
let n = 0;
for (const p of this.queries.planes.entities) {
diff --git a/src/settings.ts b/src/settings.ts
index cf1418e..3572e31 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -228,10 +228,11 @@ export class SettingsSystem extends createSystem({
this.storyStart = panel.getElementById('story-start-label') ?? undefined;
if (!this.library.length) this.loadLibrary();
on('replace-stage-button', () => {
- // Close the journal so the stage can be found again, then re-place it.
+ // "Move stage here": close the journal and place the stage again, ahead of
+ // wherever the player looks a moment from now (time to turn toward the spot).
for (const j of this.queries.journals.entities) j.setValue(ShadowJournal, 'open', false);
for (const s of this.queries.stages.entities) s.setValue(Stage, 'replaceRequested', true);
- this.status?.setProperties({ text: 'Looking for the wall ahead of you...' });
+ this.status?.setProperties({ text: 'Look where you want the stage...' });
});
on('replay-button', () => {
settings.setValue(Settings, 'replayRequested', true);
diff --git a/src/stage-runtime.ts b/src/stage-runtime.ts
index 050bf9c..e17e1bb 100644
--- a/src/stage-runtime.ts
+++ b/src/stage-runtime.ts
@@ -1,4 +1,5 @@
import { Vector3 } from '@iwsdk/core';
+import type { RoomPlane } from './wall-choice.js';
/**
* Per-frame shared stage geometry, written by StageSetupSystem and read by the
@@ -23,6 +24,11 @@ export const stageRuntime = {
quality: 0,
/** The headset's answer to our 72 Hz request: '' (not asked yet), 'granted', 'refused', 'unsupported'. */
frameRateRequest: '',
+ /**
+ * Automation only (emulator tests): when set, stage placement sees these
+ * planes instead of the headset's detected planes.
+ */
+ testPlanes: null as RoomPlane[] | null,
};
export const JOINT_COUNT = 25;
diff --git a/src/stage-setup.ts b/src/stage-setup.ts
index dd9f2fe..e130fd5 100644
--- a/src/stage-setup.ts
+++ b/src/stage-setup.ts
@@ -20,24 +20,24 @@ import {
createPaperScreen,
createShadowSurface,
} from './stage-visuals.js';
+import { chooseWall, SCREEN_DISTANCE, screenDistance, WIDTH_PER_METER, type RoomPlane, type WallChoice } from './wall-choice.js';
-const MIN_WALL = 0.6;
-const MAX_WALL = 4;
-const CLOSE_WALL = 1.0;
-const SCREEN_DISTANCE = 2;
-const FRONT_COS = Math.cos((40 * Math.PI) / 180);
/** Behind the stage in a browser (no passthrough): a deep night blue. */
const BROWSER_BACKGROUND = new Color(0x0d0a14);
-/** Stage subtends ~33 degrees: width = 2 * d * tan(16.5deg). */
-const WIDTH_PER_METER = 2 * Math.tan((16.5 * Math.PI) / 180);
const SCAN_MIN_SECONDS = 1.2;
const SCAN_TIMEOUT_SECONDS = 4;
+/**
+ * A wall that turns up after the paper-screen fallback (room data arriving
+ * late) still takes the stage, but only this soon after the scan began: later
+ * the story is under way and the stage must not jump.
+ */
+const LATE_WALL_SECONDS = 12;
+/** "Move stage here": time to turn toward the new spot before the scan starts. */
+const MOVE_DELAY_SECONDS = 1.5;
+/** A close wall (usually behind a monitor) lifts the stage above it. */
+const CLOSE_WALL = 1.0;
const EYE_TO_DESK = 0.45;
-/** How far outside a detected wall's outline the point straight ahead may fall. */
-const WALL_MARGIN = 0.25;
-/** Narrowest stage we will build on a small wall. */
-const MIN_STAGE_WIDTH = 0.35;
const DIM_FADE_SECONDS = 2.5;
/** Sliding the lamp sideways sweeps its aim across the wall this many times further. */
const SWEEP_GAIN = 3.5;
@@ -73,16 +73,18 @@ export class StageSetupSystem extends createSystem({
private tmpA = new Vector3();
private tmpB = new Vector3();
private normal = new Vector3();
- private bestNormal = new Vector3();
- private bestPoint = new Vector3();
private basis = new Matrix4();
private right = new Vector3();
private corner = new Vector3();
+ /** Detected planes in the shape wall-choice takes; objects reused frame to frame. */
+ private room: RoomPlane[] = [];
+ private roomPool: RoomPlane[] = [];
+ private wall: WallChoice | null = null;
+ private planeNormal = new Vector3();
+ private lateCheck = 0;
/** Chosen wall's horizontal extent, relative to the point straight ahead. */
- private bestUMin = 0;
- private bestUMax = 0;
private watchedRef: XRReferenceSpace | null = null;
- private onReset = () => this.resetPlacement();
+ private onReset = () => this.resetPlacement(0);
private lampMeshes: Mesh[] = [];
/** Where the lamp was placed; dragging away from here sweeps the light. */
private lampHome = new Vector3();
@@ -108,7 +110,7 @@ export class StageSetupSystem extends createSystem({
(() => {
// Phones rotate and browsers resize: re-fit the screen outside XR.
const onResize = () => {
- if (!this.inXR) this.resetPlacement();
+ if (!this.inXR) this.resetPlacement(0);
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
@@ -117,7 +119,7 @@ export class StageSetupSystem extends createSystem({
const inXR = state !== VisibilityState.NonImmersive;
if (inXR !== this.inXR) {
this.inXR = inXR;
- this.resetPlacement();
+ this.resetPlacement(0);
this.applyBrowserLook();
// Fixed foveation (lower resolution at the edges of the view) where the
// browser supports it: the dimming layer and shadow surface are fill-rate bound.
@@ -152,9 +154,10 @@ export class StageSetupSystem extends createSystem({
);
}
- private resetPlacement(): void {
+ /** Forget the placement and scan again, after `delay` seconds (time to turn toward a new spot). */
+ private resetPlacement(delay: number): void {
this.placed = false;
- this.scanTime = 0;
+ this.scanTime = -delay;
this.dimLevel = 0;
stageRuntime.ready = false;
if (this.stageEntity) this.stageEntity.setValue(Stage, 'mode', StageModes.Pending);
@@ -210,7 +213,7 @@ export class StageSetupSystem extends createSystem({
// Re-place on request (Settings) or when the headset recenters the space.
if (stage.getValue(Stage, 'replaceRequested')) {
stage.setValue(Stage, 'replaceRequested', false);
- this.resetPlacement();
+ this.resetPlacement(this.inXR ? MOVE_DELAY_SECONDS : 0);
}
const ref = this.world.xrReferenceSpace;
if (ref !== this.watchedRef) {
@@ -239,105 +242,84 @@ export class StageSetupSystem extends createSystem({
if (this.findWall()) this.placeOnWall(stage, lamp);
else if (this.scanTime >= SCAN_TIMEOUT_SECONDS) this.placeScreen(stage, lamp);
}
+ } else if (this.inXR && this.scanTime < LATE_WALL_SECONDS && stage.getValue(Stage, 'mode') === StageModes.Screen) {
+ // On the paper screen for now: a wall whose room data arrives late still wins, for a few seconds.
+ this.scanTime += delta;
+ this.lateCheck += delta;
+ if (this.lateCheck >= 0.5) {
+ this.lateCheck = 0;
+ if (this.findWall()) this.placeOnWall(stage, lamp);
+ }
}
this.updateLighting(stage, lamp, delta);
}
- /** Pick the vertical plane most directly in front of the player. */
- private findWall(): boolean {
- let bestScore = -Infinity;
+ /** The detected planes (or an emulator test's fake ones) as wall-choice geometry. */
+ private readRoom(): readonly RoomPlane[] {
+ if (stageRuntime.testPlanes) return stageRuntime.testPlanes;
+ this.room.length = 0;
for (const entity of this.queries.planes.entities) {
const plane = entity.getValue(XRPlane, '_plane') as XRPlane | undefined;
const obj = entity.object3D;
- if (!plane || !obj || plane.orientation !== 'vertical') continue;
- const label = (plane as XRPlane & { semanticLabel?: string }).semanticLabel;
- if (label && label !== 'wall') continue;
- this.normal.set(0, 1, 0).applyQuaternion(obj.quaternion);
- this.tmpA.subVectors(this.head, obj.position);
- let dist = this.tmpA.dot(this.normal);
- if (dist < 0) {
- this.normal.negate();
- dist = -dist;
- }
- if (dist < MIN_WALL || dist > MAX_WALL) continue;
- const facing = -(this.normal.x * this.forward.x + this.normal.z * this.forward.z);
- if (facing < FRONT_COS) continue;
- // The point straight ahead must lie on this wall's detected outline.
- if (!this.measureWall(plane, obj, dist, facing)) continue;
- const score = facing - dist * 0.05;
- if (score > bestScore) {
- bestScore = score;
- this.bestNormal.copy(this.normal);
- this.bestPoint.copy(obj.position);
- this.bestUMin = this.uMin;
- this.bestUMax = this.uMax;
+ if (!plane || !obj) continue;
+ const r = this.roomPool[this.room.length] ?? { orientation: '', point: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 0, z: 0 }, corners: [] };
+ this.roomPool[this.room.length] = r;
+ obj.updateMatrixWorld();
+ r.orientation = plane.orientation;
+ r.label = (plane as XRPlane & { semanticLabel?: string }).semanticLabel;
+ r.point.x = obj.position.x;
+ r.point.y = obj.position.y;
+ r.point.z = obj.position.z;
+ this.planeNormal.set(0, 1, 0).applyQuaternion(obj.quaternion);
+ r.normal.x = this.planeNormal.x;
+ r.normal.y = this.planeNormal.y;
+ r.normal.z = this.planeNormal.z;
+ const polygon = plane.polygon;
+ r.corners.length = polygon.length;
+ for (let i = 0; i < polygon.length; i++) {
+ this.corner.set(polygon[i].x, polygon[i].y, polygon[i].z).applyMatrix4(obj.matrixWorld);
+ const c = r.corners[i] ?? { x: 0, y: 0, z: 0 };
+ c.x = this.corner.x;
+ c.y = this.corner.y;
+ c.z = this.corner.z;
+ r.corners[i] = c;
}
+ this.room.push(r);
}
- return bestScore > -Infinity;
+ return this.room;
}
- private uMin = 0;
- private uMax = 0;
-
- /**
- * Project the wall outline onto the wall's horizontal axis, relative to the
- * point straight ahead of the player; reject the wall if that point (or the
- * player's eye height) falls outside the outline by more than the margin.
- */
- private measureWall(plane: XRPlane, obj: Object3D, dist: number, facing: number): boolean {
- const n = this.normal;
- this.right.set(n.z, 0, -n.x).normalize();
- this.tmpB.copy(this.head).addScaledVector(this.forward, dist / Math.max(0.2, facing));
- obj.updateMatrixWorld();
- let uMin = Infinity;
- let uMax = -Infinity;
- let vMin = Infinity;
- let vMax = -Infinity;
- for (const p of plane.polygon) {
- this.corner.set(p.x, p.y, p.z).applyMatrix4(obj.matrixWorld);
- const u = (this.corner.x - this.tmpB.x) * this.right.x + (this.corner.z - this.tmpB.z) * this.right.z;
- const v = this.corner.y - this.head.y;
- if (u < uMin) uMin = u;
- if (u > uMax) uMax = u;
- if (v < vMin) vMin = v;
- if (v > vMax) vMax = v;
- }
- this.uMin = uMin;
- this.uMax = uMax;
- return uMin - WALL_MARGIN <= 0 && uMax + WALL_MARGIN >= 0 && vMin - 0.5 <= 0 && vMax + 0.5 >= 0;
+ /** Pick the wall most directly in front of the player (see wall-choice.ts for the rules). */
+ private findWall(): boolean {
+ this.wall = chooseWall(this.head, this.forward, this.readRoom());
+ return this.wall !== null;
}
private placeOnWall(stage: Entity, lamp: Entity): void {
- const n = this.bestNormal;
- n.y = 0;
- n.normalize();
- const dist = this.tmpA.subVectors(this.head, this.bestPoint).dot(n);
+ const w = this.wall!;
+ const n = this.normal.set(w.nx, 0, w.nz);
// Ray from the head along its horizontal forward to the wall plane.
- const along = dist / Math.max(0.2, -this.forward.dot(n));
- const close = dist < CLOSE_WALL;
- // Fit the stage to the wall: shrink for narrow walls, slide it to stay on them.
- const wallWidth = this.bestUMax - this.bestUMin;
- const width = Math.max(MIN_STAGE_WIDTH, Math.min(1.5, Math.max(0.45, dist * WIDTH_PER_METER), wallWidth * 0.9));
- const half = width / 2;
- const shift =
- wallWidth <= width ? (this.bestUMin + this.bestUMax) / 2 : Math.min(this.bestUMax - half, Math.max(this.bestUMin + half, 0));
+ const along = w.dist / Math.max(0.2, -this.forward.dot(n));
+ const close = w.dist < CLOSE_WALL;
this.right.set(n.z, 0, -n.x).normalize();
this.tmpB
.copy(this.head)
.addScaledVector(this.forward, along)
- .addScaledVector(this.right, shift)
+ .addScaledVector(this.right, w.shift)
.addScaledVector(n, 0.004);
- stage.setValue(Stage, 'wallWidth', wallWidth);
+ stage.setValue(Stage, 'wallWidth', w.wallWidth);
// Close walls usually sit behind a monitor: lift the stage above it.
this.tmpB.y = this.head.y + (close ? 0.28 : 0.05);
- this.applyStage(stage, lamp, this.tmpB, n, width, dist, StageModes.Wall, close);
+ this.applyStage(stage, lamp, this.tmpB, this.tmpA.copy(n), w.width, w.dist, StageModes.Wall, close);
}
private placeScreen(stage: Entity, lamp: Entity): void {
stage.setValue(Stage, 'wallWidth', 0);
this.normal.copy(this.forward).negate();
- this.tmpB.copy(this.head).addScaledVector(this.forward, SCREEN_DISTANCE);
+ // Two metres ahead, or just in front of a nearer surface (a wall too close to use, a window).
+ const dist = this.inXR ? screenDistance(this.head, this.forward, this.readRoom()) : SCREEN_DISTANCE;
+ this.tmpB.copy(this.head).addScaledVector(this.forward, dist);
this.tmpB.y = this.head.y + 0.05;
if (!this.inXR) {
// Browser (desktop link, phone or tablet viewer): centre the screen in the
@@ -345,8 +327,8 @@ export class StageSetupSystem extends createSystem({
this.world.camera.getWorldDirection(this.tmpA);
this.tmpB.copy(this.head).addScaledVector(this.tmpA, this.fitDistance(SCREEN_DISTANCE * WIDTH_PER_METER));
}
- const width = SCREEN_DISTANCE * WIDTH_PER_METER;
- this.applyStage(stage, lamp, this.tmpB, this.normal, width, SCREEN_DISTANCE, StageModes.Screen, false);
+ const width = dist * WIDTH_PER_METER;
+ this.applyStage(stage, lamp, this.tmpB, this.normal, width, dist, StageModes.Screen, false);
}
private applyStage(
diff --git a/src/wall-choice.ts b/src/wall-choice.ts
new file mode 100644
index 0000000..1956757
--- /dev/null
+++ b/src/wall-choice.ts
@@ -0,0 +1,250 @@
+/**
+ * Choosing where the stage goes, as pure geometry (no Three.js, so the room
+ * cases can be unit-tested and replayed in the emulator with fake planes).
+ *
+ * Coordinates are world metres, y up. A plane is described by a point on it,
+ * its unit normal and its outline corners in world space.
+ */
+
+export interface V3 {
+ x: number;
+ y: number;
+ z: number;
+}
+
+export interface RoomPlane {
+ orientation: 'vertical' | 'horizontal' | string;
+ /** As the headset reports it (Quest uses words such as "wall", "door frame", "window frame"). */
+ label?: string;
+ point: V3;
+ normal: V3;
+ corners: V3[];
+}
+
+export interface WallChoice {
+ /** Unit horizontal normal pointing back toward the player. */
+ nx: number;
+ nz: number;
+ /** A point on the wall plane. */
+ point: V3;
+ /** Perpendicular distance from the eye to the wall. */
+ dist: number;
+ /** Stage width and its sideways offset from the point straight ahead (along the wall's right). */
+ width: number;
+ shift: number;
+ /** Width of the wall's outline. */
+ wallWidth: number;
+}
+
+export const MIN_WALL = 0.6;
+export const MAX_WALL = 4;
+/** Walls turned further than this from the view are not used. */
+export const MAX_WALL_ANGLE = 40;
+const FRONT_COS = Math.cos((MAX_WALL_ANGLE * Math.PI) / 180);
+/** Stage subtends ~33 degrees: width = 2 * d * tan(16.5deg). */
+export const WIDTH_PER_METER = 2 * Math.tan((16.5 * Math.PI) / 180);
+/** How far outside a detected wall's outline the point straight ahead may fall. */
+const WALL_MARGIN = 0.25;
+/** Narrowest stage we will build on a small wall. */
+export const MIN_STAGE_WIDTH = 0.35;
+const MAX_STAGE_WIDTH = 1.5;
+/** A door or window counts as part of a wall when it lies this close to its plane. */
+const OPENING_DEPTH = 0.2;
+/** The band of the wall the stage occupies, relative to eye height. */
+const STAGE_BAND_LOW = -0.4;
+const STAGE_BAND_HIGH = 0.75;
+/**
+ * Sliding the stage off a door or window: its centre no further than this from
+ * straight ahead (a seated story is watched for minutes; a window right ahead
+ * gets the paper screen in front of it instead).
+ */
+const MAX_SHIFT_TAN = Math.tan((20 * Math.PI) / 180);
+export const SCREEN_DISTANCE = 2;
+/** A paper screen never comes nearer than this (hands need room in front of it). */
+export const SCREEN_MIN_DISTANCE = 0.9;
+
+export type LabelKind = 'wall' | 'unlabelled' | 'opening' | 'other';
+
+/**
+ * Walls are planes labelled "wall" (any spelling: "wall", "wall face", "WALL_FACE")
+ * or not labelled at all. Doors and windows are openings to keep the stage off.
+ * Anything else ("invisible wall face", "wall art", "screen", furniture) is not a wall.
+ */
+export function labelKind(label: string | undefined): LabelKind {
+ const l = (label ?? '').toLowerCase().replace(/[_-]+/g, ' ').trim();
+ if (!l) return 'unlabelled';
+ if (l === 'wall' || l === 'wall face') return 'wall';
+ if (/\b(door|window)/.test(l)) return 'opening';
+ return 'other';
+}
+
+interface Measured {
+ nx: number;
+ nz: number;
+ dist: number;
+ facing: number;
+ uMin: number;
+ uMax: number;
+}
+
+/** Distance, facing and outline extent (relative to the point straight ahead) of one vertical plane. */
+function measure(plane: RoomPlane, head: V3, fx: number, fz: number, out: Measured): boolean {
+ if (plane.orientation !== 'vertical') return false;
+ let nx = plane.normal.x;
+ let nz = plane.normal.z;
+ const len = Math.hypot(nx, nz);
+ if (len < 1e-6) return false;
+ nx /= len;
+ nz /= len;
+ let dist = (head.x - plane.point.x) * nx + (head.z - plane.point.z) * nz;
+ if (dist < 0) {
+ nx = -nx;
+ nz = -nz;
+ dist = -dist;
+ }
+ const facing = -(nx * fx + nz * fz);
+ if (facing <= 0.05) return false;
+ const along = dist / Math.max(0.2, facing);
+ const ax = head.x + fx * along;
+ const az = head.z + fz * along;
+ // The wall's right, seen from the player.
+ const rx = nz;
+ const rz = -nx;
+ let uMin = Infinity;
+ let uMax = -Infinity;
+ let vMin = Infinity;
+ let vMax = -Infinity;
+ for (const c of plane.corners) {
+ const u = (c.x - ax) * rx + (c.z - az) * rz;
+ const v = c.y - head.y;
+ if (u < uMin) uMin = u;
+ if (u > uMax) uMax = u;
+ if (v < vMin) vMin = v;
+ if (v > vMax) vMax = v;
+ }
+ out.nx = nx;
+ out.nz = nz;
+ out.dist = dist;
+ out.facing = facing;
+ out.uMin = uMin;
+ out.uMax = uMax;
+ // Eye height must fall within (or near) the outline too.
+ return vMin - 0.5 <= 0 && vMax + 0.5 >= 0;
+}
+
+/**
+ * Where on [uMin, uMax] a stage of the desired width fits, keeping clear of the
+ * blocked spans and as close to straight ahead (0) as possible. Without
+ * openings this is the original rule: shrink to 90% of a narrow wall, slide to
+ * stay on it. Returns null when no clear span is wide enough.
+ */
+export function fitOnWall(
+ uMin: number,
+ uMax: number,
+ desired: number,
+ blocked: ReadonlyArray,
+ maxShift = Infinity,
+): { width: number; shift: number } | null {
+ const spans: Array<[number, number]> = [[uMin, uMax]];
+ for (const [b0, b1] of blocked) {
+ for (let i = spans.length - 1; i >= 0; i--) {
+ const [s0, s1] = spans[i];
+ if (b1 <= s0 || b0 >= s1) continue;
+ const parts: Array<[number, number]> = [];
+ if (b0 > s0) parts.push([s0, b0]);
+ if (b1 < s1) parts.push([b1, s1]);
+ spans.splice(i, 1, ...parts);
+ }
+ }
+ if (!blocked.length) {
+ const wallWidth = uMax - uMin;
+ const width = Math.max(MIN_STAGE_WIDTH, Math.min(desired, wallWidth * 0.9));
+ const half = width / 2;
+ const shift = wallWidth <= width ? (uMin + uMax) / 2 : Math.min(uMax - half, Math.max(uMin + half, 0));
+ return { width, shift };
+ }
+ let best: { width: number; shift: number } | null = null;
+ for (const [s0, s1] of spans) {
+ const len = s1 - s0;
+ const width = Math.min(desired, len * 0.9);
+ if (width < MIN_STAGE_WIDTH) continue;
+ const half = width / 2;
+ // Keep a little gap from the opening's edge when there is room for one.
+ const gap = len - width > 0.1 ? 0.05 : 0;
+ const lo = s0 + half + gap;
+ const hi = s1 - half - gap;
+ const shift = lo > hi ? (s0 + s1) / 2 : Math.min(hi, Math.max(lo, 0));
+ if (Math.abs(shift) > maxShift) continue;
+ if (!best || Math.abs(shift) < Math.abs(best.shift)) best = { width, shift };
+ }
+ return best;
+}
+
+const scratch: Measured = { nx: 0, nz: 0, dist: 0, facing: 0, uMin: 0, uMax: 0 };
+const other: Measured = { nx: 0, nz: 0, dist: 0, facing: 0, uMin: 0, uMax: 0 };
+
+/**
+ * Pick the wall most directly in front of the player: vertical, labelled a wall
+ * (or unlabelled), 0.6-4 m away, turned less than 40 degrees from the view, with
+ * the point straight ahead on its outline and room for the stage clear of any
+ * door or window on it. Labelled walls win over unlabelled planes at equal footing.
+ * `forward` is the horizontal view direction.
+ */
+export function chooseWall(head: V3, forward: V3, planes: readonly RoomPlane[]): WallChoice | null {
+ const flen = Math.hypot(forward.x, forward.z) || 1;
+ const fx = forward.x / flen;
+ const fz = forward.z / flen;
+ let best: WallChoice | null = null;
+ let bestScore = -Infinity;
+ for (const plane of planes) {
+ const kind = labelKind(plane.label);
+ if (kind !== 'wall' && kind !== 'unlabelled') continue;
+ if (!measure(plane, head, fx, fz, scratch)) continue;
+ const { nx, nz, dist, facing, uMin, uMax } = scratch;
+ if (dist < MIN_WALL || dist > MAX_WALL || facing < FRONT_COS) continue;
+ if (uMin - WALL_MARGIN > 0 || uMax + WALL_MARGIN < 0) continue;
+ // Doors and windows lying on this wall, within the stage's band of height.
+ const blocked: Array<[number, number]> = [];
+ for (const o of planes) {
+ if (o === plane || labelKind(o.label) !== 'opening') continue;
+ if (!measure(o, head, fx, fz, other)) continue;
+ if (Math.abs(other.dist - dist) > OPENING_DEPTH || other.nx * nx + other.nz * nz < 0.95) continue;
+ let vMin = Infinity;
+ let vMax = -Infinity;
+ for (const c of o.corners) {
+ vMin = Math.min(vMin, c.y - head.y);
+ vMax = Math.max(vMax, c.y - head.y);
+ }
+ if (vMax < STAGE_BAND_LOW || vMin > STAGE_BAND_HIGH) continue;
+ blocked.push([other.uMin, other.uMax]);
+ }
+ const desired = Math.min(MAX_STAGE_WIDTH, Math.max(0.45, dist * WIDTH_PER_METER));
+ const fit = fitOnWall(uMin, uMax, desired, blocked, blocked.length ? Math.max(0.3, dist * MAX_SHIFT_TAN) : Infinity);
+ if (!fit) continue;
+ const score = facing - dist * 0.05 + (kind === 'wall' ? 0.1 : 0) - Math.abs(fit.shift) * 0.1;
+ if (score > bestScore) {
+ bestScore = score;
+ best = { nx, nz, point: { ...plane.point }, dist, width: fit.width, shift: fit.shift, wallWidth: uMax - uMin };
+ }
+ }
+ return best;
+}
+
+/**
+ * How far a floating paper screen should stand: 2 m, unless any vertical
+ * surface straight ahead is nearer, in which case just in front of it (never
+ * nearer than 0.9 m, so there is room for hands).
+ */
+export function screenDistance(head: V3, forward: V3, planes: readonly RoomPlane[]): number {
+ const flen = Math.hypot(forward.x, forward.z) || 1;
+ const fx = forward.x / flen;
+ const fz = forward.z / flen;
+ let nearest = Infinity;
+ for (const plane of planes) {
+ if (!measure(plane, head, fx, fz, scratch)) continue;
+ if (scratch.uMin > 0 || scratch.uMax < 0) continue;
+ const along = scratch.dist / Math.max(0.2, scratch.facing);
+ if (along < nearest) nearest = along;
+ }
+ return Math.max(SCREEN_MIN_DISTANCE, Math.min(SCREEN_DISTANCE, nearest - 0.15));
+}
diff --git a/tests/wall-choice.test.mjs b/tests/wall-choice.test.mjs
new file mode 100644
index 0000000..241304c
--- /dev/null
+++ b/tests/wall-choice.test.mjs
@@ -0,0 +1,152 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { chooseWall, fitOnWall, labelKind, screenDistance } from '../src/wall-choice.ts';
+
+// Seated player at the origin, eyes 1.2 m up, looking down -Z.
+const head = { x: 0, y: 1.2, z: 0 };
+const ahead = { x: 0, y: 0, z: -1 };
+
+/** A vertical rectangle `width` x `height` (bottom at y0), centred at (cx, cz), facing the yaw (degrees, 0 = toward +Z). */
+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 rx = nz;
+ const rz = -nx;
+ const corners = [
+ [-1, 0],
+ [1, 0],
+ [1, 1],
+ [-1, 1],
+ ].map(([u, v]) => ({ x: center.x + (rx * u * width) / 2, y: y0 + v * height, z: center.z + (rz * u * width) / 2 }));
+ return { orientation: 'vertical', label, point: center, normal: { x: nx, y: 0, z: nz }, corners };
+}
+function floor(y = 0, label = 'floor') {
+ return {
+ orientation: 'horizontal',
+ label,
+ point: { x: 0, y, z: 0 },
+ normal: { x: 0, y: 1, z: 0 },
+ corners: [
+ { x: -2, y, z: -2 },
+ { x: 2, y, z: -2 },
+ { x: 2, y, z: 2 },
+ { x: -2, y, z: 2 },
+ ],
+ };
+}
+
+test('labels: walls in any spelling, doors and windows are openings, the rest is not a wall', () => {
+ for (const l of ['wall', 'WALL', 'wall face', 'WALL_FACE', 'wall-face']) assert.equal(labelKind(l), 'wall', l);
+ for (const l of ['door', 'door frame', 'DOOR_FRAME', 'window', 'window frame', 'window_frame']) assert.equal(labelKind(l), 'opening', l);
+ for (const l of ['invisible wall face', 'wall art', 'screen', 'other', 'table']) assert.equal(labelKind(l), 'other', l);
+ assert.equal(labelKind(undefined), 'unlabelled');
+ assert.equal(labelKind(''), 'unlabelled');
+});
+
+test('an ordinary wall ahead takes the stage, straight ahead, about 33 degrees wide', () => {
+ const w = chooseWall(head, ahead, [wall({ dist: 2 })]);
+ assert.ok(w);
+ assert.ok(Math.abs(w.dist - 2) < 1e-9);
+ assert.ok(Math.abs(w.shift) < 1e-9);
+ assert.ok(Math.abs(w.width - 2 * 2 * Math.tan((16.5 * Math.PI) / 180)) < 1e-9);
+ assert.ok(w.nz > 0.99); // normal points back at the player
+});
+
+test('no planes at all: no wall, and the paper screen stands 2 m away', () => {
+ assert.equal(chooseWall(head, ahead, []), null);
+ assert.equal(screenDistance(head, ahead, []), 2);
+});
+
+test('only a floor and ceiling: no wall', () => {
+ assert.equal(chooseWall(head, ahead, [floor(0), floor(2.6, 'ceiling')]), null);
+});
+
+test('a wall nearer than 0.6 m is not used; the screen comes no nearer than 0.9 m', () => {
+ const planes = [wall({ dist: 0.45 })];
+ assert.equal(chooseWall(head, ahead, planes), null);
+ assert.equal(screenDistance(head, ahead, planes), 0.9);
+});
+
+test('a wall farther than 4 m is not used; the screen stays at 2 m', () => {
+ const planes = [wall({ dist: 5 })];
+ assert.equal(chooseWall(head, ahead, planes), null);
+ assert.equal(screenDistance(head, ahead, planes), 2);
+});
+
+test('a wall 1.5 m ahead that cannot be used (a window) brings the screen in front of it', () => {
+ const planes = [wall({ dist: 1.5, label: 'window frame' })];
+ assert.equal(chooseWall(head, ahead, planes), null);
+ assert.ok(Math.abs(screenDistance(head, ahead, planes) - 1.35) < 1e-9);
+});
+
+test('walls at a steep angle are refused beyond 40 degrees, used below', () => {
+ assert.ok(chooseWall(head, ahead, [wall({ yaw: 30 })]));
+ assert.equal(chooseWall(head, ahead, [wall({ yaw: 55 })]), null);
+ assert.equal(chooseWall(head, ahead, [wall({ yaw: 89 })]), null);
+});
+
+test('a window just right of straight ahead: the stage slides left of it', () => {
+ const planes = [wall({ dist: 2, width: 5 }), wall({ dist: 1.98, width: 0.4, height: 1.2, y0: 0.9, cx: 0.5, label: 'window frame' })];
+ const w = chooseWall(head, ahead, planes);
+ assert.ok(w);
+ // Right edge left of the window (which starts 0.3 m right of centre), centre within 20 degrees.
+ assert.ok(w.shift + w.width / 2 <= 0.3 - 0.05 + 1e-9, `shift ${w.shift} width ${w.width}`);
+ assert.ok(Math.abs(w.shift) <= 2 * Math.tan((20 * Math.PI) / 180) + 1e-9);
+});
+
+test('a narrow window straight ahead: sliding off it would turn the stage 20+ degrees, so the paper screen', () => {
+ const planes = [wall({ dist: 2, width: 5 }), wall({ dist: 1.98, width: 0.5, height: 1.2, y0: 0.9, label: 'window frame' })];
+ assert.equal(chooseWall(head, ahead, planes), null);
+});
+
+test('a wide window straight ahead: no turning far aside for the story, the paper screen stands in front of it', () => {
+ const planes = [wall({ dist: 2, width: 5 }), wall({ dist: 1.98, width: 1.2, height: 1.2, y0: 0.9, label: 'window frame' })];
+ assert.equal(chooseWall(head, ahead, planes), null);
+ assert.ok(Math.abs(screenDistance(head, ahead, planes) - 1.83) < 1e-9);
+});
+
+test('a door filling the wall ahead: that wall is refused (paper screen instead)', () => {
+ const planes = [wall({ dist: 2, width: 1.6 }), wall({ dist: 2, width: 1.0, height: 2.1, label: 'door frame' })];
+ assert.equal(chooseWall(head, ahead, planes), null);
+});
+
+test('a window high above the stage band does not move the stage', () => {
+ const planes = [wall({ dist: 2 }), wall({ dist: 2, width: 1, height: 0.4, y0: 2.2, label: 'window' })];
+ const w = chooseWall(head, ahead, planes);
+ assert.ok(w);
+ assert.ok(Math.abs(w.shift) < 1e-9);
+});
+
+test('a labelled wall wins over an unlabelled plane at the same distance', () => {
+ const unlabelled = wall({ dist: 2, label: '' });
+ const labelled = wall({ dist: 2.05 });
+ const w = chooseWall(head, ahead, [unlabelled, labelled]);
+ assert.ok(Math.abs(w.dist - 2.05) < 1e-9);
+});
+
+test('invisible wall faces and wall art are not walls', () => {
+ assert.equal(chooseWall(head, ahead, [wall({ label: 'invisible wall face' })]), null);
+ assert.equal(chooseWall(head, ahead, [wall({ label: 'wall art' })]), null);
+});
+
+test('a wall off to the side (not straight ahead) is not used', () => {
+ assert.equal(chooseWall(head, ahead, [wall({ width: 1, cx: 2 })]), null);
+});
+
+test('fitOnWall without openings keeps the original rule', () => {
+ // Wide wall: full width, straight ahead.
+ assert.deepEqual(fitOnWall(-2, 2, 1.2, []), { width: 1.2, shift: 0 });
+ // Narrow wall: 90% of it, slid just far enough to stay on it.
+ const r = fitOnWall(-0.1, 0.5, 1.2, []);
+ assert.ok(Math.abs(r.width - 0.54) < 1e-9 && Math.abs(r.shift - 0.17) < 1e-9, JSON.stringify(r));
+});
+
+test('fitOnWall picks the clear span nearest straight ahead and refuses when nothing fits', () => {
+ const r = fitOnWall(-3, 3, 1, [[-0.5, 0.2]]);
+ assert.ok(r.shift > 0 && r.shift - 0.5 >= 0.2);
+ assert.equal(fitOnWall(-0.5, 0.5, 1, [[-0.4, 0.4]]), null);
+ // Too far to the side: refused.
+ assert.equal(fitOnWall(-3, 3, 1, [[-2.5, 2.5]], 0.6), null);
+});