From 4ad55a94875bc134b122fc7c585a5788785f302a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 22 Jul 2026 01:44:21 +0300 Subject: [PATCH] [feat] SDK api.pointerRay - a world ray for wherever the user points - moduleSDK.pointerRay(): desktop = the mouse over the viewport (window-wide NDC tracking, same math as the Scene selection raycast); VR = the pointer hand ray via the new vrControls.pointerHandRay export (handedness-resolved, never raw slots). A FRESH Raycaster per call (the shared-temp gotcha); null before the first pointer event. vrControls reached via the primed dynamic import (static edge = cycle). - the drag recipe for modules (190/untangle): click to pick, follow pointerRay() in a frame task, click to drop - works desktop + VR. - MODULES.md section; e2e sdk-pointer-ray (4 checks, all pass); build green; svelte-check 499/77 held. Co-Authored-By: Claude Fable 5 --- MODULES.md | 15 +++++++++++ src/lib/moduleSDK.js | 38 +++++++++++++++++++++++++++- src/lib/vrControls.js | 20 +++++++++++++++ tests/e2e/sdk-pointer-ray.test.cjs | 40 ++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/sdk-pointer-ray.test.cjs diff --git a/MODULES.md b/MODULES.md index 71bd7c2a..0360b0ff 100644 --- a/MODULES.md +++ b/MODULES.md @@ -167,6 +167,21 @@ api.claimInput('keys'); // ALWAYS release when your mode ends api.releaseInput('keys'); ``` +### Pointer ray (190) + +```js +// where the user is POINTING, as a THREE.Raycaster in WORLD space: the desktop +// mouse over the viewport, or the VR pointer hand's ray. Fresh instance per +// call; null before the first pointer event. The drag recipe: click to pick, +// follow pointerRay() in a frame task, click to drop (works desktop + VR). +api.registerFrameTask(() => { + const ray = api.pointerRay(); + if (!ray || !carried) return; + const hit = ray.ray.intersectPlane(dragPlane, tempVec); + if (hit) carried.position.copy(hit); +}); +``` + ### Physics (P-A) All mutations are INITIATOR-ONLY — the peer that started the simulation steps diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 700348f4..273cadfe 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { globalScene, objectsGroup, selectedObject } from '../stores/sceneStore'; +import { globalScene, objectsGroup, selectedObject, globalCamera, isVRMode } from '../stores/sceneStore'; import { peers, showToast, modulesOpen } from '../stores/appStore'; import { syncedAnimations } from '../stores/flowStore'; import { customGeometryBuilders } from './customGeometries'; @@ -69,11 +69,37 @@ const stateSyncs = {}; /** @type {any} */ let inputRuntimeRef = null; /** @type {any} */ let physicsRef = null; /** @type {any} */ let possessRef = null; +/** @type {any} */ let vrControlsRef = null; if (typeof window !== 'undefined') { import('./inputRuntime').then((m) => (inputRuntimeRef = m)); import('./physics').then((m) => (physicsRef = m)); import('./possess').then((m) => (possessRef = m)); + import('./vrControls').then((m) => (vrControlsRef = m)); } + +// --- api.pointerRay (190): where the user is POINTING, as a world ray -------- +// Desktop: the mouse over the viewport (tracked window-wide in NDC, same math +// as Scene.svelte's selection raycast). VR: the pointer hand's controller ray +// (vrControls, resolved by handedness). A FRESH Raycaster every call. +const pointerNdc = { x: 0, y: 0, seen: false }; +if (typeof window !== 'undefined') { + window.addEventListener('pointermove', (event) => { + pointerNdc.x = (event.clientX / window.innerWidth) * 2 - 1; + pointerNdc.y = -(event.clientY / window.innerHeight) * 2 + 1; + pointerNdc.seen = true; + }); +} +function pointerRayNow() { + if (get(isVRMode)) return vrControlsRef?.pointerHandRay?.() ?? null; + /** @type {any} */ + const camera = get(globalCamera); + if (!camera || !pointerNdc.seen) return null; + const fresh = new THREE.Raycaster(); + fresh.setFromCamera(new THREE.Vector2(pointerNdc.x, pointerNdc.y), camera); + return fresh; +} +/** exported for tests (__stores.moduleSDK.pointerRayNow) */ +export { pointerRayNow }; function inputApi() { return ( inputRuntimeRef ?? { @@ -186,6 +212,16 @@ function makeApi(moduleId) { onSceneClear(fn) { sceneClearHandlers.push(fn); }, + /** + * 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. + * The drag recipe (190/untangle): click to pick, follow pointerRay() in a + * frame task, click to drop. (190) + */ + pointerRay() { + return pointerRayNow(); + }, /** Handle messages other peers sent with api.send() @param {(data: any) => void} fn */ onMessage(fn) { (messageHandlers[moduleId] ??= []).push(fn); diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 33ba6572..0bbd126a 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -841,6 +841,26 @@ function controllerRay(index) { return raycaster; } +/** + * SDK (api.pointerRay): the POINTER hand's world ray for modules. Returns a + * FRESH Raycaster — the shared module-level one above is reused per frame and + * handing it out would corrupt in-flight raycasts (the temp-vector gotcha). + * Resolves the hand by handedness (never raw slots). Null outside VR. + * @returns {any | null} + */ +export function pointerHandRay() { + if (!get(isVRMode) || !renderer?.xr) return null; + const index = controllerIndexFor(get(vrMenuHand) === 'right' ? 'left' : 'right'); + if (index < 0) return null; + const controller = renderer.xr.getController(index); + if (!controller) return null; + const fresh = new THREE.Raycaster(); + const m = new THREE.Matrix4().identity().extractRotation(controller.matrixWorld); + fresh.ray.origin.setFromMatrixPosition(controller.matrixWorld); + fresh.ray.direction.set(0, 0, -1).applyMatrix4(m); + return fresh; +} + /** Raycast the quick-menu tiles @param {number} index @returns {string|null} tile action name */ export function raycastMenu(index) { const menu = get(vrMenuGroup); diff --git a/tests/e2e/sdk-pointer-ray.test.cjs b/tests/e2e/sdk-pointer-ray.test.cjs new file mode 100644 index 00000000..aa481879 --- /dev/null +++ b/tests/e2e/sdk-pointer-ray.test.cjs @@ -0,0 +1,40 @@ +// SDK api.pointerRay (190): a world-space Raycaster for wherever the user +// points — desktop mouse over the viewport (VR = the pointer hand, headless +// untestable). Fresh instance per call; null before the first pointer event. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // centre of the screen -> the ray should march into the scene + await A.page.mouse.move(640, 400); + await A.page.waitForTimeout(200); + const centre = await A.page.evaluate(() => { + const ray = window.__stores.moduleSDK.pointerRayNow(); + if (!ray) return null; + return { origin: ray.ray.origin.toArray(), direction: ray.ray.direction.toArray() }; + }); + h.check(!!centre, 'pointerRay returns a raycaster after a pointer move'); + h.check(Math.abs(Math.hypot(...(centre?.direction ?? [0, 0, 0])) - 1) < 1e-6, 'direction is normalized'); + + // the ray tracks the pointer: two screen points give diverging directions + await A.page.mouse.move(200, 300); + await A.page.waitForTimeout(100); + const left = await A.page.evaluate(() => window.__stores.moduleSDK.pointerRayNow()?.ray.direction.toArray()); + await A.page.mouse.move(1080, 500); + await A.page.waitForTimeout(100); + const right = await A.page.evaluate(() => window.__stores.moduleSDK.pointerRayNow()?.ray.direction.toArray()); + const dot = left && right ? left[0] * right[0] + left[1] * right[1] + left[2] * right[2] : 1; + h.check(dot < 0.9999, 'the ray follows the pointer (directions differ across the screen)'); + + // a fresh instance every call (safe for modules to keep) + const distinct = await A.page.evaluate(() => { + const a = window.__stores.moduleSDK.pointerRayNow(); + const b = window.__stores.moduleSDK.pointerRayNow(); + return a !== b; + }); + h.check(distinct, 'each call returns a fresh Raycaster (no shared-temp corruption)'); + + await h.finish(browser); +});