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); +});