playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
775 lines (682 loc) • 30.7 kB
JavaScript
import { math, Quat, Script, Vec3 } from 'playcanvas';
/** @import { Entity, XrInputSource } from 'playcanvas' */
/** @import { XrNavigation } from './xr-navigation.mjs' */
// Minimum 3D hand separation (meters) below which the scale solve is held, avoiding division
// blow-up when the two grip poses (near-)coincide
const EPS_SEPARATION = 1e-3;
// Minimum XZ-plane projection (meters) of the hand-pair vector below which the yaw heading is
// degenerate (hands stacked vertically) and the rotation solve is held
const EPS_XZ = 1e-4;
const tmpHandLeft = new Vec3();
const tmpHandRight = new Vec3();
const tmpHandMid = new Vec3();
const tmpHandDelta = new Vec3();
const tmpPrevDelta = new Vec3();
const tmpMidWorld = new Vec3();
const tmpSolvedPos = new Vec3();
const tmpScratch = new Vec3();
const tmpQuat = new Quat();
const tmpQuatB = new Quat();
/**
* Rotates a vector in place around the world Y axis. Matches the rotation applied by a
* yaw-only entity rotation, i.e. `rotateY(v, yaw)` computes `R(yaw) * v`.
*
* @param {Vec3} v - The vector to rotate in place.
* @param {number} angle - The rotation angle in radians.
* @returns {Vec3} The rotated vector (same object as `v`).
*/
const rotateY = (v, angle) => {
const c = Math.cos(angle);
const s = Math.sin(angle);
const x = v.x * c + v.z * s;
v.z = -v.x * s + v.z * c;
v.x = x;
return v;
};
/**
* Wraps an angle to the (-PI, PI] range.
*
* @param {number} angle - The angle in radians.
* @returns {number} The wrapped angle in radians.
*/
const wrapPi = (angle) => {
angle %= Math.PI * 2;
if (angle > Math.PI) {
angle -= Math.PI * 2;
} else if (angle <= -Math.PI) {
angle += Math.PI * 2;
}
return angle;
};
/**
* Lets the user grab the world with both hands to drag, rotate and scale the scene in XR.
* Squeeze both grip buttons (or make fists with both tracked hands - the engine emulates
* squeeze from the fist gesture) to grab the world. While grabbing, the world sticks to the
* hands:
* - Drag: move both hands together to pull the world in any direction, including vertically
* - Rotate: swing the hand pair to yaw-rotate the world around the hands (yaw only, so floors
* stay level)
* - Scale: change the distance between the hands to scale the world - spreading them apart
* grows it. Horizontally the content follows the hands; vertically the scale pivots at the
* user's feet by default ({@link XrManipulation#scalePivot}), so the user stays standing on
* the world instead of the floor rising or sinking through them. Separation changes within
* {@link XrManipulation#scaleThreshold} of the grab distance are ignored, so the incidental
* hand convergence that occurs while pushing or pulling the world does not zoom it
*
* The manipulation is applied to the {@link XrManipulation#target} entity, under which the scene
* content to manipulate should be parented. The camera rig is never modified, so the XR rig
* keeps its 1:1 real-world mapping - controller models, hand meshes, menus and any user
* scripts that measure hand-space distances are unaffected by the world being scaled.
*
* Hand motion is measured in the camera rig's local space, so rig locomotion (XrNavigation
* teleports, snap turns) occurring mid-grab does not drag the world along; the grab re-anchors
* against the moved rig instead.
*
* Notes:
* - {@link XrManipulation#minScale}/{@link XrManipulation#maxScale} bound the target's scale
* relative to its scale when the script initialized (1 = authored size).
* - The target keeps its manipulated transform when the XR session ends. Call
* {@link XrManipulation#reset} (or fire {@link XrManipulation#resetEvent}) to restore it.
* - The target's parent chain should be unrotated and unscaled. The target itself may have
* any transform; rotation stays yaw-only relative to its initial rotation.
* - World-anchored systems do not follow the target: XrNavigation's teleport ground plane
* ({@link XrNavigation#groundHeight}) stays where it is, so consider assigning
* {@link XrNavigation#castRay} if users can drag the world vertically. Physics colliders
* under the target rebuild on scale changes (mesh shapes) or ignore scale entirely
* (primitive shapes), so avoid parenting rigidbodies under the target where possible.
*
* Fires `xr:manipulation:active` (with a boolean) on the app when a grab starts or ends. Listens
* for `xr:menu:active` (fired by `XrMenu`) and blocks or ends grabs while a menu is open.
*
* The script can be attached to any entity (e.g. the camera rig alongside the other XR
* scripts). Use it alongside the `XrSession`, `XrControllers` and `XrNavigation` scripts.
*
* @example
* // Parent the scene content under a world root and grab-manipulate it
* const worldRoot = new Entity('WorldRoot');
* app.root.addChild(worldRoot);
* worldRoot.addChild(galleryEntity);
*
* cameraParent.addComponent('script');
* cameraParent.script.create(XrManipulation, {
* properties: {
* target: worldRoot,
* minScale: 0.5,
* maxScale: 4
* }
* });
* @category XR
*/
class XrManipulation extends Script {
static scriptName = 'xrManipulation';
/**
* The entity that is dragged, rotated and scaled by the grab gesture. Parent the scene
* content to manipulate under this entity. The camera rig is never modified.
* @type {Entity}
* @attribute
*/
target = null;
/**
* Enable dragging the world by moving both hands while grabbing.
* @attribute
*/
enableTranslate = true;
/**
* Enable yaw-rotating the world by swinging the hand pair while grabbing.
* @attribute
*/
enableRotate = true;
/**
* Enable scaling the world by changing the hand separation while grabbing.
* @attribute
*/
enableScale = true;
/**
* Dead zone for scale activation, as a fraction of the grab-start hand separation.
* Separation changes within this band do not scale the world - hands naturally drift
* together or apart while pushing or pulling the world, and without a dead zone that
* incidental drift reads as a pinch-zoom. Once the separation leaves the band, scaling
* engages continuously from the band's edge (no jump). Set to 0 to scale immediately.
* @attribute
* @range [0, 0.5]
* @precision 0.01
* @enabledif {enableScale}
*/
scaleThreshold = 0.15;
/**
* The vertical pivot used while scaling. One of:
* - `'feet'`: whatever sits at the user's floor level stays there while scaling - the
* user remains standing on the world, and a scene floor stays aligned with
* XrNavigation's teleport plane (default).
* - `'hands'`: the world scales symmetrically about the hand midpoint and sticks to the
* hands vertically - suits tabletop/diorama targets.
* Horizontal behavior is identical in both modes: content follows the hands in XZ.
* @attribute
* @enabledif {enableScale}
*/
scalePivot = 'feet';
/**
* Minimum scale of the target relative to its scale at initialization (1 is authored
* size). Grabbing cannot shrink the world below this factor.
* @attribute
* @range [0.01, 1]
* @precision 0.01
* @enabledif {enableScale}
*/
minScale = 0.2;
/**
* Maximum scale of the target relative to its scale at initialization (1 is authored
* size). Grabbing cannot grow the world beyond this factor.
* @attribute
* @range [1, 50]
* @precision 0.1
* @enabledif {enableScale}
*/
maxScale = 5;
/**
* App event that triggers {@link XrManipulation#reset}. Set to an empty string to disable.
* Read once at initialization.
* @type {string}
* @attribute
*/
resetEvent = 'xr:manipulation:reset';
/** @type {Set<XrInputSource>} */
_inputSources = new Set();
// True while both grips are held and the world is being manipulated
_grabbing = false;
/** @type {XrInputSource | null} */
_leftSource = null;
/** @type {XrInputSource | null} */
_rightSource = null;
/** @type {Entity | null} */
_grabTarget = null;
// Grab frame: the camera rig transform captured at grab start (and refreshed when the rig
// moves mid-grab). Hand poses are measured in rig space and mapped to world space through
// this frame, so rig locomotion does not move the grabbed world
_framePos = new Vec3();
_frameYaw = 0;
_frameScale = 1;
// Target state captured at grab start (or at the last re-anchor)
_targetPos0 = new Vec3();
_targetRot0 = new Quat();
_targetScale0 = new Vec3();
// Hand-pair reference captured at grab start (rig space): midpoint, separation, heading
_midPose0 = new Vec3();
_grabDist = 0;
/** @type {number | null} */
_grabHeading = null;
// Rig-space hand positions from the previous grabbed frame (incremental mode)
_prevLeft = new Vec3();
_prevRight = new Vec3();
// enableTranslate as of the previous grabbed frame, to detect mid-grab toggle flips
_prevTranslateEnabled = true;
// True while an XrMenu is open (grabs are blocked)
_menuActive = false;
// Event name captured from resetEvent at initialization, for symmetric unsubscription
_resetEventName = '';
// Baseline transform of the target for reset() and the min/max scale bounds, captured
// when the target is first seen (tracked so a runtime target swap recaptures it)
/** @type {Entity | null} */
_baselineTarget = null;
_initPosition = new Vec3();
_initRotation = new Quat();
_initScale = new Vec3(1, 1, 1);
initialize() {
if (!this.app.xr) {
console.error('XrManipulation script requires XR to be enabled on the application');
return;
}
if (!this.target) {
console.warn('XrManipulation: No target entity assigned. Grabbing will not work until one is set.');
}
if (!this.enableTranslate && !this.enableRotate && !this.enableScale) {
console.warn('XrManipulation: All manipulation modes are disabled. Grabbing will not work.');
}
this._captureBaseline(this.target);
const onInputAdd = (inputSource) => {
this._inputSources.add(inputSource);
};
this.app.xr.input.on('add', onInputAdd);
const onInputRemove = (inputSource) => {
this._inputSources.delete(inputSource);
if (this._grabbing && (inputSource === this._leftSource || inputSource === this._rightSource)) {
this._endGrab();
}
};
this.app.xr.input.on('remove', onInputRemove);
const onMenuActive = (active) => {
this._menuActive = !!active;
if (this._menuActive) {
this._endGrab();
}
};
this.app.on('xr:menu:active', onMenuActive);
this._resetEventName = this.resetEvent;
if (this._resetEventName) {
this.app.on(this._resetEventName, this.reset, this);
}
this.once('destroy', () => {
this.app.xr.input.off('add', onInputAdd);
this.app.xr.input.off('remove', onInputRemove);
this.app.off('xr:menu:active', onMenuActive);
if (this._resetEventName) {
this.app.off(this._resetEventName, this.reset, this);
}
this._inputSources.clear();
this._endGrab();
});
}
/**
* Restores the target to the transform it had when the script (or a newly assigned
* target) was first seen. Also fired by the {@link XrManipulation#resetEvent} app event.
*/
reset() {
this._endGrab();
const target = this._baselineTarget;
if (!target) return;
target.setPosition(this._initPosition);
target.setRotation(this._initRotation);
target.setLocalScale(this._initScale);
}
/**
* Captures the baseline transform of a newly seen target, used by {@link XrManipulation#reset}
* and as the reference for the {@link XrManipulation#minScale}/{@link XrManipulation#maxScale}
* bounds.
*
* @param {Entity | null} target - The target entity, or null.
* @private
*/
_captureBaseline(target) {
this._baselineTarget = target;
if (!target) return;
this._initPosition.copy(target.getPosition());
this._initRotation.copy(target.getRotation());
this._initScale.copy(target.getLocalScale());
}
/**
* Whether an input source currently provides a usable position for grabbing: a grip pose
* for controllers, or a tracked wrist joint for hands.
*
* @param {XrInputSource} inputSource - The input source to check.
* @returns {boolean} True when the input source can be grabbed with.
* @private
*/
_hasUsablePose(inputSource) {
return inputSource.grip || !!(inputSource.hand && inputSource.hand.tracking && inputSource.hand.wrist);
}
/**
* Whether a grabbed input source is still valid to keep grabbing with. Tracking quality is
* deliberately not checked here (only at grab start), so transient hand tracking loss
* holds the grab on frozen poses instead of dropping it.
*
* @param {XrInputSource | null} inputSource - The grabbed input source.
* @returns {boolean} True when the grab can continue with this input source.
* @private
*/
_canContinueGrab(inputSource) {
return !!inputSource && this._inputSources.has(inputSource) && inputSource.squeezing &&
(inputSource.grip || !!inputSource.hand);
}
/**
* Writes the rig-space position of an input source into `out`. Controllers expose this
* directly via their grip pose. Tracked hands only expose world space joint positions, so
* the wrist position is converted to rig space by inverting the rig's current translation
* + yaw + uniform scale (the transform the suite maintains on the rig).
*
* @param {XrInputSource} inputSource - The input source to read.
* @param {Vec3} out - The vector to write the rig-space position into.
* @returns {Vec3} The rig-space position (same object as `out`).
* @private
*/
_getRigSpacePosition(inputSource, out) {
if (inputSource.grip) {
out.copy(inputSource.getLocalPosition());
} else {
out.copy(inputSource.hand.wrist.getPosition());
const rig = this.app.xr.camera?.parent;
if (rig) {
const rigPos = rig.getPosition();
const forward = rig.forward;
const yaw = Math.atan2(-forward.x, -forward.z);
const scale = rig.getLocalScale().x || 1;
out.sub(rigPos);
rotateY(out, -yaw);
out.mulScalar(1 / scale);
}
}
return out;
}
/**
* Captures the grab frame from the camera rig's current transform. Rig-space hand
* positions are mapped to world space through this frame.
*
* @private
*/
_captureFrame() {
const rig = this.app.xr.camera?.parent;
if (rig) {
this._framePos.copy(rig.getPosition());
const forward = rig.forward;
this._frameYaw = Math.atan2(-forward.x, -forward.z);
this._frameScale = rig.getLocalScale().x || 1;
} else {
this._framePos.set(0, 0, 0);
this._frameYaw = 0;
this._frameScale = 1;
}
}
/**
* Whether the camera rig has moved since the grab frame was captured (e.g. an XrNavigation
* teleport or snap turn happened mid-grab).
*
* @returns {boolean} True when the rig transform no longer matches the grab frame.
* @private
*/
_frameStale() {
const rig = this.app.xr.camera?.parent;
if (!rig) return false;
const forward = rig.forward;
const yaw = Math.atan2(-forward.x, -forward.z);
// squared distance avoids a per-frame sqrt (1e-12 is the squared 1e-6 threshold)
const rigPos = rig.getPosition();
const dx = rigPos.x - this._framePos.x;
const dy = rigPos.y - this._framePos.y;
const dz = rigPos.z - this._framePos.z;
return dx * dx + dy * dy + dz * dz > 1e-12 || Math.abs(wrapPi(yaw - this._frameYaw)) > 1e-6;
}
/**
* Maps a rig-space position to world space through the grab frame, in place.
*
* @param {Vec3} v - The rig-space position to map in place.
* @returns {Vec3} The world-space position (same object as `v`).
* @private
*/
_frameToWorld(v) {
rotateY(v, this._frameYaw);
v.mulScalar(this._frameScale).add(this._framePos);
return v;
}
/**
* Captures the grab reference state: the target's current transform and the hand pair's
* rig-space midpoint, separation and heading. The solve maps this reference onto the
* current hand pair each frame. Recaptured whenever the applied transform deviated from
* the raw solve (scale clamped, or a component held due to degeneracy or rig motion),
* which prevents rubber-banding and heals degenerate headings.
*
* @param {Vec3} handLeft - The rig-space position of the first hand.
* @param {Vec3} handRight - The rig-space position of the second hand.
* @private
*/
_captureGrabState(handLeft, handRight) {
const target = this._grabTarget;
this._targetPos0.copy(target.getPosition());
this._targetRot0.copy(target.getRotation());
this._targetScale0.copy(target.getLocalScale());
this._midPose0.add2(handLeft, handRight).mulScalar(0.5);
tmpScratch.sub2(handRight, handLeft);
this._grabDist = tmpScratch.length();
const xz = Math.sqrt(tmpScratch.x * tmpScratch.x + tmpScratch.z * tmpScratch.z);
this._grabHeading = xz > EPS_XZ ? Math.atan2(tmpScratch.x, tmpScratch.z) : null;
}
/**
* Starts a two-handed grab with the given pair of input sources.
*
* @param {Entity} target - The target entity being manipulated.
* @param {XrInputSource} left - The first input source (left hand when available).
* @param {XrInputSource} right - The second input source (right hand when available).
* @private
*/
_startGrab(target, left, right) {
this._grabTarget = target;
this._leftSource = left;
this._rightSource = right;
this._captureFrame();
this._getRigSpacePosition(left, this._prevLeft);
this._getRigSpacePosition(right, this._prevRight);
this._captureGrabState(this._prevLeft, this._prevRight);
this._prevTranslateEnabled = this.enableTranslate;
this._grabbing = true;
this.app.fire('xr:manipulation:active', true);
}
/**
* Ends the active grab, if any. The target keeps its last applied transform.
*
* @private
*/
_endGrab() {
if (!this._grabbing) return;
this._leftSource = null;
this._rightSource = null;
this._grabTarget = null;
this._grabbing = false;
this.app.fire('xr:manipulation:active', false);
}
/**
* Applies the scale activation dead zone to a raw separation ratio: ratios within
* {@link XrManipulation#scaleThreshold} of 1 map to exactly 1, and ratios beyond the band
* are remapped relative to the band's edge so scaling engages without a jump.
*
* @param {number} ratio - The raw current/grab-start separation ratio.
* @returns {number} The dead-zoned scale ratio.
* @private
*/
_applyScaleDeadZone(ratio) {
const t = math.clamp(this.scaleThreshold, 0, 0.9);
if (t <= 0) return ratio;
if (ratio > 1 + t) return ratio / (1 + t);
if (ratio < 1 - t) return ratio / (1 - t);
return 1;
}
/**
* Clamps the relative scale factor so the target's total scale stays within
* {@link XrManipulation#minScale}/{@link XrManipulation#maxScale} of its baseline scale.
*
* @param {number} factor - The proposed relative scale factor.
* @param {number} currentScale - The target's current local X scale.
* @returns {number} The clamped relative factor.
* @private
*/
_clampScaleFactor(factor, currentScale) {
const base = this._initScale.x || 1;
const total = math.clamp((currentScale * factor) / base, this.minScale, this.maxScale);
return currentScale > 0 ? (total * base) / currentScale : 1;
}
/**
* @param {number} dt - The delta time in seconds.
*/
update(dt) {
// Track runtime target swaps regardless of XR state, so reset() always has a baseline
const target = this.target;
if (target !== this._baselineTarget) {
this._captureBaseline(target);
}
if (!this.app.xr?.active) {
this._endGrab();
return;
}
if (!target || this._menuActive || (!this.enableTranslate && !this.enableRotate && !this.enableScale)) {
this._endGrab();
return;
}
// Grab pair maintenance: the pair is fixed until broken; extra squeezing sources are
// ignored. squeezing is polled - the engine updates it before script update runs. A
// runtime target swap also ends the grab (the grab state belongs to the old target)
if (this._grabbing && (target !== this._grabTarget ||
!this._canContinueGrab(this._leftSource) || !this._canContinueGrab(this._rightSource))) {
this._endGrab();
}
if (!this._grabbing) {
let left = null;
let right = null;
let first = null;
let second = null;
for (const inputSource of this._inputSources) {
if (!inputSource.squeezing || !this._hasUsablePose(inputSource)) continue;
if (inputSource.handedness === 'left' && !left) {
left = inputSource;
} else if (inputSource.handedness === 'right' && !right) {
right = inputSource;
}
if (!first) {
first = inputSource;
} else if (!second) {
second = inputSource;
}
}
if (left && right) {
this._startGrab(target, left, right);
} else if (first && second) {
// Rare devices report 'none' handedness - fall back to the first two sources
this._startGrab(target, first, second);
}
// The first solve would reproduce the grab-start transform exactly - skip it
return;
}
this._getRigSpacePosition(this._leftSource, tmpHandLeft);
this._getRigSpacePosition(this._rightSource, tmpHandRight);
tmpHandMid.add2(tmpHandLeft, tmpHandRight).mulScalar(0.5);
tmpHandDelta.sub2(tmpHandRight, tmpHandLeft);
const sep = tmpHandDelta.length();
const sepXz = Math.sqrt(tmpHandDelta.x * tmpHandDelta.x + tmpHandDelta.z * tmpHandDelta.z);
const heading = sepXz > EPS_XZ ? Math.atan2(tmpHandDelta.x, tmpHandDelta.z) : null;
// If the rig moved mid-grab (teleport, snap turn, smooth locomotion), re-anchor
// against the new rig transform instead of dragging the world along with the rig
if (this._frameStale()) {
this._captureFrame();
this._captureGrabState(tmpHandLeft, tmpHandRight);
}
// A mid-grab translate toggle flip switches solve modes - re-anchor so the new mode
// continues from the applied transform seamlessly
if (this.enableTranslate !== this._prevTranslateEnabled) {
this._captureGrabState(tmpHandLeft, tmpHandRight);
this._prevTranslateEnabled = this.enableTranslate;
}
if (this.enableTranslate) {
this._solveAnchored(sep, heading);
} else {
this._solveIncremental(sep, sepXz);
}
this._prevLeft.copy(tmpHandLeft);
this._prevRight.copy(tmpHandRight);
}
/**
* Anchor solve: applies the world-space similarity that maps the grab-start hand pair
* onto the current hand pair to the target's grab-start transform. Drift-free - the
* grabbed content sticks to the hands exactly while unconstrained. Used while
* {@link XrManipulation#enableTranslate} is on. Reads the current hand poses from the
* module-scope temporaries filled by {@link XrManipulation#update}.
*
* @param {number} sep - Current 3D hand separation in rig space (meters).
* @param {number | null} heading - Current XZ heading of the hand pair, or null when
* degenerate (hands stacked vertically).
* @private
*/
_solveAnchored(sep, heading) {
let deltaYaw = 0;
let yawHeld = false;
if (this.enableRotate) {
if (heading !== null && this._grabHeading !== null) {
deltaYaw = wrapPi(heading - this._grabHeading);
} else {
yawHeld = true;
}
}
let scaleRel = 1;
let scaleHeld = false;
let scaleClamped = false;
if (this.enableScale) {
if (sep > EPS_SEPARATION && this._grabDist > EPS_SEPARATION) {
// Spreading the hands grows the world (pinch-zoom semantics). The dead
// zone keeps incidental separation drift during push/pull from zooming;
// in-band frames leave the grab reference untouched so a slow deliberate
// pinch can still accumulate its way out of the band
const effective = this._applyScaleDeadZone(sep / this._grabDist);
scaleRel = this._clampScaleFactor(effective, this._targetScale0.x);
scaleClamped = scaleRel !== effective;
} else {
scaleHeld = true;
}
}
// World-space grab points through the (frozen) grab frame
tmpMidWorld.copy(this._midPose0);
this._frameToWorld(tmpMidWorld);
tmpScratch.copy(tmpHandMid);
this._frameToWorld(tmpScratch);
// newPos = curMid + scaleRel * R(deltaYaw) * (targetPos0 - grabMid)
tmpSolvedPos.sub2(this._targetPos0, tmpMidWorld);
rotateY(tmpSolvedPos, deltaYaw);
tmpSolvedPos.mulScalar(scaleRel).add(tmpScratch);
// Feet pivot: pin the scale's vertical pivot to the user's floor level (the rig
// origin Y, i.e. the physical floor in local-floor space) so whatever is at the
// feet stays there while scaling. Vertical drag still passes through, and with
// scaleRel = 1 this is identical to the hands pivot
if (this.scalePivot !== 'hands') {
const feetY = this._framePos.y;
tmpSolvedPos.y = feetY + scaleRel * (this._targetPos0.y - feetY) + (tmpScratch.y - tmpMidWorld.y);
}
tmpQuat.setFromEulerAngles(0, deltaYaw * math.RAD_TO_DEG, 0);
tmpQuatB.mul2(tmpQuat, this._targetRot0);
this._grabTarget.setPosition(tmpSolvedPos);
this._grabTarget.setRotation(tmpQuatB);
this._grabTarget.setLocalScale(
this._targetScale0.x * scaleRel,
this._targetScale0.y * scaleRel,
this._targetScale0.z * scaleRel
);
// Whenever the applied transform deviated from the raw solve, rebase the grab
// reference on it: releasing a clamp responds immediately and degenerate headings
// self-heal once the hands regain XZ separation
if (yawHeld || scaleHeld || scaleClamped) {
this._captureGrabState(tmpHandLeft, tmpHandRight);
}
}
/**
* Incremental pivot solve: without the translation degree of freedom there is no fixed
* anchor - applies each frame's delta rotation/scale about the current world hand
* midpoint, so zoom and rotation pivot on the hands without dragging. Used while
* {@link XrManipulation#enableTranslate} is off. Reads the current hand poses from the
* module-scope temporaries filled by {@link XrManipulation#update}.
*
* @param {number} sep - Current 3D hand separation in rig space (meters).
* @param {number} sepXz - XZ-plane projection of the hand separation (meters).
* @private
*/
_solveIncremental(sep, sepXz) {
const curScale = this._grabTarget.getLocalScale();
tmpPrevDelta.sub2(this._prevRight, this._prevLeft);
const sepPrevXz = Math.sqrt(tmpPrevDelta.x * tmpPrevDelta.x + tmpPrevDelta.z * tmpPrevDelta.z);
let deltaYaw = 0;
if (this.enableRotate && sepXz > EPS_XZ && sepPrevXz > EPS_XZ) {
deltaYaw = wrapPi(Math.atan2(tmpHandDelta.x, tmpHandDelta.z) - Math.atan2(tmpPrevDelta.x, tmpPrevDelta.z));
}
// Scale is solved absolutely against the grab-start separation (so the dead zone
// has a stable reference), then converted to a per-frame ratio for the pivot math
let scaleRatio = 1;
if (this.enableScale && sep > EPS_SEPARATION && this._grabDist > EPS_SEPARATION && curScale.x > 0) {
const effective = this._applyScaleDeadZone(sep / this._grabDist);
const desired = this._clampScaleFactor(effective, this._targetScale0.x) * this._targetScale0.x;
scaleRatio = desired / curScale.x;
}
// Pivot: the current hand midpoint in world space through the grab frame. With
// the feet pivot, the vertical component moves to the user's floor level so
// scaling never lifts or sinks the floor underfoot
tmpMidWorld.copy(tmpHandMid);
this._frameToWorld(tmpMidWorld);
if (this.scalePivot !== 'hands') {
tmpMidWorld.y = this._framePos.y;
}
tmpSolvedPos.sub2(this._grabTarget.getPosition(), tmpMidWorld);
rotateY(tmpSolvedPos, deltaYaw);
tmpSolvedPos.mulScalar(scaleRatio).add(tmpMidWorld);
tmpQuat.setFromEulerAngles(0, deltaYaw * math.RAD_TO_DEG, 0);
tmpQuatB.mul2(tmpQuat, this._grabTarget.getRotation());
this._grabTarget.setPosition(tmpSolvedPos);
this._grabTarget.setRotation(tmpQuatB);
this._grabTarget.setLocalScale(
curScale.x * scaleRatio,
curScale.y * scaleRatio,
curScale.z * scaleRatio
);
}
}
export { XrManipulation };