diff --git a/packages/sdk-browser/src/camera/controls/firstPerson.test.ts b/packages/sdk-browser/src/camera/controls/firstPerson.test.ts index 91f521b466..42d22161d8 100644 --- a/packages/sdk-browser/src/camera/controls/firstPerson.test.ts +++ b/packages/sdk-browser/src/camera/controls/firstPerson.test.ts @@ -39,19 +39,24 @@ test('first person turns the head with the pointer and lets the lock go on dispo assert.equal(surface.listeners(), 0); }); -test('the first move after the lock is granted is dropped: the cursor jump never turns the head', () => { +test('the first move after the lock is granted is dropped: the cursor jump never turns the head, and a turn and its reverse return the head', () => { const { camera, surface, controls } = steered(createFirstPersonCameraControls); + const looking = () => [...facing(camera)].map((v) => round(v)); controls.lookSpeed = Math.PI / 400; controls.update(0); + const home = looking(); surface.fire('pointerdown', { pointerId: 1, button: 0, clientX: 0, clientY: 0 }); surface.key('pointerlockchange', {}); surface.fire('pointermove', { pointerId: 1, movementX: -900, movementY: 700 }); surface.fire('pointermove', { pointerId: 1, movementX: 200, movementY: 0 }); controls.update(0); - assert.deepEqual( - [...facing(camera)].map((v) => round(v)), - [1, 0, 0], - ); + assert.deepEqual(looking(), [1, 0, 0]); + // The examples proof's look (#527): every move after the dropped one counts, so the reverse, + // in four steps, lands on the very head the lock started from. + for (let step = 0; step < 4; step++) + surface.fire('pointermove', { pointerId: 1, movementX: -50, movementY: 0 }); + controls.update(0); + assert.deepEqual(looking(), home); }); test('first person stops a downward look at `minPitch`', () => { diff --git a/scripts/docs-examples-draw.test.ts b/scripts/docs-examples-draw.test.ts index e26f673939..4cbaf51813 100644 --- a/scripts/docs-examples-draw.test.ts +++ b/scripts/docs-examples-draw.test.ts @@ -48,8 +48,8 @@ test('a sparse example is declared by name and backend under the tenth; every ot assert.ok(share > 0 && share < 0.1, id); } // The shares declared under those measured on 2026-09-24, and the examples never declared: the - // three a refused WebGL2 session left blank, and save-the-scene, whose ground fills its frame - // (#717). They keep the tenth on both backends. + // three a refused WebGL2 session left blank, save-the-scene, whose ground fills its frame + // (#717), and click-to-pick, framed closer (#527). They keep the tenth on both backends. const named = [ 'a-staircase-from-one-step', 'a-cloud-of-points', @@ -58,13 +58,14 @@ test('a sparse example is declared by name and backend under the tenth; every ot 'fly-over-a-model-town', 'from-a-grain-to-a-planet', 'snow-of-sprites', + 'click-to-pick', ]; for (const id of named) assert.ok(ids.has(id), id); // A new declaration is asserted here too, with its literal share. for (const id of Object.keys(SPARSE)) assert.ok(named.includes(id), id); const least = (gpu: boolean) => named.map((id) => leastDrawn(id, gpu)); - assert.deepEqual(least(false), [0.04, 0.04, 0.1, 0.1, 0.1, 0.1, 0.1]); - assert.deepEqual(least(true), [0.04, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]); + assert.deepEqual(least(false), [0.04, 0.04, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]); + assert.deepEqual(least(true), [0.04, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]); }); test('the proof expects Jolt from the pages whose own source turns physics on, and from no other', async () => { diff --git a/site/examples/click-to-pick.html b/site/examples/click-to-pick.html index 5838737025..ecea6eea50 100644 --- a/site/examples/click-to-pick.html +++ b/site/examples/click-to-pick.html @@ -38,7 +38,8 @@ mesh.name = names[i]; world.scene.add(mesh); }); - world.camera.position.set(0, 4, 6); + // Close enough that the ring fills the view: six shapes on a wide grid are a small target. + world.camera.position.set(0, 3.2, 4.8); world.camera.lookAt(0, 0, 0); controls({}); diff --git a/tests/browser/renders/default-backend.browser.ts b/tests/browser/renders/default-backend.browser.ts index 9388f32f76..cd0d3bcb49 100644 --- a/tests/browser/renders/default-backend.browser.ts +++ b/tests/browser/renders/default-backend.browser.ts @@ -142,8 +142,12 @@ try { JSON.stringify(withoutGpu.cases.default.metrics), ); // The canvas is not empty: a twentieth of it at least differs from the cleared background. + // Each machine named in its own verdict, with the frame's counters: a blank side says which (#527). for (const run of [withGpu, withoutGpu]) - assert.ok(run.drawn && run.drawn.drawn > run.drawn.totalPixels / 20, JSON.stringify(run.drawn)); + assert.ok( + run.drawn && run.drawn.drawn > run.drawn.totalPixels / 20, + `${machineLabel(run.cases.default.webgpu)}: ${JSON.stringify({ drawn: run.drawn, metrics: run.cases.default.metrics })}`, + ); assert.deepEqual(errors, []); const side = (name: string, run: MachineResult) => [ name, diff --git a/tests/browser/renders/native-camera-controls.browser.ts b/tests/browser/renders/native-camera-controls.browser.ts index 56dea78d1e..5a1111f7ca 100644 --- a/tests/browser/renders/native-camera-controls.browser.ts +++ b/tests/browser/renders/native-camera-controls.browser.ts @@ -13,6 +13,7 @@ import { endGesture, loadedModules, openProbe, + pointerLocked, stepGesture, } from '../support/cameraControlsPage.ts'; import type { Page } from 'playwright'; @@ -76,9 +77,9 @@ try { assert.ok(result.changes > 0, `${kind} emitted no change`); assert.ok(result.bytes > 0, `${kind} drew nothing`); assert.notDeepEqual(moved, home, `${kind} did not move the camera`); - // The gesture and its reverse leave the very image the pose started from. - assert.equal(result.differences, 0, `${kind} did not come back to its pose`); + // The gesture and its reverse leave the pose it started from, then the very image. assert.deepEqual(result.pose, home, `${kind} did not come back to its pose`); + assert.equal(result.differences, 0, `${kind} did not come back to its image`); }; await run('controls', async () => { await drag(page, 120, 0); @@ -118,12 +119,16 @@ try { }); // A locked pointer turns the head on every move, button or not, so the look here is one // press, out and back, rather than two drags that would each reposition the cursor first. + // Once the lock is granted the head drops the first move by design, the cursor's jump (#385): + // one move is spent on it, then four out and four back all count. await run('firstPersonControls', async () => { await page.mouse.move(centre.x, centre.y); await page.mouse.down(); - await page.mouse.move(centre.x + 80, centre.y, { steps: 4 }); + await page.evaluate(pointerLocked); + await page.mouse.move(centre.x + 1, centre.y); + await page.mouse.move(centre.x + 81, centre.y, { steps: 4 }); const moved = await page.evaluate(stepGesture, 0); - await page.mouse.move(centre.x, centre.y, { steps: 4 }); + await page.mouse.move(centre.x + 1, centre.y, { steps: 4 }); await page.mouse.up(); await page.evaluate(stepGesture, 0); await hold(page, 'w'); diff --git a/tests/browser/support/cameraControlsPage.ts b/tests/browser/support/cameraControlsPage.ts index 55ad20ba2a..f7310dbd87 100644 --- a/tests/browser/support/cameraControlsPage.ts +++ b/tests/browser/support/cameraControlsPage.ts @@ -102,6 +102,24 @@ export async function endGesture() { return { differences, pose: probe.pose(), changes: probe.changes, bytes: image.length }; } +/** Resolves once the pointer lock a press asked for is granted, at once if it already is; a + * refused lock rejects. Waited for as the event, never by a fixed delay. */ +export function pointerLocked() { + return new Promise((granted, refused) => { + if (document.pointerLockElement) return granted(); + // The first of the two events settles the wait and removes both listeners; a change that + // leaves the pointer free is a lost lock, never a granted one. + const settled = new AbortController(); + const settle = () => { + settled.abort(); + if (document.pointerLockElement) granted(); + else refused(new Error('lock refused or released')); + }; + document.addEventListener('pointerlockchange', settle, { signal: settled.signal }); + document.addEventListener('pointerlockerror', settle, { signal: settled.signal }); + }); +} + /** Every module the page fetched, for the proof that no addon of the host is among them. */ export function loadedModules() { return performance.getEntriesByType('resource').map((entry) => entry.name);