@displayxr/inline3d 1.49.3 → 1.51.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 +56 -0
- package/README.md +6 -1
- package/call.d.ts +16 -0
- package/dist/call.js +6 -6
- package/docs/making-3d-look-right.md +14 -7
- package/docs/woven-canvas-rules.md +30 -1
- package/hands.d.ts +198 -0
- package/index.d.ts +4 -0
- package/js/call/share.js +90 -0
- package/js/camera/capture.js +9 -0
- package/js/camera/half-sbs.js +109 -0
- package/js/inline3d-call.js +1 -1
- package/js/inline3d-hands.js +618 -0
- package/js/inline3d-model-entry.js +3 -1
- package/js/inline3d-splat-playcanvas.js +71 -16
- package/js/inline3d-splat-shared.js +12 -1
- package/js/inline3d-viewer.js +39 -8
- package/llms.txt +2 -2
- package/model.d.ts +10 -0
- package/package.json +7 -1
- package/splat.d.ts +16 -0
- package/viewer.d.ts +12 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
// inline3d-hands.js — `@displayxr/inline3d/hands`: tracked hands on an inline-3D session, and
|
|
2
|
+
// the pinch gestures that drive a viewer's orbit from them.
|
|
3
|
+
//
|
|
4
|
+
// EXPERIMENTAL — a scene subpath, not covered by the 1.x semver promise (docs/sdk-stability.md).
|
|
5
|
+
//
|
|
6
|
+
// WHAT THE BROWSER GIVES US. From DisplayXR Browser 1.8.0 (Windows) an ordinary `inline-3d`
|
|
7
|
+
// session exposes standard WebXR Hand Input when the runtime has a hand provider (an Ultraleap
|
|
8
|
+
// tracker today): one `XRInputSource` per tracked hand (`handedness`, `profiles: ['generic-hand']`,
|
|
9
|
+
// `.hand` = the 25-joint XRHand), `inputsourceschange` as hands appear and vanish, and
|
|
10
|
+
// `XRFrame.fillPoses()` / `fillJointRadii()` against any reference space the page asked for. The
|
|
11
|
+
// joints arrive in the SAME rig frame as the eye views, so a joint drawn where the API puts it sits
|
|
12
|
+
// on the real hand, and a hand over a tile is measured in that tile's rig metres.
|
|
13
|
+
//
|
|
14
|
+
// WHAT IT DOES NOT GIVE US. No `select` / `squeeze` events on a hand source (browser-pvt#321), no
|
|
15
|
+
// controllers, no haptics. A pinch has to be computed from the thumb-tip / index-tip distance, and
|
|
16
|
+
// that is what `HandGestureController` does — a port of the DisplayXR Model Viewer's gesture math,
|
|
17
|
+
// signed off on the panel: hand-relative pinch thresholds, one pinched hand orbits, two pinched
|
|
18
|
+
// hands zoom / roll / orbit.
|
|
19
|
+
//
|
|
20
|
+
// GATE. Hands are delivered per frame only when the browser can place them in the element's rig:
|
|
21
|
+
// `XRSession.handInputWithheldReason` is `null` when they can be, else a reason string
|
|
22
|
+
// (`'camera-rig'`, `'posed-rig'`, `'rig-factors'`, `'no-canvas'`, `'not-fullscreen'`,
|
|
23
|
+
// `'scaled-rig'`). On a display rig (what `addModel` / `addSplat` / `addScene` use by default) they
|
|
24
|
+
// are available when the runtime supports rig-scoped joints (runtime v2.59+). This module surfaces
|
|
25
|
+
// that as `state.gate` rather than hiding it: a page that shows "hands off: not fullscreen" is
|
|
26
|
+
// better than one whose hands silently do nothing.
|
|
27
|
+
//
|
|
28
|
+
// Everywhere else — every other browser, the DisplayXR Browser on a box with no hand provider, a
|
|
29
|
+
// session with no `XRHand` — this module is inert: `watchHands` runs, reports no hands, and
|
|
30
|
+
// `handControls` moves nothing. Pointer and wheel orbit keep working regardless.
|
|
31
|
+
//
|
|
32
|
+
// import { createInline3D } from '@displayxr/inline3d';
|
|
33
|
+
// import { addModel } from '@displayxr/inline3d/model';
|
|
34
|
+
//
|
|
35
|
+
// const wall = await createInline3D({ lazy: false });
|
|
36
|
+
// addModel(wall, canvas, 'helmet.glb', { hands: true }); // pinch to turn, two pinches to zoom
|
|
37
|
+
//
|
|
38
|
+
// // or, by hand, on anything with the viewer pose surface:
|
|
39
|
+
// import { handControls } from '@displayxr/inline3d/hands';
|
|
40
|
+
// const ctl = handControls(wall, handle); ctl.state.gate ctl.stop()
|
|
41
|
+
|
|
42
|
+
/** The 25 WebXR hand joints, in `XRHand` iteration order. */
|
|
43
|
+
export const HAND_JOINTS = Object.freeze([
|
|
44
|
+
'wrist',
|
|
45
|
+
'thumb-metacarpal',
|
|
46
|
+
'thumb-phalanx-proximal',
|
|
47
|
+
'thumb-phalanx-distal',
|
|
48
|
+
'thumb-tip',
|
|
49
|
+
'index-finger-metacarpal',
|
|
50
|
+
'index-finger-phalanx-proximal',
|
|
51
|
+
'index-finger-phalanx-intermediate',
|
|
52
|
+
'index-finger-phalanx-distal',
|
|
53
|
+
'index-finger-tip',
|
|
54
|
+
'middle-finger-metacarpal',
|
|
55
|
+
'middle-finger-phalanx-proximal',
|
|
56
|
+
'middle-finger-phalanx-intermediate',
|
|
57
|
+
'middle-finger-phalanx-distal',
|
|
58
|
+
'middle-finger-tip',
|
|
59
|
+
'ring-finger-metacarpal',
|
|
60
|
+
'ring-finger-phalanx-proximal',
|
|
61
|
+
'ring-finger-phalanx-intermediate',
|
|
62
|
+
'ring-finger-phalanx-distal',
|
|
63
|
+
'ring-finger-tip',
|
|
64
|
+
'pinky-finger-metacarpal',
|
|
65
|
+
'pinky-finger-phalanx-proximal',
|
|
66
|
+
'pinky-finger-phalanx-intermediate',
|
|
67
|
+
'pinky-finger-phalanx-distal',
|
|
68
|
+
'pinky-finger-tip',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const JOINT_INDEX = new Map(HAND_JOINTS.map((n, i) => [n, i]));
|
|
72
|
+
const WRIST = JOINT_INDEX.get('wrist');
|
|
73
|
+
const THUMB_TIP = JOINT_INDEX.get('thumb-tip');
|
|
74
|
+
const INDEX_TIP = JOINT_INDEX.get('index-finger-tip');
|
|
75
|
+
const MIDDLE_KNUCKLE = JOINT_INDEX.get('middle-finger-phalanx-proximal');
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The gesture tuning, as the Model Viewer ships it. Distances in metres of the hand's own frame
|
|
79
|
+
* (the rig frame the joints are reported in), angles in radians.
|
|
80
|
+
*/
|
|
81
|
+
export const HAND_GESTURE_DEFAULTS = Object.freeze({
|
|
82
|
+
/** Thumb-tip to index-tip distance that STARTS a pinch, for a hand of `referencePalmM`. */
|
|
83
|
+
pinchEnterM: 0.025,
|
|
84
|
+
/** Distance that ENDS it (> enter: hysteresis, so a held pinch does not flicker). */
|
|
85
|
+
pinchExitM: 0.04,
|
|
86
|
+
/**
|
|
87
|
+
* Palm length (wrist → middle knuckle) the pinch distances are stated for. A sample's own palm
|
|
88
|
+
* length scales them: a hand delivered at a gain, or scaled into a small tile, pinches at the
|
|
89
|
+
* same finger posture as a physical one. 0 = never scale.
|
|
90
|
+
*/
|
|
91
|
+
referencePalmM: 0.095,
|
|
92
|
+
/** Radians of orbit per metre of pinch-point drag, for a `referencePalmM` hand — 10 cm is about 57°. */
|
|
93
|
+
orbitRadPerM: 10,
|
|
94
|
+
/** EMA time constant on the pinch point, seconds; 0 = off. Smooths tracker jitter before differencing. */
|
|
95
|
+
smoothTauS: 0.05,
|
|
96
|
+
/** Per-frame zoom ratio clamp, both ways — one bad sample cannot slam the zoom. */
|
|
97
|
+
maxZoomStep: 1.25,
|
|
98
|
+
/** Two-hand gestures are ignored (and their baseline dropped) while the hands are further apart than this, for `referencePalmM` hands. */
|
|
99
|
+
maxHandSeparationM: 1.2,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/** Two-threshold pinch state. `update` returns the new state. */
|
|
103
|
+
export class PinchDetector {
|
|
104
|
+
constructor() {
|
|
105
|
+
this._pinched = false;
|
|
106
|
+
}
|
|
107
|
+
get pinched() {
|
|
108
|
+
return this._pinched;
|
|
109
|
+
}
|
|
110
|
+
reset() {
|
|
111
|
+
this._pinched = false;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* @param {boolean} tracked false releases regardless of distance
|
|
115
|
+
* @param {number} tipDistance thumb-tip to index-tip, metres
|
|
116
|
+
* @param {number} enter start threshold
|
|
117
|
+
* @param {number} exit end threshold (> enter)
|
|
118
|
+
*/
|
|
119
|
+
update(tracked, tipDistance, enter, exit) {
|
|
120
|
+
if (!tracked) {
|
|
121
|
+
this._pinched = false;
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (this._pinched) {
|
|
125
|
+
if (tipDistance > exit) this._pinched = false;
|
|
126
|
+
} else if (tipDistance < enter) {
|
|
127
|
+
this._pinched = true;
|
|
128
|
+
}
|
|
129
|
+
return this._pinched;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
134
|
+
|
|
135
|
+
/** Wrap an angle difference into (-π, π]. */
|
|
136
|
+
function wrapAngle(a) {
|
|
137
|
+
while (a > Math.PI) a -= 2 * Math.PI;
|
|
138
|
+
while (a <= -Math.PI) a += 2 * Math.PI;
|
|
139
|
+
return a;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Pinch gestures → pose deltas, per frame. Pure arithmetic: no DOM, no WebXR, no engine — feed it
|
|
144
|
+
* `{ time, left, right, lengthScale }` and read `{ yaw, pitch, roll, zoom, oneHand, twoHand }`.
|
|
145
|
+
*
|
|
146
|
+
* Deltas are in the SUBJECT's convention, the one `SceneViewer.nudgePose` takes: a pinch dragged
|
|
147
|
+
* RIGHT turns the near face right (yaw +), dragged UP tilts it up (pitch −, since +pitch tilts the
|
|
148
|
+
* near face down), two hands turning counter-clockwise as the viewer sees them roll the subject
|
|
149
|
+
* counter-clockwise (roll +), and spreading them zooms in (zoom > 1). That is also what a pointer
|
|
150
|
+
* drag on the same viewer does. (The native Model Viewer rotates its RIG instead, so its yaw / roll
|
|
151
|
+
* are the negation of these — same gesture, same picture.)
|
|
152
|
+
*
|
|
153
|
+
* - pinch = thumb tip to index tip below `pinchEnterM`, until above `pinchExitM`, both scaled by
|
|
154
|
+
* `palmLength / referencePalmM` when a sample carries its palm length — and so is every other
|
|
155
|
+
* distance: the orbit gain and the separation guard are stated for a reference-size hand, so a
|
|
156
|
+
* hand that arrives scaled (a provider gain, a rig scale, a small tile) behaves the same;
|
|
157
|
+
* - one hand pinched and moving = ORBIT: lateral motion × `orbitRadPerM` → yaw (x) and pitch (y);
|
|
158
|
+
* - both hands pinched: separation ratio = ZOOM, rotation of the inter-hand vector in the display
|
|
159
|
+
* plane = ROLL, midpoint drag = ORBIT;
|
|
160
|
+
* - the pinch point (midpoint of the two tips) is EMA-smoothed before differencing;
|
|
161
|
+
* - the first frame of any gesture anchors and yields no delta; a hand lost or a hand gained
|
|
162
|
+
* re-anchors, so a mode change never produces a jump.
|
|
163
|
+
*/
|
|
164
|
+
export class HandGestureController {
|
|
165
|
+
/** @param {Partial<typeof HAND_GESTURE_DEFAULTS>} [config] */
|
|
166
|
+
constructor(config = {}) {
|
|
167
|
+
this.config = { ...HAND_GESTURE_DEFAULTS, ...config };
|
|
168
|
+
this._pinch = [new PinchDetector(), new PinchDetector()];
|
|
169
|
+
this._tipDist = [-1, -1];
|
|
170
|
+
this._handScale = [1, 1];
|
|
171
|
+
this._point = [[0, 0, 0], [0, 0, 0]];
|
|
172
|
+
this._pointValid = [false, false];
|
|
173
|
+
this._prevOne = [false, false];
|
|
174
|
+
this._prevPoint = [[0, 0, 0], [0, 0, 0]];
|
|
175
|
+
this._prevTwo = false;
|
|
176
|
+
this._prevMid = [0, 0, 0];
|
|
177
|
+
this._prevDist = 0;
|
|
178
|
+
this._prevAngle = 0;
|
|
179
|
+
this._prevTime = -1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Pinch state of a hand after the last update (`0` left, `1` right, or `'left'` / `'right'`). */
|
|
183
|
+
pinched(hand) {
|
|
184
|
+
return this._pinch[handIndex(hand)].pinched;
|
|
185
|
+
}
|
|
186
|
+
/** Last measured thumb-index tip distance, metres; < 0 when untracked. */
|
|
187
|
+
tipDistance(hand) {
|
|
188
|
+
return this._tipDist[handIndex(hand)];
|
|
189
|
+
}
|
|
190
|
+
/** Forget every anchor (tracking lost for a while, a page-side reset). */
|
|
191
|
+
reset() {
|
|
192
|
+
for (let h = 0; h < 2; h++) {
|
|
193
|
+
this._pinch[h].reset();
|
|
194
|
+
this._tipDist[h] = -1;
|
|
195
|
+
this._pointValid[h] = false;
|
|
196
|
+
this._prevOne[h] = false;
|
|
197
|
+
}
|
|
198
|
+
this._prevTwo = false;
|
|
199
|
+
this._prevTime = -1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Feed one frame. `left` / `right`: `{ thumbTip:[x,y,z], indexTip:[x,y,z], palmLength? }` or
|
|
204
|
+
* null / `{ tracked:false }` when the hand is not tracked. `time` in seconds (any origin);
|
|
205
|
+
* `lengthScale` multiplies every position first (1 = as given).
|
|
206
|
+
*
|
|
207
|
+
* @returns {{yaw:number, pitch:number, roll:number, zoom:number, oneHand:boolean, twoHand:boolean, any:boolean}}
|
|
208
|
+
* yaw / pitch / roll in radians to ADD, zoom a ratio to MULTIPLY.
|
|
209
|
+
*/
|
|
210
|
+
update({ time = 0, left = null, right = null, lengthScale = 1 } = {}) {
|
|
211
|
+
const cfg = this.config;
|
|
212
|
+
const out = { yaw: 0, pitch: 0, roll: 0, zoom: 1, oneHand: false, twoHand: false, any: false };
|
|
213
|
+
|
|
214
|
+
// Frame interval for the EMA. The first frame, a stalled clock or a long gap all count as "no
|
|
215
|
+
// smoothing history" rather than a huge step.
|
|
216
|
+
let dt = 0;
|
|
217
|
+
if (this._prevTime >= 0 && time > this._prevTime) dt = Math.min(time - this._prevTime, 0.25);
|
|
218
|
+
this._prevTime = time;
|
|
219
|
+
|
|
220
|
+
const ls = lengthScale > 0 ? lengthScale : 1;
|
|
221
|
+
const samples = [left, right];
|
|
222
|
+
const pinched = [false, false];
|
|
223
|
+
for (let h = 0; h < 2; h++) {
|
|
224
|
+
const s = samples[h];
|
|
225
|
+
if (!s || s.tracked === false || !s.thumbTip || !s.indexTip) {
|
|
226
|
+
this._pinch[h].reset();
|
|
227
|
+
this._tipDist[h] = -1;
|
|
228
|
+
this._pointValid[h] = false;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const thumb = [s.thumbTip[0] * ls, s.thumbTip[1] * ls, s.thumbTip[2] * ls];
|
|
232
|
+
const index = [s.indexTip[0] * ls, s.indexTip[1] * ls, s.indexTip[2] * ls];
|
|
233
|
+
this._tipDist[h] = dist3(thumb, index);
|
|
234
|
+
// Hand-relative thresholds: a known palm length scales the metre distances to this hand's
|
|
235
|
+
// size, gain and all.
|
|
236
|
+
let enter = cfg.pinchEnterM;
|
|
237
|
+
let exit = cfg.pinchExitM;
|
|
238
|
+
let hs = 1;
|
|
239
|
+
if (s.palmLength > 0 && cfg.referencePalmM > 0) {
|
|
240
|
+
hs = (s.palmLength * ls) / cfg.referencePalmM;
|
|
241
|
+
enter *= hs;
|
|
242
|
+
exit *= hs;
|
|
243
|
+
}
|
|
244
|
+
this._handScale[h] = hs;
|
|
245
|
+
pinched[h] = this._pinch[h].update(true, this._tipDist[h], enter, exit);
|
|
246
|
+
|
|
247
|
+
// The pinch point is the midpoint of the two tips; smooth it.
|
|
248
|
+
const raw = [0.5 * (thumb[0] + index[0]), 0.5 * (thumb[1] + index[1]), 0.5 * (thumb[2] + index[2])];
|
|
249
|
+
const p = this._point[h];
|
|
250
|
+
if (!this._pointValid[h] || cfg.smoothTauS <= 0 || dt <= 0) {
|
|
251
|
+
p[0] = raw[0];
|
|
252
|
+
p[1] = raw[1];
|
|
253
|
+
p[2] = raw[2];
|
|
254
|
+
} else {
|
|
255
|
+
const alpha = 1 - Math.exp(-dt / cfg.smoothTauS);
|
|
256
|
+
for (let i = 0; i < 3; i++) p[i] += alpha * (raw[i] - p[i]);
|
|
257
|
+
}
|
|
258
|
+
this._pointValid[h] = true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let two = pinched[0] && pinched[1];
|
|
262
|
+
// HAND-RELATIVE MOTION, like the pinch. Every distance below is divided by the pinched hands'
|
|
263
|
+
// size factor (palm / referencePalmM; 1 when unknown), so the orbit gain and the separation
|
|
264
|
+
// guard are stated for a reference-size hand and a hand that arrives scaled — a provider gain,
|
|
265
|
+
// a rig scale, a small tile — turns the subject by the same angle for the same finger travel.
|
|
266
|
+
const hsAll = two
|
|
267
|
+
? 0.5 * (this._handScale[0] + this._handScale[1])
|
|
268
|
+
: pinched[0]
|
|
269
|
+
? this._handScale[0]
|
|
270
|
+
: pinched[1]
|
|
271
|
+
? this._handScale[1]
|
|
272
|
+
: 1;
|
|
273
|
+
const mid = [0, 0, 0];
|
|
274
|
+
const v = [0, 0, 0];
|
|
275
|
+
let d = 0;
|
|
276
|
+
if (two) {
|
|
277
|
+
const [a, b] = this._point;
|
|
278
|
+
for (let i = 0; i < 3; i++) {
|
|
279
|
+
mid[i] = 0.5 * (a[i] + b[i]);
|
|
280
|
+
v[i] = b[i] - a[i];
|
|
281
|
+
}
|
|
282
|
+
d = Math.hypot(v[0], v[1], v[2]);
|
|
283
|
+
// Implausibly far apart: not a gesture. Drop the baseline so the next plausible frame
|
|
284
|
+
// re-anchors instead of integrating the glitch.
|
|
285
|
+
if (d / hsAll > cfg.maxHandSeparationM) {
|
|
286
|
+
this._prevTwo = false;
|
|
287
|
+
this._prevOne[0] = this._prevOne[1] = false;
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const one = !two && pinched[0] ? 0 : !two && pinched[1] ? 1 : -1;
|
|
292
|
+
const k = cfg.orbitRadPerM / hsAll;
|
|
293
|
+
|
|
294
|
+
if (two) {
|
|
295
|
+
out.twoHand = out.any = true;
|
|
296
|
+
// Roll is measured in the display plane (XY); the view axis is Z.
|
|
297
|
+
const angle = Math.atan2(v[1], v[0]);
|
|
298
|
+
if (this._prevTwo) {
|
|
299
|
+
// Midpoint drag = orbit, in the subject's convention (see the class comment).
|
|
300
|
+
out.yaw = (mid[0] - this._prevMid[0]) * k;
|
|
301
|
+
out.pitch = -(mid[1] - this._prevMid[1]) * k;
|
|
302
|
+
// Separation ratio = zoom, clamped so one bad sample cannot slam it.
|
|
303
|
+
if (this._prevDist > 1e-4 && d > 1e-4) {
|
|
304
|
+
out.zoom = Math.min(cfg.maxZoomStep, Math.max(1 / cfg.maxZoomStep, d / this._prevDist));
|
|
305
|
+
}
|
|
306
|
+
// The hands turning counter-clockwise (as the viewer sees them) turn the subject
|
|
307
|
+
// counter-clockwise on screen.
|
|
308
|
+
out.roll = wrapAngle(angle - this._prevAngle);
|
|
309
|
+
}
|
|
310
|
+
this._prevMid[0] = mid[0];
|
|
311
|
+
this._prevMid[1] = mid[1];
|
|
312
|
+
this._prevMid[2] = mid[2];
|
|
313
|
+
this._prevDist = d;
|
|
314
|
+
this._prevAngle = angle;
|
|
315
|
+
this._prevTwo = true;
|
|
316
|
+
this._prevOne[0] = this._prevOne[1] = false;
|
|
317
|
+
} else if (one >= 0) {
|
|
318
|
+
out.oneHand = out.any = true;
|
|
319
|
+
const p = this._point[one];
|
|
320
|
+
const q = this._prevPoint[one];
|
|
321
|
+
if (this._prevOne[one]) {
|
|
322
|
+
out.yaw = (p[0] - q[0]) * k;
|
|
323
|
+
out.pitch = -(p[1] - q[1]) * k;
|
|
324
|
+
}
|
|
325
|
+
q[0] = p[0];
|
|
326
|
+
q[1] = p[1];
|
|
327
|
+
q[2] = p[2];
|
|
328
|
+
this._prevOne[one] = true;
|
|
329
|
+
this._prevOne[one ^ 1] = false;
|
|
330
|
+
this._prevTwo = false;
|
|
331
|
+
} else {
|
|
332
|
+
this._prevTwo = false;
|
|
333
|
+
this._prevOne[0] = this._prevOne[1] = false;
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function handIndex(hand) {
|
|
340
|
+
if (hand === 'left') return 0;
|
|
341
|
+
if (hand === 'right') return 1;
|
|
342
|
+
return hand & 1;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── the session side ──────────────────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/** Can this document deliver hand sources at all? (`XRHand` is the Hand Input module's marker.) */
|
|
348
|
+
export function handInputAvailable() {
|
|
349
|
+
return typeof XRHand === 'function' || typeof XRHand === 'object';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The browser's per-frame hand gate, as a string: `'available'` when hands can be delivered,
|
|
354
|
+
* the withheld reason otherwise, `'unknown'` where the session has no such surface (not a
|
|
355
|
+
* DisplayXR Browser, or one before 1.8.0).
|
|
356
|
+
*/
|
|
357
|
+
export function handGate(session) {
|
|
358
|
+
if (!session || typeof session !== 'object' || !('handInputWithheldReason' in session)) return 'unknown';
|
|
359
|
+
const r = session.handInputWithheldReason;
|
|
360
|
+
return r === null || r === undefined ? 'available' : String(r);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const jointsOf = new WeakMap(); // XRInputSource → XRJointSpace[] (hand.values() is stable per source)
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* One tracked hand, as `watchHands` reports it. Positions are `[x, y, z]` metres in the watch's
|
|
367
|
+
* reference space (the SDK's `viewer` space by default: the rig frame the eye views use).
|
|
368
|
+
*
|
|
369
|
+
* @typedef {object} HandSample
|
|
370
|
+
* @property {boolean} tracked
|
|
371
|
+
* @property {'left'|'right'|'none'} handedness
|
|
372
|
+
* @property {Float32Array} poses 25 × 16 column-major joint transforms (`fillPoses`)
|
|
373
|
+
* @property {Float32Array} radii 25 joint radii, metres (`fillJointRadii`)
|
|
374
|
+
* @property {(name: string) => number[]} joint position of a joint by WebXR name
|
|
375
|
+
* @property {number[]} wrist
|
|
376
|
+
* @property {number[]} thumbTip
|
|
377
|
+
* @property {number[]} indexTip
|
|
378
|
+
* @property {number} palmLength wrist → middle knuckle, metres (0 if unknown)
|
|
379
|
+
* @property {XRInputSource} source
|
|
380
|
+
*/
|
|
381
|
+
|
|
382
|
+
function makeSample(source) {
|
|
383
|
+
const poses = new Float32Array(HAND_JOINTS.length * 16);
|
|
384
|
+
const radii = new Float32Array(HAND_JOINTS.length);
|
|
385
|
+
const pos = (i) => [poses[i * 16 + 12], poses[i * 16 + 13], poses[i * 16 + 14]];
|
|
386
|
+
const s = {
|
|
387
|
+
tracked: false,
|
|
388
|
+
handedness: source.handedness || 'none',
|
|
389
|
+
poses,
|
|
390
|
+
radii,
|
|
391
|
+
joint(name) {
|
|
392
|
+
const i = JOINT_INDEX.get(name);
|
|
393
|
+
return i === undefined ? null : pos(i);
|
|
394
|
+
},
|
|
395
|
+
wrist: [0, 0, 0],
|
|
396
|
+
thumbTip: [0, 0, 0],
|
|
397
|
+
indexTip: [0, 0, 0],
|
|
398
|
+
palmLength: 0,
|
|
399
|
+
source,
|
|
400
|
+
};
|
|
401
|
+
return s;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function fillSample(s, frame, refSpace) {
|
|
405
|
+
const hand = s.source.hand;
|
|
406
|
+
let joints = jointsOf.get(s.source);
|
|
407
|
+
if (!joints) {
|
|
408
|
+
joints = Array.from(hand.values());
|
|
409
|
+
jointsOf.set(s.source, joints);
|
|
410
|
+
}
|
|
411
|
+
if (joints.length !== HAND_JOINTS.length) return false;
|
|
412
|
+
if (!frame.fillPoses(joints, refSpace, s.poses)) return false;
|
|
413
|
+
if (typeof frame.fillJointRadii === 'function') frame.fillJointRadii(joints, s.radii);
|
|
414
|
+
const p = (i) => [s.poses[i * 16 + 12], s.poses[i * 16 + 13], s.poses[i * 16 + 14]];
|
|
415
|
+
s.wrist = p(WRIST);
|
|
416
|
+
s.thumbTip = p(THUMB_TIP);
|
|
417
|
+
s.indexTip = p(INDEX_TIP);
|
|
418
|
+
s.palmLength = dist3(s.wrist, p(MIDDLE_KNUCKLE));
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Watch the hands on an inline-3D session. Runs its own `session.requestAnimationFrame` loop (a
|
|
424
|
+
* second callback per frame beside the SDK's own, which WebXR allows) and calls `onFrame` every
|
|
425
|
+
* session frame with `{ time, gate, left, right, hands }` — `left` / `right` a `HandSample` or
|
|
426
|
+
* null, `hands` the number tracked this frame, `gate` as `handGate()`.
|
|
427
|
+
*
|
|
428
|
+
* Inert where there are no hands: the callback still fires with both hands null, so a page can
|
|
429
|
+
* draw its "no hands" state from the same place. Stops itself when the session ends.
|
|
430
|
+
*
|
|
431
|
+
* @param {object} wall the `createInline3D()` result (uses `wall.session` and `wall.refSpace`)
|
|
432
|
+
* @param {(f: {time:number, gate:string, left:HandSample|null, right:HandSample|null, hands:number}) => void} onFrame
|
|
433
|
+
* @param {object} [opts]
|
|
434
|
+
* @param {XRReferenceSpace} [opts.referenceSpace] joints are located in this space (default: the wall's)
|
|
435
|
+
* @param {(e: {added: XRInputSource[], removed: XRInputSource[]}) => void} [opts.onChange] `inputsourceschange`, hand sources only
|
|
436
|
+
* @returns {{ stop(): void, readonly running: boolean, readonly state: {gate:string, hands:number, supported:boolean} }}
|
|
437
|
+
*/
|
|
438
|
+
export function watchHands(wall, onFrame, opts = {}) {
|
|
439
|
+
const session = wall && wall.supported !== false ? wall.session : null;
|
|
440
|
+
const state = { gate: 'unknown', hands: 0, supported: false };
|
|
441
|
+
let running = false;
|
|
442
|
+
if (!session || typeof session.requestAnimationFrame !== 'function') {
|
|
443
|
+
return { stop() {}, get running() { return false; }, state };
|
|
444
|
+
}
|
|
445
|
+
state.supported = handInputAvailable();
|
|
446
|
+
const refSpace = opts.referenceSpace || wall.refSpace || null;
|
|
447
|
+
const samples = new WeakMap(); // source → HandSample
|
|
448
|
+
let rafId = 0;
|
|
449
|
+
running = true;
|
|
450
|
+
|
|
451
|
+
const onChange = (e) => {
|
|
452
|
+
if (typeof opts.onChange !== 'function') return;
|
|
453
|
+
const isHand = (s) => !!(s && s.hand);
|
|
454
|
+
const added = Array.from(e.added || []).filter(isHand);
|
|
455
|
+
const removed = Array.from(e.removed || []).filter(isHand);
|
|
456
|
+
if (added.length || removed.length) opts.onChange({ added, removed });
|
|
457
|
+
};
|
|
458
|
+
const onEnd = () => stop();
|
|
459
|
+
session.addEventListener('inputsourceschange', onChange);
|
|
460
|
+
session.addEventListener('end', onEnd);
|
|
461
|
+
|
|
462
|
+
const tick = (t, frame) => {
|
|
463
|
+
if (!running) return;
|
|
464
|
+
rafId = session.requestAnimationFrame(tick);
|
|
465
|
+
state.gate = handGate(session);
|
|
466
|
+
let left = null;
|
|
467
|
+
let right = null;
|
|
468
|
+
let hands = 0;
|
|
469
|
+
const sources = session.inputSources;
|
|
470
|
+
if (refSpace && sources && sources.length) {
|
|
471
|
+
for (const src of sources) {
|
|
472
|
+
if (!src || !src.hand) continue;
|
|
473
|
+
let s = samples.get(src);
|
|
474
|
+
if (!s) {
|
|
475
|
+
s = makeSample(src);
|
|
476
|
+
samples.set(src, s);
|
|
477
|
+
}
|
|
478
|
+
s.tracked = fillSample(s, frame, refSpace);
|
|
479
|
+
if (!s.tracked) continue;
|
|
480
|
+
hands++;
|
|
481
|
+
if (src.handedness === 'left') left = s;
|
|
482
|
+
else if (src.handedness === 'right') right = s;
|
|
483
|
+
else if (!right) right = s;
|
|
484
|
+
else if (!left) left = s;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
state.hands = hands;
|
|
488
|
+
onFrame({ time: t / 1000, gate: state.gate, left, right, hands, frame });
|
|
489
|
+
};
|
|
490
|
+
rafId = session.requestAnimationFrame(tick);
|
|
491
|
+
|
|
492
|
+
function stop() {
|
|
493
|
+
if (!running) return;
|
|
494
|
+
running = false;
|
|
495
|
+
try {
|
|
496
|
+
if (rafId && typeof session.cancelAnimationFrame === 'function') session.cancelAnimationFrame(rafId);
|
|
497
|
+
} catch {
|
|
498
|
+
/* a session that has already ended */
|
|
499
|
+
}
|
|
500
|
+
session.removeEventListener('inputsourceschange', onChange);
|
|
501
|
+
session.removeEventListener('end', onEnd);
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
stop,
|
|
505
|
+
get running() {
|
|
506
|
+
return running;
|
|
507
|
+
},
|
|
508
|
+
state,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const RAD2DEG = 180 / Math.PI;
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Drive a viewer's orbit from the hands: pinch to turn, two pinches to zoom and roll. `target`
|
|
516
|
+
* is anything with the viewer pose surface — an `addModel` / `addSplat` handle (its `viewer`
|
|
517
|
+
* is read each frame, so a handle whose backend has not loaded yet is fine), a `SceneViewer`,
|
|
518
|
+
* or the PlayCanvas backend's viewer. Prefers `viewer.nudgePose()` (which also counts as input
|
|
519
|
+
* for the idle turntable); falls back to `getPose` + `setPose` on anything else that has them.
|
|
520
|
+
*
|
|
521
|
+
* Opt-in, and inert without hands. Pointer and wheel orbit keep working alongside.
|
|
522
|
+
*
|
|
523
|
+
* @param {object} wall
|
|
524
|
+
* @param {object} target
|
|
525
|
+
* @param {object} [opts]
|
|
526
|
+
* @param {Partial<typeof HAND_GESTURE_DEFAULTS>} [opts.gestures] controller tuning
|
|
527
|
+
* @param {number} [opts.lengthScale=1] multiplies every hand position before the gesture math
|
|
528
|
+
* @param {boolean} [opts.roll=true] apply the two-hand roll (where the viewer supports it)
|
|
529
|
+
* @param {boolean} [opts.zoom=true] apply the two-hand zoom
|
|
530
|
+
* @param {XRReferenceSpace} [opts.referenceSpace]
|
|
531
|
+
* @param {(f: object, d: object) => void} [opts.onFrame] after each frame: the hands frame and the gesture delta
|
|
532
|
+
* @returns {{ stop(): void, readonly running: boolean, controller: HandGestureController, state: object }}
|
|
533
|
+
*/
|
|
534
|
+
export function handControls(wall, target, opts = {}) {
|
|
535
|
+
const controller = new HandGestureController(opts.gestures);
|
|
536
|
+
const lengthScale = opts.lengthScale > 0 ? opts.lengthScale : 1;
|
|
537
|
+
const applyRoll = opts.roll !== false;
|
|
538
|
+
const applyZoom = opts.zoom !== false;
|
|
539
|
+
const viewerOf = () => {
|
|
540
|
+
if (!target) return null;
|
|
541
|
+
if (typeof target.nudgePose === 'function' || typeof target.setPose === 'function') {
|
|
542
|
+
// A viewer itself — or a handle that also carries setPose. Prefer a handle's viewer: the
|
|
543
|
+
// handle's setPose on PlayCanvas is a queued proxy before the backend loads.
|
|
544
|
+
if (target.viewer && typeof target.viewer === 'object') return target.viewer;
|
|
545
|
+
return target;
|
|
546
|
+
}
|
|
547
|
+
return target.viewer || null;
|
|
548
|
+
};
|
|
549
|
+
const watch = watchHands(
|
|
550
|
+
wall,
|
|
551
|
+
(f) => {
|
|
552
|
+
const d = controller.update({ time: f.time, left: f.left, right: f.right, lengthScale });
|
|
553
|
+
if (d.any) {
|
|
554
|
+
const viewer = viewerOf();
|
|
555
|
+
if (viewer) {
|
|
556
|
+
const delta = {
|
|
557
|
+
yaw: d.yaw * RAD2DEG,
|
|
558
|
+
pitch: d.pitch * RAD2DEG,
|
|
559
|
+
zoom: applyZoom ? d.zoom : 1,
|
|
560
|
+
roll: applyRoll ? d.roll * RAD2DEG : 0,
|
|
561
|
+
};
|
|
562
|
+
if (typeof viewer.nudgePose === 'function') viewer.nudgePose(delta);
|
|
563
|
+
else if (typeof viewer.getPose === 'function' && typeof viewer.setPose === 'function') {
|
|
564
|
+
const p = viewer.getPose({ target: true });
|
|
565
|
+
viewer.setPose({
|
|
566
|
+
yaw: p.yaw + delta.yaw,
|
|
567
|
+
pitch: p.pitch + delta.pitch,
|
|
568
|
+
zoom: p.zoom * delta.zoom,
|
|
569
|
+
...(p.roll !== undefined && delta.roll ? { roll: p.roll + delta.roll } : {}),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (typeof opts.onFrame === 'function') opts.onFrame(f, d);
|
|
575
|
+
},
|
|
576
|
+
{ referenceSpace: opts.referenceSpace, onChange: opts.onChange },
|
|
577
|
+
);
|
|
578
|
+
return {
|
|
579
|
+
stop: watch.stop,
|
|
580
|
+
get running() {
|
|
581
|
+
return watch.running;
|
|
582
|
+
},
|
|
583
|
+
controller,
|
|
584
|
+
state: watch.state,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Attach `hands` to a subpath handle when its options ask for it — the shared seam `addModel`
|
|
590
|
+
* and `addSplat` (every entry, both engines) go through. `hands: true` or an options object.
|
|
591
|
+
* The controls stop with the handle's `remove()`, however many times the adapters reassign it,
|
|
592
|
+
* and are exposed as `handle.hands` (`stop()`, `state.gate`, `controller`).
|
|
593
|
+
*/
|
|
594
|
+
export function attachHandsOption(wall, handle, opts) {
|
|
595
|
+
const want = opts && opts.hands;
|
|
596
|
+
if (!want || !handle || typeof handle !== 'object' || handle.hands) return null;
|
|
597
|
+
const ctl = handControls(wall, handle, want === true ? {} : want);
|
|
598
|
+
Object.defineProperty(handle, 'hands', { value: ctl, enumerable: true, configurable: true, writable: false });
|
|
599
|
+
// Ride whatever `remove` is or becomes: the core's claim already wraps it in an accessor and the
|
|
600
|
+
// adapters assign through that setter when their module loads; compose with either shape.
|
|
601
|
+
const desc = Object.getOwnPropertyDescriptor(handle, 'remove');
|
|
602
|
+
let impl = desc && typeof desc.get === 'function' ? null : handle.remove;
|
|
603
|
+
const remove = (...args) => {
|
|
604
|
+
ctl.stop();
|
|
605
|
+
const fn = desc && typeof desc.get === 'function' ? desc.get.call(handle) : impl;
|
|
606
|
+
return typeof fn === 'function' ? fn.apply(handle, args) : undefined;
|
|
607
|
+
};
|
|
608
|
+
Object.defineProperty(handle, 'remove', {
|
|
609
|
+
configurable: true,
|
|
610
|
+
enumerable: true,
|
|
611
|
+
get: () => remove,
|
|
612
|
+
set: (fn) => {
|
|
613
|
+
if (desc && typeof desc.set === 'function') desc.set.call(handle, fn);
|
|
614
|
+
else impl = fn;
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
return ctl;
|
|
618
|
+
}
|
|
@@ -240,7 +240,9 @@ function preValidatePlayCanvas(opts) {
|
|
|
240
240
|
*/
|
|
241
241
|
async function attachThree(out, wall, canvas, src, opts, pending, mod) {
|
|
242
242
|
const m = mod || (await import('./inline3d-model.js'));
|
|
243
|
-
|
|
243
|
+
// `hands` is already wired on THIS handle (guardedAttach); the inner attach must not wire a
|
|
244
|
+
// second controller on the inner one.
|
|
245
|
+
const inner = m.addModel(wall, canvas, src, opts.hands ? { ...opts, hands: undefined } : opts);
|
|
244
246
|
for (const k of ['viewer', 'model', 'frame']) {
|
|
245
247
|
Object.defineProperty(out, k, { get: () => inner[k], set: (v) => (inner[k] = v), enumerable: true, configurable: true });
|
|
246
248
|
}
|