mujoco-react 0.1.0 → 0.3.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/README.md +209 -45
- package/dist/index.d.ts +180 -97
- package/dist/index.js +1148 -772
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ContactMarkers.tsx +12 -19
- package/src/components/Debug.tsx +168 -33
- package/src/components/DragInteraction.tsx +1 -1
- package/src/components/IkController.tsx +262 -0
- package/src/components/IkGizmo.tsx +17 -25
- package/src/components/SceneLights.tsx +2 -112
- package/src/components/SceneRenderer.tsx +8 -6
- package/src/components/SelectionHighlight.tsx +2 -49
- package/src/components/TendonRenderer.tsx +90 -26
- package/src/components/TrajectoryPlayer.tsx +14 -10
- package/src/core/IkContext.tsx +40 -0
- package/src/core/MujocoCanvas.tsx +6 -3
- package/src/core/MujocoProvider.tsx +12 -4
- package/src/core/MujocoSimProvider.tsx +69 -334
- package/src/core/SceneLoader.ts +44 -11
- package/src/core/createController.tsx +91 -0
- package/src/hooks/useCameraAnimation.ts +102 -0
- package/src/hooks/useContacts.ts +52 -22
- package/src/hooks/useJointState.ts +18 -2
- package/src/hooks/useSceneLights.ts +117 -0
- package/src/hooks/useSelectionHighlight.ts +65 -0
- package/src/index.ts +16 -1
- package/src/types.ts +59 -22
|
@@ -8,6 +8,7 @@ import { useFrame, useThree } from '@react-three/fiber';
|
|
|
8
8
|
import { useEffect, useRef } from 'react';
|
|
9
9
|
import * as THREE from 'three';
|
|
10
10
|
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
11
|
+
import { useIk } from '../core/IkContext';
|
|
11
12
|
import { findSiteByName } from '../core/SceneLoader';
|
|
12
13
|
import type { IkGizmoProps } from '../types';
|
|
13
14
|
|
|
@@ -20,25 +21,18 @@ const _scale = new THREE.Vector3(1, 1, 1);
|
|
|
20
21
|
/**
|
|
21
22
|
* IkGizmo — drei PivotControls that tracks a MuJoCo site.
|
|
22
23
|
*
|
|
24
|
+
* Must be rendered inside an `<IkController>`.
|
|
25
|
+
*
|
|
23
26
|
* Props:
|
|
24
|
-
* - `siteName` — MuJoCo site to track. Defaults to
|
|
27
|
+
* - `siteName` — MuJoCo site to track. Defaults to the IkController's configured site.
|
|
25
28
|
* - `scale` — Gizmo handle scale. Default: 0.18.
|
|
26
29
|
* - `onDrag` — Custom drag callback `(pos, quat) => void`.
|
|
27
|
-
* When omitted, dragging enables IK and writes to the
|
|
30
|
+
* When omitted, dragging enables IK and writes to the IK target.
|
|
28
31
|
* 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
32
|
*/
|
|
37
33
|
export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
|
|
38
|
-
const {
|
|
39
|
-
|
|
40
|
-
api, ikEnabledRef, status,
|
|
41
|
-
} = useMujocoSim();
|
|
34
|
+
const { mjModelRef, mjDataRef, status } = useMujocoSim();
|
|
35
|
+
const { ikTargetRef, siteIdRef, ikEnabledRef, setIkEnabled } = useIk();
|
|
42
36
|
|
|
43
37
|
const wrapperRef = useRef<THREE.Group>(null);
|
|
44
38
|
const pivotRef = useRef<THREE.Group>(null);
|
|
@@ -46,25 +40,23 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
|
|
|
46
40
|
const localSiteIdRef = useRef(-1);
|
|
47
41
|
const { controls } = useThree();
|
|
48
42
|
|
|
49
|
-
// Resolve the site ID from siteName (
|
|
43
|
+
// Resolve the site ID from siteName (only when an explicit siteName override is given)
|
|
50
44
|
useEffect(() => {
|
|
51
45
|
const model = mjModelRef.current;
|
|
52
|
-
if (!model || status !== 'ready') {
|
|
46
|
+
if (!model || status !== 'ready' || !siteName) {
|
|
53
47
|
localSiteIdRef.current = -1;
|
|
54
48
|
return;
|
|
55
49
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
} else {
|
|
59
|
-
// Default: use the provider's siteIdRef (from SceneConfig.tcpSiteName)
|
|
60
|
-
localSiteIdRef.current = siteIdRef.current;
|
|
61
|
-
}
|
|
62
|
-
}, [siteName, status, mjModelRef, siteIdRef]);
|
|
50
|
+
localSiteIdRef.current = findSiteByName(model, siteName);
|
|
51
|
+
}, [siteName, status, mjModelRef]);
|
|
63
52
|
|
|
64
53
|
// Every frame: sync the visual wrapper to the tracked site (when not dragging)
|
|
65
54
|
useFrame(() => {
|
|
66
55
|
const data = mjDataRef.current;
|
|
67
|
-
|
|
56
|
+
// Read IkController's siteIdRef directly in useFrame — avoids useEffect timing
|
|
57
|
+
// issues (React runs child effects before parent effects, so reading siteIdRef
|
|
58
|
+
// in a useEffect would see -1 before IkController resolves it).
|
|
59
|
+
const sid = siteName ? localSiteIdRef.current : siteIdRef.current;
|
|
68
60
|
if (!data || sid < 0 || !wrapperRef.current) return;
|
|
69
61
|
|
|
70
62
|
if (!draggingRef.current) {
|
|
@@ -107,7 +99,7 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
|
|
|
107
99
|
draggingRef.current = true;
|
|
108
100
|
if (!onDrag) {
|
|
109
101
|
// Default: enable IK so the robot follows
|
|
110
|
-
if (!ikEnabledRef.current)
|
|
102
|
+
if (!ikEnabledRef.current) setIkEnabled(true);
|
|
111
103
|
}
|
|
112
104
|
if (controls) (controls as unknown as { enabled: boolean }).enabled = false;
|
|
113
105
|
}}
|
|
@@ -126,7 +118,7 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
|
|
|
126
118
|
// Custom: consumer handles the drag
|
|
127
119
|
onDrag(_pos.clone(), _quat.clone());
|
|
128
120
|
} else {
|
|
129
|
-
// Default: write to
|
|
121
|
+
// Default: write to IK target
|
|
130
122
|
const target = ikTargetRef.current;
|
|
131
123
|
if (target) {
|
|
132
124
|
target.position.copy(_pos);
|
|
@@ -12,120 +12,10 @@
|
|
|
12
12
|
* Note: light_directional does NOT exist in WASM — use light_type instead.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
import * as THREE from 'three';
|
|
17
|
-
import { useThree } from '@react-three/fiber';
|
|
18
|
-
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
15
|
+
import { useSceneLights } from '../hooks/useSceneLights';
|
|
19
16
|
import type { SceneLightsProps } from '../types';
|
|
20
17
|
|
|
21
18
|
export function SceneLights({ intensity = 1.0 }: SceneLightsProps) {
|
|
22
|
-
|
|
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
|
-
|
|
19
|
+
useSceneLights(intensity);
|
|
130
20
|
return null;
|
|
131
21
|
}
|
|
@@ -22,12 +22,13 @@ export function SceneRenderer() {
|
|
|
22
22
|
const prevModelRef = useRef<MujocoModel | null>(null);
|
|
23
23
|
|
|
24
24
|
const geomBuilder = useMemo(() => {
|
|
25
|
+
if (status !== 'ready') return null;
|
|
25
26
|
return new GeomBuilder(mujocoRef.current);
|
|
26
|
-
}, [mujocoRef
|
|
27
|
+
}, [status, mujocoRef]);
|
|
27
28
|
|
|
28
29
|
// Build body groups when model loads
|
|
29
30
|
useEffect(() => {
|
|
30
|
-
if (status !== 'ready') return;
|
|
31
|
+
if (status !== 'ready' || !geomBuilder) return;
|
|
31
32
|
const model = mjModelRef.current;
|
|
32
33
|
const group = groupRef.current;
|
|
33
34
|
if (!model || !group) return;
|
|
@@ -91,11 +92,12 @@ export function SceneRenderer() {
|
|
|
91
92
|
while (obj && obj.userData.bodyID === undefined && obj.parent) {
|
|
92
93
|
obj = obj.parent;
|
|
93
94
|
}
|
|
94
|
-
|
|
95
|
+
const bodyID = obj?.userData.bodyID;
|
|
96
|
+
if (typeof bodyID === 'number' && bodyID > 0) {
|
|
95
97
|
const model = mjModelRef.current;
|
|
96
|
-
if (model && onSelectionRef.current) {
|
|
97
|
-
const name = getName(model, model.name_bodyadr[
|
|
98
|
-
onSelectionRef.current(
|
|
98
|
+
if (model && bodyID < model.nbody && onSelectionRef.current) {
|
|
99
|
+
const name = getName(model, model.name_bodyadr[bodyID]);
|
|
100
|
+
onSelectionRef.current(bodyID, name);
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
}}
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
* SelectionHighlight — highlight a selected body with emissive color (spec 6.5)
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { useThree } from '@react-three/fiber';
|
|
10
|
-
import * as THREE from 'three';
|
|
8
|
+
import { useSelectionHighlight } from '../hooks/useSelectionHighlight';
|
|
11
9
|
import type { SelectionHighlightProps } from '../types';
|
|
12
10
|
|
|
13
11
|
/**
|
|
@@ -19,51 +17,6 @@ export function SelectionHighlight({
|
|
|
19
17
|
color = '#ff4444',
|
|
20
18
|
emissiveIntensity = 0.3,
|
|
21
19
|
}: SelectionHighlightProps) {
|
|
22
|
-
|
|
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
|
-
|
|
20
|
+
useSelectionHighlight(bodyId, { color, emissiveIntensity });
|
|
68
21
|
return null;
|
|
69
22
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Tendons use a default color and width.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { useRef } from 'react';
|
|
14
|
+
import { useEffect, useRef } from 'react';
|
|
15
15
|
import { useFrame } from '@react-three/fiber';
|
|
16
16
|
import * as THREE from 'three';
|
|
17
17
|
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
@@ -19,12 +19,18 @@ import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
|
19
19
|
const DEFAULT_TENDON_COLOR = new THREE.Color(0.3, 0.3, 0.8);
|
|
20
20
|
const DEFAULT_TENDON_WIDTH = 0.002;
|
|
21
21
|
|
|
22
|
+
// Preallocated temp vector to avoid per-frame allocations
|
|
23
|
+
const _tmpVec = new THREE.Vector3();
|
|
24
|
+
|
|
22
25
|
export function TendonRenderer() {
|
|
23
26
|
const { mjModelRef, mjDataRef, status } = useMujocoSim();
|
|
24
27
|
const groupRef = useRef<THREE.Group>(null);
|
|
25
28
|
const meshesRef = useRef<THREE.Mesh[]>([]);
|
|
29
|
+
const curvesRef = useRef<THREE.CatmullRomCurve3[]>([]);
|
|
30
|
+
const materialRef = useRef<THREE.MeshStandardMaterial | null>(null);
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
// Build tendon meshes once when model loads
|
|
33
|
+
useEffect(() => {
|
|
28
34
|
const model = mjModelRef.current;
|
|
29
35
|
const data = mjDataRef.current;
|
|
30
36
|
const group = groupRef.current;
|
|
@@ -33,49 +39,107 @@ export function TendonRenderer() {
|
|
|
33
39
|
const ntendon = model.ntendon ?? 0;
|
|
34
40
|
if (ntendon === 0) return;
|
|
35
41
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
// Shared material for all tendons
|
|
43
|
+
const material = new THREE.MeshStandardMaterial({
|
|
44
|
+
color: DEFAULT_TENDON_COLOR,
|
|
45
|
+
roughness: 0.6,
|
|
46
|
+
metalness: 0.1,
|
|
47
|
+
});
|
|
48
|
+
materialRef.current = material;
|
|
49
|
+
|
|
50
|
+
const meshes: THREE.Mesh[] = [];
|
|
51
|
+
const curves: THREE.CatmullRomCurve3[] = [];
|
|
52
|
+
|
|
53
|
+
for (let t = 0; t < ntendon; t++) {
|
|
54
|
+
const wrapNum = model.ten_wrapnum[t];
|
|
55
|
+
if (wrapNum < 2) {
|
|
56
|
+
meshes.push(null!);
|
|
57
|
+
curves.push(null!);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Initial dummy points — will be overwritten in useFrame
|
|
62
|
+
const points = Array.from({ length: wrapNum }, () => new THREE.Vector3());
|
|
63
|
+
const curve = new THREE.CatmullRomCurve3(points, false);
|
|
64
|
+
const segments = Math.max(wrapNum * 2, 4);
|
|
65
|
+
const geometry = new THREE.TubeGeometry(curve, segments, DEFAULT_TENDON_WIDTH, 6, false);
|
|
66
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
67
|
+
mesh.frustumCulled = false;
|
|
68
|
+
group.add(mesh);
|
|
69
|
+
meshes.push(mesh);
|
|
70
|
+
curves.push(curve);
|
|
41
71
|
}
|
|
42
|
-
|
|
72
|
+
|
|
73
|
+
meshesRef.current = meshes;
|
|
74
|
+
curvesRef.current = curves;
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
for (const mesh of meshes) {
|
|
78
|
+
if (!mesh) continue;
|
|
79
|
+
group.remove(mesh);
|
|
80
|
+
mesh.geometry.dispose();
|
|
81
|
+
}
|
|
82
|
+
material.dispose();
|
|
83
|
+
meshesRef.current = [];
|
|
84
|
+
curvesRef.current = [];
|
|
85
|
+
materialRef.current = null;
|
|
86
|
+
};
|
|
87
|
+
}, [status, mjModelRef, mjDataRef]);
|
|
88
|
+
|
|
89
|
+
// Update curve control points and rebuild geometry each frame
|
|
90
|
+
useFrame(() => {
|
|
91
|
+
const model = mjModelRef.current;
|
|
92
|
+
const data = mjDataRef.current;
|
|
93
|
+
if (!model || !data) return;
|
|
94
|
+
|
|
95
|
+
const ntendon = model.ntendon ?? 0;
|
|
96
|
+
const meshes = meshesRef.current;
|
|
97
|
+
const curves = curvesRef.current;
|
|
43
98
|
|
|
44
99
|
for (let t = 0; t < ntendon; t++) {
|
|
100
|
+
const mesh = meshes[t];
|
|
101
|
+
const curve = curves[t];
|
|
102
|
+
if (!mesh || !curve) continue;
|
|
103
|
+
|
|
45
104
|
const wrapAdr = model.ten_wrapadr[t];
|
|
46
105
|
const wrapNum = model.ten_wrapnum[t];
|
|
47
|
-
if (wrapNum < 2) continue;
|
|
48
106
|
|
|
49
|
-
//
|
|
50
|
-
|
|
107
|
+
// Update existing control points in-place
|
|
108
|
+
let validCount = 0;
|
|
51
109
|
for (let w = 0; w < wrapNum; w++) {
|
|
52
110
|
const idx = (wrapAdr + w) * 3;
|
|
53
111
|
if (data.wrap_xpos && idx + 2 < data.wrap_xpos.length) {
|
|
54
112
|
const x = data.wrap_xpos[idx];
|
|
55
113
|
const y = data.wrap_xpos[idx + 1];
|
|
56
114
|
const z = data.wrap_xpos[idx + 2];
|
|
57
|
-
// Skip zero points (uninitialized wrap points)
|
|
58
115
|
if (x !== 0 || y !== 0 || z !== 0) {
|
|
59
|
-
|
|
116
|
+
if (validCount < curve.points.length) {
|
|
117
|
+
curve.points[validCount].set(x, y, z);
|
|
118
|
+
}
|
|
119
|
+
validCount++;
|
|
60
120
|
}
|
|
61
121
|
}
|
|
62
122
|
}
|
|
63
123
|
|
|
64
|
-
if (
|
|
124
|
+
if (validCount < 2) {
|
|
125
|
+
mesh.visible = false;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
65
128
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
curve
|
|
69
|
-
|
|
129
|
+
// Trim or pad points array to match valid count
|
|
130
|
+
if (curve.points.length !== validCount) {
|
|
131
|
+
curve.points.length = validCount;
|
|
132
|
+
while (curve.points.length < validCount) {
|
|
133
|
+
curve.points.push(new THREE.Vector3());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
70
136
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
group.add(mesh);
|
|
78
|
-
meshesRef.current.push(mesh);
|
|
137
|
+
// Rebuild geometry from updated curve
|
|
138
|
+
mesh.geometry.dispose();
|
|
139
|
+
mesh.geometry = new THREE.TubeGeometry(
|
|
140
|
+
curve, Math.max(validCount * 2, 4), DEFAULT_TENDON_WIDTH, 6, false
|
|
141
|
+
);
|
|
142
|
+
mesh.visible = true;
|
|
79
143
|
}
|
|
80
144
|
});
|
|
81
145
|
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* TrajectoryPlayer — component form of trajectory playback (spec 13.2)
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { useEffect } from 'react';
|
|
8
|
+
import { useEffect, useRef } from 'react';
|
|
9
|
+
import { useFrame } from '@react-three/fiber';
|
|
9
10
|
import { useTrajectoryPlayer } from '../hooks/useTrajectoryPlayer';
|
|
10
11
|
import type { TrajectoryPlayerProps } from '../types';
|
|
11
12
|
|
|
@@ -21,6 +22,9 @@ export function TrajectoryPlayer({
|
|
|
21
22
|
onFrame,
|
|
22
23
|
}: TrajectoryPlayerProps) {
|
|
23
24
|
const player = useTrajectoryPlayer(trajectory, { fps, loop });
|
|
25
|
+
const onFrameRef = useRef(onFrame);
|
|
26
|
+
onFrameRef.current = onFrame;
|
|
27
|
+
const lastReportedFrameRef = useRef(-1);
|
|
24
28
|
|
|
25
29
|
useEffect(() => {
|
|
26
30
|
if (playing) {
|
|
@@ -28,17 +32,17 @@ export function TrajectoryPlayer({
|
|
|
28
32
|
} else {
|
|
29
33
|
player.pause();
|
|
30
34
|
}
|
|
31
|
-
}, [playing]);
|
|
35
|
+
}, [playing, player]);
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
// Use useFrame instead of setInterval to sync with the render loop
|
|
38
|
+
useFrame(() => {
|
|
39
|
+
if (!onFrameRef.current) return;
|
|
40
|
+
const currentFrame = player.frame;
|
|
41
|
+
if (currentFrame !== lastReportedFrameRef.current && player.playing) {
|
|
42
|
+
lastReportedFrameRef.current = currentFrame;
|
|
43
|
+
onFrameRef.current(currentFrame);
|
|
40
44
|
}
|
|
41
|
-
}
|
|
45
|
+
});
|
|
42
46
|
|
|
43
47
|
return null;
|
|
44
48
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* IkContext — React context for the IK controller plugin.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createContext, useContext } from 'react';
|
|
9
|
+
import * as THREE from 'three';
|
|
10
|
+
|
|
11
|
+
export interface IkContextValue {
|
|
12
|
+
ikEnabledRef: React.RefObject<boolean>;
|
|
13
|
+
ikCalculatingRef: React.RefObject<boolean>;
|
|
14
|
+
ikTargetRef: React.RefObject<THREE.Group>;
|
|
15
|
+
siteIdRef: React.RefObject<number>;
|
|
16
|
+
setIkEnabled(enabled: boolean): void;
|
|
17
|
+
moveTarget(pos: THREE.Vector3, duration?: number): void;
|
|
18
|
+
syncTargetToSite(): void;
|
|
19
|
+
solveIK(pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null;
|
|
20
|
+
getGizmoStats(): { pos: THREE.Vector3; rot: THREE.Euler } | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const IkContext = createContext<IkContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Access the IK controller context.
|
|
27
|
+
*
|
|
28
|
+
* - `useIk()` — throws if no `<IkController>` ancestor (use inside `<IkController>`)
|
|
29
|
+
* - `useIk({ optional: true })` — returns `null` if no ancestor (use in components
|
|
30
|
+
* that optionally interact with IK, e.g. keyboard controllers that disable IK)
|
|
31
|
+
*/
|
|
32
|
+
export function useIk(): IkContextValue;
|
|
33
|
+
export function useIk(options: { optional: true }): IkContextValue | null;
|
|
34
|
+
export function useIk(options?: { optional?: boolean }): IkContextValue | null {
|
|
35
|
+
const ctx = useContext(IkContext);
|
|
36
|
+
if (!ctx && !options?.optional) {
|
|
37
|
+
throw new Error('useIk() must be used inside an <IkController>');
|
|
38
|
+
}
|
|
39
|
+
return ctx;
|
|
40
|
+
}
|
|
@@ -7,14 +7,16 @@ import { Canvas } from '@react-three/fiber';
|
|
|
7
7
|
import { forwardRef, useEffect } from 'react';
|
|
8
8
|
import { useMujoco } from './MujocoProvider';
|
|
9
9
|
import { MujocoSimProvider } from './MujocoSimProvider';
|
|
10
|
-
import { MujocoCanvasProps } from '../types';
|
|
10
|
+
import { MujocoCanvasProps, MujocoSimAPI } from '../types';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* MujocoCanvas — thin R3F Canvas wrapper for MuJoCo scenes.
|
|
14
14
|
* Accepts all R3F Canvas props and forwards them through.
|
|
15
15
|
* Supports declarative physics config props (spec 1.1).
|
|
16
|
+
*
|
|
17
|
+
* Forward ref exposes MujocoSimAPI (not the canvas element).
|
|
16
18
|
*/
|
|
17
|
-
export const MujocoCanvas = forwardRef<
|
|
19
|
+
export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
|
|
18
20
|
function MujocoCanvas(
|
|
19
21
|
{
|
|
20
22
|
config,
|
|
@@ -49,10 +51,11 @@ export const MujocoCanvas = forwardRef<HTMLCanvasElement, MujocoCanvasProps>(
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
return (
|
|
52
|
-
<Canvas
|
|
54
|
+
<Canvas {...canvasProps}>
|
|
53
55
|
<MujocoSimProvider
|
|
54
56
|
mujoco={mujoco}
|
|
55
57
|
config={config}
|
|
58
|
+
apiRef={ref}
|
|
56
59
|
onReady={onReady}
|
|
57
60
|
onError={onError}
|
|
58
61
|
onStep={onStep}
|
|
@@ -22,6 +22,8 @@ export function useMujoco(): MujocoContextValue {
|
|
|
22
22
|
|
|
23
23
|
interface MujocoProviderProps {
|
|
24
24
|
wasmUrl?: string;
|
|
25
|
+
/** Timeout in ms for WASM module load. Default: 30000. */
|
|
26
|
+
timeout?: number;
|
|
25
27
|
children: React.ReactNode;
|
|
26
28
|
onError?: (error: Error) => void;
|
|
27
29
|
}
|
|
@@ -30,7 +32,7 @@ interface MujocoProviderProps {
|
|
|
30
32
|
* MujocoProvider — WASM / module lifecycle.
|
|
31
33
|
* Loads the MuJoCo WASM module on mount and provides it to children via context.
|
|
32
34
|
*/
|
|
33
|
-
export function MujocoProvider({ wasmUrl, children, onError }: MujocoProviderProps) {
|
|
35
|
+
export function MujocoProvider({ wasmUrl, timeout = 30000, children, onError }: MujocoProviderProps) {
|
|
34
36
|
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
35
37
|
const [error, setError] = useState<string | null>(null);
|
|
36
38
|
const moduleRef = useRef<MujocoModule | null>(null);
|
|
@@ -39,7 +41,7 @@ export function MujocoProvider({ wasmUrl, children, onError }: MujocoProviderPro
|
|
|
39
41
|
useEffect(() => {
|
|
40
42
|
isMounted.current = true;
|
|
41
43
|
|
|
42
|
-
loadMujoco({
|
|
44
|
+
const wasmPromise = loadMujoco({
|
|
43
45
|
...(wasmUrl ? { locateFile: (path: string) => path.endsWith('.wasm') ? wasmUrl : path } : {}),
|
|
44
46
|
printErr: (text: string) => {
|
|
45
47
|
if (text.includes('Aborted') && isMounted.current) {
|
|
@@ -47,7 +49,13 @@ export function MujocoProvider({ wasmUrl, children, onError }: MujocoProviderPro
|
|
|
47
49
|
setStatus('error');
|
|
48
50
|
}
|
|
49
51
|
},
|
|
50
|
-
})
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
55
|
+
setTimeout(() => reject(new Error(`WASM module load timed out after ${timeout}ms`)), timeout)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
Promise.race([wasmPromise, timeoutPromise])
|
|
51
59
|
.then((inst: unknown) => {
|
|
52
60
|
if (isMounted.current) {
|
|
53
61
|
moduleRef.current = inst as MujocoModule;
|
|
@@ -66,7 +74,7 @@ export function MujocoProvider({ wasmUrl, children, onError }: MujocoProviderPro
|
|
|
66
74
|
return () => {
|
|
67
75
|
isMounted.current = false;
|
|
68
76
|
};
|
|
69
|
-
}, [wasmUrl]);
|
|
77
|
+
}, [wasmUrl, timeout]);
|
|
70
78
|
|
|
71
79
|
return (
|
|
72
80
|
<MujocoContext.Provider
|