mujoco-react 0.2.0 → 1.0.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 +287 -48
- package/dist/index.d.ts +215 -135
- package/dist/index.js +1176 -795
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/src/components/ContactMarkers.tsx +19 -22
- package/src/components/Debug.tsx +173 -36
- package/src/components/DragInteraction.tsx +5 -3
- package/src/components/FlexRenderer.tsx +3 -2
- 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 +13 -8
- package/src/components/SelectionHighlight.tsx +2 -49
- package/src/components/TendonRenderer.tsx +93 -28
- package/src/components/TrajectoryPlayer.tsx +14 -10
- package/src/core/IkContext.tsx +40 -0
- package/src/core/MujocoCanvas.tsx +1 -5
- package/src/core/MujocoPhysics.tsx +79 -0
- package/src/core/MujocoProvider.tsx +12 -4
- package/src/core/MujocoSimProvider.tsx +56 -340
- package/src/core/SceneLoader.ts +45 -18
- 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 +18 -1
- package/src/types.ts +53 -26
|
@@ -11,20 +11,27 @@
|
|
|
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
|
+
import type { ThreeElements } from '@react-three/fiber';
|
|
16
17
|
import * as THREE from 'three';
|
|
17
18
|
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
18
19
|
|
|
19
20
|
const DEFAULT_TENDON_COLOR = new THREE.Color(0.3, 0.3, 0.8);
|
|
20
21
|
const DEFAULT_TENDON_WIDTH = 0.002;
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
// Preallocated temp vector to avoid per-frame allocations
|
|
24
|
+
const _tmpVec = new THREE.Vector3();
|
|
25
|
+
|
|
26
|
+
export function TendonRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
|
|
23
27
|
const { mjModelRef, mjDataRef, status } = useMujocoSim();
|
|
24
28
|
const groupRef = useRef<THREE.Group>(null);
|
|
25
29
|
const meshesRef = useRef<THREE.Mesh[]>([]);
|
|
30
|
+
const curvesRef = useRef<THREE.CatmullRomCurve3[]>([]);
|
|
31
|
+
const materialRef = useRef<THREE.MeshStandardMaterial | null>(null);
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
// Build tendon meshes once when model loads
|
|
34
|
+
useEffect(() => {
|
|
28
35
|
const model = mjModelRef.current;
|
|
29
36
|
const data = mjDataRef.current;
|
|
30
37
|
const group = groupRef.current;
|
|
@@ -33,52 +40,110 @@ export function TendonRenderer() {
|
|
|
33
40
|
const ntendon = model.ntendon ?? 0;
|
|
34
41
|
if (ntendon === 0) return;
|
|
35
42
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
// Shared material for all tendons
|
|
44
|
+
const material = new THREE.MeshStandardMaterial({
|
|
45
|
+
color: DEFAULT_TENDON_COLOR,
|
|
46
|
+
roughness: 0.6,
|
|
47
|
+
metalness: 0.1,
|
|
48
|
+
});
|
|
49
|
+
materialRef.current = material;
|
|
50
|
+
|
|
51
|
+
const meshes: THREE.Mesh[] = [];
|
|
52
|
+
const curves: THREE.CatmullRomCurve3[] = [];
|
|
53
|
+
|
|
54
|
+
for (let t = 0; t < ntendon; t++) {
|
|
55
|
+
const wrapNum = model.ten_wrapnum[t];
|
|
56
|
+
if (wrapNum < 2) {
|
|
57
|
+
meshes.push(null!);
|
|
58
|
+
curves.push(null!);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Initial dummy points — will be overwritten in useFrame
|
|
63
|
+
const points = Array.from({ length: wrapNum }, () => new THREE.Vector3());
|
|
64
|
+
const curve = new THREE.CatmullRomCurve3(points, false);
|
|
65
|
+
const segments = Math.max(wrapNum * 2, 4);
|
|
66
|
+
const geometry = new THREE.TubeGeometry(curve, segments, DEFAULT_TENDON_WIDTH, 6, false);
|
|
67
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
68
|
+
mesh.frustumCulled = false;
|
|
69
|
+
group.add(mesh);
|
|
70
|
+
meshes.push(mesh);
|
|
71
|
+
curves.push(curve);
|
|
41
72
|
}
|
|
42
|
-
|
|
73
|
+
|
|
74
|
+
meshesRef.current = meshes;
|
|
75
|
+
curvesRef.current = curves;
|
|
76
|
+
|
|
77
|
+
return () => {
|
|
78
|
+
for (const mesh of meshes) {
|
|
79
|
+
if (!mesh) continue;
|
|
80
|
+
group.remove(mesh);
|
|
81
|
+
mesh.geometry.dispose();
|
|
82
|
+
}
|
|
83
|
+
material.dispose();
|
|
84
|
+
meshesRef.current = [];
|
|
85
|
+
curvesRef.current = [];
|
|
86
|
+
materialRef.current = null;
|
|
87
|
+
};
|
|
88
|
+
}, [status, mjModelRef, mjDataRef]);
|
|
89
|
+
|
|
90
|
+
// Update curve control points and rebuild geometry each frame
|
|
91
|
+
useFrame(() => {
|
|
92
|
+
const model = mjModelRef.current;
|
|
93
|
+
const data = mjDataRef.current;
|
|
94
|
+
if (!model || !data) return;
|
|
95
|
+
|
|
96
|
+
const ntendon = model.ntendon ?? 0;
|
|
97
|
+
const meshes = meshesRef.current;
|
|
98
|
+
const curves = curvesRef.current;
|
|
43
99
|
|
|
44
100
|
for (let t = 0; t < ntendon; t++) {
|
|
101
|
+
const mesh = meshes[t];
|
|
102
|
+
const curve = curves[t];
|
|
103
|
+
if (!mesh || !curve) continue;
|
|
104
|
+
|
|
45
105
|
const wrapAdr = model.ten_wrapadr[t];
|
|
46
106
|
const wrapNum = model.ten_wrapnum[t];
|
|
47
|
-
if (wrapNum < 2) continue;
|
|
48
107
|
|
|
49
|
-
//
|
|
50
|
-
|
|
108
|
+
// Update existing control points in-place
|
|
109
|
+
let validCount = 0;
|
|
51
110
|
for (let w = 0; w < wrapNum; w++) {
|
|
52
111
|
const idx = (wrapAdr + w) * 3;
|
|
53
112
|
if (data.wrap_xpos && idx + 2 < data.wrap_xpos.length) {
|
|
54
113
|
const x = data.wrap_xpos[idx];
|
|
55
114
|
const y = data.wrap_xpos[idx + 1];
|
|
56
115
|
const z = data.wrap_xpos[idx + 2];
|
|
57
|
-
// Skip zero points (uninitialized wrap points)
|
|
58
116
|
if (x !== 0 || y !== 0 || z !== 0) {
|
|
59
|
-
|
|
117
|
+
if (validCount < curve.points.length) {
|
|
118
|
+
curve.points[validCount].set(x, y, z);
|
|
119
|
+
}
|
|
120
|
+
validCount++;
|
|
60
121
|
}
|
|
61
122
|
}
|
|
62
123
|
}
|
|
63
124
|
|
|
64
|
-
if (
|
|
125
|
+
if (validCount < 2) {
|
|
126
|
+
mesh.visible = false;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
65
129
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
curve
|
|
69
|
-
|
|
130
|
+
// Trim or pad points array to match valid count
|
|
131
|
+
if (curve.points.length !== validCount) {
|
|
132
|
+
curve.points.length = validCount;
|
|
133
|
+
while (curve.points.length < validCount) {
|
|
134
|
+
curve.points.push(new THREE.Vector3());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
70
137
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
group.add(mesh);
|
|
78
|
-
meshesRef.current.push(mesh);
|
|
138
|
+
// Rebuild geometry from updated curve
|
|
139
|
+
mesh.geometry.dispose();
|
|
140
|
+
mesh.geometry = new THREE.TubeGeometry(
|
|
141
|
+
curve, Math.max(validCount * 2, 4), DEFAULT_TENDON_WIDTH, 6, false
|
|
142
|
+
);
|
|
143
|
+
mesh.visible = true;
|
|
79
144
|
}
|
|
80
145
|
});
|
|
81
146
|
|
|
82
147
|
if (status !== 'ready') return null;
|
|
83
|
-
return <group ref={groupRef} />;
|
|
148
|
+
return <group {...props} ref={groupRef} />;
|
|
84
149
|
}
|
|
@@ -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
|
+
}
|
|
@@ -24,15 +24,12 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
|
|
|
24
24
|
onError,
|
|
25
25
|
onStep,
|
|
26
26
|
onSelection,
|
|
27
|
-
// Declarative physics config
|
|
27
|
+
// Declarative physics config
|
|
28
28
|
gravity,
|
|
29
29
|
timestep,
|
|
30
30
|
substeps,
|
|
31
31
|
paused,
|
|
32
32
|
speed,
|
|
33
|
-
interpolate,
|
|
34
|
-
gravityCompensation,
|
|
35
|
-
mjcfLights,
|
|
36
33
|
children,
|
|
37
34
|
...canvasProps
|
|
38
35
|
},
|
|
@@ -65,7 +62,6 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
|
|
|
65
62
|
substeps={substeps}
|
|
66
63
|
paused={paused}
|
|
67
64
|
speed={speed}
|
|
68
|
-
interpolate={interpolate}
|
|
69
65
|
>
|
|
70
66
|
{children}
|
|
71
67
|
</MujocoSimProvider>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { forwardRef, useEffect } from 'react';
|
|
7
|
+
import { useMujoco } from './MujocoProvider';
|
|
8
|
+
import { MujocoSimProvider } from './MujocoSimProvider';
|
|
9
|
+
import type { MujocoSimAPI, SceneConfig } from '../types';
|
|
10
|
+
|
|
11
|
+
export interface MujocoPhysicsProps {
|
|
12
|
+
/** Scene/robot configuration. */
|
|
13
|
+
config: SceneConfig;
|
|
14
|
+
/** Fires when model is loaded and API is ready. */
|
|
15
|
+
onReady?: (api: MujocoSimAPI) => void;
|
|
16
|
+
/** Fires on scene load failure. */
|
|
17
|
+
onError?: (error: Error) => void;
|
|
18
|
+
/** Called each physics step. */
|
|
19
|
+
onStep?: (time: number) => void;
|
|
20
|
+
/** Called on body double-click selection. */
|
|
21
|
+
onSelection?: (bodyId: number, name: string) => void;
|
|
22
|
+
/** Override model gravity. */
|
|
23
|
+
gravity?: [number, number, number];
|
|
24
|
+
/** Override model.opt.timestep. */
|
|
25
|
+
timestep?: number;
|
|
26
|
+
/** mj_step calls per frame. */
|
|
27
|
+
substeps?: number;
|
|
28
|
+
/** Declarative pause. */
|
|
29
|
+
paused?: boolean;
|
|
30
|
+
/** Simulation speed multiplier. */
|
|
31
|
+
speed?: number;
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* MujocoPhysics — physics provider for use inside a user-owned R3F Canvas.
|
|
37
|
+
*
|
|
38
|
+
* This is the R3F-idiomatic alternative to MujocoCanvas. Instead of wrapping
|
|
39
|
+
* the Canvas, place this inside your own <Canvas>:
|
|
40
|
+
*
|
|
41
|
+
* ```tsx
|
|
42
|
+
* <MujocoProvider>
|
|
43
|
+
* <Canvas shadows camera={...}>
|
|
44
|
+
* <MujocoPhysics config={config} paused={paused}>
|
|
45
|
+
* <SceneRenderer />
|
|
46
|
+
* <OrbitControls />
|
|
47
|
+
* </MujocoPhysics>
|
|
48
|
+
* </Canvas>
|
|
49
|
+
* </MujocoProvider>
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* Forward ref exposes MujocoSimAPI.
|
|
53
|
+
*/
|
|
54
|
+
export const MujocoPhysics = forwardRef<MujocoSimAPI, MujocoPhysicsProps>(
|
|
55
|
+
function MujocoPhysics({ onError, children, ...props }, ref) {
|
|
56
|
+
const { mujoco, status: wasmStatus, error: wasmError } = useMujoco();
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (wasmStatus === 'error' && onError) {
|
|
60
|
+
onError(new Error(wasmError ?? 'WASM load failed'));
|
|
61
|
+
}
|
|
62
|
+
}, [wasmStatus, wasmError, onError]);
|
|
63
|
+
|
|
64
|
+
if (wasmStatus === 'error' || wasmStatus === 'loading' || !mujoco) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<MujocoSimProvider
|
|
70
|
+
mujoco={mujoco}
|
|
71
|
+
apiRef={ref}
|
|
72
|
+
onError={onError}
|
|
73
|
+
{...props}
|
|
74
|
+
>
|
|
75
|
+
{children}
|
|
76
|
+
</MujocoSimProvider>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
);
|
|
@@ -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
|