Spaces:
Running
Running
| // sim.js - Three.js-based 3D simulation of Reachy Mini. | |
| // | |
| // Renders the robot using its real URDF + STL meshes from the upstream SDK, | |
| // fetched browser-side from raw.githubusercontent.com (CORS allowed). | |
| // Exposes the same surface as the live ReachyMini SDK so block-generated code | |
| // can target either without changes. | |
| // | |
| // IMPORTANT - visual fidelity caveat: | |
| // The Stewart platform's 6 legs are not solved via inverse kinematics in v1. | |
| // Instead, we rotate the head's link transform directly to match the | |
| // commanded RPY. The legs visually stay static while the head tilts. Body | |
| // yaw and antennas are driven via their actual URDF joints, so those parts | |
| // look correct. v2 can layer on real Stewart IK if/when needed. | |
| import * as THREE from 'https://esm.sh/three@0.184.0'; | |
| import { OrbitControls } from 'https://esm.sh/three@0.184.0/examples/jsm/controls/OrbitControls.js'; | |
| import URDFLoader from 'https://esm.sh/urdf-loader@0.12.7'; | |
| // Split hosting strategy: | |
| // * URDF itself (plain XML, not in LFS) → raw.githubusercontent.com | |
| // * STL meshes (Git LFS) → media.githubusercontent.com (resolves LFS) | |
| // | |
| // raw.* serves LFS pointer files (131 bytes of "version https://git-lfs..."), | |
| // which STLLoader parses as binary and reads garbage triangle counts (60GB | |
| // Float32Array allocation failures). media.* returns actual binary blobs and | |
| // has access-control-allow-origin: *. | |
| const URDF_RAW_BASE = 'https://raw.githubusercontent.com/pollen-robotics/reachy_mini/v1.7.1/src/reachy_mini/descriptions/reachy_mini/urdf/'; | |
| const URDF_MEDIA_BASE = 'https://media.githubusercontent.com/media/pollen-robotics/reachy_mini/v1.7.1/src/reachy_mini/descriptions/reachy_mini/urdf/'; | |
| const URDF_FILE = URDF_RAW_BASE + 'robot_no_collision.urdf'; | |
| const deg = d => d * Math.PI / 180; | |
| const rad = r => r * 180 / Math.PI; | |
| const lerp = (a, b, k) => a + (b - a) * k; | |
| export class ReachySim extends EventTarget { | |
| constructor(container) { | |
| super(); | |
| this.container = container; | |
| this.ready = false; | |
| this.error = null; | |
| // ---------- Scene ---------- | |
| // Light backdrop so dark details (antennas, eye rims, cable) read | |
| // clearly against it. We keep the dark app chrome around the tile; | |
| // only the 3D viewport itself is bright. | |
| this.scene = new THREE.Scene(); | |
| this.scene.background = new THREE.Color(0xF3F4F6); | |
| this.scene.fog = new THREE.Fog(0xF3F4F6, 0.6, 1.4); | |
| // ---------- Camera ---------- | |
| this.camera = new THREE.PerspectiveCamera(28, 1, 0.01, 10); | |
| this.camera.position.set(0.42, 0.32, 0.42); | |
| // ---------- Renderer ---------- | |
| this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); | |
| this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| this.renderer.shadowMap.enabled = true; | |
| this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| this.renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| this.renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| this.renderer.toneMappingExposure = 1.05; | |
| container.appendChild(this.renderer.domElement); | |
| this.renderer.domElement.style.width = '100%'; | |
| this.renderer.domElement.style.height = '100%'; | |
| this.renderer.domElement.style.display = 'block'; | |
| // ---------- Lights ---------- | |
| this.scene.add(new THREE.AmbientLight(0xffffff, 0.35)); | |
| const key = new THREE.DirectionalLight(0xffffff, 1.6); | |
| key.position.set(0.45, 0.7, 0.5); | |
| key.castShadow = true; | |
| key.shadow.mapSize.set(1024, 1024); | |
| key.shadow.camera.left = -0.4; | |
| key.shadow.camera.right = 0.4; | |
| key.shadow.camera.top = 0.4; | |
| key.shadow.camera.bottom = -0.4; | |
| key.shadow.camera.near = 0.1; | |
| key.shadow.camera.far = 2.0; | |
| key.shadow.bias = -0.0005; | |
| this.scene.add(key); | |
| const fill = new THREE.DirectionalLight(0xff8855, 0.45); | |
| fill.position.set(-0.5, 0.3, -0.4); | |
| this.scene.add(fill); | |
| const rim = new THREE.DirectionalLight(0x6688ff, 0.25); | |
| rim.position.set(0, 0.5, -0.6); | |
| this.scene.add(rim); | |
| // ---------- Ground ---------- | |
| const ground = new THREE.Mesh( | |
| new THREE.CircleGeometry(0.45, 80), | |
| new THREE.MeshStandardMaterial({ | |
| color: 0xE5E7EB, // soft gray - gives a hint of a stage | |
| roughness: 0.9, | |
| metalness: 0.0, | |
| }) | |
| ); | |
| ground.rotation.x = -Math.PI / 2; | |
| ground.receiveShadow = true; | |
| this.scene.add(ground); | |
| // ---------- Controls ---------- | |
| this.controls = new OrbitControls(this.camera, this.renderer.domElement); | |
| this.controls.target.set(0, 0.18, 0); | |
| this.controls.minDistance = 0.22; | |
| this.controls.maxDistance = 1.0; | |
| this.controls.enableDamping = true; | |
| this.controls.dampingFactor = 0.08; | |
| this.controls.enablePan = false; | |
| this.controls.minPolarAngle = Math.PI * 0.10; | |
| this.controls.maxPolarAngle = Math.PI * 0.55; | |
| this.controls.update(); | |
| // ---------- Robot state ---------- | |
| this.robot = null; // URDF root Object3D (set after load) | |
| this.headLink = null; // Head Object3D - rotated directly for RPY | |
| this.headLinkBaseQuat = null; // Original head orientation (preserved) | |
| // Two parallel state objects: TARGET (where commanded) and ACTUAL | |
| // (current rendered). Each frame we lerp ACTUAL → TARGET to mimic | |
| // the daemon's smooth interpolation. | |
| this._tgt = { | |
| head: { roll: 0, pitch: 0, yaw: 0 }, // degrees | |
| antennas: { right: 0, left: 0 }, // degrees | |
| body_yaw: 0, // degrees | |
| }; | |
| this._cur = { | |
| head: { roll: 0, pitch: 0, yaw: 0 }, | |
| antennas: { right: 0, left: 0 }, | |
| body_yaw: 0, | |
| }; | |
| this._isMoving = false; | |
| this._asleep = false; // tracks wake/sleep "mode" (visual only) | |
| // ---------- Resize ---------- | |
| this._handleResize(); | |
| new ResizeObserver(() => this._handleResize()).observe(container); | |
| // ---------- Render loop ---------- | |
| this._tick = this._tick.bind(this); | |
| requestAnimationFrame(this._tick); | |
| // ---------- Load URDF ---------- | |
| this._loadURDF(); | |
| } | |
| // ============================================================ | |
| // URDF + mesh loading | |
| // ============================================================ | |
| _loadURDF() { | |
| const loader = new URDFLoader(); | |
| // URDF references meshes as "package://assets/foo.stl". With packages | |
| // set to a string base, urdf-loader strips "package://" and prepends | |
| // the base - so meshes resolve to URDF_MEDIA_BASE + "assets/foo.stl". | |
| // workingPath is for any non-package-prefixed relative mesh paths | |
| // (none in this URDF, but set defensively). | |
| loader.packages = URDF_MEDIA_BASE; | |
| loader.workingPath = URDF_MEDIA_BASE; | |
| loader.load( | |
| URDF_FILE, | |
| (robot) => { | |
| // URDF uses Z-up; Three.js scene is Y-up. Rotate so robot | |
| // stands upright. Also gentle scale tweak (URDF is meters). | |
| robot.rotation.x = -Math.PI / 2; | |
| robot.position.y = 0; | |
| robot.traverse((n) => { | |
| if (n.isMesh) { | |
| n.castShadow = true; | |
| n.receiveShadow = true; | |
| } | |
| }); | |
| this.scene.add(robot); | |
| this.robot = robot; | |
| this._findHeadLink(); | |
| // ---- DIAG ---- | |
| // Log robot bounding box + renderer size so we can see if the | |
| // model is being rendered offscreen or at wrong scale. | |
| const box = new THREE.Box3().setFromObject(robot); | |
| const size = new THREE.Vector3(); box.getSize(size); | |
| const center = new THREE.Vector3(); box.getCenter(center); | |
| console.log('[sim] URDF loaded.'); | |
| console.log('[sim] bbox size:', size.x.toFixed(3), size.y.toFixed(3), size.z.toFixed(3), 'm'); | |
| console.log('[sim] bbox center:', center.x.toFixed(3), center.y.toFixed(3), center.z.toFixed(3)); | |
| console.log('[sim] joints found:', Object.keys(robot.joints || {}).length, | |
| 'links:', Object.keys(robot.links || {}).length); | |
| console.log('[sim] head link:', this.headLink?.name || '(none)'); | |
| console.log('[sim] renderer size:', this.renderer.domElement.width, 'x', this.renderer.domElement.height, | |
| ' container:', this.container.clientWidth, 'x', this.container.clientHeight); | |
| // Auto-frame: re-target camera to robot's actual center & size | |
| // so wrong assumptions about coordinate frame don't leave the | |
| // model offscreen. Only adjust if the bounds look sane. | |
| if (size.length() > 0.05 && size.length() < 100) { | |
| const dist = Math.max(size.x, size.y, size.z) * 1.8; | |
| this.camera.position.set( | |
| center.x + dist * 0.7, | |
| center.y + dist * 0.5, | |
| center.z + dist * 0.7 | |
| ); | |
| this.controls.target.copy(center); | |
| this.controls.update(); | |
| console.log('[sim] auto-framed; camera at', this.camera.position.toArray().map(n => n.toFixed(3))); | |
| } | |
| // Force a renderer resize in case container was 0×0 at init. | |
| this._handleResize(); | |
| this.ready = true; | |
| this.dispatchEvent(new CustomEvent('ready')); | |
| }, | |
| undefined, | |
| (err) => { | |
| console.error('[sim] URDF load failed:', err); | |
| this.error = err; | |
| this.dispatchEvent(new CustomEvent('error', { detail: err })); | |
| } | |
| ); | |
| } | |
| // Find the link that actually carries the visible head geometry. The | |
| // URDF's "head" link is a logical frame with zero meshes; the head shell, | |
| // antennas, camera, etc. all hang off "xl_330" (the Stewart platform's | |
| // top plate, which is the parent of "head" via the fixed head_frame joint). | |
| // So we rotate xl_330 - physically incorrect (the Stewart legs stay put) | |
| // but visually correct for the head pose. Falls back to any link with the | |
| // most meshes if the URDF gets renamed in a future version. | |
| _findHeadLink() { | |
| if (!this.robot || !this.robot.links) return; | |
| // Preferred: xl_330 (visible Stewart platform top + head children). | |
| // Then: anything matching 'head*' that has meshes. | |
| // Finally: link with the most descendant meshes. | |
| if (this.robot.links['xl_330']) { | |
| this.headLink = this.robot.links['xl_330']; | |
| } | |
| if (!this.headLink) { | |
| let bestName = null, bestCount = 0; | |
| for (const [name, link] of Object.entries(this.robot.links)) { | |
| let count = 0; | |
| link.traverse(n => { if (n.isMesh) count++; }); | |
| if (name.toLowerCase().includes('head') && count > bestCount) { | |
| bestName = name; bestCount = count; | |
| } | |
| } | |
| if (bestName) this.headLink = this.robot.links[bestName]; | |
| } | |
| if (this.headLink) { | |
| this.headLinkBaseQuat = this.headLink.quaternion.clone(); | |
| let meshCount = 0; | |
| this.headLink.traverse(n => { if (n.isMesh) meshCount++; }); | |
| console.log('[sim] head pivot link:', this.headLink.name || '(unnamed)', | |
| '- subtree mesh count:', meshCount, | |
| '- initial quat:', this.headLinkBaseQuat.toArray().map(n => n.toFixed(3))); | |
| } else { | |
| console.warn('[sim] could not find head pivot link; head RPY will be ignored'); | |
| } | |
| } | |
| // ============================================================ | |
| // Render loop | |
| // ============================================================ | |
| _tick() { | |
| requestAnimationFrame(this._tick); | |
| // Smooth-lerp current state toward targets. ~12% per frame at 60fps | |
| // ≈ 200ms time-to-arrive, matching the daemon's interpolation feel. | |
| const k = 0.12; | |
| this._cur.head.roll = lerp(this._cur.head.roll, this._tgt.head.roll, k); | |
| this._cur.head.pitch = lerp(this._cur.head.pitch, this._tgt.head.pitch, k); | |
| this._cur.head.yaw = lerp(this._cur.head.yaw, this._tgt.head.yaw, k); | |
| this._cur.body_yaw = lerp(this._cur.body_yaw, this._tgt.body_yaw, k); | |
| this._cur.antennas.right = lerp(this._cur.antennas.right, this._tgt.antennas.right, k); | |
| this._cur.antennas.left = lerp(this._cur.antennas.left, this._tgt.antennas.left, k); | |
| const eps = 0.4; | |
| this._isMoving = | |
| Math.abs(this._cur.head.roll - this._tgt.head.roll) > eps || | |
| Math.abs(this._cur.head.pitch - this._tgt.head.pitch) > eps || | |
| Math.abs(this._cur.head.yaw - this._tgt.head.yaw) > eps || | |
| Math.abs(this._cur.body_yaw - this._tgt.body_yaw) > eps || | |
| Math.abs(this._cur.antennas.right - this._tgt.antennas.right) > eps || | |
| Math.abs(this._cur.antennas.left - this._tgt.antennas.left) > eps; | |
| if (this.robot) this._applyToURDF(); | |
| this.controls.update(); | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| _applyToURDF() { | |
| const c = this._cur; | |
| // Body yaw - actual joint | |
| if (this.robot.joints?.['yaw_body']) { | |
| this.robot.setJointValue('yaw_body', deg(c.body_yaw)); | |
| } | |
| // Antennas - actual joints | |
| if (this.robot.joints?.['right_antenna']) { | |
| this.robot.setJointValue('right_antenna', deg(c.antennas.right)); | |
| } | |
| if (this.robot.joints?.['left_antenna']) { | |
| this.robot.setJointValue('left_antenna', deg(c.antennas.left)); | |
| } | |
| // Head - rotate the head link's quaternion directly. | |
| // We set the quaternion as (baseQuat * RPY-rotation) so the head's | |
| // intrinsic orientation (from URDF) is preserved while RPY is applied | |
| // on top in the head's local frame. Then force the local matrix to | |
| // recompute so urdf-loader's downstream traversals see our change. | |
| if (this.headLink && this.headLinkBaseQuat) { | |
| const e = new THREE.Euler( | |
| deg(c.head.roll), | |
| deg(c.head.pitch), | |
| deg(c.head.yaw), | |
| 'XYZ' | |
| ); | |
| const q = new THREE.Quaternion().setFromEuler(e); | |
| this.headLink.quaternion.multiplyQuaternions(this.headLinkBaseQuat, q); | |
| this.headLink.updateMatrix(); | |
| this.headLink.updateMatrixWorld(true); | |
| } | |
| } | |
| _handleResize() { | |
| if (!this.container) return; | |
| const w = this.container.clientWidth || 1; | |
| const h = this.container.clientHeight || 1; | |
| this.camera.aspect = w / h; | |
| this.camera.updateProjectionMatrix(); | |
| this.renderer.setSize(w, h, false); | |
| } | |
| // ============================================================ | |
| // Public API - mirrors live ReachyMini SDK so block-generated | |
| // code can target either without conditional logic. | |
| // ============================================================ | |
| setHeadRpyDeg(roll, pitch, yaw) { | |
| this._tgt.head = { | |
| roll: clamp(Number(roll) || 0, -40, 40), | |
| pitch: clamp(Number(pitch) || 0, -40, 40), | |
| yaw: clamp(Number(yaw) || 0, -180, 180), | |
| }; | |
| } | |
| setAntennasDeg(rightDeg, leftDeg) { | |
| this._tgt.antennas = { | |
| right: clamp(Number(rightDeg) || 0, -175, 175), | |
| left: clamp(Number(leftDeg) || 0, -175, 175), | |
| }; | |
| } | |
| setBodyYawDeg(yawDeg) { | |
| this._tgt.body_yaw = clamp(Number(yawDeg) || 0, -160, 160); | |
| } | |
| // SDK-compat: setTarget accepts {head: number[16], antennas: [r,l] in rad, | |
| // body_yaw: rad}. We only support antennas + body_yaw for v1; head matrix | |
| // would need full 4×4 → RPY conversion, which we'd add later. | |
| setTarget({ head, antennas, body_yaw } = {}) { | |
| if (Array.isArray(antennas) && antennas.length === 2) { | |
| this.setAntennasDeg(rad(antennas[0]), rad(antennas[1])); | |
| } | |
| if (typeof body_yaw === 'number') { | |
| this.setBodyYawDeg(rad(body_yaw)); | |
| } | |
| // head matrix → RPY: TODO | |
| } | |
| async wakeUp() { | |
| this._asleep = false; | |
| this.setHeadRpyDeg(0, -8, 0); | |
| this.setAntennasDeg(20, -20); | |
| await this._wait(300); | |
| this.setAntennasDeg(0, 0); | |
| this.setHeadRpyDeg(0, 0, 0); | |
| await this._wait(400); | |
| } | |
| async gotoSleep() { | |
| this._asleep = true; | |
| this.setHeadRpyDeg(0, 25, 0); // head dips forward | |
| this.setAntennasDeg(-30, 30); | |
| await this._wait(800); | |
| } | |
| isAwake() { return !this._asleep; } | |
| ensureAwake() { return Promise.resolve(); } | |
| playSound(file) { | |
| // No speakers in sim - surface as an event so the UI can show a toast. | |
| this.dispatchEvent(new CustomEvent('playSound', { detail: { file } })); | |
| } | |
| setMotorMode(mode) { | |
| this.dispatchEvent(new CustomEvent('motorMode', { detail: { mode } })); | |
| } | |
| setVolume(v) { return Promise.resolve(v); } | |
| getVersion() { return Promise.resolve('sim'); } | |
| // Sensing accessors (read from CURRENT, smooth-lerped state) | |
| getHeadRpy() { return { ...this._cur.head }; } | |
| getBodyYaw() { return this._cur.body_yaw; } | |
| getAntennas() { return { ...this._cur.antennas }; } | |
| isMoving() { return this._isMoving; } | |
| // ---- Convenience ---- | |
| resetView() { | |
| this.camera.position.set(0.42, 0.32, 0.42); | |
| this.controls.target.set(0, 0.18, 0); | |
| this.controls.update(); | |
| } | |
| _wait(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| } | |
| function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); } | |