| |
| |
| |
| |
| import { useEffect, useRef } from "react"; |
| import Box from "@mui/material/Box"; |
| import * as THREE from "three"; |
| import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"; |
|
|
| let stagePromise = null; |
| let stageReady = false; |
|
|
| export const isMenuReachyReady = () => stageReady; |
| export const preloadMenuReachy = () => { |
| if (!stagePromise) { |
| stagePromise = new Promise((resolve, reject) => { |
| const started = performance.now(); |
| const wait = () => { |
| const root = window.rl?.reachyRig?.root; |
| if (root) { |
| stageReady = true; |
| resolve(buildStage(root)); |
| return; |
| } |
| |
| |
| |
| if (performance.now() - started > 30000) { |
| stageReady = true; |
| reject(new Error("Reachy Mini title rig was not ready")); |
| return; |
| } |
| requestAnimationFrame(wait); |
| }; |
| wait(); |
| }); |
| } |
| return stagePromise; |
| }; |
|
|
| function buildStage(sourceRoot) { |
| const scene = new THREE.Scene(); |
| const camera = new THREE.PerspectiveCamera(30, 1, 0.02, 20); |
| camera.position.set(0, 0.28, 0.95); |
| camera.lookAt(0, 0.25, 0); |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); |
| renderer.setClearColor(0x000000, 0); |
| renderer.setPixelRatio(Math.min(2, window.devicePixelRatio)); |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; |
| const el = renderer.domElement; |
| el.style.position = "absolute"; |
| el.style.inset = "0"; |
| el.style.width = "100%"; |
| el.style.height = "100%"; |
| el.style.opacity = "0"; |
| el.style.transition = "opacity .45s ease"; |
|
|
| const pmrem = new THREE.PMREMGenerator(renderer); |
| scene.environment = pmrem.fromScene(new RoomEnvironment()).texture; |
| scene.environmentIntensity = 0.35; |
| scene.add(new THREE.HemisphereLight(0xffffff, 0x24232b, 1.8)); |
| const key = new THREE.DirectionalLight(0xffffff, 2.1); |
| key.position.set(2, 4, 3); |
| scene.add(key); |
| const rim = new THREE.DirectionalLight(0xffb366, 0.8); |
| rim.position.set(-2, 3, -2); |
| scene.add(rim); |
|
|
| const rig = sourceRoot.clone(true); |
| rig.scale.setScalar(1.15); |
| rig.traverse((node) => { if (node.isMesh) node.frustumCulled = false; }); |
| scene.add(rig); |
| const bounds = new THREE.Box3().setFromObject(rig); |
| const center = bounds.getCenter(new THREE.Vector3()); |
| rig.position.x -= center.x; |
| rig.position.y -= bounds.min.y; |
| rig.position.z -= center.z; |
| |
| |
| |
| const fitted = new THREE.Box3().setFromObject(rig); |
| const fittedCenter = fitted.getCenter(new THREE.Vector3()); |
| const radius = fitted.getSize(new THREE.Vector3()).length() * 0.5; |
| camera.position.set(fittedCenter.x, fittedCenter.y + radius * 0.08, fittedCenter.z + Math.max(0.8, radius * 2.8)); |
| camera.lookAt(fittedCenter); |
| rig.updateMatrixWorld(true); |
|
|
| const sourceByName = new Map(); |
| |
| |
| |
| sourceRoot.traverse((node) => { if (node.name) sourceByName.set(node.name, node); }); |
| const head = [...rig.children].flatMap((node) => { |
| const found = []; |
| node.traverse((child) => { if (child.name.endsWith("/xl_330")) found.push(child); }); |
| return found; |
| })[0] ?? rig.getObjectByName("reachy/xl_330"); |
| const leftAntenna = rig.getObjectByName("reachy/dc15_a01_horn_dummy_8"); |
| const rightAntenna = rig.getObjectByName("reachy/dc15_a01_horn_dummy_7"); |
| const headBase = new THREE.Quaternion(); |
| const leftBase = new THREE.Quaternion(); |
| const rightBase = new THREE.Quaternion(); |
| const copyLivePose = () => rig.traverse((node) => { |
| const source = node.name ? sourceByName.get(node.name) : null; |
| if (!source || node === rig) return; |
| node.position.copy(source.position); |
| node.quaternion.copy(source.quaternion); |
| }); |
|
|
| let mount = null; |
| let raf = 0; |
| let previous = 0; |
| const loop = (now) => { |
| raf = requestAnimationFrame(loop); |
| copyLivePose(); |
| if (head) { |
| headBase.copy(head.quaternion); |
| head.quaternion.multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler( |
| Math.sin(now * 0.0011) * 0.08, |
| Math.sin(now * 0.0008) * 0.18, |
| 0, |
| ))); |
| } |
| if (leftAntenna && rightAntenna) { |
| leftBase.copy(leftAntenna.quaternion); |
| rightBase.copy(rightAntenna.quaternion); |
| const antennaWave = Math.sin(now * 0.0022) * 0.28; |
| leftAntenna.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), antennaWave)); |
| rightAntenna.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), -antennaWave)); |
| } |
| renderer.render(scene, camera); |
| previous = now; |
| if (el.style.opacity === "0") el.style.opacity = "1"; |
| }; |
| const resize = () => { |
| if (!mount) return; |
| const width = mount.clientWidth, height = mount.clientHeight; |
| if (!width || !height) return; |
| camera.aspect = width / height; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(width, height); |
| }; |
| const observer = new ResizeObserver(resize); |
| return { |
| attach(host) { |
| if (mount) this.detach(); |
| mount = host; |
| mount.appendChild(el); |
| observer.observe(mount); |
| resize(); |
| previous = performance.now(); |
| raf = requestAnimationFrame(loop); |
| }, |
| detach() { |
| cancelAnimationFrame(raf); |
| observer.disconnect(); |
| el.remove(); |
| mount = null; |
| }, |
| }; |
| } |
|
|
| export default function MenuReachy({ sx }) { |
| const hostRef = useRef(null); |
| useEffect(() => { |
| let disposed = false; |
| preloadMenuReachy().then((stage) => { |
| if (!disposed && hostRef.current) stage.attach(hostRef.current); |
| }).catch((error) => console.warn("menu Reachy disabled:", error)); |
| return () => { |
| disposed = true; |
| stagePromise?.then((stage) => stage.detach()).catch(() => {}); |
| }; |
| }, []); |
| return <Box ref={hostRef} aria-label="Reachy Mini" sx={{ position: "relative", touchAction: "none", userSelect: "none", ...sx }} />; |
| } |
|
|