File size: 9,526 Bytes
970fc3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
const HAND_SIDES = Object.freeze(["left", "right"]);
const half = Math.SQRT1_2;
const HAND_GRIP_BASIS = Object.freeze({ left: Object.freeze([half, 0, half, 0]), right: Object.freeze([half, 0, -half, 0]) });
const fittedRotation = (side, degrees) => {
  const [x, y, z, w] = HAND_GRIP_BASIS[side], s = Math.sin(degrees * Math.PI / 360), c = Math.cos(degrees * Math.PI / 360);
  return [c * x + s * w, c * y - s * z, c * z + s * y, c * w - s * x];
};
const HANDS_METADATA = Object.freeze({
  version: 1,
  modelId: "whitebox-controller-hands",
  modelVersion: 5,
  space: "content-world",
  units: "meters",
  poseConvention: "palm-after-grip-offset",
  clock: "animation-frame-ms",
  grasp: "controller-fingers",
  calibration: "default-unmeasured",
  appearance: Object.freeze({ color: "#58a6d8" }),
  dimensions: Object.freeze({ palmWidth: 0.078, palmLength: 0.086, palmThickness: 0.03, wristLength: 0.04, fingerRadius: 8e-3 }),
  fixedPose: Object.freeze({ grip: 0.65, trigger: 0.15 }),
  gripToPalm: Object.freeze({
    // WebXR: grip +Y points toward the arm, -Z toward the thumb, and
    // the back of the hand faces -X (left) / +X (right). Model +Y is
    // distal and +Z is palmar. The grip origin is inside the curled fist.
    left: Object.freeze({ positionMeters: Object.freeze([-0.022, 0.012, 0.016]), quaternionXYZW: Object.freeze(fittedRotation("left", 45)) }),
    right: Object.freeze({ positionMeters: Object.freeze([0.022, 0.012, 0.016]), quaternionXYZW: Object.freeze(fittedRotation("right", 45)) })
  }),
  armModel: Object.freeze({
    version: 1,
    solver: "two-bone-pole-1",
    tracking: "head-and-grips-inferred-shoulders-elbows",
    shoulderWidth: 0.38,
    headToShoulderDrop: 0.24,
    headToShoulderBack: 0.07,
    upperArmLength: 0.29,
    forearmLength: 0.26,
    maxShoulderShift: 0.06,
    upperArmRadius: 0.043,
    forearmRadius: 0.033,
    palmToWrist: Object.freeze([0, -0.062, 0]),
    headYawDeadband: 0.96,
    maxTorsoTurnSpeed: 0.65,
    poleSmoothing: 8
  })
});
const vector = (v, n) => Array.isArray(v) && v.length === n && v.every(Number.isFinite);
const keys = (o, allowed) => o && typeof o === "object" && !Array.isArray(o) && Object.keys(o).every((k) => allowed.includes(k));
const unit = (n) => Number.isFinite(n) && n >= 0 && n <= 1;
function validHandPose(p, maxPosition = 100) {
  return keys(p, ["positionMeters", "quaternionXYZW"]) && vector(p.positionMeters, 3) && p.positionMeters.every((n) => Math.abs(n) <= maxPosition) && vector(p.quaternionXYZW, 4) && Math.abs(Math.hypot(...p.quaternionXYZW) - 1) < 1e-3;
}
function emptyHands(sampleId = 0, frameTimeMs = 0) {
  return { sampleId, frameTimeMs, hands: HAND_SIDES.map((side) => ({ side, tracked: false, pose: null, grip: 0, trigger: 0, thumb: 0 })) };
}
function validateHandsSample(sample, metadata) {
  if (!keys(sample, ["sampleId", "frameTimeMs", "hands", "arms"]) || !Number.isSafeInteger(sample.sampleId) || sample.sampleId < 0 || !Number.isFinite(sample.frameTimeMs) || sample.frameTimeMs < 0 || !Array.isArray(sample.hands) || sample.hands.length !== 2) throw new Error("Invalid hand sample");
  for (const [i, hand] of sample.hands.entries()) {
    if (!keys(hand, ["side", "tracked", "pose", "grip", "trigger", "thumb"]) || hand.side !== HAND_SIDES[i] || typeof hand.tracked !== "boolean" || !unit(hand.grip) || !unit(hand.trigger) || (hand.thumb !== void 0 || metadata?.modelVersion >= 4) && !unit(hand.thumb) || (hand.tracked ? !validHandPose(hand.pose) : hand.pose !== null)) throw new Error("Invalid hand pose");
  }
  if (sample.arms !== void 0) validateArmsSample(sample, metadata);
  return sample;
}
function validateHandsMetadata(meta) {
  if (!keys(meta, Object.keys(HANDS_METADATA)) || ![1, 2, 3, 4, 5].includes(meta.modelVersion) || ["version", "modelId", "space", "units", "poseConvention", "clock"].some((k) => meta[k] !== HANDS_METADATA[k]) || meta.grasp !== (meta.modelVersion >= 4 ? "controller-fingers" : "fixed-natural") || !["default-unmeasured", "user-adjusted", "device-calibrated"].includes(meta.calibration)) throw new Error("Invalid hand model version");
  if (meta.appearance !== void 0 || meta.modelVersion >= 4) {
    if (!keys(meta.appearance, ["color"]) || !/^#[\da-f]{6}$/i.test(meta.appearance.color)) throw new Error("Invalid hand model colors");
  }
  if (!keys(meta.dimensions, Object.keys(HANDS_METADATA.dimensions)) || Object.keys(HANDS_METADATA.dimensions).some((k) => !Number.isFinite(meta.dimensions[k]) || meta.dimensions[k] < 3e-3 || meta.dimensions[k] > 0.25) || !keys(meta.fixedPose, ["grip", "trigger"]) || !unit(meta.fixedPose.grip) || !unit(meta.fixedPose.trigger) || !keys(meta.gripToPalm, HAND_SIDES) || HAND_SIDES.some((side) => !validHandPose(meta.gripToPalm[side], 0.25))) throw new Error("Invalid hand model configuration");
  if (meta.armModel !== void 0) {
    const a = meta.armModel, defaults = HANDS_METADATA.armModel;
    if (meta.modelVersion < 2 || !keys(a, Object.keys(defaults)) || ["version", "solver", "tracking"].some((k) => a[k] !== defaults[k]) || !vector(a.palmToWrist, 3) || a.palmToWrist.some((n) => Math.abs(n) > 0.25) || Object.keys(defaults).filter((k) => typeof defaults[k] === "number" && k !== "version").some((k) => !Number.isFinite(a[k]) || a[k] <= 0 || a[k] > (k === "poleSmoothing" ? 30 : k === "headYawDeadband" ? Math.PI : k === "maxTorsoTurnSpeed" ? 3 : 1)) || a.maxShoulderShift > 0.12) throw new Error("Invalid arm model configuration");
  }
  return meta;
}
function emptyArms() {
  return { torsoYawRadians: null, joints: HAND_SIDES.map((side) => ({ side, status: "untracked", shoulder: null, elbow: null, wrist: null, shoulderShiftMeters: 0, reachErrorMeters: 0 })) };
}
function validateArmsSample(sample, metadata) {
  const a = sample.arms, point = (p) => vector(p, 3) && p.every((n) => Math.abs(n) <= 100), distance = (p, q) => Math.hypot(...p.map((n, i) => n - q[i]));
  if (metadata && !metadata.armModel) throw new Error("Arm model configuration is missing");
  if (!keys(a, ["torsoYawRadians", "joints"]) || !(a.torsoYawRadians === null || Number.isFinite(a.torsoYawRadians) && Math.abs(a.torsoYawRadians) <= Math.PI) || !Array.isArray(a.joints) || a.joints.length !== 2) throw new Error("Invalid arm sample");
  for (const [i, j] of a.joints.entries()) {
    if (!keys(j, ["side", "status", "shoulder", "elbow", "wrist", "shoulderShiftMeters", "reachErrorMeters"]) || j.side !== HAND_SIDES[i] || !["untracked", "solved", "shoulder-adjusted", "forearm-only"].includes(j.status) || !Number.isFinite(j.shoulderShiftMeters) || j.shoulderShiftMeters < 0 || j.shoulderShiftMeters > 0.120001 || !Number.isFinite(j.reachErrorMeters) || j.reachErrorMeters < 0 || j.reachErrorMeters > 200) throw new Error("Invalid arm joint");
    if (j.status === "untracked") {
      if (j.shoulder !== null || j.elbow !== null || j.wrist !== null || j.shoulderShiftMeters !== 0 || j.reachErrorMeters !== 0) throw new Error("Untracked arms must be hidden");
      continue;
    }
    if (!sample.hands[i].tracked || a.torsoYawRadians === null || ![j.shoulder, j.elbow, j.wrist].every(point)) throw new Error("Invalid arm tracking");
    if (metadata) {
      const m = metadata.armModel, p = sample.hands[i].pose, [x, y, z, w] = p.quaternionXYZW, [vx, vy, vz] = m.palmToWrist;
      const tx = 2 * (y * vz - z * vy), ty = 2 * (z * vx - x * vz), tz = 2 * (x * vy - y * vx);
      const wrist = [vx + w * tx + y * tz - z * ty, vy + w * ty + z * tx - x * tz, vz + w * tz + x * ty - y * tx].map((n, k) => n + p.positionMeters[k]);
      if (distance(wrist, j.wrist) > 1e-4) throw new Error("Wrist and hand poses do not match");
      if (j.shoulderShiftMeters > m.maxShoulderShift + 1e-5 || Math.abs(distance(j.elbow, j.wrist) - m.forearmLength) > 1e-4 || j.status !== "forearm-only" && (Math.abs(distance(j.shoulder, j.elbow) - m.upperArmLength) > 1e-4 || j.reachErrorMeters > 1e-4)) throw new Error("Invalid arm length");
    }
  }
}
function handMetadataForSize(scale = 1, armScale = 1, gripTilt = 45, presentationScale = 1) {
  if (!Number.isFinite(scale) || scale < 0.7 || scale > 1.3) throw new Error("Hand size must be between 70% and 130%");
  if (!Number.isFinite(armScale) || armScale < 0.8 || armScale > 1.2) throw new Error("Arm length must be between 80% and 120%");
  if (!Number.isFinite(gripTilt) || gripTilt < 0 || gripTilt > 90) throw new Error("Grip tilt must be between 0° and 90°");
  if (!Number.isFinite(presentationScale) || presentationScale < 0.5 || presentationScale > 1.5) throw new Error("Invalid body display scale");
  scale *= presentationScale;
  armScale *= presentationScale;
  const m = structuredClone(HANDS_METADATA);
  for (const k of Object.keys(m.dimensions)) m.dimensions[k] *= scale;
  for (const side of HAND_SIDES) {
    m.gripToPalm[side].positionMeters = m.gripToPalm[side].positionMeters.map((n) => n * scale);
    m.gripToPalm[side].quaternionXYZW = fittedRotation(side, gripTilt);
  }
  m.armModel.palmToWrist = m.armModel.palmToWrist.map((n) => n * scale);
  m.armModel.headToShoulderDrop = 0.21;
  m.armModel.upperArmRadius *= presentationScale;
  m.armModel.forearmRadius *= presentationScale;
  m.armModel.upperArmLength *= armScale;
  m.armModel.forearmLength *= armScale;
  if (scale !== 1 || armScale !== 1 || gripTilt !== 45) m.calibration = "user-adjusted";
  return m;
}
export {
  HANDS_METADATA,
  HAND_GRIP_BASIS,
  HAND_SIDES,
  emptyArms,
  emptyHands,
  handMetadataForSize,
  validHandPose,
  validateArmsSample,
  validateHandsMetadata,
  validateHandsSample
};