From c162c20cc2950da9274ccdb1c086656fbaf8db8d Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:33:05 +0300 Subject: [PATCH 1/5] [fix] 30 P0: the pointer-capture throw in play (~100 errors on a Quest = one per press) - three 0.185's OrbitControls.onPointerDown calls domElement.setPointerCapture with no guard, and the editor stays MOUNTED in play. Chromium refuses a capture with InvalidStateError while the page holds a pointer lock, and the throw lands before _addPointer, so EVERY press in play threw once and later surfaced as "Something went wrong ... Copy diagnostics" (the Quest diagnostics log, nodes/2.*.js:372:15736). - Half 1: Scene's frame task stands the active OrbitControls down while isLocked === true and restores exactly what it stood down when play ends. Per FRAME, not on the isLocked edge: TransformControls unmounts in play and its auto-pause cleanup writes enabled = true unconditionally a tick later, which would stomp an edge write. - Half 2: setPointerCapture is guarded on the element the controls listen on (skipped while document.pointerLockElement is set). Smaller than subclassing, which would mean replacing threlte's with a hand-built ; it also covers the camera preview's OrbitControls, which listens on the same element. It calls the prototype method at call time, and is removed on destroy. - New suite pointer-capture-play (12): Chromium's rule modelled in-page (a pointerLockElement getter + a prototype setPointerCapture that throws under a lock, checked as a premise), 20 real CDP presses in play, a hand re-enable to prove the guard alone, no diagnostics toast after leaving play, and the editor still orbits. - Counterfactuals: both halves removed -> 20 errors for 20 presses, the toast, 6 red (the reported bug, reproduced); capture guard alone removed -> the re-enabled-controls check red (2 errors) + the toast. - svelte-check 336/47 = base (line shifts only); vitest 196/196; build green; selection-extras 19/19 at base. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 47 +++++- tests/e2e/pointer-capture-play.test.cjs | 192 ++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/pointer-capture-play.test.cjs diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 8a36b46d..6973b668 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -95,7 +95,51 @@ import { Mesh, Vector3 } from 'three' - let { scene, camera, renderer, dpr } = useThrelte(); + let { scene, camera, renderer, dpr, dom } = useThrelte(); + + // 30 P0: THE POINTER-CAPTURE THROW — ~100 errors on a Quest were one per press. + // three 0.185's OrbitControls.onPointerDown calls `domElement.setPointerCapture` with + // no guard (its TransformControls sibling guards exactly this), and Chromium refuses a + // capture with InvalidStateError while the page holds a pointer lock — which is every + // press in play, since the editor controls stay mounted there. The throw lands before + // `_addPointer`, so it fired once per press and surfaced as "Something went wrong". + // Two halves: the controls stand down while playing (syncOrbitForPlay, per frame), and + // the capture itself is guarded on the element they listen on — the smaller of the two + // ways to reach it (a subclass would mean replacing threlte's with a + // hand-built ), and it covers the camera preview's OrbitControls too, which + // listens on the same element. It calls the PROTOTYPE method at call time rather than + // a captured copy, so nothing else on the element is shadowed for good. + const pointerCaptureGuard = (el: any) => { + if (!el || typeof el.setPointerCapture !== 'function' || el.__captureGuarded) return () => {}; + el.__captureGuarded = true; + el.setPointerCapture = function (this: any, pointerId: number) { + if (this.ownerDocument?.pointerLockElement) return; // the lock already owns the pointer + return Element.prototype.setPointerCapture.call(this, pointerId); + }; + return () => { + delete el.setPointerCapture; + delete el.__captureGuarded; + }; + }; + onDestroy(pointerCaptureGuard(dom)); + // the first half: while playing, every OrbitControls that drives the view stands + // down. Per FRAME rather than on the isLocked edge, because + // unmounts in play and its auto-pause CLEANUP writes `enabled = true` + // unconditionally a tick later (the enableZoom note on the mount below) — a write on + // the edge would be stomped. Whatever this stood down comes back when play ends. + const orbitStoodDown = new Set(); + function syncOrbitForPlay() { + const controls: any = $activeOrbit; + if ($isLocked === true) { + if (controls && controls.enabled !== false) { + controls.enabled = false; + orbitStoodDown.add(controls); + } + } else if (orbitStoodDown.size) { + for (const stood of orbitStoodDown) stood.enabled = true; + orbitStoodDown.clear(); + } + } // 26-D: the quality governor's two knobs that live here. Resolution goes through // threlte's OWN dpr (renderer.setPixelRatio directly would be undone by threlte's resize @@ -276,6 +320,7 @@ useTask((delta) => { rotation += 0.25 * delta; + syncOrbitForPlay(); // 30 P0: the editor camera controls stand down while playing // 21-B B3: play-mode grab/carry. The ray is NDC (0,0) every frame, so it // belongs in the frame loop rather than on a pointer event. tickPlayInteract(delta, camera.current); diff --git a/tests/e2e/pointer-capture-play.test.cjs b/tests/e2e/pointer-capture-play.test.cjs new file mode 100644 index 00000000..bdc1d008 --- /dev/null +++ b/tests/e2e/pointer-capture-play.test.cjs @@ -0,0 +1,192 @@ +// 30 P0 — THE POINTER-CAPTURE THROW. ~100 errors on a Quest were one per press. +// +// three 0.185's OrbitControls.onPointerDown calls `domElement.setPointerCapture` with +// no guard, and the editor's stays MOUNTED in play (only its zoom +// stands down). Chromium refuses a pointer capture while the page holds a pointer +// lock — InvalidStateError — and the throw lands before `_addPointer`, so EVERY press +// in play threw once and, after leaving play, surfaced as "Something went wrong". +// +// Headless has no real pointer lock, so the suite supplies Chromium's rule itself: a +// `document.pointerLockElement` getter that answers the canvas while "locked", and a +// prototype `setPointerCapture` that throws InvalidStateError exactly when one is set. +// That model is checked FIRST (premise), so the suite cannot pass by the stub doing +// nothing. The fix has two independent halves and each has its own check: +// (1) the editor OrbitControls is DISABLED while isLocked === true, so its +// onPointerDown returns before the capture (checked on the instance itself); +// (2) the capture on the canvas wrapper is GUARDED under a lock, for the moment +// something re-enables the controls (TransformControls' unmount cleanup does, +// unconditionally) — checked by re-enabling it by hand and pressing again. + +const h = require('./helpers.cjs'); + +const PRESSES = 20; + +/** real CDP presses at the canvas centre; returns how many pointerdowns the canvas saw */ +async function pressCanvas(page, n) { + const box = await page.evaluate(() => { + let renderer = null; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + const r = renderer.domElement.getBoundingClientRect(); + window.__p0downs = 0; + renderer.domElement.addEventListener('pointerdown', () => window.__p0downs++, { once: false }); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }); + for (let i = 0; i < n; i++) { + await page.mouse.move(box.x + (i % 5), box.y + (i % 3)); + await page.mouse.down(); + await page.mouse.up(); + await page.waitForTimeout(30); + } + return page.evaluate(() => window.__p0downs); +} + +const captureErrors = (peer) => h.pageErrors(peer).filter((m) => /setPointerCapture|InvalidStateError/.test(m)); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + // Chromium's rule, installed on the prototype so every element obeys it + await page.evaluate(() => { + let locked = null; + Object.defineProperty(document, 'pointerLockElement', { configurable: true, get: () => locked }); + window.__p0lock = (on) => { + let renderer = null; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + locked = on ? renderer.domElement : null; + }; + const real = Element.prototype.setPointerCapture; + Element.prototype.setPointerCapture = function (id) { + if (document.pointerLockElement) + throw new DOMException("Failed to execute 'setPointerCapture' on 'Element': InvalidStateError", 'InvalidStateError'); + return real.call(this, id); + }; + }); + + // a game-like scene: a floor, a crate, a collectible-ish marker + await page.evaluate(() => { + const cmd = window.__stores.commandsHandler.sceneCommand; + cmd('/create box'); + cmd('/create sphere'); + }); + await page.waitForTimeout(800); + + // ---- premise: the model throws, the way Chromium does ---- + const premise = await page.evaluate(() => { + window.__p0lock(true); + try { + document.body.setPointerCapture(1); + return 'no throw'; + } catch (e) { + return e.name; + } finally { + window.__p0lock(false); + } + }); + h.check(premise === 'InvalidStateError', `premise: a capture under the stubbed lock throws InvalidStateError (${premise})`); + + // ---- enter play with the lock held ---- + const before = captureErrors(A).length; + await page.evaluate(() => { + window.__p0lock(true); + window.__stores.isLocked.set(true); + }); + await page.waitForTimeout(600); + const orbitInPlay = await page.evaluate(() => { + let oc = null; + window.__stores.orbitControls.subscribe((v) => (oc = v))(); + return oc ? oc.enabled : 'none'; + }); + h.check(orbitInPlay === false, `the editor OrbitControls stands down in play (enabled=${orbitInPlay})`); + + const downs = await pressCanvas(page, PRESSES); + h.check(downs === PRESSES, `premise: the canvas really received ${PRESSES} pointerdowns (${downs})`); + const inPlay = captureErrors(A).length - before; + h.check(inPlay === 0, `ZERO setPointerCapture errors over ${PRESSES} presses in play (${inPlay})`); + + // ---- guard (2): something re-enables the controls mid-play (TransformControls' + // unmount cleanup does exactly this) — the capture itself must still not throw ---- + const guardBefore = captureErrors(A).length; + await page.evaluate(() => { + let oc = null; + window.__stores.orbitControls.subscribe((v) => (oc = v))(); + oc.enabled = true; + }); + // press straight away, before the next frame can stand it down again + const stomped = await page.evaluate(() => { + let renderer = null; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + const wrapper = renderer.domElement.parentElement; + let oc = null; + window.__stores.orbitControls.subscribe((v) => (oc = v))(); + oc.enabled = true; + const errors = []; + const onErr = (e) => { if (/setPointerCapture|InvalidStateError/.test(String(e.message))) errors.push(String(e.message)); }; + window.addEventListener('error', onErr); + const ev = new PointerEvent('pointerdown', { pointerId: 1, button: 0, bubbles: true, clientX: 300, clientY: 300 }); + renderer.domElement.dispatchEvent(ev); + window.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, button: 0, bubbles: true })); + document.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, button: 0, bubbles: true })); + window.removeEventListener('error', onErr); + return { sameEl: oc.domElement === wrapper || oc.domElement === renderer.domElement, errors }; + }); + await page.waitForTimeout(300); + h.check(stomped.sameEl, 'premise: the OrbitControls listens on the canvas or its wrapper'); + const stompErrors = captureErrors(A).length - guardBefore + stomped.errors.length; + h.check(stompErrors === 0, `a re-enabled OrbitControls under the lock still captures nothing (${stompErrors} errors)`); + const reDisabled = await page.evaluate(() => { + let oc = null; + window.__stores.orbitControls.subscribe((v) => (oc = v))(); + return oc.enabled; + }); + h.check(reDisabled === false, `...and the next frame stands it down again (enabled=${reDisabled})`); + + // ---- leave play: no "Something went wrong", and the editor still orbits ---- + await page.evaluate(() => { + window.__p0lock(false); + window.__stores.isLocked.set(false); + }); + await page.waitForTimeout(2600); // Controls' own effect walks false -> null + const toast = await page.evaluate(() => document.body.innerText.includes('Something went wrong')); + h.check(!toast, 'no "Something went wrong" toast after leaving play'); + const locked = await page.evaluate(() => { + let v; + window.__stores.isLocked.subscribe((x) => (v = x))(); + return v; + }); + h.check(locked !== true, `back in the editor (isLocked=${locked})`); + const enabledAfter = await page.evaluate(() => { + let oc = null; + window.__stores.orbitControls.subscribe((v) => (oc = v))(); + return oc.enabled; + }); + h.check(enabledAfter === true, `the editor OrbitControls is enabled again (${enabledAfter})`); + + const camBefore = await page.evaluate(() => { + let cam = null; + window.__stores.globalCamera.subscribe((v) => (cam = v))(); + return cam.position.toArray(); + }); + const box = await page.evaluate(() => { + let renderer = null; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + const r = renderer.domElement.getBoundingClientRect(); + return { x: r.left + r.width * 0.3, y: r.top + r.height * 0.6 }; + }); + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) await page.mouse.move(box.x + i * 18, box.y - i * 4); + await page.mouse.up(); + await page.waitForTimeout(600); + const camAfter = await page.evaluate(() => { + let cam = null; + window.__stores.globalCamera.subscribe((v) => (cam = v))(); + return cam.position.toArray(); + }); + const moved = Math.hypot(camAfter[0] - camBefore[0], camAfter[1] - camBefore[1], camAfter[2] - camBefore[2]); + h.check(moved > 0.2, `a drag orbits the editor camera after Esc (moved ${moved.toFixed(2)})`); + h.check(captureErrors(A).length === 0, `no capture error anywhere in the run (${captureErrors(A).length})`); + + await h.finish(browser); +}); From 4b1e7edb018493c13446f2e8cdbb1a2af3759aab Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 00:53:50 +0300 Subject: [PATCH 2/5] =?UTF-8?q?[feat]=2030=20P1:=20edit=20and=20interact?= =?UTF-8?q?=20=E2=80=94=20two=20editor=20click=20modes,=20click=20handlers?= =?UTF-8?q?=20routed=20by=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New LOCAL store `editorMode` ('edit' | 'interact', sceneStore; never sent, never saved). EDIT (default): a viewport click SELECTS first; a module click handler runs only if it registered for Edit; On Click nodes do NOT fire from the editor pick. INTERACT: a click is play's tap aimed by the cursor (module groups, handlers that run in 'interact', On Click nodes, the miss), nothing is selected and the gizmo is put away; a press on a dynamic body while a sim runs CARRIES it along the cursor ray with playInteract's own hold (one spring, one throw, one cancel discipline — only the aim differs) and the camera controls stand down for that gesture. Play is unchanged. - SDK: `api.registerClickHandler(fn, {modes})`, modes any of 'edit'|'interact'|'play'; ABSENT = ['interact', 'play'], so a module that never passes it stops eating editor clicks (the reported Jam Room case: Piano / Drum machine / Sampler never selectable). One dispatch `runClickHandlers(object, mode)` for the editor pick, Interact and the play tap. A core handler pushed straight into the array (vrPatch's plug click — patching is authoring) runs in every mode. VR's trigger passes no mode and keeps offering every handler first (VR has no mode toggle yet — recorded). - UI: an Edit/Interact toggle cell ('Interact mode (I)', a real + {#if open} + {#each rows as row (row.name)} +
+
+ +
+ {#if expanded[row.name]} + {#each row.children as child (child.uuid)} +

+ {child.name} +

+ {/each} + {#if row.more} +

+{row.more} more

+ {/if} + {#if !row.children.length && !row.more} +

No named parts

+ {/if} + {/if} +
+ {/each} + {/if} + +{/if} + +{#if menu} + (menu = null)} /> +{/if} diff --git a/src/lib/moduleContent.js b/src/lib/moduleContent.js new file mode 100644 index 00000000..457e8afc --- /dev/null +++ b/src/lib/moduleContent.js @@ -0,0 +1,220 @@ +// 30 P3: MODULE CONTENT in the object list — a LEAF (THREE + svelte/store + sceneStore + +// helperLayer). moduleSDK writes the registry, the object list reads it, objectActions +// clears the selection; none of them may import each other through here, which is why +// it is its own file (moduleSDK primes objectActions DYNAMICALLY to dodge that cycle, and +// a registry living in either one would close it). +// +// THE FINDING (roadmap 30, "Selection and the object list", cause 3): scene-root module +// content — the dungeon, the untangle board, the piano, the sabers — lives outside +// `objectsGroup` by golden rule 5, so it was never picked and never LISTED. Only the +// advanced System filter polled `systemGroupNames`, and it named no module. Every module +// group is listed now, READ-ONLY: the module owns it and regenerates it from its own +// state, so a rename, a delete or a reparent here would be undone on the next rebuild +// (or, worse, not undone and never replicated). +// +// Selecting one selects a PROXY, never the group: a scene-root box on the helper layer +// sized to the group's bounds. It is not in objectsGroup, so it never replicates, never +// saves and never enters the selection SET (applySelectionSet filters uuids it cannot +// find there) — the selection outline, the gizmo and every "act on the selection" +// command stay exactly what they were. +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { globalScene } from '../stores/sceneStore'; +import { markHelper } from './helperLayer'; + +/** named children listed per group, and how deep */ +export const LIST_CAP = 200; +export const LIST_DEPTH = 2; + +/** + * @typedef {{ name: string, moduleId: string, moduleName: string, label: string, + * icon: string | null, kinds: Set }} ModuleGroup + */ + +/** @type {Map} scene-root group name -> who registered it */ +const groups = new Map(); + +/** bumps on every registry change so the list re-derives */ +export const moduleGroupsRevision = writable(0); + +/** + * Record a module's scene-root group. `kind` is which register* call named it + * ('interactive' | 'system' | 'listed'); the row lives while any kind still holds it. + * @param {string} name @param {{id: string, name?: string}} owner @param {string} kind + * @param {{label?: string, icon?: string}} [opts] + */ +export function noteModuleGroup(name, owner, kind, opts = {}) { + if (!name || !owner?.id) return; + const entry = groups.get(name) ?? { + name, + moduleId: owner.id, + moduleName: owner.name || owner.id, + label: '', + icon: null, + kinds: new Set() + }; + entry.kinds.add(kind); + if (opts.label) entry.label = String(opts.label).slice(0, 80); + if (opts.icon) entry.icon = String(opts.icon); + groups.set(name, entry); + moduleGroupsRevision.update((n) => n + 1); +} + +/** @param {string} name @param {string} kind */ +export function forgetModuleGroup(name, kind) { + const entry = groups.get(name); + if (!entry) return; + entry.kinds.delete(kind); + if (kind === 'listed') { + entry.label = ''; + entry.icon = null; + } + if (!entry.kinds.size) groups.delete(name); + if (get(moduleSelection)?.name === name && !groups.has(name)) clearModuleSelection(); + moduleGroupsRevision.update((n) => n + 1); +} + +/** every registered module group, in a stable order (module, then name) */ +export function moduleGroupList() { + return [...groups.values()].sort( + (a, b) => a.moduleName.localeCompare(b.moduleName) || a.name.localeCompare(b.name) + ); +} + +/** @param {string} name */ +export function moduleGroupOf(name) { + return groups.get(name) ?? null; +} + +/** A group name is an id ('untangle-module'); until the module names it with + * registerListedGroup, show it the way a person would write it ('Untangle module'). + * @param {string} name */ +export function humanize(name) { + const words = String(name).replace(/[-_]+/g, ' ').trim(); + return words ? words[0].toUpperCase() + words.slice(1) : String(name); +} + +/** + * The rows the list draws: one per registered group PRESENT in the scene, with its named + * descendants down to LIST_DEPTH, capped at LIST_CAP (the rest counted, not dropped + * silently). A group a module registered but has not built yet is not listed — there is + * nothing to frame or select. + * @param {any} scene + */ +export function moduleContentRows(scene) { + if (!scene) return []; + /** @type {any[]} */ + const rows = []; + for (const entry of moduleGroupList()) { + const root = scene.getObjectByName(entry.name); + if (!root || root.parent !== scene) continue; + /** @type {{name: string, depth: number, uuid: string}[]} */ + const children = []; + let total = 0; + /** @param {any} node @param {number} depth */ + const walk = (node, depth) => { + for (const child of node.children ?? []) { + if (child.userData?.isModuleProxy) continue; + if (child.name) { + total++; + if (children.length < LIST_CAP) children.push({ name: child.name, depth, uuid: child.uuid }); + } + if (depth < LIST_DEPTH) walk(child, depth + 1); + } + }; + walk(root, 1); + rows.push({ + name: entry.name, + label: entry.label || humanize(entry.name), + moduleId: entry.moduleId, + moduleName: entry.moduleName, + icon: entry.icon, + object: root, + visible: root.visible !== false, + children, + more: Math.max(0, total - children.length) + }); + } + return rows; +} + +// --- the selection: a proxy, never the group ---------------------------------------- + +/** @type {import('svelte/store').Writable<{name: string, moduleId: string, moduleName: string, label: string} | null>} */ +export const moduleSelection = writable(null); + +/** @type {any} */ +let proxy = null; +const box = new THREE.Box3(); +let lastFit = 0; + +/** @param {any} root */ +function fitProxy(root) { + if (!proxy || !root) return false; + box.makeEmpty(); + // bounds of the GROUP only — the proxy is a scene-root sibling, never inside it + box.setFromObject(root); + if (box.isEmpty()) { + const at = root.getWorldPosition(new THREE.Vector3()); + box.setFromCenterAndSize(at, new THREE.Vector3(0.5, 0.5, 0.5)); + } + proxy.box.copy(box); + proxy.updateMatrixWorld(true); + return true; +} + +/** + * Select a module group: park the proxy around it and publish the selection. Returns the + * group's world bounds (the caller frames them), or null when the group is not in the scene. + * @param {string} name + */ +export function selectModuleGroup(name) { + const scene = /** @type {any} */ (get(globalScene)); + const root = scene?.getObjectByName(name); + const entry = groups.get(name); + if (!root || !entry) return null; + if (!proxy) { + proxy = new THREE.Box3Helper(new THREE.Box3(), new THREE.Color(0xf59e0b)); + proxy.name = 'module-content-proxy'; + proxy.userData.isModuleProxy = true; + proxy.material.depthTest = false; + proxy.material.transparent = true; + proxy.renderOrder = 999; + markHelper(proxy); + } + if (proxy.parent !== scene) scene.add(proxy); + fitProxy(root); + moduleSelection.set({ name, moduleId: entry.moduleId, moduleName: entry.moduleName, label: entry.label || humanize(name) }); + return box.clone(); +} + +export function clearModuleSelection() { + if (proxy?.parent) proxy.parent.remove(proxy); + if (get(moduleSelection)) moduleSelection.set(null); +} + +/** Per frame (Scene's task): module content moves on its own, so the box follows it — + * at a few Hz, because setFromObject walks the whole group. */ +export function tickModuleProxy() { + const selection = get(moduleSelection); + if (!selection || !proxy?.parent) return; + const now = performance.now(); + if (now - lastFit < 250) return; + lastFit = now; + const root = proxy.parent.getObjectByName(selection.name); + if (!root) { + clearModuleSelection(); + return; + } + fitProxy(root); +} + +/** test/debug view */ +export function moduleContentDebug() { + return { + groups: moduleGroupList().map((g) => ({ name: g.name, moduleId: g.moduleId, label: g.label, kinds: [...g.kinds] })), + selection: get(moduleSelection), + proxyInScene: !!proxy?.parent, + proxyBox: proxy?.parent ? { min: proxy.box.min.toArray(), max: proxy.box.max.toArray() } : null + }; +} diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index e2ffb17b..da0f691d 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -42,6 +42,9 @@ import { APP_VERSION } from './version.js'; import { ndcFromClient } from './canvasRect'; // 27-B: recovery paths report through the diagnostics ring (hardening audit H4) import { log } from './diagnostics'; +// 30 P3: the object list's Module content registry — a LEAF, so no cycle +import { noteModuleGroup, forgetModuleGroup } from './moduleContent'; +export { moduleContentDebug } from './moduleContent'; import { safeStorage } from './safeStorage'; // Module SDK v1 — in-repo modules under src/modules// register through @@ -554,20 +557,37 @@ function makeApi(moduleId, moduleName = moduleId) { registerInteractiveGroup(name) { moduleInteractiveGroups.push(name); registerSystemGroup(name); // clickable module content is also listable + noteModuleGroup(name, { id: moduleId, name: moduleName }, 'interactive'); // 30 P3 onDispose(() => { arrayRemove(moduleInteractiveGroups, name); arrayRemove(systemGroupNames, name); + forgetModuleGroup(name, 'interactive'); removeSceneRootGroup(name); // module-owned viewport content goes with the module }); }, /** List a scene-root group under the object list's System filter @param {string} name */ registerSystemGroup(name) { registerSystemGroup(name); + noteModuleGroup(name, { id: moduleId, name: moduleName }, 'system'); // 30 P3 onDispose(() => { arrayRemove(systemGroupNames, name); + forgetModuleGroup(name, 'system'); removeSceneRootGroup(name); }); }, + /** + * 30 P3: list a scene-root group in the object list's "Module content" section under + * a label a person can read (the group's own name is usually an id). Groups passed to + * registerInteractiveGroup / registerSystemGroup are listed anyway; this names them, + * or lists one that is neither. Read-only there: a click selects a PROXY and frames + * it, and the Inspector points at your module's toolbox and nodes. + * @param {string} name the scene-root group's object name + * @param {{label?: string, icon?: string}} [options] + */ + registerListedGroup(name, options = {}) { + noteModuleGroup(name, { id: moduleId, name: moduleName }, 'listed', options ?? {}); + onDispose(() => forgetModuleGroup(name, 'listed')); + }, /** * Runs when the scene is cleared (locally or by a peer) — remove your * viewport content and reset module state here. diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index d1b92219..a6e3ab43 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -36,6 +36,8 @@ import { canEditObject, warnViewerReadOnly } from './objectPermissions'; import { stripEditOverlays, isEditOverlay } from './editOverlays'; // B7: the transient marker (a LEAF — two stores only, so no cycle back through history) import { markTransient } from './transientObjects'; +// 30 P3: a LEAF (THREE + stores), so a static import here closes no cycle +import { clearModuleSelection } from './moduleContent'; // D2: a LEAF (svelte stores + THREE), so a static import here closes no cycle import { shareDuplicatedMaterials, linkMaterials } from './materialSharing'; import { @@ -165,6 +167,7 @@ export function applySelectionSet(uuids, openProperties = false) { !locked.find((lockedUuid) => lockedUuid[1] === uuid) ); applyMemberTints(group, clean); + if (clean.length) clearModuleSelection(); // 30 P3: an object selection replaces the proxy const previous = get(selectedObjects); selectedObjects.set(clean); if (clean.length) lastSelection = { uuids: [...clean], origin: null }; // 24-B1 @@ -307,6 +310,7 @@ export function deselectObject() { applyMemberTints(get(objectsGroup), []); broadcastSelectionRelease(get(selectedObjects)); selectedObjects.set([]); + clearModuleSelection(); // 30 P3: the Module content proxy goes with any deselect if (controls && !get(isVRMode)) controls.detach(); // selectedObject keeps the last object on purpose — the open inspector binds // to $selectedObject.position/material and would crash on an empty value diff --git a/tests/e2e/object-list-modules.test.cjs b/tests/e2e/object-list-modules.test.cjs new file mode 100644 index 00000000..12776cdd --- /dev/null +++ b/tests/e2e/object-list-modules.test.cjs @@ -0,0 +1,263 @@ +// 30 P3 — EVERY MODULE'S CONTENT IS LISTED. The object list's "Module content" section. +// +// Measured before this (roadmap 30, "Selection and the object list", cause 3): scene-root +// module content — the untangle board, the dungeon, the piano, the sabers — lives +// outside objectsGroup by golden rule 5, so it was never picked and never LISTED (only +// the advanced System filter polled it). Now every group a module registers gets one +// READ-ONLY row: the module's name as a badge, expandable to its named children, a click +// that selects a PROXY and frames it, and an Inspector card saying whose it is. +// +// The proxy is the thing to distrust, so it is checked from every side: it never enters +// objectsGroup, never reaches a peer, never appears in a saved payload, and a deselect +// removes it. Skip-never-fail when the untangle scene or zip cannot be reached (the +// game-untangle sources: UNTANGLE_TPSCENE / UNTANGLE_ZIP / the feed / a sibling checkout). +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES_BASE = (process.env.UNTANGLE_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2').replace(/\/$/, ''); +const MODULES_BASE = (process.env.UNTANGLE_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); +const ROOT = path.resolve(__dirname, '../../..'); +const TEMPLATE_LEVEL = 2; + +async function fetchBytes(url) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(20000) }); + if (!res.ok) { + console.log(' (source) ' + url + ' -> HTTP ' + res.status); + return null; + } + return Buffer.from(await res.arrayBuffer()); + } catch (error) { + console.log(' (source) ' + url + ' -> ' + error.message); + return null; + } +} +async function sceneBytes() { + if (process.env.UNTANGLE_TPSCENE) { + const p = process.env.UNTANGLE_TPSCENE; + return fs.existsSync(p) ? { bytes: fs.readFileSync(p), from: p } : null; + } + const feed = await fetchBytes(SCENES_BASE + '/games/untangle/scene.tpscene'); + if (feed) return { bytes: feed, from: SCENES_BASE }; + for (const dir of ['theprototype.app-scenes', 'scenes']) { + const p = path.join(ROOT, dir, 'games/untangle/scene.tpscene'); + if (fs.existsSync(p)) return { bytes: fs.readFileSync(p), from: p }; + } + return null; +} +async function zipBytes() { + if (process.env.UNTANGLE_ZIP) { + const p = process.env.UNTANGLE_ZIP; + return fs.existsSync(p) ? { bytes: fs.readFileSync(p), from: p } : null; + } + const local = []; + if (process.env.MODULES_REPO) local.push(path.join(process.env.MODULES_REPO, 'untangle.zip')); + local.push(h.moduleZipPath('untangle')); + for (const dir of fs.readdirSync(ROOT)) if (/^(theprototype\.app-)?modules/.test(dir)) local.push(path.join(ROOT, dir, 'untangle.zip')); + for (const p of local) if (p && fs.existsSync(p)) return { bytes: fs.readFileSync(p), from: p }; + const cdn = await fetchBytes(MODULES_BASE + '/untangle.zip'); + return cdn ? { bytes: cdn, from: MODULES_BASE } : null; +} +async function installZip(peer, bytes, label) { + await peer.page.evaluate(() => window.__stores.modulesOpen.set(true)); + await peer.page.waitForTimeout(400); + await peer.page.getByRole('tab', { name: /^User/ }).click(); + await peer.page.waitForTimeout(200); + await peer.page.locator('#install-module-zip').setInputFiles({ name: 'untangle.zip', mimeType: 'application/zip', buffer: bytes }); + await h.eventually( + () => peer.page.evaluate(() => window.__stores.moduleSDK.loadedModules.map((m) => m.id)), + (ids) => ids.includes('untangle'), + label + ': the untangle module installed from the real zip', + 20000 + ); + await peer.page.evaluate(() => window.__stores.modulesOpen.set(false)); + await peer.page.waitForTimeout(300); +} + + +const GROUP = 'untangle-module'; +const objectCount = (page) => + page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + let n = 0; + g.traverse(() => n++); + return n; + }); +const debug = (page) => page.evaluate(() => window.__stores.moduleSDK.moduleContentDebug()); + +h.run(async () => { + const scene = await sceneBytes(); + if (!scene) { + console.log('SKIP: games/untangle/scene.tpscene unreachable'); + return; + } + const zip = await zipBytes(); + if (!zip) { + console.log('SKIP: untangle.zip unreachable'); + return; + } + console.log(' scene from ' + scene.from + ' / module from ' + zip.from); + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + const page = A.page; + await installZip(A, zip.bytes, 'A'); + await installZip(B, zip.bytes, 'B'); + await page.evaluate(async (arr) => { + const s = window.__stores; + await s.sessions.applySession(await s.sessions.readSessionZip(new Uint8Array(arr).buffer), { backup: false }); + }, Array.from(scene.bytes)); + await h.eventually( + () => page.evaluate((g) => { + let sc; + window.__stores.globalScene.subscribe((v) => (sc = v))(); + return sc?.getObjectByName(g)?.children.filter((c) => c.name.startsWith('untangle-dot-')).length ?? 0; + }, GROUP), + (n) => n > 0, + '(premise) the untangle board is built at the scene root' + ); + await h.connect(A, B); + await page.waitForTimeout(1500); + + // ---------------------------------------------------------------- 1 + console.log('\n=== 1. the section lists the board, read-only ==='); + await page.evaluate(() => window.__stores.objectListClose.set(false)); + await page.waitForTimeout(1500); + const row = page.locator(`#module-content .module-content-row[data-group="${GROUP}"]`); + h.check((await row.count()) === 1, '1.1 the object list has a Module content row for the untangle board'); + const label = ((await row.locator('.module-content-name').textContent()) ?? '').trim(); + h.check(/untangle/i.test(label), `1.2 ...labelled for a person (${label})`); + const badge = ((await row.locator('.module-content-badge').textContent()) ?? '').trim(); + h.check(/untangle/i.test(badge), `1.3 ...with the module's name as its badge (${badge})`); + await row.locator('button[aria-label="Show children"]').click(); + await page.waitForTimeout(300); + const kids = await row.locator('.module-content-child').allTextContents(); + h.check(kids.some((t) => t.trim().startsWith('untangle-dot-')), `1.4 expanding it lists the dots (${kids.length} named parts)`); + // the tree above is untouched: the board is NOT a scene row + const sceneRows = await page.evaluate((g) => [...document.querySelectorAll('#object-tree [role="treeitem"]')].some((el) => el.textContent.includes(g)), GROUP); + h.check(!sceneRows, '1.5 the board is not smuggled into the scene tree'); + await row.click({ button: 'right' }); + await page.waitForTimeout(300); + const menu = await page.evaluate(() => [...document.querySelectorAll('[role="menuitem"]')].map((el) => el.textContent.trim())); + h.check(menu.includes('Frame it') && !menu.some((t) => /delete|rename|duplicate/i.test(t)), `1.6 its menu is read-only (${menu.join(' | ')})`); + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + + // ---------------------------------------------------------------- 2 + console.log('\n=== 2. a click selects a PROXY and frames it ==='); + const countsA = await objectCount(page); + const countsB = await objectCount(B.page); + const camBefore = await page.evaluate(() => { + let c; + window.__stores.globalCamera.subscribe((v) => (c = v))(); + return c.position.toArray(); + }); + await row.locator('.module-content-name').click(); + await page.waitForTimeout(900); + const d = await debug(page); + h.check(d.selection?.name === GROUP, `2.1 the proxy selection names the board (${d.selection?.name})`); + h.check(d.proxyInScene, '2.2 the proxy box is in the scene'); + const inside = await page.evaluate((g) => { + const s = window.__stores; + let sc; + s.globalScene.subscribe((v) => (sc = v))(); + const box = new s.THREE.Box3().setFromObject(sc.getObjectByName(g)); + const proxy = sc.getObjectByName('module-content-proxy'); + return !!proxy && proxy.parent === sc && proxy.box.containsBox(box.expandByScalar(-1e-4)); + }, GROUP); + h.check(inside, '2.3 ...at the SCENE ROOT, sized to the board\'s bounds'); + const camAfter = await page.evaluate(() => { + let c; + window.__stores.globalCamera.subscribe((v) => (c = v))(); + return c.position.toArray(); + }); + h.check(Math.hypot(...camAfter.map((v, i) => v - camBefore[i])) > 0.2, '2.4 the camera framed it'); + const card = ((await page.locator('#module-content-card').textContent().catch(() => '')) ?? '').trim(); + h.check(/untangle/i.test(card) && /module/i.test(card), `2.5 the Inspector shows whose it is (${card.slice(0, 80)})`); + const sel = await page.evaluate(() => { + let v; + window.__stores.selectedObjects.subscribe((x) => (v = x))(); + return v.length; + }); + h.check(sel === 0, '2.6 the object SELECTION stays empty (the proxy never joins the set)'); + + // ---------------------------------------------------------------- 3 + console.log('\n=== 3. the proxy never replicates and never saves ==='); + h.check((await objectCount(page)) === countsA, `3.1 objectsGroup unchanged on A (${countsA})`); + await page.waitForTimeout(1200); + h.check((await objectCount(B.page)) === countsB, `3.2 ...and on the peer (${countsB})`); + const saved = await page.evaluate(() => JSON.stringify(window.__stores.sessions.buildSessionPayload('probe'))); + h.check(!saved.includes('module-content-proxy'), '3.3 a saved payload does not contain the proxy'); + const onB = await B.page.evaluate(() => { + let sc; + window.__stores.globalScene.subscribe((v) => (sc = v))(); + return !!sc.getObjectByName('module-content-proxy'); + }); + h.check(!onB, '3.4 the peer has no proxy'); + + // ---------------------------------------------------------------- 4 + console.log('\n=== 4. a deselect puts it away ==='); + await page.evaluate(() => window.__stores.objectActions.deselectObject()); + await page.waitForTimeout(300); + const gone = await debug(page); + h.check(!gone.proxyInScene && !gone.selection, '4.1 deselect clears the proxy and the selection'); + + // ---------------------------------------------------------------- 5 + console.log('\n=== 5. in the VIEWPORT: an Edit click on the board selects its proxy; Interact plays ==='); + await page.evaluate(() => window.__stores.inspectorClose.set(true)); + await page.evaluate((g) => { + const s = window.__stores; + let sc; + s.globalScene.subscribe((v) => (sc = v))(); + const box = new s.THREE.Box3().setFromObject(sc.getObjectByName(g)); + const c = box.getCenter(new s.THREE.Vector3()); + s.objectActions.flyTo([c.x, c.y + 0.4, c.z + 3.2], c.toArray(), 0); + }, GROUP); + await page.waitForTimeout(900); + const dot = await page.evaluate((g) => { + const s = window.__stores; + let sc, camera, renderer; + s.globalScene.subscribe((v) => (sc = v))(); + s.globalCamera.subscribe((v) => (camera = v))(); + s.globalRenderer.subscribe((v) => (renderer = v))(); + const root = sc.getObjectByName(g); + const target = root.children.find((c) => c.name.startsWith('untangle-dot-')); + const p = target.getWorldPosition(new s.THREE.Vector3()); + const v = p.clone().project(camera); + const rect = renderer.domElement.getBoundingClientRect(); + const x = rect.left + ((v.x + 1) / 2) * rect.width; + const y = rect.top + ((1 - v.y) / 2) * rect.height; + const ray = new s.THREE.Raycaster(); + ray.setFromCamera(new s.THREE.Vector2(v.x, v.y), camera); + const hit = ray.intersectObject(root, true)[0]; + let n = hit?.object; + while (n && n.parent !== root) n = n.parent; + return { x, y, hitsBoard: !!hit, name: n?.name ?? null, onCanvas: document.elementFromPoint(x, y) === renderer.domElement }; + }, GROUP); + h.check(dot.hitsBoard && dot.onCanvas, `5.1 (premise) the pixel lands on the board (${dot.name})`); + await page.evaluate(() => { + window.__stores.objectActions.deselectObject(); + window.__stores.objectActions.setEditorMode('edit'); + }); + await page.waitForTimeout(1700); + await page.mouse.click(dot.x, dot.y); + await page.waitForTimeout(500); + const edit = await debug(page); + h.check(edit.selection?.name === GROUP, `5.2 an EDIT click on the board selects its proxy (${edit.selection?.name ?? 'nothing'})`); + await page.evaluate(() => { + window.__stores.objectActions.deselectObject(); + window.__stores.objectActions.setEditorMode('interact'); + }); + await page.waitForTimeout(1700); + await page.mouse.click(dot.x, dot.y); + await page.waitForTimeout(500); + const inter = await debug(page); + h.check(!inter.selection, '5.3 in INTERACT the same click selects nothing (it belongs to the module)'); + await page.mouse.click(dot.x + 200, dot.y + 150); // drop whatever the module picked up + await page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + + h.check(h.pageErrors(A).length === 0, `the page threw nothing (${h.pageErrors(A).join(' / ')})`); + await h.finish(browser); +}); From 86d4ee0e2d72e9622c19d7b50521eee2d35db97c Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 23 Sep 2026 01:27:11 +0300 Subject: [PATCH 5/5] [feat] 30 P4: api.pointerRay() is the crosshair ray under a pointer lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Under a pointer lock the cursor is pinned, so pointerRay() kept answering with the mouse position from the moment the lock began — a STALE ray that never moved again. Untangle's carried dot followed it, which is part of why a drag in play went nowhere. While isLocked === true, the pointer is locked and play is not in its menu substate (playPointerFree), pointerRay() now returns the CROSSHAIR ray: the view's centre, NDC (0,0) through the play camera — the ray playInteract already aims with. - Hook for 30-core-flow: a scene whose play block says `cursor: 'free'` plays with the real cursor and no lock, so its ray stays the mouse's. Read defensively through resolvePlaySettings (absent = 'locked', today's play) — nothing to merge when that field lands. - editor-modes gains section 7 (pointer lock stubbed): the mouse ray is off-centre, the locked ray matches the crosshair's direction (dot > 0.99999) and origin, and the mouse ray is back once unlocked. 45/45. - Counterfactual: the mouse ray always -> 7.2 red (dot 0.853839: the locked ray is the stale mouse ray). - svelte-check 335/47 (nothing new); vitest 214/214; build green. Held: sdk-game-seams 78/78, play-interact 46/46. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/moduleSDK.js | 35 ++++++++++++++++++++++++++-- tests/e2e/editor-modes.test.cjs | 41 ++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index da0f691d..7a22dcc3 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -2,7 +2,9 @@ import { keyOf, letterOf } from './keyOf'; import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { globalScene, objectsGroup, selectedObject, selectedObjects, globalCamera, isVRMode, isLocked } from '../stores/sceneStore'; +import { globalScene, objectsGroup, selectedObject, selectedObjects, globalCamera, isVRMode, isLocked, playPointerFree } from '../stores/sceneStore'; +// 30 P4: the scene's play block decides whether play aims with a crosshair (a leaf chain) +import { resolvePlaySettings } from './playSettings'; import { peers, showToast, modulesOpen, userdata } from '../stores/appStore'; import { syncedAnimations, flowGraphs, flowValues, flowTriggers, allNodes, findNodeAnyGraph, SCENE_GRAPH } from '../stores/flowStore'; import { customGeometryBuilders } from './customGeometries'; @@ -284,11 +286,38 @@ if (typeof window !== 'undefined') { pointerClient.seen = true; }); } +/** 30 P4: the CROSSHAIR ray, a fresh Raycaster through the centre of the view */ +const SCREEN_CENTRE = new THREE.Vector2(0, 0); + +/** + * 30 P4: does play aim with a CROSSHAIR right now? Under a pointer lock the cursor is + * pinned and its last client position is where the mouse happened to be when the lock + * began — a STALE ray that never moves again (untangle's carried dot followed it, so a + * drag in play went nowhere). So while playing, the pointer locked, and not in the menu + * substate (the pointer is free there, over the HUD), the ray is the view's centre — + * play mode's own NDC (0,0), the one playInteract aims with. + */ +function crosshairAims() { + if (get(isLocked) !== true || get(playPointerFree)) return false; + if (typeof document === 'undefined' || !document.pointerLockElement) return false; + // 30-core-flow: free cursor — a scene whose play block says `cursor: 'free'` plays with + // the real cursor and no lock, so its ray IS the mouse ray. Read defensively: the field + // lands with that lane, and absent means 'locked' (today's play). + const settings = /** @type {any} */ (resolvePlaySettings(get(globalScene))); + return settings?.cursor !== 'free'; +} + function pointerRayNow() { if (get(isVRMode)) return vrControlsRef?.pointerHandRay?.() ?? null; /** @type {any} */ const camera = get(globalCamera); - if (!camera || !pointerClient.seen) return null; + if (!camera) return null; + if (crosshairAims()) { + const centre = new THREE.Raycaster(); + centre.setFromCamera(SCREEN_CENTRE, camera); + return centre; + } + if (!pointerClient.seen) return null; const fresh = new THREE.Raycaster(); const ndc = ndcFromClient(pointerClient.x, pointerClient.y); fresh.setFromCamera(new THREE.Vector2(ndc.x, ndc.y), camera); @@ -601,6 +630,8 @@ function makeApi(moduleId, moduleName = moduleId) { * Where the user is POINTING, as a THREE.Raycaster in world space — * desktop mouse over the viewport, or the VR pointer hand's ray. A fresh * instance per call (safe to keep). Null before the first pointer event. + * 30 P4: in play under a pointer lock it is the CROSSHAIR ray (the view's + * centre) — the mouse ray is frozen there; a free-cursor game keeps the mouse. * The drag recipe (190/untangle): click to pick, follow pointerRay() in a * frame task, click to drop. (190) */ diff --git a/tests/e2e/editor-modes.test.cjs b/tests/e2e/editor-modes.test.cjs index 98e53297..4249524f 100644 --- a/tests/e2e/editor-modes.test.cjs +++ b/tests/e2e/editor-modes.test.cjs @@ -1,4 +1,4 @@ -// 30 P1 — EDIT and INTERACT, and P4 — api.pointerRay under a pointer lock. +// 30 P1 — EDIT and INTERACT, and (section 7) P4 — api.pointerRay under a pointer lock. // // The user's report: "interact with objects" and "edit mesh/transform" were one mode, so // some objects could not be moved or even SELECTED — the music modules' click handlers @@ -390,6 +390,45 @@ h.run(async () => { await page.evaluate(() => window.__stores.physics.stopSimulation()); await page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + // ---------------------------------------------------------------- 7 + console.log('\n=== 7. P4: api.pointerRay() under a pointer lock is the CROSSHAIR ray ==='); + await page.mouse.move(40, 300); // a mouse ray well off-centre + await page.waitForTimeout(100); + const rays = await page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + let renderer; + s.globalRenderer.subscribe((v) => (renderer = v))(); + const free = s.moduleSDK.pointerRayNow(); + const freeDir = free ? free.ray.direction.toArray() : null; + let lockEl = renderer.domElement; + Object.defineProperty(document, 'pointerLockElement', { configurable: true, get: () => lockEl }); + s.isLocked.set(true); + const locked = s.moduleSDK.pointerRayNow(); + let camera; + s.globalCamera.subscribe((v) => (camera = v))(); + const centre = new THREE.Raycaster(); + centre.setFromCamera(new THREE.Vector2(0, 0), camera); + const out = { + freeDir, + lockedDir: locked ? locked.ray.direction.toArray() : null, + centreDir: centre.ray.direction.toArray(), + lockedOrigin: locked ? locked.ray.origin.toArray() : null, + centreOrigin: centre.ray.origin.toArray() + }; + s.isLocked.set(null); + lockEl = null; + delete document.pointerLockElement; + const after = s.moduleSDK.pointerRayNow(); + out.afterDir = after ? after.ray.direction.toArray() : null; + return out; + }); + const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + h.check(rays.freeDir && dot(rays.freeDir, rays.centreDir) < 0.999, '7.1 (premise) the mouse ray is off-centre'); + h.check(rays.lockedDir && dot(rays.lockedDir, rays.centreDir) > 0.99999, `7.2 under a lock pointerRay is the crosshair ray (dot ${rays.lockedDir ? dot(rays.lockedDir, rays.centreDir).toFixed(6) : 'null'})`); + h.check(rays.lockedOrigin && Math.hypot(...rays.lockedOrigin.map((v, i) => v - rays.centreOrigin[i])) < 1e-6, '7.3 ...from the camera'); + h.check(rays.afterDir && dot(rays.afterDir, rays.freeDir) > 0.99999, '7.4 unlocked, it is the mouse ray again'); + h.check(h.pageErrors(A).filter((m) => /setPointerCapture/.test(m)).length === 0, 'no pointer-capture errors in the run'); await h.finish(browser); });