reachyconverse / sim.js
Eze Ransom
Deploy: voice loop + sim asset + robot picker UX
ef62023
// 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)); }