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.