@displayxr/inline3d 1.49.3 → 1.50.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/hands.d.ts ADDED
@@ -0,0 +1,198 @@
1
+ // Type definitions for @displayxr/inline3d/hands.
2
+ // EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
+ //
4
+ // Tracked hands on an inline-3D session (WebXR Hand Input, DisplayXR Browser 1.8.0+ on Windows
5
+ // with a hand provider), and the pinch gestures that drive a viewer's orbit from them. Inert
6
+ // everywhere else: `watchHands` reports no hands and `handControls` moves nothing.
7
+
8
+ import type { Inline3D, Inline3DUnsupported } from './index.js';
9
+
10
+ /** The 25 WebXR hand joint names, in `XRHand` iteration order. */
11
+ export const HAND_JOINTS: ReadonlyArray<string>;
12
+
13
+ /** Gesture tuning. Metres in the joints' frame (the rig frame), radians. */
14
+ export interface HandGestureConfig {
15
+ /** Thumb-tip to index-tip distance that starts a pinch, for a hand of `referencePalmM` (default 0.025). */
16
+ pinchEnterM: number;
17
+ /** Distance that ends it, > enter (default 0.04). */
18
+ pinchExitM: number;
19
+ /**
20
+ * Palm length every distance here is stated for; a sample's own palm length scales them (pinch
21
+ * thresholds, orbit gain, separation guard), so a scaled hand behaves the same. 0 = never scale
22
+ * (default 0.095).
23
+ */
24
+ referencePalmM: number;
25
+ /** Radians of orbit per metre of pinch-point drag, for a reference-size hand (default 10). */
26
+ orbitRadPerM: number;
27
+ /** EMA time constant on the pinch point, seconds; 0 = off (default 0.05). */
28
+ smoothTauS: number;
29
+ /** Per-frame zoom ratio clamp, both ways (default 1.25). */
30
+ maxZoomStep: number;
31
+ /** Two-hand gestures are ignored while the hands are further apart than this (default 1.2). */
32
+ maxHandSeparationM: number;
33
+ }
34
+ export const HAND_GESTURE_DEFAULTS: Readonly<HandGestureConfig>;
35
+
36
+ /** Two-threshold pinch state. */
37
+ export declare class PinchDetector {
38
+ readonly pinched: boolean;
39
+ reset(): void;
40
+ /** `tracked: false` releases regardless of distance. Returns the new state. */
41
+ update(tracked: boolean, tipDistance: number, enter: number, exit: number): boolean;
42
+ }
43
+
44
+ /** One hand's input to the gesture controller. Positions `[x, y, z]`, metres. */
45
+ export interface HandGestureSample {
46
+ tracked?: boolean;
47
+ thumbTip: ArrayLike<number>;
48
+ indexTip: ArrayLike<number>;
49
+ /** Wrist → middle knuckle, metres; 0 / absent = unknown (the metre thresholds apply as given). */
50
+ palmLength?: number;
51
+ }
52
+
53
+ /**
54
+ * The pose delta for one frame, in the SUBJECT's convention (what `SceneViewer.nudgePose` takes):
55
+ * yaw / pitch / roll in radians to ADD, zoom a ratio to MULTIPLY. A pinch dragged right → yaw +;
56
+ * dragged up → pitch −; two hands turning counter-clockwise → roll +; spreading → zoom > 1.
57
+ */
58
+ export interface HandGestureDelta {
59
+ yaw: number;
60
+ pitch: number;
61
+ roll: number;
62
+ zoom: number;
63
+ /** Exactly one hand pinched this frame. */
64
+ oneHand: boolean;
65
+ /** Both hands pinched this frame. */
66
+ twoHand: boolean;
67
+ any: boolean;
68
+ }
69
+
70
+ /**
71
+ * Pinch gestures → pose deltas, per frame. Pure arithmetic (no DOM, no WebXR): a port of the
72
+ * DisplayXR Model Viewer's controls. Hand-relative pinch (thresholds scale by palm length), one
73
+ * pinched hand orbits, two pinched hands zoom (separation ratio), roll (twist in the display
74
+ * plane) and orbit (midpoint drag). The first frame of a gesture anchors; a hand gained or lost
75
+ * re-anchors, so a mode change never jumps.
76
+ */
77
+ export declare class HandGestureController {
78
+ constructor(config?: Partial<HandGestureConfig>);
79
+ config: HandGestureConfig;
80
+ update(frame: {
81
+ /** Seconds, any origin (drives the EMA). */
82
+ time?: number;
83
+ left?: HandGestureSample | null;
84
+ right?: HandGestureSample | null;
85
+ /** Multiplies every position first (default 1). */
86
+ lengthScale?: number;
87
+ }): HandGestureDelta;
88
+ /** Pinch state after the last update: `0` / `'left'`, `1` / `'right'`. */
89
+ pinched(hand: 0 | 1 | 'left' | 'right'): boolean;
90
+ /** Last thumb-index tip distance, metres; < 0 when untracked. */
91
+ tipDistance(hand: 0 | 1 | 'left' | 'right'): number;
92
+ /** Forget every anchor. */
93
+ reset(): void;
94
+ }
95
+
96
+ /** Can this document deliver hand sources at all (`XRHand` present)? */
97
+ export function handInputAvailable(): boolean;
98
+
99
+ /**
100
+ * The browser's per-frame hand gate: `'available'`, a withheld reason (`'camera-rig'`,
101
+ * `'posed-rig'`, `'rig-factors'`, `'no-canvas'`, `'not-fullscreen'`, `'scaled-rig'`), or
102
+ * `'unknown'` where the session has no such surface.
103
+ */
104
+ export function handGate(session: XRSession | null | undefined): string;
105
+
106
+ /** One tracked hand as `watchHands` reports it. Positions `[x, y, z]` metres in the watch's reference space. */
107
+ export interface HandSample {
108
+ readonly tracked: boolean;
109
+ readonly handedness: 'left' | 'right' | 'none';
110
+ /** 25 × 16 column-major joint transforms (`XRFrame.fillPoses`). */
111
+ readonly poses: Float32Array;
112
+ /** 25 joint radii, metres. */
113
+ readonly radii: Float32Array;
114
+ /** Position of a joint by WebXR name, or null for an unknown name. */
115
+ joint(name: string): number[] | null;
116
+ readonly wrist: number[];
117
+ readonly thumbTip: number[];
118
+ readonly indexTip: number[];
119
+ /** Wrist → middle knuckle, metres. */
120
+ readonly palmLength: number;
121
+ readonly source: XRInputSource;
122
+ }
123
+
124
+ export interface HandsFrame {
125
+ /** Seconds (the session frame time / 1000). */
126
+ time: number;
127
+ /** As `handGate()`. */
128
+ gate: string;
129
+ left: HandSample | null;
130
+ right: HandSample | null;
131
+ /** Hands tracked this frame. */
132
+ hands: number;
133
+ frame: XRFrame;
134
+ }
135
+
136
+ export interface HandsWatch {
137
+ stop(): void;
138
+ readonly running: boolean;
139
+ readonly state: { gate: string; hands: number; supported: boolean };
140
+ }
141
+
142
+ export interface WatchHandsOptions {
143
+ /** Joints are located in this space (default: the wall's `refSpace`). */
144
+ referenceSpace?: XRReferenceSpace;
145
+ /** `inputsourceschange`, hand sources only. */
146
+ onChange?: (e: { added: XRInputSource[]; removed: XRInputSource[] }) => void;
147
+ }
148
+
149
+ /**
150
+ * Watch the hands on an inline-3D session: `onFrame` every session frame with the tracked hands
151
+ * (or none). Runs its own `session.requestAnimationFrame` loop beside the SDK's; stops itself
152
+ * when the session ends.
153
+ */
154
+ export function watchHands(
155
+ wall: Inline3D | Inline3DUnsupported | null | undefined,
156
+ onFrame: (f: HandsFrame) => void,
157
+ opts?: WatchHandsOptions,
158
+ ): HandsWatch;
159
+
160
+ export interface HandControlOptions extends WatchHandsOptions {
161
+ /** Controller tuning. */
162
+ gestures?: Partial<HandGestureConfig>;
163
+ /** Multiplies every hand position before the gesture math (default 1). */
164
+ lengthScale?: number;
165
+ /** Apply the two-hand roll where the viewer has a roll axis (default true). */
166
+ roll?: boolean;
167
+ /** Apply the two-hand zoom (default true). */
168
+ zoom?: boolean;
169
+ /** After each frame: the hands frame and the gesture delta. */
170
+ onFrame?: (f: HandsFrame, d: HandGestureDelta) => void;
171
+ }
172
+
173
+ /** What `handControls` returns, and what `handle.hands` is on an `addModel` / `addSplat` handle. */
174
+ export interface HandControls {
175
+ stop(): void;
176
+ readonly running: boolean;
177
+ readonly controller: HandGestureController;
178
+ readonly state: { gate: string; hands: number; supported: boolean };
179
+ }
180
+
181
+ /** Anything with the viewer pose surface: a SceneViewer, the PlayCanvas viewer, or a handle whose `viewer` is one. */
182
+ export interface HandControlTarget {
183
+ nudgePose?(delta: { yaw?: number; pitch?: number; zoom?: number; roll?: number }): void;
184
+ getPose?(opts?: { target?: boolean }): { yaw: number; pitch: number; zoom: number; roll?: number };
185
+ setPose?(pose: { yaw?: number; pitch?: number; zoom?: number; roll?: number }): void;
186
+ readonly viewer?: HandControlTarget | null;
187
+ }
188
+
189
+ /**
190
+ * Drive a viewer's orbit from the hands: one pinch dragged turns it, two pinches spread / twist
191
+ * / dragged zoom / roll / turn it. Prefers `viewer.nudgePose()` (eased, counts as input for the
192
+ * idle turntable), falls back to `getPose` + `setPose`. Pointer and wheel keep working.
193
+ */
194
+ export function handControls(
195
+ wall: Inline3D | Inline3DUnsupported | null | undefined,
196
+ target: HandControlTarget,
197
+ opts?: HandControlOptions,
198
+ ): HandControls;
@@ -41,6 +41,7 @@
41
41
  // `(stream, info) => MediaStream | Promise<MediaStream>`, returning a rectified SBS stream.
