mujoco-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +510 -0
  3. package/dist/index.d.ts +1080 -0
  4. package/dist/index.js +3518 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +64 -0
  7. package/src/components/ContactListener.tsx +26 -0
  8. package/src/components/ContactMarkers.tsx +81 -0
  9. package/src/components/Debug.tsx +227 -0
  10. package/src/components/DragInteraction.tsx +227 -0
  11. package/src/components/FlexRenderer.tsx +102 -0
  12. package/src/components/IkGizmo.tsx +146 -0
  13. package/src/components/SceneLights.tsx +131 -0
  14. package/src/components/SceneRenderer.tsx +104 -0
  15. package/src/components/SelectionHighlight.tsx +69 -0
  16. package/src/components/TendonRenderer.tsx +84 -0
  17. package/src/components/TrajectoryPlayer.tsx +44 -0
  18. package/src/core/GenericIK.ts +339 -0
  19. package/src/core/MujocoCanvas.tsx +72 -0
  20. package/src/core/MujocoProvider.tsx +78 -0
  21. package/src/core/MujocoSimProvider.tsx +1201 -0
  22. package/src/core/SceneLoader.ts +275 -0
  23. package/src/hooks/useActuators.ts +36 -0
  24. package/src/hooks/useBodyState.ts +56 -0
  25. package/src/hooks/useContacts.ts +125 -0
  26. package/src/hooks/useCtrl.ts +40 -0
  27. package/src/hooks/useCtrlNoise.ts +59 -0
  28. package/src/hooks/useGamepad.ts +77 -0
  29. package/src/hooks/useGravityCompensation.ts +22 -0
  30. package/src/hooks/useJointState.ts +64 -0
  31. package/src/hooks/useKeyboardTeleop.ts +97 -0
  32. package/src/hooks/usePolicy.ts +56 -0
  33. package/src/hooks/useSensor.ts +83 -0
  34. package/src/hooks/useSitePosition.ts +62 -0
  35. package/src/hooks/useTrajectoryPlayer.ts +105 -0
  36. package/src/hooks/useTrajectoryRecorder.ts +97 -0
  37. package/src/hooks/useVideoRecorder.ts +82 -0
  38. package/src/index.ts +108 -0
  39. package/src/rendering/CapsuleGeometry.ts +35 -0
  40. package/src/rendering/GeomBuilder.ts +140 -0
  41. package/src/rendering/Reflector.ts +225 -0
  42. package/src/types.ts +619 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * FlexRenderer — render deformable flex bodies (spec 6.4)
