Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions MODULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion src/lib/moduleSDK.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 ?? {
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions src/lib/vrControls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions tests/e2e/sdk-pointer-ray.test.cjs
Original file line number Diff line number Diff line change
@@ -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);
});