42
42
 
43
43
  import { looksSbs } from './geometry.js';
44
+ import { isHalfSbsCamera, unsqueezeHalfSbs } from './half-sbs.js';
44
45
  import { isAppShareDevice, isAppShareHint } from './app-share.js';
45
46
 
46
47
  const MONO_CONSTRAINTS = { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } };
@@ -213,6 +214,14 @@ export async function openCamera(want = 'auto', o = {}) {
213
214
  // or — on a browser without the app-share device patch, which lists the app as a "3D Camera
214
215
  // (DisplayXR) - <app>" — baseline 0 and FOV 0, which no real camera advertises. Every probe
215
216
  // path checks this on the OPENED track (rejectOpened), since the label cannot tell them apart.
217
+ // A half-SBS webcam: send true side-by-side eyes, whichever path opened it. 'mono' stays raw.
218
+ if (want !== 'mono' && isHalfSbsCamera(t.label)) {
219
+ const pair = await unsqueezeHalfSbs(t);
220
+ if (pair) {
221
+ log('camera-half-sbs', { label: t.label, width: pair.width, height: pair.height });
222
+ return { ...pair, deviceId: st.deviceId || null, label: t.label, stereo: null, appShare: false };
223
+ }
224
+ }
216
225
  const appShare = isAppShareDevice({ label: t.label || '', deviceId: st.deviceId || '' }) || isAppShareHint(st.displayxrStereo);
217
226
  return { stream: s, track: t, width: st.width, height: st.height, deviceId: st.deviceId || null, label: t.label || '', stereo, appShare };
218
227
  };
