@realitycollective/xrblocks-uiextensions 0.1.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ Change log for the Reality Collective WebXR UI Extensions packages. All four packages are versioned and released together; the version below is the one carried by the `v<version>` release tag.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Preview builds are not listed separately. The entry for a version accumulates while its previews are published, and is dated when that version is released.
6
+
7
+ ## [0.1.0]
8
+
9
+ ### Added
10
+
11
+ - `@realitycollective/webxr-uiextensions` - engine-free core: window manager, dock state and regions, drag maths, hold-to-drag, control models (stepper/toggle/expandable/log), the `SceneDescriptor` scene format, window chrome conventions and the platform-adapter contract.
12
+ - `@realitycollective/iwsdk-uiextensions` - Meta IWSDK adapter binding the core onto IWSDK's ECS, UIKitML and interaction systems, with shipped examples.
13
+ - `@realitycollective/xrblocks-uiextensions` - EXPERIMENTAL Google XR Blocks / plain three.js adapter: panel document, window host, follow and scale maths, desktop controls and locomotion, pointer forwarding.
14
+ - `@realitycollective/uix-devtools` - dev-only tooling: edit-session launch gate, runtime UIKitML compilation, and the `uix-dev` CLI (Cloudflare quick tunnel, QR onboarding, environment doctor).
15
+ - Demo clients: the IWSDK showcase, the devtools playground, and the multiplatform lab that picks its pipeline from the hardware.
16
+
17
+ [0.1.0]: https://github.com/realitycollective/WebXR-UIExtensions/commits/main
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Reality Collective
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @realitycollective/xrblocks-uiextensions
2
+
3
+ **EXPERIMENTAL** adapter for [Google XR Blocks](https://github.com/google/xrblocks) and plain three.js. It hosts the same [`@realitycollective/webxr-uiextensions`](../webxr-uiextensions/README.md) core as the IWSDK adapter, with the same UIKitML panels, window chrome and window manager, inside any three.js WebXR scene. An XR Blocks Script gives you exactly that kind of scene.
4
+
5
+ > **Maturity:** the IWSDK adapter is the most complete one, and this adapter is built to match it. It now has nearly all the same windowing features, and its desktop path is verified in a real browser (panels render, mouse clicks reach uikit controls, WASD/jump/ crouch move the camera). It has still had NO on-device pass on Android XR hardware - treat the XR Blocks path specifically as unverified.
6
+
7
+ ## Feature matrix vs the IWSDK adapter
8
+
9
+ | Feature | IWSDK | XR Blocks / three.js (this package) |
10
+ | --- | --- | --- |
11
+ | UIKitML panel hosting (runtime interpret, scale-to-fit) | ✅ | ✅ `UixPanelDocument` |
12
+ | Window lifecycle + chrome (focus/PIN/MIN/X, pin labels) | ✅ | ✅ `UixWindowHost` |
13
+ | Portable scene descriptors (`applyScene`) | ✅ | ✅ implements `SceneTarget` |
14
+ | Panel-ready wiring (`onPanelReady`) | ✅ | ✅ implements `WindowHost` |
15
+ | Follow mode (body-follow, yaw-only, eased) | ✅ | ✅ pure `follow-math` |
16
+ | Dock regions (wall/belt, slots, follow) | ✅ | ✅ `createRegion` / `dock` |
17
+ | Desktop mouse input (hover, click, drag-to-look) | ✅ | ✅ via `@pmndrs/pointer-events` |
18
+ | Desktop locomotion (WASD, jump, crouch, sprint) | n/a | ✅ `DesktopControls` |
19
+ | XR select-ray click forwarding | ✅ | ✅ minimal (`forwardClick`) |
20
+ | Title-bar ray drag (`@pmndrs/handle`) | ✅ | ⬜ roadmap |
21
+ | Drop-to-dock by dragging | ✅ | ⬜ roadmap (needs drag) |
22
+ | System keyboard text input | ✅ | ⬜ untested on Android XR |
23
+
24
+ ## Required renderer setup (read this first)
25
+
26
+ uikit draws panel backgrounds, borders and **text glyphs** all as transparent meshes, stacked by `renderOrder`. three.js sorts transparent objects by camera distance by default, which is meaningless for coplanar UI layers - at grazing angles or close range a panel background can sort in front of its own text and labels silently vanish. uikit also clips panel content with local clipping planes, which three.js ignores unless enabled.
27
+
28
+ Apply both settings to any renderer you create:
29
+
30
+ ```ts
31
+ import { configureRendererForUikit } from '@realitycollective/xrblocks-uiextensions';
32
+
33
+ const renderer = new WebGLRenderer({ antialias: true });
34
+ configureRendererForUikit(renderer); // transparent sort + local clipping
35
+ ```
36
+
37
+ IWSDK does this internally, which is why panels look right there with no setup. **A hand-rolled three.js host must do it explicitly**, and under XR Blocks you should apply it to the renderer `xb.init()` creates.
38
+
39
+ ## Usage in an XR Blocks Script
40
+
41
+ ```ts
42
+ import * as xb from 'xrblocks';
43
+ import {
44
+ DockMode,
45
+ connectUIExtensions,
46
+ forwardClick,
47
+ } from '@realitycollective/xrblocks-uiextensions';
48
+
49
+ class MyScript extends xb.Script {
50
+ async init() {
51
+ this.uix = connectUIExtensions({ scene: this, camera: xb.camera });
52
+ const config = await fetch('./ui/my-window.json').then((r) => r.json());
53
+ this.uix.createWindow({
54
+ id: 'status',
55
+ title: 'Status',
56
+ config,
57
+ dockMode: DockMode.BodyFollow,
58
+ });
59
+ }
60
+ update() {
61
+ this.uix.update(xb.getDeltaTime());
62
+ }
63
+ onSelectStart(event) {
64
+ /* raycast from event.target, then forwardClick(intersections) -
65
+ see demos/webxr-multiplatform for the complete wiring */
66
+ }
67
+ }
68
+
69
+ xb.add(new MyScript());
70
+ await xb.init();
71
+ ```
72
+
73
+ Nothing here imports `xrblocks` - the glue binds to plain three.js shapes (`scene: Object3D`, `camera`), so the same host works in a hand-rolled three.js WebXR app.
74
+
75
+ ## Known constraint: three versions
76
+
77
+ `xrblocks@0.19` declares a peer of `three@^0.184`, while IWSDK mandates the `super-three@0.181` fork used workspace-wide. Vite resolves a single `three` per bundle so the pairing works in practice, but npm's peer check cannot express it - this workspace uses `legacy-peer-deps` (see the root `.npmrc`). Revisit when IWSDK's three catches up.
78
+
79
+ ## Testing
80
+
81
+ ```bash
82
+ npm test # scale/follow/pointer math + a headless host lifecycle suite
83
+ ```
84
+
85
+ ## Demo
86
+
87
+ [`demos/webxr-multiplatform`](../../demos/webxr-multiplatform/README.md) - detects the platform and boots this adapter on Android XR (or via `?uix-engine=xrblocks` anywhere, including XR Blocks' desktop simulator).
88
+
89
+ ## Live demos
90
+
91
+ - Showcase: **[webxr-uiextensions.pages.dev](https://webxr-uiextensions.pages.dev)**
92
+ - Multiplatform lab: **[webxr-uix-lab.pages.dev](https://webxr-uix-lab.pages.dev)**
93
+
94
+ ## License
95
+
96
+ MIT © Reality Collective
@@ -0,0 +1,60 @@
1
+ /**
2
+ * DesktopControls - first-person camera control for mouse + keyboard, the
3
+ * stand-in for a headset's head tracking when running on a desktop.
4
+ *
5
+ * - WASD / arrows walk, Shift sprints, Space jumps, C (or Left Ctrl) crouches
6
+ * - Right-mouse drag looks around (left mouse stays free for clicking UI)
7
+ * - All motion runs through the pure model in `desktop-locomotion.ts`
8
+ *
9
+ * Deliberately NOT pointer-lock: the whole point of the desktop pipeline is
10
+ * clicking spatial UI, and pointer lock would swallow those clicks.
11
+ */
12
+ import type { PerspectiveCamera } from 'three';
13
+ import { type LocomotionOptions, type LocomotionState } from './desktop-locomotion.js';
14
+ export interface DesktopControlsOptions {
15
+ /** Element that receives the input listeners (usually the canvas). */
16
+ domElement: HTMLElement;
17
+ /** Radians of rotation per pixel of mouse drag. Default 0.0025. */
18
+ lookSensitivity?: number;
19
+ /** Locomotion tuning; see {@link DEFAULT_LOCOMOTION}. */
20
+ locomotion?: Partial<LocomotionOptions>;
21
+ /** Starting ground position [x, z]. */
22
+ start?: [number, number];
23
+ /** Starting heading in radians (0 = facing -Z). */
24
+ startYaw?: number;
25
+ }
26
+ /**
27
+ * True when a key event is being typed into a DOM text field - `<input>`,
28
+ * `<textarea>`, `<select>` or anything contenteditable.
29
+ *
30
+ * Overlays share the page with the scene (the devtools playground puts a
31
+ * source editor over it), and their keystrokes are theirs alone: WASD must
32
+ * not walk the camera mid-word, and Space must reach the field instead of
33
+ * being swallowed as a jump.
34
+ *
35
+ * A structural check rather than `instanceof HTMLElement`, so it stays
36
+ * unit-testable with no DOM.
37
+ */
38
+ export declare function isTextEntryTarget(target: unknown): boolean;
39
+ export declare class DesktopControls {
40
+ readonly state: LocomotionState;
41
+ readonly options: LocomotionOptions;
42
+ /** Heading in radians, about +Y. */
43
+ yaw: number;
44
+ /** Look-up/down in radians, clamped to just under ±90°. */
45
+ pitch: number;
46
+ private readonly camera;
47
+ private readonly domElement;
48
+ private readonly lookSensitivity;
49
+ private readonly input;
50
+ private looking;
51
+ private lastX;
52
+ private lastY;
53
+ private readonly disposers;
54
+ constructor(camera: PerspectiveCamera, options: DesktopControlsOptions);
55
+ private bind;
56
+ /** Advance movement and write the pose onto the camera. */
57
+ update(delta: number): void;
58
+ private applyToCamera;
59
+ dispose(): void;
60
+ }
@@ -0,0 +1,129 @@
1
+ import { DEFAULT_LOCOMOTION, createLocomotionState, intentForKey, stepLocomotion, } from './desktop-locomotion.js';
2
+ const MAX_PITCH = Math.PI / 2 - 0.05;
3
+ /**
4
+ * True when a key event is being typed into a DOM text field - `<input>`,
5
+ * `<textarea>`, `<select>` or anything contenteditable.
6
+ *
7
+ * Overlays share the page with the scene (the devtools playground puts a
8
+ * source editor over it), and their keystrokes are theirs alone: WASD must
9
+ * not walk the camera mid-word, and Space must reach the field instead of
10
+ * being swallowed as a jump.
11
+ *
12
+ * A structural check rather than `instanceof HTMLElement`, so it stays
13
+ * unit-testable with no DOM.
14
+ */
15
+ export function isTextEntryTarget(target) {
16
+ if (target === null || typeof target !== 'object') {
17
+ return false;
18
+ }
19
+ const element = target;
20
+ if (element.isContentEditable === true) {
21
+ return true;
22
+ }
23
+ const tag = typeof element.tagName === 'string' ? element.tagName.toUpperCase() : '';
24
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
25
+ }
26
+ export class DesktopControls {
27
+ state;
28
+ options;
29
+ /** Heading in radians, about +Y. */
30
+ yaw;
31
+ /** Look-up/down in radians, clamped to just under ±90°. */
32
+ pitch = 0;
33
+ camera;
34
+ domElement;
35
+ lookSensitivity;
36
+ input = {
37
+ forward: false,
38
+ back: false,
39
+ left: false,
40
+ right: false,
41
+ jump: false,
42
+ crouch: false,
43
+ sprint: false,
44
+ };
45
+ looking = false;
46
+ lastX = 0;
47
+ lastY = 0;
48
+ disposers = [];
49
+ constructor(camera, options) {
50
+ this.camera = camera;
51
+ this.domElement = options.domElement;
52
+ this.lookSensitivity = options.lookSensitivity ?? 0.0025;
53
+ this.options = { ...DEFAULT_LOCOMOTION, ...options.locomotion };
54
+ this.state = createLocomotionState(this.options);
55
+ if (options.start) {
56
+ [this.state.x, this.state.z] = options.start;
57
+ }
58
+ this.yaw = options.startYaw ?? 0;
59
+ this.bind();
60
+ this.applyToCamera();
61
+ }
62
+ bind() {
63
+ const onKey = (down) => (event) => {
64
+ const intent = intentForKey(event.code);
65
+ if (intent === undefined) {
66
+ return;
67
+ }
68
+ // Only keydown is filtered: a keyup always releases, so a key held
69
+ // before focus moved into a field can never stick on.
70
+ if (down && isTextEntryTarget(event.target)) {
71
+ return;
72
+ }
73
+ // Space would otherwise scroll the page.
74
+ if (event.code === 'Space') {
75
+ event.preventDefault();
76
+ }
77
+ this.input[intent] = down;
78
+ };
79
+ const keydown = onKey(true);
80
+ const keyup = onKey(false);
81
+ window.addEventListener('keydown', keydown);
82
+ window.addEventListener('keyup', keyup);
83
+ this.disposers.push(() => window.removeEventListener('keydown', keydown));
84
+ this.disposers.push(() => window.removeEventListener('keyup', keyup));
85
+ // Right-drag to look; left button is reserved for UI interaction.
86
+ const pointerdown = (event) => {
87
+ if (event.button !== 2) {
88
+ return;
89
+ }
90
+ this.looking = true;
91
+ this.lastX = event.clientX;
92
+ this.lastY = event.clientY;
93
+ };
94
+ const pointermove = (event) => {
95
+ if (!this.looking) {
96
+ return;
97
+ }
98
+ const dx = event.clientX - this.lastX;
99
+ const dy = event.clientY - this.lastY;
100
+ this.lastX = event.clientX;
101
+ this.lastY = event.clientY;
102
+ this.yaw -= dx * this.lookSensitivity;
103
+ this.pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, this.pitch - dy * this.lookSensitivity));
104
+ };
105
+ const stopLooking = () => void (this.looking = false);
106
+ const contextmenu = (event) => event.preventDefault();
107
+ this.domElement.addEventListener('pointerdown', pointerdown);
108
+ window.addEventListener('pointermove', pointermove);
109
+ window.addEventListener('pointerup', stopLooking);
110
+ this.domElement.addEventListener('contextmenu', contextmenu);
111
+ this.disposers.push(() => this.domElement.removeEventListener('pointerdown', pointerdown), () => window.removeEventListener('pointermove', pointermove), () => window.removeEventListener('pointerup', stopLooking), () => this.domElement.removeEventListener('contextmenu', contextmenu));
112
+ }
113
+ /** Advance movement and write the pose onto the camera. */
114
+ update(delta) {
115
+ stepLocomotion(this.state, this.input, delta, this.options, this.yaw);
116
+ this.applyToCamera();
117
+ }
118
+ applyToCamera() {
119
+ this.camera.position.set(this.state.x, this.state.y, this.state.z);
120
+ this.camera.rotation.set(this.pitch, this.yaw, 0, 'YXZ');
121
+ }
122
+ dispose() {
123
+ for (const dispose of this.disposers) {
124
+ dispose();
125
+ }
126
+ this.disposers.length = 0;
127
+ }
128
+ }
129
+ //# sourceMappingURL=desktop-controls.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"desktop-controls.js","sourceRoot":"","sources":["../src/desktop-controls.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * DesktopControls - first-person camera control for mouse + keyboard, the\n * stand-in for a headset's head tracking when running on a desktop.\n *\n * - WASD / arrows walk, Shift sprints, Space jumps, C (or Left Ctrl) crouches\n * - Right-mouse drag looks around (left mouse stays free for clicking UI)\n * - All motion runs through the pure model in `desktop-locomotion.ts`\n *\n * Deliberately NOT pointer-lock: the whole point of the desktop pipeline is\n * clicking spatial UI, and pointer lock would swallow those clicks.\n */\nimport type { PerspectiveCamera } from 'three';\nimport {\n DEFAULT_LOCOMOTION,\n createLocomotionState,\n intentForKey,\n stepLocomotion,\n type LocomotionOptions,\n type LocomotionState,\n type MoveInput,\n} from './desktop-locomotion.js';\n\nexport interface DesktopControlsOptions {\n /** Element that receives the input listeners (usually the canvas). */\n domElement: HTMLElement;\n /** Radians of rotation per pixel of mouse drag. Default 0.0025. */\n lookSensitivity?: number;\n /** Locomotion tuning; see {@link DEFAULT_LOCOMOTION}. */\n locomotion?: Partial<LocomotionOptions>;\n /** Starting ground position [x, z]. */\n start?: [number, number];\n /** Starting heading in radians (0 = facing -Z). */\n startYaw?: number;\n}\n\nconst MAX_PITCH = Math.PI / 2 - 0.05;\n\n/**\n * True when a key event is being typed into a DOM text field - `<input>`,\n * `<textarea>`, `<select>` or anything contenteditable.\n *\n * Overlays share the page with the scene (the devtools playground puts a\n * source editor over it), and their keystrokes are theirs alone: WASD must\n * not walk the camera mid-word, and Space must reach the field instead of\n * being swallowed as a jump.\n *\n * A structural check rather than `instanceof HTMLElement`, so it stays\n * unit-testable with no DOM.\n */\nexport function isTextEntryTarget(target: unknown): boolean {\n if (target === null || typeof target !== 'object') {\n return false;\n }\n const element = target as { tagName?: unknown; isContentEditable?: unknown };\n if (element.isContentEditable === true) {\n return true;\n }\n const tag =\n typeof element.tagName === 'string' ? element.tagName.toUpperCase() : '';\n return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';\n}\n\nexport class DesktopControls {\n readonly state: LocomotionState;\n readonly options: LocomotionOptions;\n /** Heading in radians, about +Y. */\n yaw: number;\n /** Look-up/down in radians, clamped to just under ±90°. */\n pitch = 0;\n\n private readonly camera: PerspectiveCamera;\n private readonly domElement: HTMLElement;\n private readonly lookSensitivity: number;\n private readonly input: MoveInput = {\n forward: false,\n back: false,\n left: false,\n right: false,\n jump: false,\n crouch: false,\n sprint: false,\n };\n private looking = false;\n private lastX = 0;\n private lastY = 0;\n private readonly disposers: Array<() => void> = [];\n\n constructor(camera: PerspectiveCamera, options: DesktopControlsOptions) {\n this.camera = camera;\n this.domElement = options.domElement;\n this.lookSensitivity = options.lookSensitivity ?? 0.0025;\n this.options = { ...DEFAULT_LOCOMOTION, ...options.locomotion };\n this.state = createLocomotionState(this.options);\n if (options.start) {\n [this.state.x, this.state.z] = options.start;\n }\n this.yaw = options.startYaw ?? 0;\n\n this.bind();\n this.applyToCamera();\n }\n\n private bind(): void {\n const onKey = (down: boolean) => (event: KeyboardEvent) => {\n const intent = intentForKey(event.code);\n if (intent === undefined) {\n return;\n }\n // Only keydown is filtered: a keyup always releases, so a key held\n // before focus moved into a field can never stick on.\n if (down && isTextEntryTarget(event.target)) {\n return;\n }\n // Space would otherwise scroll the page.\n if (event.code === 'Space') {\n event.preventDefault();\n }\n this.input[intent] = down;\n };\n const keydown = onKey(true);\n const keyup = onKey(false);\n window.addEventListener('keydown', keydown);\n window.addEventListener('keyup', keyup);\n this.disposers.push(() => window.removeEventListener('keydown', keydown));\n this.disposers.push(() => window.removeEventListener('keyup', keyup));\n\n // Right-drag to look; left button is reserved for UI interaction.\n const pointerdown = (event: PointerEvent) => {\n if (event.button !== 2) {\n return;\n }\n this.looking = true;\n this.lastX = event.clientX;\n this.lastY = event.clientY;\n };\n const pointermove = (event: PointerEvent) => {\n if (!this.looking) {\n return;\n }\n const dx = event.clientX - this.lastX;\n const dy = event.clientY - this.lastY;\n this.lastX = event.clientX;\n this.lastY = event.clientY;\n this.yaw -= dx * this.lookSensitivity;\n this.pitch = Math.max(\n -MAX_PITCH,\n Math.min(MAX_PITCH, this.pitch - dy * this.lookSensitivity),\n );\n };\n const stopLooking = () => void (this.looking = false);\n const contextmenu = (event: Event) => event.preventDefault();\n\n this.domElement.addEventListener('pointerdown', pointerdown);\n window.addEventListener('pointermove', pointermove);\n window.addEventListener('pointerup', stopLooking);\n this.domElement.addEventListener('contextmenu', contextmenu);\n this.disposers.push(\n () => this.domElement.removeEventListener('pointerdown', pointerdown),\n () => window.removeEventListener('pointermove', pointermove),\n () => window.removeEventListener('pointerup', stopLooking),\n () => this.domElement.removeEventListener('contextmenu', contextmenu),\n );\n }\n\n /** Advance movement and write the pose onto the camera. */\n update(delta: number): void {\n stepLocomotion(this.state, this.input, delta, this.options, this.yaw);\n this.applyToCamera();\n }\n\n private applyToCamera(): void {\n this.camera.position.set(this.state.x, this.state.y, this.state.z);\n this.camera.rotation.set(this.pitch, this.yaw, 0, 'YXZ');\n }\n\n dispose(): void {\n for (const dispose of this.disposers) {\n dispose();\n }\n this.disposers.length = 0;\n }\n}\n"]}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Desktop locomotion - WASD walking, jump and crouch for a first-person
3
+ * camera, so a mouse-and-keyboard user can move around a scene that would
4
+ * otherwise rely on a headset's room-scale tracking.
5
+ *
6
+ * The MOTION MODEL here is pure and frame-rate independent (no three.js, no
7
+ * DOM), so it is fully unit-testable; `DesktopLocomotion` in
8
+ * `desktop-controls.ts` is the thin adapter that feeds it key state and
9
+ * writes the result onto a camera.
10
+ */
11
+ /** Which movement intents are active this frame. */
12
+ export interface MoveInput {
13
+ forward: boolean;
14
+ back: boolean;
15
+ left: boolean;
16
+ right: boolean;
17
+ jump: boolean;
18
+ crouch: boolean;
19
+ /** Shift - move faster. */
20
+ sprint: boolean;
21
+ }
22
+ export declare const NO_INPUT: MoveInput;
23
+ export interface LocomotionOptions {
24
+ /** Walking speed, m/s. */
25
+ speed: number;
26
+ /** Multiplier applied while sprinting. */
27
+ sprintMultiplier: number;
28
+ /** Eye height when standing, meters. */
29
+ standHeight: number;
30
+ /** Eye height when crouching, meters. */
31
+ crouchHeight: number;
32
+ /** How fast the eye height eases between stand and crouch (1/s). */
33
+ crouchSpeed: number;
34
+ /** Upward velocity imparted by a jump, m/s. */
35
+ jumpVelocity: number;
36
+ /** Gravity, m/s² (positive magnitude). */
37
+ gravity: number;
38
+ }
39
+ export declare const DEFAULT_LOCOMOTION: LocomotionOptions;
40
+ /** Mutable locomotion state. `x`/`z` are ground position; `y` is eye height. */
41
+ export interface LocomotionState {
42
+ x: number;
43
+ z: number;
44
+ /** Current eye height (includes any jump arc). */
45
+ y: number;
46
+ /** Vertical velocity, m/s. Zero while grounded. */
47
+ verticalVelocity: number;
48
+ /** True when standing on the ground (jump allowed). */
49
+ grounded: boolean;
50
+ }
51
+ export declare function createLocomotionState(options?: LocomotionOptions): LocomotionState;
52
+ /**
53
+ * Ground-plane movement direction in WORLD space for a given yaw.
54
+ *
55
+ * Yaw is the camera's heading (0 = looking down -Z, matching three.js).
56
+ * Returns a normalised [x, z] so diagonal movement isn't faster, or
57
+ * [0, 0] when no direction is held.
58
+ */
59
+ export declare function moveDirection(input: MoveInput, yaw: number): [number, number];
60
+ /** The eye height being eased toward, ignoring any jump arc. */
61
+ export declare function targetStanceHeight(input: MoveInput, options: LocomotionOptions): number;
62
+ /**
63
+ * Advance the locomotion state by `delta` seconds.
64
+ *
65
+ * Horizontal movement is direct (no inertia - precise for UI testing);
66
+ * vertical motion is a simple ballistic arc over the stance height, so
67
+ * jumping while crouched jumps from the lower stance as expected.
68
+ * Mutates and returns `state`.
69
+ */
70
+ export declare function stepLocomotion(state: LocomotionState, input: MoveInput, delta: number, options?: LocomotionOptions, yaw?: number): LocomotionState;
71
+ /** Map a `KeyboardEvent.code` to the movement intent it drives. */
72
+ export declare function intentForKey(code: string): keyof MoveInput | undefined;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Desktop locomotion - WASD walking, jump and crouch for a first-person
3
+ * camera, so a mouse-and-keyboard user can move around a scene that would
4
+ * otherwise rely on a headset's room-scale tracking.
5
+ *
6
+ * The MOTION MODEL here is pure and frame-rate independent (no three.js, no
7
+ * DOM), so it is fully unit-testable; `DesktopLocomotion` in
8
+ * `desktop-controls.ts` is the thin adapter that feeds it key state and
9
+ * writes the result onto a camera.
10
+ */
11
+ export const NO_INPUT = {
12
+ forward: false,
13
+ back: false,
14
+ left: false,
15
+ right: false,
16
+ jump: false,
17
+ crouch: false,
18
+ sprint: false,
19
+ };
20
+ export const DEFAULT_LOCOMOTION = {
21
+ speed: 2.2,
22
+ sprintMultiplier: 2,
23
+ standHeight: 1.6,
24
+ crouchHeight: 0.9,
25
+ crouchSpeed: 8,
26
+ jumpVelocity: 3.6,
27
+ gravity: 9.81,
28
+ };
29
+ export function createLocomotionState(options = DEFAULT_LOCOMOTION) {
30
+ return {
31
+ x: 0,
32
+ z: 0,
33
+ y: options.standHeight,
34
+ verticalVelocity: 0,
35
+ grounded: true,
36
+ };
37
+ }
38
+ /**
39
+ * Ground-plane movement direction in WORLD space for a given yaw.
40
+ *
41
+ * Yaw is the camera's heading (0 = looking down -Z, matching three.js).
42
+ * Returns a normalised [x, z] so diagonal movement isn't faster, or
43
+ * [0, 0] when no direction is held.
44
+ */
45
+ export function moveDirection(input, yaw) {
46
+ // Local axes: forward is -Z, right is +X.
47
+ const localZ = (input.back ? 1 : 0) - (input.forward ? 1 : 0);
48
+ const localX = (input.right ? 1 : 0) - (input.left ? 1 : 0);
49
+ if (localX === 0 && localZ === 0) {
50
+ return [0, 0];
51
+ }
52
+ const length = Math.hypot(localX, localZ);
53
+ const nx = localX / length;
54
+ const nz = localZ / length;
55
+ // Rotate the local direction by the camera yaw about +Y.
56
+ const sin = Math.sin(yaw);
57
+ const cos = Math.cos(yaw);
58
+ return [nx * cos + nz * sin, -nx * sin + nz * cos];
59
+ }
60
+ /** The eye height being eased toward, ignoring any jump arc. */
61
+ export function targetStanceHeight(input, options) {
62
+ return input.crouch ? options.crouchHeight : options.standHeight;
63
+ }
64
+ /**
65
+ * Advance the locomotion state by `delta` seconds.
66
+ *
67
+ * Horizontal movement is direct (no inertia - precise for UI testing);
68
+ * vertical motion is a simple ballistic arc over the stance height, so
69
+ * jumping while crouched jumps from the lower stance as expected.
70
+ * Mutates and returns `state`.
71
+ */
72
+ export function stepLocomotion(state, input, delta, options = DEFAULT_LOCOMOTION, yaw = 0) {
73
+ if (!(delta > 0)) {
74
+ return state;
75
+ }
76
+ const [dx, dz] = moveDirection(input, yaw);
77
+ const speed = options.speed * (input.sprint ? options.sprintMultiplier : 1);
78
+ state.x += dx * speed * delta;
79
+ state.z += dz * speed * delta;
80
+ const stance = targetStanceHeight(input, options);
81
+ if (state.grounded) {
82
+ if (input.jump) {
83
+ state.verticalVelocity = options.jumpVelocity;
84
+ state.grounded = false;
85
+ }
86
+ else {
87
+ // Ease the eye height toward the stance (crouch/stand transition).
88
+ const alpha = Math.min(1, options.crouchSpeed * delta);
89
+ state.y += (stance - state.y) * alpha;
90
+ state.verticalVelocity = 0;
91
+ }
92
+ }
93
+ if (!state.grounded) {
94
+ state.verticalVelocity -= options.gravity * delta;
95
+ state.y += state.verticalVelocity * delta;
96
+ if (state.y <= stance) {
97
+ state.y = stance;
98
+ state.verticalVelocity = 0;
99
+ state.grounded = true;
100
+ }
101
+ }
102
+ return state;
103
+ }
104
+ /** Map a `KeyboardEvent.code` to the movement intent it drives. */
105
+ export function intentForKey(code) {
106
+ switch (code) {
107
+ case 'KeyW':
108
+ case 'ArrowUp':
109
+ return 'forward';
110
+ case 'KeyS':
111
+ case 'ArrowDown':
112
+ return 'back';
113
+ case 'KeyA':
114
+ case 'ArrowLeft':
115
+ return 'left';
116
+ case 'KeyD':
117
+ case 'ArrowRight':
118
+ return 'right';
119
+ case 'Space':
120
+ return 'jump';
121
+ case 'KeyC':
122
+ case 'ControlLeft':
123
+ return 'crouch';
124
+ case 'ShiftLeft':
125
+ case 'ShiftRight':
126
+ return 'sprint';
127
+ default:
128
+ return undefined;
129
+ }
130
+ }
131
+ //# sourceMappingURL=desktop-locomotion.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"desktop-locomotion.js","sourceRoot":"","sources":["../src/desktop-locomotion.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Desktop locomotion - WASD walking, jump and crouch for a first-person\n * camera, so a mouse-and-keyboard user can move around a scene that would\n * otherwise rely on a headset's room-scale tracking.\n *\n * The MOTION MODEL here is pure and frame-rate independent (no three.js, no\n * DOM), so it is fully unit-testable; `DesktopLocomotion` in\n * `desktop-controls.ts` is the thin adapter that feeds it key state and\n * writes the result onto a camera.\n */\n\n/** Which movement intents are active this frame. */\nexport interface MoveInput {\n forward: boolean;\n back: boolean;\n left: boolean;\n right: boolean;\n jump: boolean;\n crouch: boolean;\n /** Shift - move faster. */\n sprint: boolean;\n}\n\nexport const NO_INPUT: MoveInput = {\n forward: false,\n back: false,\n left: false,\n right: false,\n jump: false,\n crouch: false,\n sprint: false,\n};\n\nexport interface LocomotionOptions {\n /** Walking speed, m/s. */\n speed: number;\n /** Multiplier applied while sprinting. */\n sprintMultiplier: number;\n /** Eye height when standing, meters. */\n standHeight: number;\n /** Eye height when crouching, meters. */\n crouchHeight: number;\n /** How fast the eye height eases between stand and crouch (1/s). */\n crouchSpeed: number;\n /** Upward velocity imparted by a jump, m/s. */\n jumpVelocity: number;\n /** Gravity, m/s² (positive magnitude). */\n gravity: number;\n}\n\nexport const DEFAULT_LOCOMOTION: LocomotionOptions = {\n speed: 2.2,\n sprintMultiplier: 2,\n standHeight: 1.6,\n crouchHeight: 0.9,\n crouchSpeed: 8,\n jumpVelocity: 3.6,\n gravity: 9.81,\n};\n\n/** Mutable locomotion state. `x`/`z` are ground position; `y` is eye height. */\nexport interface LocomotionState {\n x: number;\n z: number;\n /** Current eye height (includes any jump arc). */\n y: number;\n /** Vertical velocity, m/s. Zero while grounded. */\n verticalVelocity: number;\n /** True when standing on the ground (jump allowed). */\n grounded: boolean;\n}\n\nexport function createLocomotionState(\n options: LocomotionOptions = DEFAULT_LOCOMOTION,\n): LocomotionState {\n return {\n x: 0,\n z: 0,\n y: options.standHeight,\n verticalVelocity: 0,\n grounded: true,\n };\n}\n\n/**\n * Ground-plane movement direction in WORLD space for a given yaw.\n *\n * Yaw is the camera's heading (0 = looking down -Z, matching three.js).\n * Returns a normalised [x, z] so diagonal movement isn't faster, or\n * [0, 0] when no direction is held.\n */\nexport function moveDirection(input: MoveInput, yaw: number): [number, number] {\n // Local axes: forward is -Z, right is +X.\n const localZ = (input.back ? 1 : 0) - (input.forward ? 1 : 0);\n const localX = (input.right ? 1 : 0) - (input.left ? 1 : 0);\n if (localX === 0 && localZ === 0) {\n return [0, 0];\n }\n const length = Math.hypot(localX, localZ);\n const nx = localX / length;\n const nz = localZ / length;\n // Rotate the local direction by the camera yaw about +Y.\n const sin = Math.sin(yaw);\n const cos = Math.cos(yaw);\n return [nx * cos + nz * sin, -nx * sin + nz * cos];\n}\n\n/** The eye height being eased toward, ignoring any jump arc. */\nexport function targetStanceHeight(\n input: MoveInput,\n options: LocomotionOptions,\n): number {\n return input.crouch ? options.crouchHeight : options.standHeight;\n}\n\n/**\n * Advance the locomotion state by `delta` seconds.\n *\n * Horizontal movement is direct (no inertia - precise for UI testing);\n * vertical motion is a simple ballistic arc over the stance height, so\n * jumping while crouched jumps from the lower stance as expected.\n * Mutates and returns `state`.\n */\nexport function stepLocomotion(\n state: LocomotionState,\n input: MoveInput,\n delta: number,\n options: LocomotionOptions = DEFAULT_LOCOMOTION,\n yaw = 0,\n): LocomotionState {\n if (!(delta > 0)) {\n return state;\n }\n\n const [dx, dz] = moveDirection(input, yaw);\n const speed = options.speed * (input.sprint ? options.sprintMultiplier : 1);\n state.x += dx * speed * delta;\n state.z += dz * speed * delta;\n\n const stance = targetStanceHeight(input, options);\n\n if (state.grounded) {\n if (input.jump) {\n state.verticalVelocity = options.jumpVelocity;\n state.grounded = false;\n } else {\n // Ease the eye height toward the stance (crouch/stand transition).\n const alpha = Math.min(1, options.crouchSpeed * delta);\n state.y += (stance - state.y) * alpha;\n state.verticalVelocity = 0;\n }\n }\n\n if (!state.grounded) {\n state.verticalVelocity -= options.gravity * delta;\n state.y += state.verticalVelocity * delta;\n if (state.y <= stance) {\n state.y = stance;\n state.verticalVelocity = 0;\n state.grounded = true;\n }\n }\n\n return state;\n}\n\n/** Map a `KeyboardEvent.code` to the movement intent it drives. */\nexport function intentForKey(code: string): keyof MoveInput | undefined {\n switch (code) {\n case 'KeyW':\n case 'ArrowUp':\n return 'forward';\n case 'KeyS':\n case 'ArrowDown':\n return 'back';\n case 'KeyA':\n case 'ArrowLeft':\n return 'left';\n case 'KeyD':\n case 'ArrowRight':\n return 'right';\n case 'Space':\n return 'jump';\n case 'KeyC':\n case 'ControlLeft':\n return 'crouch';\n case 'ShiftLeft':\n case 'ShiftRight':\n return 'sprint';\n default:\n return undefined;\n }\n}\n"]}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Follow-mode math - pure tuple arithmetic (no three.js) so it is fully
3
+ * unit-testable. Windows in `body-follow` mode chase a target point that is
4
+ * the viewer's head position plus a yaw-rotated offset (pitch/roll are
5
+ * deliberately ignored so panels never tilt with the viewer's head).
6
+ */
7
+ import type { HeadPose, Vec3Tuple } from '@realitycollective/webxr-uiextensions';
8
+ /** Extract the yaw (rotation about +Y) from a quaternion [x, y, z, w]. */
9
+ export declare function yawFromQuaternion(q: readonly [number, number, number, number]): number;
10
+ /** Rotate an offset by a yaw angle (about +Y). */
11
+ export declare function rotateOffsetByYaw(offset: Vec3Tuple, yaw: number): Vec3Tuple;
12
+ /** World-space target position for a following window. */
13
+ export declare function followTarget(pose: HeadPose, offset: Vec3Tuple): Vec3Tuple;
14
+ /**
15
+ * Frame-rate-independent exponential approach: fraction of the remaining
16
+ * distance to cover this frame, for smoothing speed `speed` (1/s) over
17
+ * `delta` seconds. 0 when delta/speed are non-positive, approaches 1 for
18
+ * large steps.
19
+ */
20
+ export declare function approachAlpha(speed: number, delta: number): number;
21
+ /** Move `current` toward `target` by `alpha`, returning the new position. */
22
+ export declare function approach(current: Vec3Tuple, target: Vec3Tuple, alpha: number): Vec3Tuple;
23
+ /** Squared distance between two points (cheap tolerance checks). */
24
+ export declare function distanceSquared(a: Vec3Tuple, b: Vec3Tuple): number;
@@ -0,0 +1,52 @@
1
+ /** Extract the yaw (rotation about +Y) from a quaternion [x, y, z, w]. */
2
+ export function yawFromQuaternion(q) {
3
+ const [x, y, z, w] = q;
4
+ // Yaw of the quaternion's forward vector, projected onto the XZ plane.
5
+ const forwardX = 2 * (x * z + w * y);
6
+ const forwardZ = 1 - 2 * (x * x + y * y);
7
+ return Math.atan2(forwardX, forwardZ);
8
+ }
9
+ /** Rotate an offset by a yaw angle (about +Y). */
10
+ export function rotateOffsetByYaw(offset, yaw) {
11
+ const sin = Math.sin(yaw);
12
+ const cos = Math.cos(yaw);
13
+ const [x, y, z] = offset;
14
+ return [x * cos + z * sin, y, -x * sin + z * cos];
15
+ }
16
+ /** World-space target position for a following window. */
17
+ export function followTarget(pose, offset) {
18
+ const rotated = rotateOffsetByYaw(offset, yawFromQuaternion(pose.quaternion));
19
+ return [
20
+ pose.position[0] + rotated[0],
21
+ pose.position[1] + rotated[1],
22
+ pose.position[2] + rotated[2],
23
+ ];
24
+ }
25
+ /**
26
+ * Frame-rate-independent exponential approach: fraction of the remaining
27
+ * distance to cover this frame, for smoothing speed `speed` (1/s) over
28
+ * `delta` seconds. 0 when delta/speed are non-positive, approaches 1 for
29
+ * large steps.
30
+ */
31
+ export function approachAlpha(speed, delta) {
32
+ if (!(speed > 0) || !(delta > 0)) {
33
+ return 0;
34
+ }
35
+ return 1 - Math.exp(-speed * delta);
36
+ }
37
+ /** Move `current` toward `target` by `alpha`, returning the new position. */
38
+ export function approach(current, target, alpha) {
39
+ return [
40
+ current[0] + (target[0] - current[0]) * alpha,
41
+ current[1] + (target[1] - current[1]) * alpha,
42
+ current[2] + (target[2] - current[2]) * alpha,
43
+ ];
44
+ }
45
+ /** Squared distance between two points (cheap tolerance checks). */
46
+ export function distanceSquared(a, b) {
47
+ const dx = a[0] - b[0];
48
+ const dy = a[1] - b[1];
49
+ const dz = a[2] - b[2];
50
+ return dx * dx + dy * dy + dz * dz;
51
+ }
52
+ //# sourceMappingURL=follow-math.js.map