All files / src/components/VirtualWalkthrough xr-start-pose.ts

100% Statements 21/21
91.66% Branches 11/12
100% Functions 5/5
100% Lines 21/21

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97                                                                                        2x 2x     2x     2x 16x   16x             2x 30x             2x 10x     2x 17x 17x 17x   17x                   2x 17x 17x 17x 17x   17x    
/**
 * Where to put the XR rig so the head starts at a chosen spot facing a chosen point.
 *
 * PlayCanvas writes the head pose into the camera entity's *local* transform every frame, so the
 * only thing a scene can move is the rig the camera hangs off. The head sits at an arbitrary offset
 * within the rig — the reference space is pinned to wherever the user physically stood when the
 * session began — so the rig pose has to be solved backwards from the head pose rather than set to
 * the target outright.
 *
 * All angles are degrees and all coordinates world-space XZ; Y is left to the caller, since the
 * user stands on the rig floor rather than on the scene's ground plane.
 */
import { clampToCircle } from './xr-scene-bounds';
 
export type Point2 = { x: number; z: number };
 
export type XrStartBounds = Point2 & { radius: number };
 
export type XrStartPoseInput = {
  /** Where the head is now. */
  head: Point2;
  /** Yaw of the head's forward direction now. */
  headYaw: number;
  /** Where the rig is now. */
  rig: Point2;
  /** Where the head should end up. */
  target: Point2;
  /** What the head should face from there. */
  focus: Point2;
  /**
   * Circle to keep the target inside. Omit (or pass a non-positive radius) to place the head on the
   * target as given.
   */
  bounds?: XrStartBounds;
};
 
export type XrStartPose = {
  /** Yaw to turn the rig by, about its own origin. */
  yawDelta: number;
  /** Where the rig ends up once turned. */
  x: number;
  z: number;
};
 
const RAD_TO_DEG = 180 / Math.PI;
const DEG_TO_RAD = Math.PI / 180;
 
/** Below this length a direction vector carries no usable heading. */
const DIRECTION_EPSILON = 1e-6;
 
/** Wraps to (-180, 180] so the rig always turns the short way round. */
const normalizeAngle = (degrees: number): number => {
  const wrapped = ((((degrees + 180) % 360) + 360) % 360) - 180;
 
  return wrapped === -180 ? 180 : wrapped;
};
 
/**
 * Yaw of a heading on the XZ plane, matching PlayCanvas' right-handed CCW Y rotation:
 * forward = (-sin(yaw), 0, -cos(yaw)).
 */
export const yawFromDirection = (dx: number, dz: number): number | null =>
  Math.abs(dx) < DIRECTION_EPSILON && Math.abs(dz) < DIRECTION_EPSILON ? null : Math.atan2(-dx, -dz) * RAD_TO_DEG;
 
/**
 * Yaw an entity is facing, from its world basis vectors. Falls back to the right vector when the
 * forward vector is vertical — a head tipped fully up or down still has a heading, but its forward
 * vector has no horizontal component to read it from.
 */
export const yawFromBasis = (forward: { x: number; z: number }, right: { x: number; z: number }): number =>
  yawFromDirection(forward.x, forward.z) ?? Math.atan2(-right.z, right.x) * RAD_TO_DEG;
 
/** Rotates a point about the origin by `degrees` of PlayCanvas yaw. */
const rotateXz = (point: Point2, degrees: number): Point2 => {
  const radians = degrees * DEG_TO_RAD;
  const cos = Math.cos(radians);
  const sin = Math.sin(radians);
 
  return { x: point.x * cos + point.z * sin, z: -point.x * sin + point.z * cos };
};
 
/**
 * Solves for the rig pose that lands the head on `target` facing `focus`.
 *
 * Turning the rig swings the head around the rig's origin, so the turn is applied to the head's
 * offset within the rig first and the rig is then placed so that swung offset lands on the target.
 * A focus point on top of the target gives no heading, so the rig is only moved, not turned.
 */
export const xrStartRigPose = ({ head, headYaw, rig, target, focus, bounds }: XrStartPoseInput): XrStartPose => {
  const landing = bounds ? clampToCircle(target.x, target.z, bounds.x, bounds.z, bounds.radius) : target;
  const facingYaw = yawFromDirection(focus.x - landing.x, focus.z - landing.z);
  const yawDelta = facingYaw === null ? 0 : normalizeAngle(facingYaw - headYaw);
  const offset = rotateXz({ x: head.x - rig.x, z: head.z - rig.z }, yawDelta);
 
  return { yawDelta, x: landing.x - offset.x, z: landing.z - offset.z };
};