@@ -0,0 +1,83 @@
1
+ // camera/half-sbs.js — USB stereo webcams that send a half-width side-by-side frame: one 16:9
2
+ // image whose left and right halves are each an eye squeezed 2:1 in width.
3
+ //
4
+ // Shape cannot identify these: a 3840x2160 half-SBS frame looks exactly like a 4K webcam's, so they
5
+ // are listed by USB id. Asking one for a wide mode does not help either: the browser reaches
6
+ // 3840x1080 by cropping the middle rows of 3840x2160, which still reads as a pair but leaves each
7
+ // eye 2x too tall with half the vertical view. So the camera is read in its native mode and every
8
+ // frame is redrawn, eyes un-squeezed, into a true side-by-side track.
9
+
10
+ /** USB `vvvv:pppp` ids of cameras that send half-SBS (the browser's label ends with the id). */
11
+ export const HALF_SBS_USB_IDS = Object.freeze(['0733:011a']);
12
+
13
+ /** The widest eye sent: the runtime's own output for such a camera (2560x720 side by side). */
14
+ const MAX_EYE_WIDTH = 1280;
15
+ const NATIVE_CONSTRAINTS = { width: { ideal: 3840 }, height: { ideal: 2160 }, frameRate: { ideal: 30 }, resizeMode: 'none' };
16
+
17
+ /** Does this device label name a half-SBS camera? */
18
+ export function isHalfSbsCamera(label) {
19
+ const l = String(label || '').toLowerCase();
20
+ return HALF_SBS_USB_IDS.some((id) => l.includes(`(${id})`));
21
+ }
22
+
23
+ /** The full-SBS frame size for a half-SBS source: square-pixel eyes, no upscaling, even numbers. */
24
+ export function unsqueezedSize(width, height) {
25
+ const eyeW = Math.min(MAX_EYE_WIDTH, width);
26
+ const eyeH = Math.max(2, Math.round((eyeW * height) / width / 2) * 2);
27
+ return { width: eyeW * 2, height: eyeH };
28
+ }
29
+
30
+ /**
31
+ * Put `source` in its native mode and return a side-by-side track with the eyes un-squeezed, or
32
+ * null when that cannot be done here (the engine has no MediaStreamTrackProcessor or the camera
33
+ * refuses a 16:9 mode); the source is then left open as it is. Stopping the returned track stops
34
+ * the source.
35
+ *
36
+ * @param {MediaStreamTrack} source
37
+ * @returns {Promise<{stream: MediaStream, track: MediaStreamTrack, width: number, height: number}|null>}
38
+ */
39
+ export async function unsqueezeHalfSbs(source) {
40
+ if (typeof MediaStreamTrackProcessor !== 'function' || typeof document === 'undefined') return null;
41
+ try {
42
+ await source.applyConstraints(NATIVE_CONSTRAINTS);
43
+ } catch {
44
+ return null;
45
+ }
46
+ const st = source.getSettings();
47
+ if (!(st.width > 0) || !(st.height > 0) || Math.abs(st.width / st.height - 16 / 9) > 0.01) return null;
48
+
49
+ const { width, height } = unsqueezedSize(st.width, st.height);
50
+ const canvas = document.createElement('canvas');
51
+ canvas.width = width;
52
+ canvas.height = height;
53
+ const ctx = canvas.getContext('2d', { alpha: false });
54
+ if (!ctx) return null;
55
+ const stream = canvas.captureStream(st.frameRate > 0 ? st.frameRate : 30);
56
+ const track = stream.getVideoTracks()[0];
57
+ const reader = new MediaStreamTrackProcessor({ track: source }).readable.getReader();
58
+
59
+ let stopped = false;
60
+ const stopTrack = track.stop.bind(track);
61
+ track.stop = () => {
62
+ stopped = true;
63
+ reader.cancel().catch(() => {});
64
+ source.stop();
65
+ stopTrack();
66
+ };
67
+ (async () => {
68
+ for (;;) {
69
+ const { value: frame, done } = await reader.read();
70
+ if (done) break;
71
+ ctx.drawImage(frame, 0, 0, width, height);
72
+ frame.close();
73
+ }
74
+ })()
75
+ .catch(() => {})
76
+ .finally(() => {
77
+ if (stopped) return;
78
+ // The camera went away: this track ends with it, like the device's own would.
79
+ track.stop();
80
+ track.dispatchEvent(new Event('ended'));
81
+ });
82
+ return { stream, track, width, height };
83
+ }