| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
|
|
| |
| |
| |
| |
| this.scene = new THREE.Scene(); |
| this.scene.background = new THREE.Color(0xF3F4F6); |
| this.scene.fog = new THREE.Fog(0xF3F4F6, 0.6, 1.4); |
|
|
| |
| this.camera = new THREE.PerspectiveCamera(28, 1, 0.01, 10); |
| this.camera.position.set(0.42, 0.32, 0.42); |
|
|
| |
| 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'; |
|
|
| |
| 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); |
|
|
| |
| const ground = new THREE.Mesh( |
| new THREE.CircleGeometry(0.45, 80), |
| new THREE.MeshStandardMaterial({ |
| color: 0xE5E7EB, |
| roughness: 0.9, |
| metalness: 0.0, |
| }) |
| ); |
| ground.rotation.x = -Math.PI / 2; |
| ground.receiveShadow = true; |
| this.scene.add(ground); |
|
|
| |
| 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(); |
|
|
| |
| this.robot = null; |
| this.headLink = null; |
| this.headLinkBaseQuat = null; |
|
|
| |
| |
| |
| this._tgt = { |
| head: { roll: 0, pitch: 0, yaw: 0 }, |
| antennas: { right: 0, left: 0 }, |
| body_yaw: 0, |
| }; |
| this._cur = { |
| head: { roll: 0, pitch: 0, yaw: 0 }, |
| antennas: { right: 0, left: 0 }, |
| body_yaw: 0, |
| }; |
| this._isMoving = false; |
| this._asleep = false; |
|
|
| |
| this._handleResize(); |
| new ResizeObserver(() => this._handleResize()).observe(container); |
|
|
| |
| this._tick = this._tick.bind(this); |
| requestAnimationFrame(this._tick); |
|
|
| |
| this._loadURDF(); |
| } |
|
|
| |
| |
| |
| _loadURDF() { |
| const loader = new URDFLoader(); |
| |
| |
| |
| |
| |
| loader.packages = URDF_MEDIA_BASE; |
| loader.workingPath = URDF_MEDIA_BASE; |
|
|
| loader.load( |
| URDF_FILE, |
| (robot) => { |
| |
| |
| 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(); |
|
|
| |
| |
| |
| 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); |
|
|
| |
| |
| |
| 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))); |
| } |
| |
| 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 })); |
| } |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _findHeadLink() { |
| if (!this.robot || !this.robot.links) return; |
| |
| |
| |
| 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'); |
| } |
| } |
|
|
| |
| |
| |
| _tick() { |
| requestAnimationFrame(this._tick); |
|
|
| |
| |
| 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; |
| |
| if (this.robot.joints?.['yaw_body']) { |
| this.robot.setJointValue('yaw_body', deg(c.body_yaw)); |
| } |
| |
| 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)); |
| } |
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
|
|
| 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); |
| } |
|
|
| |
| |
| |
| 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)); |
| } |
| |
| } |
|
|
| 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); |
| this.setAntennasDeg(-30, 30); |
| await this._wait(800); |
| } |
|
|
| isAwake() { return !this._asleep; } |
| ensureAwake() { return Promise.resolve(); } |
|
|
| playSound(file) { |
| |
| 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'); } |
|
|
| |
| getHeadRpy() { return { ...this._cur.head }; } |
| getBodyYaw() { return this._cur.body_yaw; } |
| getAntennas() { return { ...this._cur.antennas }; } |
| isMoving() { return this._isMoving; } |
|
|
| |
| 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)); } |
|
|