PageSourceSearch

https://shengyangzhuang.com/assets/js/robots.js

js shengyangzhuang.com collected 2026-10-09 22:09:00 UTC 30,807 bytes, 559 lines download raw bytes

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.