6
+ */
7
+
8
+ import { useEffect, useRef } from 'react';
9
+ import { useFrame } from '@react-three/fiber';
10
+ import * as THREE from 'three';
11
+ import { useMujocoSim } from '../core/MujocoSimProvider';
12
+
13
+ /**
14
+ * Renders MuJoCo flex (deformable) bodies as dynamic meshes.
15
+ * Vertices are updated every frame from flexvert_xpos.
16
+ */
17
+ export function FlexRenderer() {
18
+ const { mjModelRef, mjDataRef, status } = useMujocoSim();
19
+ const groupRef = useRef<THREE.Group>(null);
20
+ const meshesRef = useRef<THREE.Mesh[]>([]);
21
+
22
+ // Build flex meshes once when model is ready
23
+ useEffect(() => {
24
+ const model = mjModelRef.current;
25
+ const group = groupRef.current;
26
+ if (!model || !group || status !== 'ready') return;
27
+
28
+ const nflex = model.nflex ?? 0;
29
+ if (nflex === 0) return;
30
+
31
+ for (let f = 0; f < nflex; f++) {
32
+ const vertAdr = model.flex_vertadr[f];
33
+ const vertNum = model.flex_vertnum[f];
34
+
35
+ if (vertNum === 0) continue;
36
+
37
+ const geometry = new THREE.BufferGeometry();
38
+ const positions = new Float32Array(vertNum * 3);
39
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
40
+
41
+ // Note: flex_faceadr/flex_facenum/flex_face are NOT available in mujoco-js WASM.
42
+ // Without face data we render as a point cloud. If future WASM versions expose
43
+ // face arrays, index-based triangle rendering can be added here.
44
+
45
+ geometry.computeVertexNormals();
46
+
47
+ let color = new THREE.Color(0.5, 0.5, 0.5);
48
+ if (model.flex_rgba) {
49
+ color = new THREE.Color(
50
+ model.flex_rgba[4 * f],
51
+ model.flex_rgba[4 * f + 1],
52
+ model.flex_rgba[4 * f + 2],
53
+ );
54
+ }
55
+
56
+ const material = new THREE.MeshStandardMaterial({
57
+ color,
58
+ roughness: 0.7,
59
+ side: THREE.DoubleSide,
60
+ });
61
+
62
+ const mesh = new THREE.Mesh(geometry, material);
63
+ mesh.userData.flexId = f;
64
+ mesh.userData.vertAdr = vertAdr;
65
+ mesh.userData.vertNum = vertNum;
66
+ group.add(mesh);
67
+ meshesRef.current.push(mesh);
68
+ }
69
+
70
+ return () => {
71
+ for (const mesh of meshesRef.current) {
72
+ group.remove(mesh);
73
+ mesh.geometry.dispose();
74
+ (mesh.material as THREE.Material).dispose();
75
+ }
76
+ meshesRef.current = [];
77
+ };
78
+ }, [status, mjModelRef]);
79
+
80
+ // Update vertex positions every frame
81
+ useFrame(() => {
82
+ const data = mjDataRef.current;
83
+ if (!data || !data.flexvert_xpos) return;
84
+
85
+ for (const mesh of meshesRef.current) {
86
+ const vertAdr = mesh.userData.vertAdr;
87
+ const vertNum = mesh.userData.vertNum;
88
+ const posAttr = mesh.geometry.getAttribute('position') as THREE.BufferAttribute;
89
+ if (!posAttr) continue;
90
+
91
+ for (let v = 0; v < vertNum; v++) {
92
+ const srcIdx = (vertAdr + v) * 3;
93
+ posAttr.setXYZ(v, data.flexvert_xpos[srcIdx], data.flexvert_xpos[srcIdx + 1], data.flexvert_xpos[srcIdx + 2]);
94
+ }
95
+ posAttr.needsUpdate = true;
96
+ mesh.geometry.computeVertexNormals();
97
+ }
98
+ });
99
+
100
+ if (status !== 'ready') return null;
101
+ return <group ref={groupRef} />;
102
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { PivotControls } from '@react-three/drei';
7
+ import { useFrame, useThree } from '@react-three/fiber';
8
+ import { useEffect, useRef } from 'react';
9
+ import * as THREE from 'three';
10
+ import { useMujocoSim } from '../core/MujocoSimProvider';
11
+ import { findSiteByName } from '../core/SceneLoader';
12
+ import type { IkGizmoProps } from '../types';
13
+
14
+ // Preallocated temps to avoid GC pressure in useFrame
15
+ const _mat4 = new THREE.Matrix4();
16
+ const _pos = new THREE.Vector3();
17
+ const _quat = new THREE.Quaternion();
18
+ const _scale = new THREE.Vector3(1, 1, 1);
19
+
20
+ /**
21
+ * IkGizmo — drei PivotControls that tracks a MuJoCo site.
22
+ *
23
+ * Props:
24
+ * - `siteName` — MuJoCo site to track. Defaults to `SceneConfig.tcpSiteName`.
25
+ * - `scale` — Gizmo handle scale. Default: 0.18.
26
+ * - `onDrag` — Custom drag callback `(pos, quat) => void`.
27
+ * When omitted, dragging enables IK and writes to the provider's IK target.
28
+ * When provided, the consumer handles what happens during drag.
29
+ *
30
+ * Multiple gizmos can be rendered — each tracks its own site.
31
+ * Zero gizmos is fine — programmatic IK control works via the provider API.
32
+ *
33
+ * Uses a tiny invisible mesh as child instead of axesHelper — PivotControls
34
+ * computes an anchor offset from children's bounding box, and axesHelper's
35
+ * (0→0.15) bounds would shift the handles away from the TCP origin.
36
+ */
37
+ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
38
+ const {
39
+ ikTargetRef, mjModelRef, mjDataRef, siteIdRef,
40
+ api, ikEnabledRef, status,
41
+ } = useMujocoSim();
42
+
43
+ const wrapperRef = useRef<THREE.Group>(null);
44
+ const pivotRef = useRef<THREE.Group>(null);
45
+ const draggingRef = useRef(false);
46
+ const localSiteIdRef = useRef(-1);
47
+ const { controls } = useThree();
48
+
49
+ // Resolve the site ID from siteName (or fall back to provider's tcpSiteName)
50
+ useEffect(() => {
51
+ const model = mjModelRef.current;
52
+ if (!model || status !== 'ready') {
53
+ localSiteIdRef.current = -1;
54
+ return;
55
+ }
56
+ if (siteName) {
57
+ localSiteIdRef.current = findSiteByName(model, siteName);
58
+ } else {
59
+ // Default: use the provider's siteIdRef (from SceneConfig.tcpSiteName)
60
+ localSiteIdRef.current = siteIdRef.current;
61
+ }
62
+ }, [siteName, status, mjModelRef, siteIdRef]);
63
+
64
+ // Every frame: sync the visual wrapper to the tracked site (when not dragging)
65
+ useFrame(() => {
66
+ const data = mjDataRef.current;
67
+ const sid = localSiteIdRef.current;
68
+ if (!data || sid < 0 || !wrapperRef.current) return;
69
+
70
+ if (!draggingRef.current) {
71
+ const p = data.site_xpos;
72
+ const m = data.site_xmat;
73
+ const i3 = sid * 3;
74
+ const i9 = sid * 9;
75
+
76
+ // Position wrapper at the site
77
+ wrapperRef.current.position.set(p[i3], p[i3 + 1], p[i3 + 2]);
78
+ // MuJoCo site_xmat is row-major 3x3; THREE.Matrix4.set() is row-major
79
+ _mat4.set(
80
+ m[i9], m[i9 + 1], m[i9 + 2], 0,
81
+ m[i9 + 3], m[i9 + 4], m[i9 + 5], 0,
82
+ m[i9 + 6], m[i9 + 7], m[i9 + 8], 0,
83
+ 0, 0, 0, 1,
84
+ );
85
+ wrapperRef.current.quaternion.setFromRotationMatrix(_mat4);
86
+
87
+ // Reset any accumulated drag delta so handles stay at wrapper origin
88
+ if (pivotRef.current) {
89
+ pivotRef.current.matrix.identity();
90
+ }
91
+ }
92
+ });
93
+
94
+ // Don't render until the model is loaded (avoids gizmo at origin)
95
+ if (status !== 'ready') return null;
96
+
97
+ return (
98
+ <group ref={wrapperRef}>
99
+ <PivotControls
100
+ ref={pivotRef}
101
+ autoTransform
102
+ scale={scale}
103
+ fixed={false}
104
+ depthTest={false}
105
+ disableScaling
106
+ onDragStart={() => {
107
+ draggingRef.current = true;
108
+ if (!onDrag) {
109
+ // Default: enable IK so the robot follows
110
+ if (!ikEnabledRef.current) api.setIkEnabled(true);
111
+ }
112
+ if (controls) (controls as unknown as { enabled: boolean }).enabled = false;
113
+ }}
114
+ onDragEnd={() => {
115
+ draggingRef.current = false;
116
+ // Reset PivotControls so it doesn't accumulate across drags
117
+ if (pivotRef.current) {
118
+ pivotRef.current.matrix.identity();
119
+ pivotRef.current.matrixWorldNeedsUpdate = true;
120
+ }
121
+ if (controls) (controls as unknown as { enabled: boolean }).enabled = true;
122
+ }}
123
+ onDrag={(_l, _dl, world) => {
124
+ world.decompose(_pos, _quat, _scale);
125
+ if (onDrag) {
126
+ // Custom: consumer handles the drag
127
+ onDrag(_pos.clone(), _quat.clone());
128
+ } else {
129
+ // Default: write to provider's IK target
130
+ const target = ikTargetRef.current;
131
+ if (target) {
132
+ target.position.copy(_pos);
133
+ target.quaternion.copy(_quat);
134
+ }
135
+ }
136
+ }}
137
+ >
138
+ {/* Invisible zero-size child: gives PivotControls a valid child
139
+ without creating bounding-box anchor offset */}
140
+ <mesh visible={false}>
141
+ <sphereGeometry args={[0.001]} />
142
+ </mesh>
143
+ </PivotControls>
144
+ </group>
145
+ );
146
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * SceneLights — auto-create Three.js lights from MJCF <light> elements (spec 6.3)
6
+ *
7
+ * WASM fields used: model.nlight, light_pos, light_dir, light_diffuse,
8
+ * light_specular, light_active, light_type, light_castshadow,
9
+ * light_attenuation, light_cutoff, light_exponent, light_intensity
10
+ *
11
+ * light_type: 0 = directional, 1 = spot (maps to mjLIGHT_DIRECTIONAL/mjLIGHT_SPOT)
12
+ * Note: light_directional does NOT exist in WASM — use light_type instead.
13
+ */
14
+
15
+ import { useEffect, useRef } from 'react';
16
+ import * as THREE from 'three';
17
+ import { useThree } from '@react-three/fiber';
18
+ import { useMujocoSim } from '../core/MujocoSimProvider';
19
+ import type { SceneLightsProps } from '../types';
20
+
21
+ export function SceneLights({ intensity = 1.0 }: SceneLightsProps) {
22
+ const { mjModelRef, status } = useMujocoSim();
23
+ const { scene } = useThree();
24
+ const lightsRef = useRef<THREE.Light[]>([]);
25
+ const targetsRef = useRef<THREE.Object3D[]>([]);
26
+
27
+ useEffect(() => {
28
+ const model = mjModelRef.current;
29
+ if (!model || status !== 'ready') return;
30
+
31
+ // Clean up previous lights
32
+ for (const light of lightsRef.current) {
33
+ scene.remove(light);
34
+ light.dispose();
35
+ }
36
+ for (const t of targetsRef.current) scene.remove(t);
37
+ lightsRef.current = [];
38
+ targetsRef.current = [];
39
+
40
+ const nlight = model.nlight ?? 0;
41
+ if (nlight === 0) return;
42
+
43
+ for (let i = 0; i < nlight; i++) {
44
+ // Check if light is active
45
+ const active = model.light_active ? model.light_active[i] : 1;
46
+ if (!active) continue;
47
+
48
+ // light_type: 0 = directional, 1 = spot (no light_directional in WASM)
49
+ const lightType = model.light_type ? model.light_type[i] : 0;
50
+ const isDirectional = lightType === 0;
51
+ const castShadow = model.light_castshadow ? model.light_castshadow[i] !== 0 : false;
52
+
53
+ // Read intensity from model if available, otherwise use prop
54
+ const mjIntensity = model.light_intensity ? model.light_intensity[i] : 1.0;
55
+ const finalIntensity = intensity * mjIntensity;
56
+
57
+ // Read diffuse color
58
+ const dr = model.light_diffuse ? model.light_diffuse[3 * i] : 1;
59
+ const dg = model.light_diffuse ? model.light_diffuse[3 * i + 1] : 1;
60
+ const db = model.light_diffuse ? model.light_diffuse[3 * i + 2] : 1;
61
+ const color = new THREE.Color(dr, dg, db);
62
+
63
+ // Read position and direction
64
+ const px = model.light_pos[3 * i];
65
+ const py = model.light_pos[3 * i + 1];
66
+ const pz = model.light_pos[3 * i + 2];
67
+ const dx = model.light_dir[3 * i];
68
+ const dy = model.light_dir[3 * i + 1];
69
+ const dz = model.light_dir[3 * i + 2];
70
+
71
+ if (isDirectional) {
72
+ const light = new THREE.DirectionalLight(color, finalIntensity);
73
+ light.position.set(px, py, pz);
74
+ light.target.position.set(px + dx, py + dy, pz + dz);
75
+ light.castShadow = castShadow;
76
+ if (castShadow) {
77
+ light.shadow.mapSize.width = 1024;
78
+ light.shadow.mapSize.height = 1024;
79
+ light.shadow.camera.near = 0.1;
80
+ light.shadow.camera.far = 50;
81
+ const d = 5;
82
+ light.shadow.camera.left = -d;
83
+ light.shadow.camera.right = d;
84
+ light.shadow.camera.top = d;
85
+ light.shadow.camera.bottom = -d;
86
+ }
87
+ scene.add(light);
88
+ scene.add(light.target);
89
+ lightsRef.current.push(light);
90
+ targetsRef.current.push(light.target);
91
+ } else {
92
+ // Spot light
93
+ const cutoff = model.light_cutoff ? model.light_cutoff[i] : 45;
94
+ const exponent = model.light_exponent ? model.light_exponent[i] : 10;
95
+ const angle = (cutoff * Math.PI) / 180;
96
+ const light = new THREE.SpotLight(color, finalIntensity, 0, angle, exponent / 128);
97
+ light.position.set(px, py, pz);
98
+ light.target.position.set(px + dx, py + dy, pz + dz);
99
+ light.castShadow = castShadow;
100
+
101
+ if (model.light_attenuation) {
102
+ const att1 = model.light_attenuation[3 * i + 1]; // linear
103
+ const att2 = model.light_attenuation[3 * i + 2]; // quadratic
104
+ light.decay = att2 > 0 ? 2 : (att1 > 0 ? 1 : 0);
105
+ light.distance = att1 > 0 ? 1 / att1 : 0;
106
+ }
107
+
108
+ if (castShadow) {
109
+ light.shadow.mapSize.width = 512;
110
+ light.shadow.mapSize.height = 512;
111
+ }
112
+ scene.add(light);
113
+ scene.add(light.target);
114
+ lightsRef.current.push(light);
115
+ targetsRef.current.push(light.target);
116
+ }
117
+ }
118
+
119
+ return () => {
120
+ for (const light of lightsRef.current) {
121
+ scene.remove(light);
122
+ light.dispose();
123
+ }
124
+ for (const t of targetsRef.current) scene.remove(t);
125
+ lightsRef.current = [];
126
+ targetsRef.current = [];
127
+ };
128
+ }, [status, mjModelRef, scene, intensity]);
129
+
130
+ return null;
131
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { useFrame } from '@react-three/fiber';
7
+ import { useEffect, useMemo, useRef } from 'react';
8
+ import * as THREE from 'three';
9
+ import { GeomBuilder } from '../rendering/GeomBuilder';
10
+ import { MujocoModel } from '../types';
11
+ import { getName } from '../core/SceneLoader';
12
+ import { useMujocoSim } from '../core/MujocoSimProvider';
13
+
14
+ /**
15
+ * SceneRenderer — creates and syncs MuJoCo body meshes every frame.
16
+ * Replaces RenderSystem.initScene() + RenderSystem.update() body loop.
17
+ */
18
+ export function SceneRenderer() {
19
+ const { mjModelRef, mjDataRef, mujocoRef, onSelectionRef, status } = useMujocoSim();
20
+ const groupRef = useRef<THREE.Group>(null);
21
+ const bodyRefs = useRef<(THREE.Group | null)[]>([]);
22
+ const prevModelRef = useRef<MujocoModel | null>(null);
23
+
24
+ const geomBuilder = useMemo(() => {
25
+ return new GeomBuilder(mujocoRef.current);
26
+ }, [mujocoRef.current]);
27
+
28
+ // Build body groups when model loads
29
+ useEffect(() => {
30
+ if (status !== 'ready') return;
31
+ const model = mjModelRef.current;
32
+ const group = groupRef.current;
33
+ if (!model || !group) return;
34
+
35
+ // Skip if model hasn't changed
36
+ if (prevModelRef.current === model) return;
37
+ prevModelRef.current = model;
38
+
39
+ // Clear previous bodies
40
+ while (group.children.length > 0) {
41
+ group.remove(group.children[0]);
42
+ }
43
+
44
+ // Create body groups with geometry
45
+ const refs: (THREE.Group | null)[] = [];
46
+ for (let i = 0; i < model.nbody; i++) {
47
+ const bodyGroup = new THREE.Group();
48
+ bodyGroup.userData.bodyID = i;
49
+
50
+ for (let g = 0; g < model.ngeom; g++) {
51
+ if (model.geom_bodyid[g] === i) {
52
+ const mesh = geomBuilder.create(model, g);
53
+ if (mesh) bodyGroup.add(mesh);
54
+ }
55
+ }
56
+
57
+ group.add(bodyGroup);
58
+ refs.push(bodyGroup);
59
+ }
60
+ bodyRefs.current = refs;
61
+ }, [status, geomBuilder, mjModelRef]);
62
+
63
+ // Sync body positions from mjData every frame
64
+ useFrame(() => {
65
+ const data = mjDataRef.current;
66
+ if (!data) return;
67
+ const bodies = bodyRefs.current;
68
+ for (let i = 0; i < bodies.length; i++) {
69
+ const ref = bodies[i];
70
+ if (!ref) continue;
71
+ ref.position.set(
72
+ data.xpos[i * 3],
73
+ data.xpos[i * 3 + 1],
74
+ data.xpos[i * 3 + 2]
75
+ );
76
+ ref.quaternion.set(
77
+ data.xquat[i * 4 + 1],
78
+ data.xquat[i * 4 + 2],
79
+ data.xquat[i * 4 + 3],
80
+ data.xquat[i * 4]
81
+ );
82
+ }
83
+ });
84
+
85
+ return (
86
+ <group
87
+ ref={groupRef}
88
+ onDoubleClick={(e) => {
89
+ e.stopPropagation();
90
+ let obj: THREE.Object3D | null = e.object;
91
+ while (obj && obj.userData.bodyID === undefined && obj.parent) {
92
+ obj = obj.parent;
93
+ }
94
+ if (obj && obj.userData.bodyID !== undefined && obj.userData.bodyID > 0) {
95
+ const model = mjModelRef.current;
96
+ if (model && onSelectionRef.current) {
97
+ const name = getName(model, model.name_bodyadr[obj.userData.bodyID]);
98
+ onSelectionRef.current(obj.userData.bodyID, name);
99
+ }
100
+ }
101
+ }}
102
+ />
103
+ );
104
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * SelectionHighlight — highlight a selected body with emissive color (spec 6.5)
6
+ */
7
+
8
+ import { useEffect, useRef } from 'react';
9
+ import { useThree } from '@react-three/fiber';
10
+ import * as THREE from 'three';
11
+ import type { SelectionHighlightProps } from '../types';
12
+
13
+ /**
14
+ * Applies emissive highlight to all meshes belonging to a body.
15
+ * Restores original emissive when bodyId changes or component unmounts.
16
+ */
17
+ export function SelectionHighlight({
18
+ bodyId,
19
+ color = '#ff4444',
20
+ emissiveIntensity = 0.3,
21
+ }: SelectionHighlightProps) {
22
+ const { scene } = useThree();
23
+ const prevMeshesRef = useRef<{ mesh: THREE.Mesh; originalEmissive: THREE.Color; originalIntensity: number }[]>([]);
24
+
25
+ useEffect(() => {
26
+ // Restore previous highlights
27
+ for (const entry of prevMeshesRef.current) {
28
+ const mat = entry.mesh.material as THREE.MeshStandardMaterial;
29
+ if (mat.emissive) {
30
+ mat.emissive.copy(entry.originalEmissive);
31
+ mat.emissiveIntensity = entry.originalIntensity;
32
+ }
33
+ }
34
+ prevMeshesRef.current = [];
35
+
36
+ if (bodyId === null || bodyId < 0) return;
37
+
38
+ // Find all meshes belonging to this body
39
+ const highlightColor = new THREE.Color(color);
40
+ scene.traverse((obj) => {
41
+ if (obj.userData.bodyID === bodyId && (obj as THREE.Mesh).isMesh) {
42
+ const mesh = obj as THREE.Mesh;
43
+ const mat = mesh.material as THREE.MeshStandardMaterial;
44
+ if (mat.emissive) {
45
+ prevMeshesRef.current.push({
46
+ mesh,
47
+ originalEmissive: mat.emissive.clone(),
48
+ originalIntensity: mat.emissiveIntensity ?? 0,
49
+ });
50
+ mat.emissive.copy(highlightColor);
51
+ mat.emissiveIntensity = emissiveIntensity;
52
+ }
53
+ }
54
+ });
55
+
56
+ return () => {
57
+ for (const entry of prevMeshesRef.current) {
58
+ const mat = entry.mesh.material as THREE.MeshStandardMaterial;
59
+ if (mat.emissive) {
60
+ mat.emissive.copy(entry.originalEmissive);
61
+ mat.emissiveIntensity = entry.originalIntensity;
62
+ }
63
+ }
64
+ prevMeshesRef.current = [];
65
+ };
66
+ }, [bodyId, color, emissiveIntensity, scene]);
67
+
68
+ return null;
69
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * TendonRenderer — render tendons as tube geometries (spec 6.4)
6
+ *
7
+ * WASM fields used: model.ntendon, model.ten_wrapadr, model.ten_wrapnum
8
+ * data.wrap_xpos, data.ten_wrapadr (runtime)
9
+ *
10
+ * Note: ten_rgba and ten_width are NOT available in mujoco-js 0.0.7.
11
+ * Tendons use a default color and width.
12
+ */
13
+
14
+ import { useRef } from 'react';
15
+ import { useFrame } from '@react-three/fiber';
16
+ import * as THREE from 'three';
17
+ import { useMujocoSim } from '../core/MujocoSimProvider';
18
+
19
+ const DEFAULT_TENDON_COLOR = new THREE.Color(0.3, 0.3, 0.8);
20
+ const DEFAULT_TENDON_WIDTH = 0.002;
21
+
22
+ export function TendonRenderer() {
23
+ const { mjModelRef, mjDataRef, status } = useMujocoSim();
24
+ const groupRef = useRef<THREE.Group>(null);
25
+ const meshesRef = useRef<THREE.Mesh[]>([]);
26
+
27
+ useFrame(() => {
28
+ const model = mjModelRef.current;
29
+ const data = mjDataRef.current;
30
+ const group = groupRef.current;
31
+ if (!model || !data || !group) return;
32
+
33
+ const ntendon = model.ntendon ?? 0;
34
+ if (ntendon === 0) return;
35
+
36
+ // Clean up old meshes
37
+ for (const mesh of meshesRef.current) {
38
+ group.remove(mesh);
39
+ mesh.geometry.dispose();
40
+ (mesh.material as THREE.Material).dispose();
41
+ }
42
+ meshesRef.current = [];
43
+
44
+ for (let t = 0; t < ntendon; t++) {
45
+ const wrapAdr = model.ten_wrapadr[t];
46
+ const wrapNum = model.ten_wrapnum[t];
47
+ if (wrapNum < 2) continue;
48
+
49
+ // Get wrap path points from data
50
+ const points: THREE.Vector3[] = [];
51
+ for (let w = 0; w < wrapNum; w++) {
52
+ const idx = (wrapAdr + w) * 3;
53
+ if (data.wrap_xpos && idx + 2 < data.wrap_xpos.length) {
54
+ const x = data.wrap_xpos[idx];
55
+ const y = data.wrap_xpos[idx + 1];
56
+ const z = data.wrap_xpos[idx + 2];
57
+ // Skip zero points (uninitialized wrap points)
58
+ if (x !== 0 || y !== 0 || z !== 0) {
59
+ points.push(new THREE.Vector3(x, y, z));
60
+ }
61
+ }
62
+ }
63
+
64
+ if (points.length < 2) continue;
65
+
66
+ const curve = new THREE.CatmullRomCurve3(points, false);
67
+ const geometry = new THREE.TubeGeometry(
68
+ curve, Math.max(points.length * 2, 4), DEFAULT_TENDON_WIDTH, 6, false
69
+ );
70
+
71
+ const material = new THREE.MeshStandardMaterial({
72
+ color: DEFAULT_TENDON_COLOR,
73
+ roughness: 0.6,
74
+ metalness: 0.1,
75
+ });
76
+ const mesh = new THREE.Mesh(geometry, material);
77
+ group.add(mesh);
78
+ meshesRef.current.push(mesh);
79
+ }
80
+ });
81
+
82
+ if (status !== 'ready') return null;
83
+ return <group ref={groupRef} />;
84
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * TrajectoryPlayer — component form of trajectory playback (spec 13.2)
6
+ */
7
+
8
+ import { useEffect } from 'react';
9
+ import { useTrajectoryPlayer } from '../hooks/useTrajectoryPlayer';
10
+ import type { TrajectoryPlayerProps } from '../types';
11
+
12
+ /**
13
+ * Component wrapper for useTrajectoryPlayer.
14
+ * Provides declarative trajectory playback controlled via props.
15
+ */
16
+ export function TrajectoryPlayer({
17
+ trajectory,
18
+ fps = 30,
19
+ loop = false,
20
+ playing = false,
21
+ onFrame,
22
+ }: TrajectoryPlayerProps) {
23
+ const player = useTrajectoryPlayer(trajectory, { fps, loop });
24
+
25
+ useEffect(() => {
26
+ if (playing) {
27
+ player.play();
28
+ } else {
29
+ player.pause();
30
+ }
31
+ }, [playing]);
32
+
33
+ useEffect(() => {
34
+ if (onFrame) {
35
+ // Poll frame changes (lightweight, no extra useFrame needed)
36
+ const interval = setInterval(() => {
37
+ if (player.playing) onFrame(player.frame);
38
+ }, 1000 / fps);
39
+ return () => clearInterval(interval);
40
+ }
41
+ }, [onFrame, fps]);
42
+
43
+ return null;
44
+ }