1// Interactive robots for the "Robotic Platforms" section. 2// Each <div class="robot-viewer" data-robot="..."> gets a three.js scene. Arms idle and then follow the 3// cursor with their end-effector (CCD inverse kinematics); Spaleka gallops, driven by the cursor. 4import * as THREE from 'three'; 5import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; 6import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; 7import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; 8 9const DEG = Math.PI / 180; 10 11// Kinematic trees in the robot's own (z-up) frame. Each body sits at `pos`/`quat` (w, x, y, z) in its 12// parent's frame; bodies with an `axis` are revolute joints rotating about that axis in their own frame. 13// Meshes in the GLB are named "<body>__<palette key>" and are expressed in their body's frame. 14const ROBOTS = { 15 // KUKA LBR iiwa 7 R800, from lbr_description/urdf/iiwa7 (lbr-stack/lbr_fri_ros2_stack) 16 iiwa7: { 17 model: 'assets/models/iiwa7.glb', 18 palette: { body: 0xeeeeeb, light: 0xc4c8cc, accent: 0xff7a00, dark: 0x3a3a3a }, 19 bodies: [ 20 { name: 'link_0' }, 21 { name: 'link_1', parent: 'link_0', pos: [0, 0, 0.1475], axis: [0, 0, 1], range: [-170 * DEG, 170 * DEG] }, 22 { name: 'link_2', parent: 'link_1', pos: [0, -0.0105, 0.1925], axis: [0, 1, 0], range: [-120 * DEG, 120 * DEG] }, 23 { name: 'link_3', parent: 'link_2', pos: [0, 0.0105, 0.2075], axis: [0, 0, 1], range: [-170 * DEG, 170 * DEG] }, 24 { name: 'link_4', parent: 'link_3', pos: [0, 0.0105, 0.1925], axis: [0, -1, 0], range: [-120 * DEG, 120 * DEG] }, 25 { name: 'link_5', parent: 'link_4', pos: [0, -0.0105, 0.2075], axis: [0, 0, 1], range: [-170 * DEG, 170 * DEG] }, 26 { name: 'link_6', parent: 'link_5', pos: [0, -0.0707, 0.1925], axis: [0, 1, 0], range: [-120 * DEG, 120 * DEG] }, 27 { name: 'link_7', parent: 'link_6', pos: [0, 0.0707, 0.091], axis: [0, 0, 1], range: [-175 * DEG, 175 * DEG], ik: false }, 28 ], 29 home: [0, 30 * DEG, 0, -65 * DEG, 0, 35 * DEG, 0], 30 tip: { body: 'link_7', offset: [0, 0, 0.035] }, 31 reach: 0.3, 32 yaw: 0.05, 33 }, 34 // Franka Emika Panda, from google-deepmind/mujoco_menagerie/franka_emika_panda 35 panda: { 36 model: 'assets/models/panda.glb', 37 palette: { body: 0xf4f5f6, light: 0xe2e6e9, accent: 0x0a8ac7, dark: 0x353638 }, 38 bodies: [ 39 { name: 'link0' }, 40 { name: 'link1', parent: 'link0', pos: [0, 0, 0.333], axis: [0, 0, 1], range: [-2.8973, 2.8973] }, 41 { name: 'link2', parent: 'link1', quat: [1, -1, 0, 0], axis: [0, 0, 1], range: [-1.7628, 1.7628] }, 42 { name: 'link3', parent: 'link2', pos: [0, -0.316, 0], quat: [1, 1, 0, 0], axis: [0, 0, 1], range: [-2.8973, 2.8973] }, 43 { name: 'link4', parent: 'link3', pos: [0.0825, 0, 0], quat: [1, 1, 0, 0], axis: [0, 0, 1], range: [-3.0718, -0.0698] }, 44 { name: 'link5', parent: 'link4', pos: [-0.0825, 0.384, 0], quat: [1, -1, 0, 0], axis: [0, 0, 1], range: [-2.8973, 2.8973] }, 45 { name: 'link6', parent: 'link5', quat: [1, 1, 0, 0], axis: [0, 0, 1], range: [-0.0175, 3.7525] }, 46 { name: 'link7', parent: 'link6', pos: [0.088, 0, 0], quat: [1, 1, 0, 0], axis: [0, 0, 1], range: [-2.8973, 2.8973], ik: false }, 47 { name: 'hand', parent: 'link7', pos: [0, 0, 0.107], quat: [0.9238795, 0, 0, -0.3826834] }, 48 { name: 'left_finger', parent: 'hand', pos: [0, 0.03, 0.0584] }, 49 { name: 'right_finger', parent: 'hand', pos: [0, -0.03, 0.0584], quat: [0, 0, 0, 1] }, 50 ], 51 home: [0, 0.2, 0, -1.9, 0, 2.0, 0.785], 52 tip: { body: 'hand', offset: [0, 0, 0.1034] }, 53 reach: 0.25, 54 yaw: 0.05, 55 }, 56 // AgileX PiPER, from google-deepmind/mujoco_menagerie/agilex_piper 57 piper: { 58 model: 'assets/models/piper.glb', 59 palette: { body: 0xa9acb0, light: 0xdadada, accent: 0xd12626, dark: 0x2b2b2d }, 60 bodies: [ 61 { name: 'base_link' }, 62 { name: 'link1', parent: 'base_link', pos: [0, 0, 0.123], quat: [0.707105, 0, 0, -0.707108], axis: [0, 0, 1], range: [-2.618, 2.618] }, 63 { name: 'link2', parent: 'link1', quat: [0.499998, 0.5, -0.500002, -0.5], axis: [0, 0, 1], range: [0, 3.14] }, 64 { name: 'link3', parent: 'link2', pos: [0.28358, 0.028726, 0], quat: [0.998726, 0, 0, 0.0504536], axis: [0, 0, 1], range: [-2.697, 0] }, 65 { name: 'link4', parent: 'link3', pos: [-0.24221, 0.068514, 0], quat: [0.544767, -0.544769, -0.450809, 0.450808], axis: [0, 0, 1], range: [-1.832, 1.832] }, 66 { name: 'link5', parent: 'link4', quat: [0.707105, 0.707108, 0, 0], axis: [0, 0, 1], range: [-1.22, 1.22] }, 67 { name: 'link6', parent: 'link5', pos: [0, 0.091, 0.0014165], quat: [0, 0, -0.707105, -0.707108], axis: [0, 0, 1], range: [-3.14, 3.14], ik: fal
67se }, 68 { name: 'link7', parent: 'link6', pos: [0, 0, 0.13503], quat: [1.89468e-08, 1.89469e-08, 0.707108, 0.707105] }, 69 { name: 'link8', parent: 'link6', pos: [0, 0, 0.13503], quat: [1.89468e-08, -1.89469e-08, -0.707108, 0.707105] }, 70 ], 71 home: [0, 1.25, -1.05, 0, 0.75, 0], 72 tip: { body: 'link6', offset: [0, 0, 0.15] }, 73 reach: 0.14, 74 yaw: -0.35, 75 }, 76 // Spaleka half-cheetah, from CheetahTailLab spaleka_control.xml (MuJoCo model of the lab's robot). 77 // Each leg is a closed five-bar linkage: two hip motors drive two links that meet at the foot, so 78 // the gait drives foot positions and `solveLeg` works the joint angles back out. 79 spaleka: { 80 model: 'assets/models/spaleka.glb', 81 palette: { body: 0x8f959c, light: 0xb2b8be, accent: 0xd8dce0, dark: 0x27292c }, 82 bodies: [ 83 { name: 'spine_roll_link', pos: [-0.0095, 0.0, 0.395], quat: [0.70711, -0.0, 0.70711, -0.0], joint: 'spine_roll', axis: [0.0, 0.0, 1.0], range: [-0.6, 0.6] }, 84 { name: 'rod_right', parent: 'spine_roll_link', pos: [-0.02831, -0.01626, 0.07163], quat: [0.39104, 0.59291, -0.49877, -0.49677] }, 85 { name: 'rod_left', parent: 'spine_roll_link', pos: [-0.02831, 0.01626, 0.07163], quat: [0.57303, 0.37123, 0.51958, 0.51368] }, 86 { name: 'spine_universal_link', parent: 'spine_roll_link', pos: [0.0, 0.0, 0.104], quat: [0.5, -0.5, -0.5, 0.5], joint: 'spine_yaw', axis: [0.0, 0.0, 1.0], range: [-0.65, 0.65] }, 87 { name: 'hindquarters', parent: 'spine_universal_link', pos: [0.079, -0.065, 0.0], quat: [0.48146, 0.51788, 0.48146, 0.51788], joint: 'spine_pitch', axis: [0.0, 0.0, -1.0], range: [-0.65, 0.65] }, 88 { name: 'upper_leg_m1', parent: 'hindquarters', pos: [-0.258, -0.025, 0.006], quat: [-0.21901, 0.0, -0.0, 0.97572], joint: 'leg_m1', axis: [0.0, 0.0, 1.0], range: [-1.2, 1.2] }, 89 { name: 'lower_m1', parent: 'upper_leg_m1', pos: [0.1745, 0.0, 0.034], quat: [-0.22188, 0.0, 0.0, 0.97507], joint: 'knee_m1', axis: [0.0, 0.0, 1.0], range: [-1.4, 1.4] }, 90 { name: 'upper_leg_m2', parent: 'hindquarters', pos: [-0.138, -0.025, 0.006], quat: [0.98877, -0.0, 0.0, -0.14944], joint: 'leg_m2', axis: [0.0, 0.0, 1.0], range: [-1.2, 1.2] }, 91 { name: 'lower_m2', parent: 'upper_leg_m2', pos: [0.1745, -0.0, 0.034], quat: [0.96903, -0.0, -0.0, -0.24695], joint: 'knee_m2', axis: [0.0, 0.0, 1.0], range: [-1.4, 1.4] }, 92 { name: 'upper_leg_m3', parent: 'hindquarters', pos: [-0.258, -0.025, -0.164], quat: [-0.0, 0.97572, 0.21901, -0.0], joint: 'leg_m3', axis: [0.0, 0.0, 1.0], range: [-1.2, 1.2] }, 93 { name: 'lower_m3', parent: 'upper_leg_m3', pos: [-0.1745, 0.0, 0.034], quat: [0.22188, 0.0, 0.0, 0.97507], joint: 'knee_m3', axis: [0.0, 0.0, 1.0], range: [-1.4, 1.4] }, 94 { name: 'upper_leg_m4', parent: 'hindquarters', pos: [-0.138, -0.025, -0.164], quat: [-0.0, 0.14944, 0.98877, -0.0], joint: 'leg_m4', axis: [0.0, 0.0, 1.0], range: [-1.2, 1.2] }, 95 { name: 'lower_m4', parent: 'upper_leg_m4', pos: [-0.1745, -0.0, 0.034], quat: [0.0, -0.24695, 0.96903, 0.0], joint: 'knee_m4', axis: [0.0, 0.0, 1.0], range: [-1.4, 1.4] }, 96 { name: 'spine_motor_left_pulley', parent: 'hindquarters', pos: [0.0, -0.105, -0.018], joint: 'spine_motor_left', axis: [0.0, 0.0, 1.0] }, 97 { name: 'spine_motor_right_pulley', parent: 'hindquarters', pos: [0.0, -0.105, -0.14], quat: [0.0, -0.0, 1.0, -0.0], joint: 'spine_motor_right', axis: [0.0, 0.0, 1.0] }, 98 { name: 'tail_gimbal', parent: 'hindquarters', pos: [-0.3355, 0.095, -0.109], quat: [-0.70711, 0.0, -0.0, 0.70711], joint: 'tail_pitch', axis: [0.0, 0.0, 1.0], range: [-1.57, 1.57] }, 99 { name: 'tail', parent: 'tail_gimbal', pos: [0.0, -0.0323, 0.042], quat: [-0.0, 0.0, -0.70711, 0.70711], joint: 'tail_roll', axis: [0.0, 0.0, 1.0], range: [-1.57, 1.57] }, 100 { name: 'spine_crank_left_link', parent: 'spine_universal_link', pos: [0.0685, -0.065, 0.0], quat: [0.48146, 0.51788, 0.48146, 0.51788], joint: 'spine_crank_left', axis: [0.0, 0.0, 1.0] }, 101 { name: 'spine_crank_right_link', parent: 'spine_universal_link', pos: [-0.0685, -0.065, 0.0], quat: [0.48146, 0.51788, 0.48146, 0.51788], joint: 'spine_crank_right', axis: [0.0, 0.0, 1.0] }, 102 ], 103 yaw: 0.05, 104 cameraDir: [0.3, 0.3, 1.35], 105 exposure: 0.8, 106 fill: 0.88, // tight crop;
106 the tail tip may reach the edge mid-stride 107 gait: { 108 drop: 0.0964, // sink the robot so the neutral stance meets the ground plane 109 tailBase: 0.95, // resting tail pitch (the MJCF zero pose stands the tail straight up) 110 stance: 0.42, // fraction of the cycle a foot spends on the ground 111 lead: 0.14, // phase offset between the two legs (a bounding gallop) 112 // Five-bar legs, solved in their own plane: `p` hip positions, `foot` the neutral foot point, 113 // `up` which way is up in plane coordinates. a0/b0 are the zero-pose link directions. 114 legs: [ 115 { 116 foot: [0.055, -0.33], up: -1, 117 branches: [ 118 { hip: 'leg_m1', knee: 'knee_m1', p: [0.0, 0.0], L1: 0.1745, L2: 0.2955, a0: -2.7, b0: -0.68171, elbow: -1, kneeSign: 1 }, 119 { hip: 'leg_m2', knee: 'knee_m2', p: [0.12, 0.0], L1: 0.1745, L2: 0.3, a0: -0.3, b0: -2.36987, elbow: 1, kneeSign: 1 }, 120 ], 121 }, 122 { 123 foot: [0.055, 0.33], up: 1, 124 branches: [ 125 { hip: 'leg_m3', knee: 'knee_m3', p: [0.0, 0.0], L1: 0.1745, L2: 0.2955, a0: 2.7, b0: 0.68171, elbow: 1, kneeSign: 1 }, 126 { hip: 'leg_m4', knee: 'knee_m4', p: [0.12, 0.0], L1: 0.1745, L2: 0.3, a0: 0.3, b0: 2.36987, elbow: -1, kneeSign: -1 }, 127 ], 128 }, 129 ], 130 // The differential rods are a closed loop too: point each rod at the crank it connects to. 131 aims: [ 132 { body: 'rod_right', axis: [0, 1, 0], length: 0.106, target: { body: 'spine_crank_right_link', point: [-0.00067, 0.05, 0.03139] } }, 133 { body: 'rod_left', axis: [0, 1, 0], length: 0.106, target: { body: 'spine_crank_left_link', point: [-0.00067, 0.05, -0.03139] } }, 134 ], 135 }, 136 }, 137}; 138const DEFAULT_CAMERA_DIR = [1.05, 0.5, 1.25]; // y-up world, looking from front-right 139const IDLE_AMPLITUDE = 0.07; 140const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 141 142const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder); 143const clamp = THREE.MathUtils.clamp; 144const damp = (a, b, rate, dt) => a + (b - a) * (1 - Math.exp(-rate * dt)); 145const wrapPi = a => Math.atan2(Math.sin(a), Math.cos(a)); 146 147// Shared pointer state (client coordinates); far away means "no cursor". 148const pointer = { x: -1e4, y: -1e4 }; 149window.addEventListener('pointermove', e => { pointer.x = e.clientX; pointer.y = e.clientY; }, { passive: true }); 150const releaseTouch = e => { if (e.pointerType !== 'mouse') { pointer.x = pointer.y = -1e4; } }; 151window.addEventListener('pointerup', releaseTouch, { passive: true }); 152window.addEventListener('pointercancel', releaseTouch, { passive: true }); 153document.documentElement.addEventListener('mouseleave', () => { pointer.x = pointer.y = -1e4; }); 154 155function buildRobot(spec, gltf) { 156 const parts = new Map(); 157 gltf.scene.traverse(o => { 158 if (!o.isMesh) return; 159 const [body, key] = o.name.split('__'); 160 o.material = new THREE.MeshStandardMaterial({ 161 color: spec.palette[key] ?? spec.palette.body, 162 roughness: key === 'dark' ? 0.55 : 0.35, 163 metalness: key === 'body' ? 0.15 : 0.05, 164 }); 165 o.castShadow = true; 166 if (!parts.has(body)) parts.set(body, []); 167 parts.get(body).push(o); 168 }); 169 170 const frames = new Map(); // body name -> the group that the body's joint rotates 171 const joints = []; 172 const jointByName = new Map(); 173 let root = null; 174 for (const b of spec.bodies) { 175 const fixed = new THREE.Group(); // the body's fixed offset from its parent 176 if (b.pos) fixed.position.fromArray(b.pos); 177 if (b.quat) fixed.quaternion.set(b.quat[1], b.quat[2], b.quat[3], b.quat[0]).normalize(); 178 const frame = new THREE.Group(); // rotated by this body's joint 179 fixed.add(frame); 180 frames.set(b.name, frame); 181 if (b.parent) frames.get(b.parent).add(fixed); else root = fixed; 182 for (const mesh of parts.get(b.name) ?? []) frame.add(mesh); 183 if (b.axis) { 184 const joint = { 185 name: b.joint ?? b.name, 186 frame, 187 axis: new THREE.Vector3().fromArray(b.axis).normalize(), 188 range: b.range, 189 ik: b.ik !== false, 190 }; 191 joints.push(joint); 192 jointByName.set(joint.name, joint); 193 } 194 } 195 return { root, frames, joints, jointByName }; 196} 197 198// --- behaviour: arms reach for the cursor ---------------------------------- 199function reachBehavior(spec, robot, ctx) { 200 const home = spec.home.slice(); 201 const qTarget = home.slice(); // IK / idle target 202 const q = home.slice(); // displayed joint angles, smoothed toward qTarget 203 204 const setJoints = angles =>
204 robot.joints.forEach((j, i) => { 205 j.frame.quaternion.setFromAxisAngle(j.axis, angles[i]); 206 }); 207 208 const plane = new THREE.Plane(); // target plane through the home tip, facing the camera 209 const homeTip = new THREE.Vector3(); 210 const goal = new THREE.Vector3(); 211 const smoothGoal = new THREE.Vector3(); 212 const hit = new THREE.Vector3(); 213 const ndc = new THREE.Vector2(); 214 const raycaster = new THREE.Raycaster(); 215 const tipPos = new THREE.Vector3(), pivot = new THREE.Vector3(), axisW = new THREE.Vector3(); 216 const toTip = new THREE.Vector3(), toGoal = new THREE.Vector3(), cross = new THREE.Vector3(); 217 const quat = new THREE.Quaternion(); 218 219 const tip = new THREE.Object3D(); 220 tip.position.fromArray(spec.tip.offset); 221 robot.frames.get(spec.tip.body).add(tip); 222 223 setJoints(home); 224 ctx.world.updateMatrixWorld(true); 225 tip.getWorldPosition(homeTip); 226 smoothGoal.copy(homeTip); 227 228 function solveIK() { 229 for (let iter = 0; iter < 4; iter++) { 230 tip.getWorldPosition(tipPos); 231 if (tipPos.distanceTo(smoothGoal) < 0.004) return; 232 for (let i = robot.joints.length - 1; i >= 0; i--) { 233 const j = robot.joints[i]; 234 if (!j.ik) continue; 235 tip.getWorldPosition(tipPos); 236 j.frame.getWorldPosition(pivot); 237 axisW.copy(j.axis).applyQuaternion(j.frame.getWorldQuaternion(quat)).normalize(); 238 toTip.subVectors(tipPos, pivot).projectOnPlane(axisW); 239 toGoal.subVectors(smoothGoal, pivot).projectOnPlane(axisW); 240 if (toTip.lengthSq() < 1e-6 || toGoal.lengthSq() < 1e-6) continue; 241 toTip.normalize(); 242 toGoal.normalize(); 243 let angle = Math.acos(clamp(toTip.dot(toGoal), -1, 1)); 244 if (cross.crossVectors(toTip, toGoal).dot(axisW) < 0) angle = -angle; 245 qTarget[i] = clamp(qTarget[i] + clamp(angle, -0.05, 0.05), j.range[0], j.range[1]); 246 j.frame.quaternion.setFromAxisAngle(j.axis, qTarget[i]); 247 j.frame.updateMatrixWorld(true); 248 } 249 } 250 } 251 252 return { 253 // the arm must stay framed over the whole area its tip can reach 254 expandFrame(points) { 255 const r = spec.reach * 0.6; 256 for (const d of [[r, 0, 0], [-r, 0, 0], [0, r, 0], [0, -r * 0.5, 0], [0, 0, r], [0, 0, -r]]) { 257 points.push(homeTip.clone().add(new THREE.Vector3(...d))); 258 } 259 }, 260 onCamera(camera) { 261 plane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()).negate(), homeTip); 262 }, 263 update(dt, t, s, rect) { 264 const idle = i => home[i] + (reducedMotion ? 0 : Math.sin(t * (0.45 + 0.04 * i) + 0.8 * i) * IDLE_AMPLITUDE); 265 if (s > 0.01) { 266 ndc.set((pointer.x - rect.left) / rect.width * 2 - 1, -((pointer.y - rect.top) / rect.height) * 2 + 1); 267 raycaster.setFromCamera(ndc, ctx.camera); 268 if (raycaster.ray.intersectPlane(plane, hit)) { 269 goal.subVectors(hit, homeTip).clampLength(0, spec.reach).multiplyScalar(s).add(homeTip); 270 smoothGoal.lerp(goal, 1 - Math.exp(-5 * dt)); 271 } 272 // Gently pull the redundant joints back toward the home posture so the arm never drifts into odd poses. 273 qTarget.forEach((v, i) => { qTarget[i] = damp(v, idle(i), 0.8, dt); }); 274 setJoints(qTarget); 275 ctx.world.updateMatrixWorld(true); 276 solveIK(); 277 } else { 278 qTarget.forEach((_, i) => { qTarget[i] = idle(i); }); 279 smoothGoal.copy(homeTip); 280 } 281 q.forEach((v, i) => { q[i] = damp(v, qTarget[i], 4 + 0.2 * i, dt); }); 282 setJoints(q); 283 }, 284 }; 285} 286 287// --- behaviour: Spaleka gallops -------------------------------------------- 288function gaitBehavior(spec, robot, ctx) { 289 const g = spec.gait; 290 const baseZ = robot.root.position.z - g.drop; 291 robot.root.position.z = baseZ; 292 293 const setJoint = (name, value) => { 294 const j = robot.jointByName.get(name); 295 if (j) j.frame.quaternion.setFromAxisAngle(j.axis, j.range ? clamp(value, j.range[0], j.range[1]) : value); 296 }; 297 298 // Analytic inverse kinematics for one five-bar leg: both branches are driven to the same foot point, 299 // which is what keeps the closed linkage together. 300 function solveLeg(leg, fx, fy) { 301 for (const b of leg.branches) { 302 const dx = fx - b.p[0], dy = fy - b.p[1]; 303 const reach = clamp(Math.hypot(dx, dy), Math.abs(b.L1 - b.L2) + 1e-3, b.L1 + b.L2 - 1e-3);
304 const alpha = Math.atan2(dy, dx); 305 const phi = Math.acos(clamp((reach * reach - b.L1 * b.L1 - b.L2 * b.L2) / (2 * b.L1 * b.L2), -1, 1)); 306 const beta = Math.atan2(b.L2 * Math.sin(phi), b.L1 + b.L2 * Math.cos(phi)); 307 const dir1 = alpha + b.elbow * beta; 308 const kx = b.p[0] + b.L1 * Math.cos(dir1), ky = b.p[1] + b.L1 * Math.sin(dir1); 309 const dir2 = Math.atan2(fy - ky, fx - kx); 310 const q1 = wrapPi(dir1 - b.a0); 311 setJoint(b.hip, q1); 312 setJoint(b.knee, (wrapPi(dir2 - b.b0) - q1) / b.kneeSign); 313 } 314 } 315 316 // Where a foot sits within its cycle: pushing back along the ground, then swinging forward. 317 function footOffset(ph, stride, lift, out) { 318 if (ph < g.stance) { 319 const u = ph / g.stance; 320 out[0] = (0.5 - u) * stride; 321 out[1] = 0; 322 } else { 323 const u = (ph - g.stance) / (1 - g.stance); 324 out[0] = (u - 0.5) * stride; 325 out[1] = lift * Math.sin(Math.PI * u); 326 } 327 } 328 329 const state = { speed: 0, stride: 0, lift: 0, rise: 0, yaw: 0, roll: 0, tailPitch: 0, tailRoll: 0, dir: 1 }; 330 const off = [0, 0]; 331 let phase = 0; 332 333 // Rest pose, so the camera is framed on the robot as it actually stands. 334 setJoint('tail_pitch', g.tailBase); 335 g.legs.forEach(leg => solveLeg(leg, leg.foot[0], leg.foot[1])); 336 ctx.world.updateMatrixWorld(true); 337 338 const aimTarget = new THREE.Vector3(), aimOrigin = new THREE.Vector3(), aimDir = new THREE.Vector3(); 339 const aimScale = new THREE.Vector3(), aimQuat = new THREE.Quaternion(); 340 341 function aimRods() { 342 for (const aim of g.aims) { 343 const frame = robot.frames.get(aim.body); 344 const fixed = frame.parent; 345 const targetFrame = robot.frames.get(aim.target.body); 346 aimTarget.fromArray(aim.target.point).applyMatrix4(targetFrame.matrixWorld); 347 fixed.matrixWorld.decompose(aimOrigin, aimQuat, aimScale); 348 aimDir.subVectors(aimTarget, aimOrigin).applyQuaternion(aimQuat.invert()); 349 const length = aimDir.length(); 350 if (length < 1e-6) continue; 351 aimDir.divideScalar(length); 352 frame.quaternion.setFromUnitVectors(new THREE.Vector3().fromArray(aim.axis).normalize(), aimDir); 353 frame.scale.set(1, 1, 1).setComponent(aim.axis.findIndex(v => v !== 0), length / aim.length); 354 frame.updateMatrixWorld(true); 355 } 356 } 357 358 return { 359 expandFrame(points) { 360 const box = new THREE.Box3().setFromPoints(points).expandByScalar(0.05); 361 points.push(box.min.clone(), box.max.clone()); 362 }, 363 onCamera() {}, 364 update(dt, t, s, rect) { 365 const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2; 366 const hx = s > 0.01 ? clamp((pointer.x - cx) / (rect.width * 0.6), -1, 1) : 0; 367 const hy = s > 0.01 ? clamp((cy - pointer.y) / (rect.height * 0.6), -1, 1) : 0; 368 369 // Cursor across the card sets pace and heading; cursor height sets how high it bounds. 370 state.dir = Math.abs(hx) > 0.08 ? Math.sign(hx) : state.dir; 371 state.speed = damp(state.speed, s * (0.9 + 1.6 * Math.abs(hx)), 2.5, dt); 372 state.stride = damp(state.stride, s * (0.085 + 0.05 * Math.abs(hx)), 3, dt); 373 state.lift = damp(state.lift, s * (0.04 + 0.05 * Math.max(0, hy)), 3, dt); 374 state.rise = damp(state.rise, s * 0.035 * hy, 2.5, dt); 375 state.yaw = damp(state.yaw, 0.32 * hx * s, 2.5, dt); 376 state.roll = damp(state.roll, -0.16 * hx * s, 2.5, dt); 377 state.tailPitch = damp(state.tailPitch, -0.5 * hy * s, 2.5, dt); 378 state.tailRoll = damp(state.tailRoll, -0.5 * hx * s, 2.5, dt); 379 380 phase = (phase + state.speed * dt) % 1; 381 const breathe = reducedMotion ? 0 : Math.sin(t * 0.7) * 0.02; 382 const cycle = 2 * Math.PI * phase; 383 const bob = state.lift * 0.5 * Math.sin(cycle + 0.6); 384 const pitch = state.lift * 0.8 * Math.sin(cycle + 1.2) + breathe; 385 386 robot.root.position.z = baseZ + state.rise + bob; 387 setJoint('spine_roll', state.roll); 388 setJoint('spine_yaw', state.yaw); 389 setJoint('spine_pitch', pitch); 390 // Belt-coupled differential: the two motors sum to spine pitch and differ by spine yaw. 391 setJoint('spine_motor_left', pitch + state.yaw * 1.5); 392 setJoint('spine_motor_right', -pitch + state.yaw * 1.5); 393 setJoint('spine_crank_left', state.yaw * 1.5); 394 setJoint('spine_crank_right', -state.yaw * 1.5); 395 setJoint('tail_pitch', g.tailBase + state.tailPitch + state.lift * 1.6 * Math.sin(cycle + 3.0)); 396 setJoint('tail_roll', state.tailRoll); 397
398 g.legs.forEach((leg, i) => { 399 footOffset((phase + (i ? g.lead : 0)) % 1, state.stride, state.lift, off); 400 solveLeg(leg, leg.foot[0] + state.dir * off[0], leg.foot[1] + leg.up * (off[1] - state.rise - bob)); 401 }); 402 403 ctx.world.updateMatrixWorld(true); 404 aimRods(); 405 }, 406 }; 407} 408 409function initViewer(el) { 410 const spec = ROBOTS[el.dataset.robot]; 411 if (!spec) return; 412 413 const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'low-power' }); 414 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 415 renderer.setClearColor(0x000000, 0); 416 renderer.shadowMap.enabled = true; 417 renderer.shadowMap.type = THREE.PCFShadowMap; 418 renderer.toneMapping = THREE.ACESFilmicToneMapping; 419 renderer.toneMappingExposure = spec.exposure ?? 1; 420 421 const scene = new THREE.Scene(); 422 const pmrem = new THREE.PMREMGenerator(renderer); 423 scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; 424 scene.environmentIntensity = 0.55; 425 pmrem.dispose(); 426 427 const sun = new THREE.DirectionalLight(0xffffff, 2.2); 428 sun.position.set(1.5, 3, 2); 429 sun.castShadow = true; 430 sun.shadow.mapSize.set(1024, 1024); 431 sun.shadow.camera.left = sun.shadow.camera.bottom = -1; 432 sun.shadow.camera.right = sun.shadow.camera.top = 1; 433 sun.shadow.radius = 4; 434 scene.add(sun, new THREE.HemisphereLight(0xffffff, 0xd8dde6, 0.6)); 435 436 const ground = new THREE.Mesh(new THREE.CircleGeometry(1.2, 48), new THREE.ShadowMaterial({ opacity: 0.16 })); 437 ground.rotation.x = -Math.PI / 2; 438 ground.receiveShadow = true; 439 scene.add(ground); 440 441 const camera = new THREE.PerspectiveCamera(30, 1, 0.02, 20); 442 const cameraDir = new THREE.Vector3().fromArray(spec.cameraDir ?? DEFAULT_CAMERA_DIR).normalize(); 443 const world = new THREE.Group(); 444 world.rotation.x = -Math.PI / 2; // robot z-up -> three.js y-up 445 scene.add(world); 446 const yawGroup = new THREE.Group(); 447 yawGroup.rotation.z = spec.yaw; 448 world.add(yawGroup); 449 450 let robot = null, behavior = null; 451 let lastTime = 0, t = 0; 452 453 // Points the camera must keep in frame: every mesh's bounding-box corners, plus whatever the 454 // behaviour needs room for. 455 const framePoints = []; 456 function collectFramePoints() { 457 const corner = new THREE.Vector3(); 458 robot.root.traverse(o => { 459 if (!o.isMesh) return; 460 const { min, max } = o.geometry.boundingBox ?? (o.geometry.computeBoundingBox(), o.geometry.boundingBox); 461 for (let k = 0; k < 8; k++) { 462 corner.set(k & 1 ? max.x : min.x, k & 2 ? max.y : min.y, k & 4 ? max.z : min.z); 463 framePoints.push(corner.clone().applyMatrix4(o.matrixWorld)); 464 } 465 }); 466 behavior.expandFrame(framePoints); 467 } 468 469 function fitCamera() { 470 const w = el.clientWidth, h = el.clientHeight; 471 if (!w || !h) return; 472 renderer.setSize(w, h, false); 473 camera.aspect = w / h; 474 if (!robot) return; 475 const center = new THREE.Box3().setFromPoints(framePoints).getCenter(new THREE.Vector3()); 476 const right = new THREE.Vector3(), up = new THREE.Vector3(), p = new THREE.Vector3(); 477 let dist = 3; 478 for (let iter = 0; iter < 6; iter++) { // converge on distance and centring in screen space 479 camera.position.copy(center).addScaledVector(cameraDir, dist); 480 camera.lookAt(center); 481 camera.updateMatrixWorld(); 482 camera.updateProjectionMatrix(); 483 let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity; 484 for (const fp of framePoints) { 485 p.copy(fp).project(camera); 486 x0 = Math.min(x0, p.x); x1 = Math.max(x1, p.x); 487 y0 = Math.min(y0, p.y); y1 = Math.max(y1, p.y); 488 } 489 const halfH = Math.tan(camera.fov * DEG / 2) * dist; 490 right.setFromMatrixColumn(camera.matrixWorld, 0); 491 up.setFromMatrixColumn(camera.matrixWorld, 1); 492 center.addScaledVector(right, (x0 + x1) / 2 * halfH * camera.aspect) 493 .addScaledVector(up, (y0 + y1) / 2 * halfH); 494 dist *= Math.max((x1 - x0) / 2, (y1 - y0) / 2) / (spec.fill ?? 0.84); 495 } 496 camera.near = dist / 20; 497 camera.far = dist * 4; 498 camera.updateProjectionMatrix(); 499 behavior.onCamera(camera); 500 } 501 502 // How strongly the robot should attend to the cursor: 1 inside the card's neighbourhood, easing to 0. 503 function attention(rect) { 504 const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2; 505 const d = Math.hypot(pointer.x - cx, pointer.y - cy); 506 const inner = 0.65 * Math.max(rect.width, rect.height), outer = inner + 220; 507 if (d <= inner) return 1; 508 if (d >= outer) return 0; 509 const t = 1 - (d - inner) / (outer - inner); 510 return t * t * (3 - 2 * t); 511 } 512 513 // Browsers pause requestAnimationFrame in hidden tabs; off-screen cards skip their work here. 514 function tick(now) { 515 requestAnimationFrame(tick); 516 const dt = clamp((now - lastTime) / 1000, 0, 0.05);
517 lastTime = now; 518 const rect = el.getBoundingClientRect(); 519 if (rect.bottom < 0 || rect.top > window.innerHeight || !rect.width) return; 520 t += dt; 521 behavior.update(dt, t, attention(rect), rect); 522 renderer.render(scene, camera); 523 } 524 525 loader.load(spec.model, gltf => { 526 robot = buildRobot(spec, gltf); 527 yawGroup.add(robot.root); 528 behavior = spec.gait ? gaitBehavior(spec, robot, { world, camera, el }) : reachBehavior(spec, robot, { world, camera, el }); 529 world.updateMatrixWorld(true); 530 collectFramePoints(); 531 532 el.appendChild(renderer.domElement); 533 renderer.domElement.setAttribute('aria-hidden', 'true'); 534 fitCamera(); 535 renderer.render(scene, camera); 536 el.classList.add('is-ready'); 537 538 new ResizeObserver(fitCamera).observe(el); 539 lastTime = performance.now(); 540 requestAnimationFrame(tick); 541 }, undefined, err => { 542 console.error(`Failed to load ${spec.model}:`, err); 543 renderer.dispose(); 544 }); 545} 546 547// Only build a viewer once its card is about to scroll into view. 548const lazy = new IntersectionObserver(entries => { 549 for (const entry of entries) { 550 if (!entry.isIntersecting) continue; 551 lazy.unobserve(entry.target); 552 try { 553 initViewer(entry.target); 554 } catch (err) { // e.g. no WebGL: the card keeps showing its image 555 console.warn('Robot viewer disabled:', err); 556 } 557 } 558}, { rootMargin: '300px' }); 559document.querySelectorAll('.robot-viewer[data-robot]').forEach(el => lazy.observe(el));
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.