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.
- package/LICENSE +177 -0
- package/README.md +510 -0
- package/dist/index.d.ts +1080 -0
- package/dist/index.js +3518 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/components/ContactListener.tsx +26 -0
- package/src/components/ContactMarkers.tsx +81 -0
- package/src/components/Debug.tsx +227 -0
- package/src/components/DragInteraction.tsx +227 -0
- package/src/components/FlexRenderer.tsx +102 -0
- package/src/components/IkGizmo.tsx +146 -0
- package/src/components/SceneLights.tsx +131 -0
- package/src/components/SceneRenderer.tsx +104 -0
- package/src/components/SelectionHighlight.tsx +69 -0
- package/src/components/TendonRenderer.tsx +84 -0
- package/src/components/TrajectoryPlayer.tsx +44 -0
- package/src/core/GenericIK.ts +339 -0
- package/src/core/MujocoCanvas.tsx +72 -0
- package/src/core/MujocoProvider.tsx +78 -0
- package/src/core/MujocoSimProvider.tsx +1201 -0
- package/src/core/SceneLoader.ts +275 -0
- package/src/hooks/useActuators.ts +36 -0
- package/src/hooks/useBodyState.ts +56 -0
- package/src/hooks/useContacts.ts +125 -0
- package/src/hooks/useCtrl.ts +40 -0
- package/src/hooks/useCtrlNoise.ts +59 -0
- package/src/hooks/useGamepad.ts +77 -0
- package/src/hooks/useGravityCompensation.ts +22 -0
- package/src/hooks/useJointState.ts +64 -0
- package/src/hooks/useKeyboardTeleop.ts +97 -0
- package/src/hooks/usePolicy.ts +56 -0
- package/src/hooks/useSensor.ts +83 -0
- package/src/hooks/useSitePosition.ts +62 -0
- package/src/hooks/useTrajectoryPlayer.ts +105 -0
- package/src/hooks/useTrajectoryRecorder.ts +97 -0
- package/src/hooks/useVideoRecorder.ts +82 -0
- package/src/index.ts +108 -0
- package/src/rendering/CapsuleGeometry.ts +35 -0
- package/src/rendering/GeomBuilder.ts +140 -0
- package/src/rendering/Reflector.ts +225 -0
- 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
|
+
}
|