/** * @license * SPDX-License-Identifier: Apache-2.0 */ import { PivotControls } from '@react-three/drei'; import { useFrame, useThree } from '@react-three/fiber'; import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { useMujocoContext } from '../core/MujocoSimProvider'; import { findSiteByName } from '../core/SceneLoader'; import { CAPTURE_EXCLUDE_KEY } from '../rendering/cameraFrameCapture'; import type { IkGizmoProps } from '../types'; // Preallocated temps to avoid GC pressure in useFrame const _mat4 = new THREE.Matrix4(); const _pos = new THREE.Vector3(); const _quat = new THREE.Quaternion(); const _scale = new THREE.Vector3(1, 1, 1); /** * IkGizmo — drei PivotControls that tracks a MuJoCo site. * * Requires a `controller` from `useIkController()`. * * Props: * - `controller` — IkContextValue from `useIkController()`. * - `siteName` — MuJoCo site to track. Defaults to the controller's configured site. * - `scale` — Gizmo handle scale. Default: 0.18. * - `onDrag` — Custom drag callback `({ position, quaternion }) => void`. * When omitted, dragging enables IK and writes to the IK target. * When provided, the consumer handles what happens during drag. */ export function IkGizmo({ controller, siteName, scale = 0.18, onDrag }: IkGizmoProps) { const { mjModelRef, mjDataRef, status } = useMujocoContext(); const { ikTargetRef, siteIdRef, ikEnabledRef, setIkEnabled } = controller; const wrapperRef = useRef(null); const pivotRef = useRef(null); const draggingRef = useRef(false); const localSiteIdRef = useRef(-1); const { controls } = useThree(); // Resolve the site ID from siteName (only when an explicit siteName override is given) useEffect(() => { const model = mjModelRef.current; if (!model || status !== 'ready' || !siteName) { localSiteIdRef.current = -1; return; } localSiteIdRef.current = findSiteByName(model, siteName); }, [siteName, status, mjModelRef]); // Every frame: sync the visual wrapper to the tracked site (when not dragging) useFrame(() => { const data = mjDataRef.current; const sid = siteName ? localSiteIdRef.current : siteIdRef.current; if (!data || sid < 0 || !wrapperRef.current) return; if (!draggingRef.current) { const p = data.site_xpos; const m = data.site_xmat; const i3 = sid * 3; const i9 = sid * 9; // Position wrapper at the site wrapperRef.current.position.set(p[i3], p[i3 + 1], p[i3 + 2]); _mat4.set( m[i9], m[i9 + 1], m[i9 + 2], 0, m[i9 + 3], m[i9 + 4], m[i9 + 5], 0, m[i9 + 6], m[i9 + 7], m[i9 + 8], 0, 0, 0, 0, 1, ); wrapperRef.current.quaternion.setFromRotationMatrix(_mat4); // Reset any accumulated drag delta so handles stay at wrapper origin if (pivotRef.current) { pivotRef.current.matrix.identity(); } } }); // Don't render until the model is loaded (avoids gizmo at origin) if (status !== 'ready') return null; return ( { draggingRef.current = true; if (!onDrag) { // Default: enable IK so the robot follows if (!ikEnabledRef.current) setIkEnabled(true); } if (controls) (controls as unknown as { enabled: boolean }).enabled = false; }} onDragEnd={() => { draggingRef.current = false; // Reset PivotControls so it doesn't accumulate across drags if (pivotRef.current) { pivotRef.current.matrix.identity(); pivotRef.current.matrixWorldNeedsUpdate = true; } if (controls) (controls as unknown as { enabled: boolean }).enabled = true; }} onDrag={(_l, _dl, world) => { world.decompose(_pos, _quat, _scale); if (onDrag) { // Custom: consumer handles the drag onDrag({ position: _pos.clone(), quaternion: _quat.clone() }); } else { // Default: write to IK target const target = ikTargetRef.current; if (target) { target.position.copy(_pos); target.quaternion.copy(_quat); } } }} > {/* Invisible zero-size child: gives PivotControls a valid child without creating bounding-box anchor offset */} ); }