PageSourceSearch

https://jellyho.github.io/static/js/robot.js

js jellyho.github.io collected 2026-10-03 09:22:46 UTC 11,786 bytes, 291 lines download raw bytes

1// Heavy 3D dependencies are imported lazily (see start()), after the page has
2// loaded and gone idle, so the background never competes with the content.
3let THREE, URDFLoader, GLTFLoader, MeshoptDecoder, OBJLoader, MTLLoader;
4
5/* ----------------------------------------------------------------------------
6 * Franka Emika Panda arm background.
7 *  - CCD inverse kinematics: the end-effector reaches toward the mouse cursor.
8 *  - A Waddle Dee model rides at the EEF, always turned toward the camera.
9 *  - Loaded lazily after the page is idle; meshes are meshopt-compressed glTF.
10 *  - Flat, light-free look (ambient/hemisphere only) for a clean, cheap render.
11 *  - Auto-fit camera, parked in the lower-right corner.
12 * Model: justagist/franka_panda_description (URDF + Collada visual meshes).
13 * -------------------------------------------------------------------------- */
14const CONFIG = {
15    offsetX: 0.82,    // viewport fraction: + = right  (range ~ -1..1)
16    offsetY: -0.40,   // viewport fraction: - = down
17    fitPadding: 3.3,  // >1 = smaller arm (more margin)
18    basePose: [0.4, -0.5, 0, -2.0, 0, 1.6, 0.78], // panda_joint1..7 seed pose (rad)
19    ikChain: ['panda_joint1', 'panda_joint2', 'panda_joint3', 'panda_joint4', 'panda_joint5', 'panda_joint6', 'panda_joint7'],
20    ikIterations: 4,
21    ikDamping: 0.4,   // per-iteration step (lower = lazier/smoother)
22    targetEase: 0.12, // mouse-target smoothing
23};
24
25/* Waddle Dee model: billboarded at the end-effector, always facing the camera. */
26const EEF_MODEL = {
27    path: 'models/waddledee/',
28    mtl: 'waddledee.mtl',
29    obj: 'waddledee.obj',
30    targetSize: 0.17,                 // longest dimension in meters
31    faceOffset: [0, 0, 0],            // correction so the FACE points at the camera
32};
33
34const canvas = document.getElementById('robot-canvas');
35// Where the model assets live. Override with data-base on the canvas (e.g. previews).
36const ASSET_BASE = (canvas && canvas.dataset.base) || './static/';
37const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
38const isMobile = window.matchMedia('(max-width: 768px)').matches;
39
40if (canvas && !reduceMotion && !isMobile) {
41    whenIdle(() => start().catch(err => console.error('[robot] init failed:', err)));
42}
43
44function whenIdle(fn) {
45    const go = () => ('requestIdleCallback' in window)
46        ? requestIdleCallback(fn, { timeout: 2500 })
47        : setTimeout(fn, 400);
48    if (document.readyState === 'complete') go();
49    else window.addEventListener('load', go, { once: true });
50}
51
52async function start() {
53    [
54        THREE,
55        { default: URDFLoader },
56        { GLTFLoader },
57        { MeshoptDecoder },
58        { OBJLoader },
59        { MTLLoader },
60    ] = await Promise.all([
61        import('three'),
62        import('urdf-loader'),
63        import('three/addons/loaders/GLTFLoader.js'),
64        import('three/addons/libs/meshopt_decoder.module.js'),
65        import('three/addons/loaders/OBJLoader.js'),
66        import('three/addons/loaders/MTLLoader.js'),
67    ]);
68    await initRobot(canvas);
69}
70
71/* Matte, light-independent look for the arm (glTF defaults can be metallic). */
72function matte(root) {
73    root.traverse(o => {
74        if (!o.isMesh) return;
75        (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => {
76            if ('metalness' in m) m.metalness = 0;
77            if ('roughness' in m) m.roughness = 1;
78            if ('shininess' in m) m.shininess = 0;
79            if (m.specular) m.specular.setHex(0x000000);
80        });
81    });
82}
83
84/* --- Load the Waddle Dee OBJ/MTL, flatten to unlit, auto-fit, return a group. --- */
85async function loadEefModel() {
86    const modelPath = ASSET_BASE + EEF_MODEL.path;
87    const mtl = await new MTLLoader().setPath(modelPath).loadAsync(EEF_MODEL.mtl);
88    mtl.preload();
89    const obj = await new OBJLoader().setMaterials(mtl).setPath(modelPath).loadAsync(EEF_MODEL.obj);
90
91    // Unlit flat material using the model's texture (clean, light-independent).
92    obj.traverse(o => {
93        if (!o.isMesh) return;
94        const src = Array.isArray(o.material) ? o.material[0] : o.material;
95        const map = src && src.map ? src.map : null;
96        if (map) map.colorSpace = THREE.SRGBColorSpace;
97        o.material = new THREE.MeshBasicMaterial({ map, color: map ? 0xffffff : 0xffb7d5 });
98    });
99
100    // Center at origin and scale to a consistent size.
101    const box = new THREE.Box3().setFromObject(obj);
102    const size = box.getSize(new THREE.Vector3());
103    const center = box.getCenter(new THREE.Vector3());
104    const s = EEF_MODEL.targetSize / (Math.max(size.x, size.y, size.z) || 1);
105    obj.position.sub(center);
106
107    const wrap = new THREE.Group();
108    wrap.add(obj);
109    wrap.scale.setScalar(s);
110    return wrap;
111}
112
113async function initRobot(canvas) {
114    const scene = new THREE.Scene();
115
116    const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
117    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1)); // low-spec
118    renderer.setSize(window.innerWidth, window.innerHeight);
119    renderer.outputColorSpace = THREE.SRGBColorSpace;
120
121    const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.01, 100);
122
123    // Flat, soft lighting only — no directional lights, so no shiny reflections.
124    scene.add(new THREE.HemisphereLight(0xffffff, 0xdfe6ef, 1.6));
125    scene.add(new THREE.AmbientLight(0xffffff, 0.55));
126
127    const pivot = new THREE.Group();
128    scene.add(pivot);
129
130    let robot = null;
131    let eef = null;
132    let eefModel = null;
133    let framed = false;
134    const pointer = { x: 0, y: 0 };
135
136    const manager = new THREE.LoadingManager();
137    const loader = new URDFLoader(manager);
138    const gltfLoader = new GLTFLoader(manager).setMeshoptDecoder(MeshoptDecoder);
139    loader.loadMeshCb = (path, mgr, done) => {
140        gltfLoader.load(
141            path,
142            (gltf) => { matte(gltf.scene); done(gltf.scene); },
143            undefined,
144            (err) => { console.error('[robot] mesh failed:', path, err); done(null, err); }
145        );
146    };
147    // Resolves when the URDF and all its meshes have finished loading.
148    const allLoaded = new Promise(res => { manager.onLoad = res; });
149
150    console.log('[robot] loading URDF…');
151    robot = await new Promise((resolve, reject) => {
152        loader.load(ASSET_BASE + 'models/franka/panda_arm.urdf', resolve, undefined, reject);
153    });
154
155    robot.rotation.x = -Math.PI / 2; // URDF Z-up -> three.js Y-up
156    CONFIG.basePose.forEach((v, i) => {
157        const n = `panda_joint${i + 1}`;
158        if (robot.joints[n]) robot.setJointValue(n, v);
159    });
160    pivot.add(robot);
161
162    // Attach the Waddle Dee model to the end-effector flange.
163    const eefLink = robot.links['panda_link8'] || robot.links['panda_link7'];
164    try {
165        // Added to the scene (not the flange) so its rotation is independent
166        // of the arm — it gets billboarded toward the camera every frame.
167        eefModel = await loadEefModel();
168        scene.add(eefModel);
169    } catch (err) {
170        console.error('[robot] EEF model failed to load:', err);
171    }
172    eef = new THREE.Object3D();        // IK end-effector marker (drives model position)
173    eef.position.set(0, 0, 0.06);
174    eefLink.add(eef);
175    console.log('[robot] URDF + EEF model ready. links:', Object.keys(robot.links));
176
177    // ---- Camera framing: fit, then park lower-right (after meshes load). ----
178    await allLoaded;
179    {
180        robot.updateMatrixWorld(true);
181        const box = new THREE.Box3().setFromObject(pivot);
182        const size = box.getSize(new THREE.Vector3());
183        const center = box.getCenter(new THREE.Vector3());
184        robot.position.sub(center); // recenter model at the pivot origin
185
186        const maxDim = Math.max(size.x, size.y, size.z) || 1;
187        const fov = camera.fov * Math.PI / 180;
188        const dist = (maxDim / 2) / Math.tan(fov / 2) * CONFIG.fitPadding;
189
190        camera.position.set(0, 0, dist);
191        camera.lookAt(0, 0, 0);
192
193        const vH = 2 * dist * Math.tan(fov / 2);
194        const vW = vH * camera.aspect;
195        pivot.position.x = (vW / 2) * CONFIG.offsetX;
196        pivot.position.y = (vH / 2) * CONFIG.offsetY;
197
198        framed = true;
199        console.log('[robot] framed.');
200    }
201
202    window.addEventListener('pointermove', (e) => {
203        pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
204        pointer.y = (e.clientY / window.innerHeight) * 2 - 1;
205    });
206
207    window.addEventListener('resize', () => {
208        camera.aspect = window.innerWidth / window.innerHeight;
209        camera.updateProjectionMatrix();
210        renderer.setSize(window.innerWidth, window.innerHeight);
211    });
212
213    // ---- CCD IK: drive the EEF toward the mouse target on the z=0 plane. ----
214    const target = new THREE.Vector3();
215    const smooth = new THREE.Vector3(0, 0, 0);
216    const _jp = new THREE.Vector3(), _ee = new THREE.Vector3(), _ax = new THREE.Vector3();
217    const _a = new THREE.Vector3(), _b = new THREE.Vector3(), _c = new THREE.Vector3();
218    let seeded = false;
219
220    function mouseTarget(out) {
221        out.set(pointer.x, -pointer.y, 0.5).unproject(camera);
222        out.sub(camera.position).normalize();
223        const t = (0 - camera.position.z) / out.z;
224        out.multiplyScalar(t).add(camera.position);
225    }
226
227    function solveIK() {
228        for (let it = 0; it < CONFIG.ikIterations; it++) {
229            for (let k = CONFIG.ikChain.length - 1; k >= 0; k--) {
230                const joint = robot.joints[CONFIG.ikChain[k]];
231                if (!joint) continue;
232                robot.updateMatrixWorld(true);
233                joint.getWorldPosition(_jp);
234                eef.getWorldPosition(_ee);
235                _ax.copy(joint.axis).transformDirection(joint.matrixWorld).normalize();
236
237                _a.subVectors(_ee, _jp);          // joint -> end-effector
238                _a.addScaledVector(_ax, -_a.dot(_ax)); // project onto joint plane
239                _b.subVectors(smooth, _jp);       // joint -> target
240                _b.addScaledVector(_ax, -_b.dot(_ax));
241                if (_a.lengthSq() < 1e-8 || _b.lengthSq() < 1e-8) continue;
242                _a.normalize(); _b.normalize();
243
244                let ang = Math.acos(THREE.MathUtils.clamp(_a.dot(_b), -1, 1));
245                if (_c.crossVectors(_a, _b).dot(_ax) < 0) ang = -ang;
246                joint.setJointValue(joint.angle + ang * CONFIG.ikDamping); // clamps to limits
247            }
248        }
249    }
250
251    // One render loop, ever. Restarting on visibilitychange without cancelling the
252    // pending frame used to stack a new loop on every hide/show cycle.
253    let running = true;
254    let rafId = 0;
255    document.addEventListener('visibilitychange', () => {
256        if (document.hidden) {
257            running = false;
258            if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
259        } else if (!rafId) {
260            running = true;
261            animate();
262        }
263    });
264
265    function animate() {
266        if (!running) { rafId = 0; return; }
267        rafId = requestAnimationFrame(animate);
268
269        if (robot && framed) {
270            mouseTarget(target);
271            if (!seeded) { smooth.copy(target); seeded = true; }
272            smooth.lerp(target, CONFIG.targetEase);
273            solveIK();
274
275            // Billboard Waddle Dee: park at the EEF, but always face the camera.
276            if (eefModel) {
277                eef.getWorldPosition(_ee);
278                eefModel.position.copy(_ee);
279                eefModel.lookAt(camera.position);
280                const fo = EEF_MODEL.faceOffset;
281                eefModel.rotateX(fo[0]);
282                eefModel.rotateY(fo[1]);
283                eefModel.rotateZ(fo[2]);
284            }
285        }
286
287        renderer.render(scene, camera);
288    }
289
290    animate();
291}

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.