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,1201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useFrame, useThree } from '@react-three/fiber';
|
|
7
|
+
import {
|
|
8
|
+
createContext,
|
|
9
|
+
useCallback,
|
|
10
|
+
useContext,
|
|
11
|
+
useEffect,
|
|
12
|
+
useMemo,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
} from 'react';
|
|
16
|
+
import * as THREE from 'three';
|
|
17
|
+
import { MujocoData, MujocoModel, MujocoModule } from '../types';
|
|
18
|
+
import { GenericIK } from './GenericIK';
|
|
19
|
+
import {
|
|
20
|
+
ActuatorInfo,
|
|
21
|
+
BodyInfo,
|
|
22
|
+
ContactInfo,
|
|
23
|
+
GeomInfo,
|
|
24
|
+
IKSolveFn,
|
|
25
|
+
JointInfo,
|
|
26
|
+
ModelOptions,
|
|
27
|
+
MujocoSimAPI,
|
|
28
|
+
PhysicsStepCallback,
|
|
29
|
+
RayHit,
|
|
30
|
+
SceneConfig,
|
|
31
|
+
SensorInfo,
|
|
32
|
+
SiteInfo,
|
|
33
|
+
StateSnapshot,
|
|
34
|
+
} from '../types';
|
|
35
|
+
import {
|
|
36
|
+
loadScene,
|
|
37
|
+
findKeyframeByName,
|
|
38
|
+
findBodyByName,
|
|
39
|
+
findGeomByName,
|
|
40
|
+
findSensorByName,
|
|
41
|
+
findActuatorByName,
|
|
42
|
+
getName,
|
|
43
|
+
} from './SceneLoader';
|
|
44
|
+
|
|
45
|
+
// ---- Joint type names ----
|
|
46
|
+
const JOINT_TYPE_NAMES = ['free', 'ball', 'slide', 'hinge'];
|
|
47
|
+
// ---- Geom type names ----
|
|
48
|
+
const GEOM_TYPE_NAMES = ['plane', 'hfield', 'sphere', 'capsule', 'ellipsoid', 'cylinder', 'box', 'mesh'];
|
|
49
|
+
// ---- Sensor type names (subset — MuJoCo has many) ----
|
|
50
|
+
// Sensor type names matching mjtSensor enum in mujoco WASM (mujoco-js 0.0.7)
|
|
51
|
+
const SENSOR_TYPE_NAMES: Record<number, string> = {
|
|
52
|
+
0: 'touch', 1: 'accelerometer', 2: 'velocimeter', 3: 'gyro',
|
|
53
|
+
4: 'force', 5: 'torque', 6: 'magnetometer', 7: 'rangefinder',
|
|
54
|
+
8: 'camprojection', 9: 'jointpos', 10: 'jointvel', 11: 'tendonpos',
|
|
55
|
+
12: 'tendonvel', 13: 'actuatorpos', 14: 'actuatorvel', 15: 'actuatorfrc',
|
|
56
|
+
16: 'jointactfrc', 17: 'tendonactfrc', 18: 'ballquat', 19: 'ballangvel',
|
|
57
|
+
20: 'jointlimitpos', 21: 'jointlimitvel', 22: 'jointlimitfrc',
|
|
58
|
+
23: 'tendonlimitpos', 24: 'tendonlimitvel', 25: 'tendonlimitfrc',
|
|
59
|
+
26: 'framepos', 27: 'framequat', 28: 'framexaxis', 29: 'frameyaxis',
|
|
60
|
+
30: 'framezaxis', 31: 'framelinvel', 32: 'frameangvel',
|
|
61
|
+
33: 'framelinacc', 34: 'frameangacc', 35: 'subtreecom',
|
|
62
|
+
36: 'subtreelinvel', 37: 'subtreeangmom', 38: 'insidesite',
|
|
63
|
+
39: 'geomdist', 40: 'geomnormal', 41: 'geomfromto',
|
|
64
|
+
42: 'contact', 43: 'e_potential', 44: 'e_kinetic',
|
|
65
|
+
45: 'clock', 46: 'tactile', 47: 'plugin', 48: 'user',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Preallocated force/torque temps for applyForce/applyTorque
|
|
69
|
+
const _applyForce = new Float64Array(3);
|
|
70
|
+
const _applyTorque = new Float64Array(3);
|
|
71
|
+
const _applyPoint = new Float64Array(3);
|
|
72
|
+
const _rayPnt = new Float64Array(3);
|
|
73
|
+
const _rayVec = new Float64Array(3);
|
|
74
|
+
const _rayGeomId = new Int32Array(1);
|
|
75
|
+
|
|
76
|
+
// ---- Internal context types ----
|
|
77
|
+
|
|
78
|
+
export interface MujocoSimContextValue {
|
|
79
|
+
api: MujocoSimAPI;
|
|
80
|
+
mjModelRef: React.RefObject<MujocoModel | null>;
|
|
81
|
+
mjDataRef: React.RefObject<MujocoData | null>;
|
|
82
|
+
mujocoRef: React.RefObject<MujocoModule>;
|
|
83
|
+
configRef: React.RefObject<SceneConfig>;
|
|
84
|
+
siteIdRef: React.RefObject<number>;
|
|
85
|
+
gripperIdRef: React.RefObject<number>;
|
|
86
|
+
ikEnabledRef: React.RefObject<boolean>;
|
|
87
|
+
ikCalculatingRef: React.RefObject<boolean>;
|
|
88
|
+
pausedRef: React.RefObject<boolean>;
|
|
89
|
+
speedRef: React.RefObject<number>;
|
|
90
|
+
substepsRef: React.RefObject<number>;
|
|
91
|
+
ikTargetRef: React.RefObject<THREE.Group>;
|
|
92
|
+
genericIkRef: React.RefObject<GenericIK>;
|
|
93
|
+
ikSolveFnRef: React.RefObject<IKSolveFn>;
|
|
94
|
+
firstIkEnableRef: React.RefObject<boolean>;
|
|
95
|
+
gizmoAnimRef: React.RefObject<{
|
|
96
|
+
active: boolean;
|
|
97
|
+
startPos: THREE.Vector3;
|
|
98
|
+
endPos: THREE.Vector3;
|
|
99
|
+
startRot: THREE.Quaternion;
|
|
100
|
+
endRot: THREE.Quaternion;
|
|
101
|
+
startTime: number;
|
|
102
|
+
duration: number;
|
|
103
|
+
}>;
|
|
104
|
+
cameraAnimRef: React.RefObject<{
|
|
105
|
+
active: boolean;
|
|
106
|
+
startPos: THREE.Vector3;
|
|
107
|
+
endPos: THREE.Vector3;
|
|
108
|
+
startRot: THREE.Quaternion;
|
|
109
|
+
endRot: THREE.Quaternion;
|
|
110
|
+
startTarget: THREE.Vector3;
|
|
111
|
+
endTarget: THREE.Vector3;
|
|
112
|
+
startTime: number;
|
|
113
|
+
duration: number;
|
|
114
|
+
resolve: (() => void) | null;
|
|
115
|
+
}>;
|
|
116
|
+
onSelectionRef: React.RefObject<
|
|
117
|
+
((bodyId: number, name: string) => void) | undefined
|
|
118
|
+
>;
|
|
119
|
+
beforeStepCallbacks: React.RefObject<Set<PhysicsStepCallback>>;
|
|
120
|
+
afterStepCallbacks: React.RefObject<Set<PhysicsStepCallback>>;
|
|
121
|
+
status: 'loading' | 'ready' | 'error';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const MujocoSimContext = createContext<MujocoSimContextValue | null>(null);
|
|
125
|
+
|
|
126
|
+
export function useMujocoSim(): MujocoSimContextValue {
|
|
127
|
+
const ctx = useContext(MujocoSimContext);
|
|
128
|
+
if (!ctx)
|
|
129
|
+
throw new Error('useMujocoSim must be used inside <MujocoSimProvider>');
|
|
130
|
+
return ctx;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function useBeforePhysicsStep(callback: PhysicsStepCallback) {
|
|
134
|
+
const { beforeStepCallbacks } = useMujocoSim();
|
|
135
|
+
const callbackRef = useRef(callback);
|
|
136
|
+
callbackRef.current = callback;
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const wrapped: PhysicsStepCallback = (model, data) => callbackRef.current(model, data);
|
|
140
|
+
beforeStepCallbacks.current.add(wrapped);
|
|
141
|
+
return () => { beforeStepCallbacks.current.delete(wrapped); };
|
|
142
|
+
}, [beforeStepCallbacks]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function useAfterPhysicsStep(callback: PhysicsStepCallback) {
|
|
146
|
+
const { afterStepCallbacks } = useMujocoSim();
|
|
147
|
+
const callbackRef = useRef(callback);
|
|
148
|
+
callbackRef.current = callback;
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const wrapped: PhysicsStepCallback = (model, data) => callbackRef.current(model, data);
|
|
152
|
+
afterStepCallbacks.current.add(wrapped);
|
|
153
|
+
return () => { afterStepCallbacks.current.delete(wrapped); };
|
|
154
|
+
}, [afterStepCallbacks]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface MujocoSimProviderProps {
|
|
158
|
+
mujoco: MujocoModule;
|
|
159
|
+
config: SceneConfig;
|
|
160
|
+
onReady?: (api: MujocoSimAPI) => void;
|
|
161
|
+
onError?: (error: Error) => void;
|
|
162
|
+
onStep?: (time: number) => void;
|
|
163
|
+
onSelection?: (bodyId: number, name: string) => void;
|
|
164
|
+
// Declarative physics config props (spec 1.1)
|
|
165
|
+
gravity?: [number, number, number];
|
|
166
|
+
timestep?: number;
|
|
167
|
+
substeps?: number;
|
|
168
|
+
paused?: boolean;
|
|
169
|
+
speed?: number;
|
|
170
|
+
interpolate?: boolean;
|
|
171
|
+
children: React.ReactNode;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function MujocoSimProvider({
|
|
175
|
+
mujoco,
|
|
176
|
+
config,
|
|
177
|
+
onReady,
|
|
178
|
+
onError,
|
|
179
|
+
onStep,
|
|
180
|
+
onSelection,
|
|
181
|
+
gravity,
|
|
182
|
+
timestep,
|
|
183
|
+
substeps,
|
|
184
|
+
paused,
|
|
185
|
+
speed,
|
|
186
|
+
interpolate,
|
|
187
|
+
children,
|
|
188
|
+
}: MujocoSimProviderProps) {
|
|
189
|
+
const { gl, camera } = useThree();
|
|
190
|
+
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
191
|
+
|
|
192
|
+
// --- Refs ---
|
|
193
|
+
const mjModelRef = useRef<MujocoModel | null>(null);
|
|
194
|
+
const mjDataRef = useRef<MujocoData | null>(null);
|
|
195
|
+
const mujocoRef = useRef<MujocoModule>(mujoco);
|
|
196
|
+
const configRef = useRef<SceneConfig>(config);
|
|
197
|
+
const siteIdRef = useRef(-1);
|
|
198
|
+
const gripperIdRef = useRef(-1);
|
|
199
|
+
const ikEnabledRef = useRef(false);
|
|
200
|
+
const ikCalculatingRef = useRef(false);
|
|
201
|
+
const pausedRef = useRef(paused ?? false);
|
|
202
|
+
const speedRef = useRef(speed ?? 1);
|
|
203
|
+
const substepsRef = useRef(substeps ?? 1);
|
|
204
|
+
const interpolateRef = useRef(interpolate ?? false);
|
|
205
|
+
const firstIkEnableRef = useRef(true);
|
|
206
|
+
const stepsToRunRef = useRef(0); // for single-step mode (spec 1.2)
|
|
207
|
+
|
|
208
|
+
// Interpolation state (spec 11.1)
|
|
209
|
+
const prevXposRef = useRef<Float64Array | null>(null);
|
|
210
|
+
const prevXquatRef = useRef<Float64Array | null>(null);
|
|
211
|
+
const interpAlphaRef = useRef(0);
|
|
212
|
+
|
|
213
|
+
const onSelectionRef = useRef(onSelection);
|
|
214
|
+
onSelectionRef.current = onSelection;
|
|
215
|
+
const onStepRef = useRef(onStep);
|
|
216
|
+
onStepRef.current = onStep;
|
|
217
|
+
|
|
218
|
+
const beforeStepCallbacks = useRef(new Set<PhysicsStepCallback>());
|
|
219
|
+
const afterStepCallbacks = useRef(new Set<PhysicsStepCallback>());
|
|
220
|
+
|
|
221
|
+
configRef.current = config;
|
|
222
|
+
|
|
223
|
+
// Sync declarative props to refs
|
|
224
|
+
useEffect(() => { pausedRef.current = paused ?? false; }, [paused]);
|
|
225
|
+
useEffect(() => { speedRef.current = speed ?? 1; }, [speed]);
|
|
226
|
+
useEffect(() => { substepsRef.current = substeps ?? 1; }, [substeps]);
|
|
227
|
+
useEffect(() => { interpolateRef.current = interpolate ?? false; }, [interpolate]);
|
|
228
|
+
|
|
229
|
+
// Sync gravity prop (spec 1.1)
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!gravity) return;
|
|
232
|
+
const model = mjModelRef.current;
|
|
233
|
+
if (!model?.opt?.gravity) return;
|
|
234
|
+
model.opt.gravity[0] = gravity[0];
|
|
235
|
+
model.opt.gravity[1] = gravity[1];
|
|
236
|
+
model.opt.gravity[2] = gravity[2];
|
|
237
|
+
}, [gravity]);
|
|
238
|
+
|
|
239
|
+
// Sync timestep prop (spec 1.1)
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (timestep === undefined) return;
|
|
242
|
+
const model = mjModelRef.current;
|
|
243
|
+
if (!model?.opt) return;
|
|
244
|
+
model.opt.timestep = timestep;
|
|
245
|
+
}, [timestep]);
|
|
246
|
+
|
|
247
|
+
const ikTargetRef = useRef<THREE.Group>(new THREE.Group());
|
|
248
|
+
const genericIkRef = useRef<GenericIK>(new GenericIK(mujoco));
|
|
249
|
+
|
|
250
|
+
const gizmoAnimRef = useRef({
|
|
251
|
+
active: false,
|
|
252
|
+
startPos: new THREE.Vector3(),
|
|
253
|
+
endPos: new THREE.Vector3(),
|
|
254
|
+
startRot: new THREE.Quaternion(),
|
|
255
|
+
endRot: new THREE.Quaternion(),
|
|
256
|
+
startTime: 0,
|
|
257
|
+
duration: 1000,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const cameraAnimRef = useRef({
|
|
261
|
+
active: false,
|
|
262
|
+
startPos: new THREE.Vector3(),
|
|
263
|
+
endPos: new THREE.Vector3(),
|
|
264
|
+
startRot: new THREE.Quaternion(),
|
|
265
|
+
endRot: new THREE.Quaternion(),
|
|
266
|
+
startTarget: new THREE.Vector3(),
|
|
267
|
+
endTarget: new THREE.Vector3(),
|
|
268
|
+
startTime: 0,
|
|
269
|
+
duration: 0,
|
|
270
|
+
resolve: null as (() => void) | null,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const orbitTargetRef = useRef(new THREE.Vector3(0, 0, 0));
|
|
274
|
+
|
|
275
|
+
// --- Helper: sync gizmo to actual MuJoCo site position ---
|
|
276
|
+
const syncGizmoToSite = useCallback((data: MujocoData, siteId: number, target: THREE.Group) => {
|
|
277
|
+
if (siteId === -1) return;
|
|
278
|
+
const sitePos = data.site_xpos.subarray(siteId * 3, siteId * 3 + 3);
|
|
279
|
+
const siteMat = data.site_xmat.subarray(siteId * 9, siteId * 9 + 9);
|
|
280
|
+
target.position.set(sitePos[0], sitePos[1], sitePos[2]);
|
|
281
|
+
const m = new THREE.Matrix4().set(
|
|
282
|
+
siteMat[0], siteMat[1], siteMat[2], 0,
|
|
283
|
+
siteMat[3], siteMat[4], siteMat[5], 0,
|
|
284
|
+
siteMat[6], siteMat[7], siteMat[8], 0,
|
|
285
|
+
0, 0, 0, 1
|
|
286
|
+
);
|
|
287
|
+
target.quaternion.setFromRotationMatrix(m);
|
|
288
|
+
}, []);
|
|
289
|
+
|
|
290
|
+
// IK solve function
|
|
291
|
+
const ikSolveFn = useCallback(
|
|
292
|
+
(pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null => {
|
|
293
|
+
const model = mjModelRef.current;
|
|
294
|
+
const data = mjDataRef.current;
|
|
295
|
+
if (!model || !data || siteIdRef.current === -1) return null;
|
|
296
|
+
return genericIkRef.current.solve(
|
|
297
|
+
model, data, siteIdRef.current,
|
|
298
|
+
configRef.current.numArmJoints ?? 7,
|
|
299
|
+
pos, quat, currentQ
|
|
300
|
+
);
|
|
301
|
+
},
|
|
302
|
+
[]
|
|
303
|
+
);
|
|
304
|
+
const ikSolveFnRef = useRef<IKSolveFn>(ikSolveFn);
|
|
305
|
+
ikSolveFnRef.current = ikSolveFn;
|
|
306
|
+
|
|
307
|
+
// --- Load scene on mount ---
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
let disposed = false;
|
|
310
|
+
|
|
311
|
+
(async () => {
|
|
312
|
+
try {
|
|
313
|
+
const result = await loadScene(mujoco, config);
|
|
314
|
+
if (disposed) {
|
|
315
|
+
result.mjModel.delete();
|
|
316
|
+
result.mjData.delete();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
mjModelRef.current = result.mjModel;
|
|
321
|
+
mjDataRef.current = result.mjData;
|
|
322
|
+
siteIdRef.current = result.siteId;
|
|
323
|
+
gripperIdRef.current = result.gripperId;
|
|
324
|
+
|
|
325
|
+
// Apply declarative physics props after load
|
|
326
|
+
if (gravity && result.mjModel.opt?.gravity) {
|
|
327
|
+
result.mjModel.opt.gravity[0] = gravity[0];
|
|
328
|
+
result.mjModel.opt.gravity[1] = gravity[1];
|
|
329
|
+
result.mjModel.opt.gravity[2] = gravity[2];
|
|
330
|
+
}
|
|
331
|
+
if (timestep !== undefined && result.mjModel.opt) {
|
|
332
|
+
result.mjModel.opt.timestep = timestep;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (ikTargetRef.current) {
|
|
336
|
+
syncGizmoToSite(result.mjData, result.siteId, ikTargetRef.current);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
setStatus('ready');
|
|
340
|
+
} catch (e: unknown) {
|
|
341
|
+
if (!disposed) {
|
|
342
|
+
setStatus('error');
|
|
343
|
+
onError?.(e instanceof Error ? e : new Error(String(e)));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
})();
|
|
347
|
+
|
|
348
|
+
return () => {
|
|
349
|
+
disposed = true;
|
|
350
|
+
mjModelRef.current?.delete();
|
|
351
|
+
mjDataRef.current?.delete();
|
|
352
|
+
mjModelRef.current = null;
|
|
353
|
+
mjDataRef.current = null;
|
|
354
|
+
try { mujoco.FS.unmount('/working'); } catch { /* ignore */ }
|
|
355
|
+
};
|
|
356
|
+
}, [mujoco, config]);
|
|
357
|
+
|
|
358
|
+
// Fire onReady when status changes to ready
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
if (status === 'ready' && onReady) {
|
|
361
|
+
onReady(apiRef.current);
|
|
362
|
+
}
|
|
363
|
+
}, [status]);
|
|
364
|
+
|
|
365
|
+
// --- Physics step (priority -1) ---
|
|
366
|
+
useFrame((state) => {
|
|
367
|
+
const model = mjModelRef.current;
|
|
368
|
+
const data = mjDataRef.current;
|
|
369
|
+
if (!model || !data) return;
|
|
370
|
+
|
|
371
|
+
// Gizmo animation
|
|
372
|
+
const ga = gizmoAnimRef.current;
|
|
373
|
+
const target = ikTargetRef.current;
|
|
374
|
+
if (ga.active && target) {
|
|
375
|
+
const now = performance.now();
|
|
376
|
+
const elapsed = now - ga.startTime;
|
|
377
|
+
const t = Math.min(elapsed / ga.duration, 1.0);
|
|
378
|
+
const ease = 1 - Math.pow(1 - t, 3);
|
|
379
|
+
target.position.lerpVectors(ga.startPos, ga.endPos, ease);
|
|
380
|
+
target.quaternion.slerpQuaternions(ga.startRot, ga.endRot, ease);
|
|
381
|
+
if (t >= 1.0) ga.active = false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Camera animation
|
|
385
|
+
const ca = cameraAnimRef.current;
|
|
386
|
+
if (ca.active) {
|
|
387
|
+
const now = performance.now();
|
|
388
|
+
const progress = Math.min((now - ca.startTime) / ca.duration, 1.0);
|
|
389
|
+
const ease =
|
|
390
|
+
progress < 0.5
|
|
391
|
+
? 4 * progress * progress * progress
|
|
392
|
+
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
|
|
393
|
+
camera.position.lerpVectors(ca.startPos, ca.endPos, ease);
|
|
394
|
+
camera.quaternion.slerpQuaternions(ca.startRot, ca.endRot, ease);
|
|
395
|
+
orbitTargetRef.current.lerpVectors(ca.startTarget, ca.endTarget, ease);
|
|
396
|
+
const orbitControls = state.controls as { target?: THREE.Vector3 };
|
|
397
|
+
if (orbitControls?.target) {
|
|
398
|
+
orbitControls.target.copy(orbitTargetRef.current);
|
|
399
|
+
}
|
|
400
|
+
if (progress >= 1.0) {
|
|
401
|
+
ca.active = false;
|
|
402
|
+
camera.position.copy(ca.endPos);
|
|
403
|
+
camera.quaternion.copy(ca.endRot);
|
|
404
|
+
orbitTargetRef.current.copy(ca.endTarget);
|
|
405
|
+
ca.resolve?.();
|
|
406
|
+
ca.resolve = null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Check single-step mode (spec 1.2)
|
|
411
|
+
const shouldStep = !pausedRef.current || stepsToRunRef.current > 0;
|
|
412
|
+
if (!shouldStep) return;
|
|
413
|
+
|
|
414
|
+
// Zero generalized applied forces
|
|
415
|
+
for (let i = 0; i < model.nv; i++) {
|
|
416
|
+
data.qfrc_applied[i] = 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Before-step callbacks
|
|
420
|
+
for (const cb of beforeStepCallbacks.current) {
|
|
421
|
+
cb(model, data);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// IK
|
|
425
|
+
if (ikEnabledRef.current && target) {
|
|
426
|
+
ikCalculatingRef.current = true;
|
|
427
|
+
const numArm = configRef.current.numArmJoints ?? 7;
|
|
428
|
+
const currentQ: number[] = [];
|
|
429
|
+
for (let i = 0; i < numArm; i++) currentQ.push(data.qpos[i]);
|
|
430
|
+
const solution = ikSolveFnRef.current(target.position, target.quaternion, currentQ);
|
|
431
|
+
if (solution) {
|
|
432
|
+
for (let i = 0; i < numArm; i++) data.ctrl[i] = solution[i];
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
ikCalculatingRef.current = false;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Step physics with substeps (spec 1.1)
|
|
439
|
+
const numSubsteps = substepsRef.current;
|
|
440
|
+
if (stepsToRunRef.current > 0) {
|
|
441
|
+
// Single-step mode (spec 1.2)
|
|
442
|
+
for (let s = 0; s < stepsToRunRef.current; s++) {
|
|
443
|
+
mujoco.mj_step(model, data);
|
|
444
|
+
}
|
|
445
|
+
stepsToRunRef.current = 0;
|
|
446
|
+
} else {
|
|
447
|
+
const startSimTime = data.time;
|
|
448
|
+
const frameTime = (1.0 / 60.0) * speedRef.current;
|
|
449
|
+
while (data.time - startSimTime < frameTime) {
|
|
450
|
+
for (let s = 0; s < numSubsteps; s++) {
|
|
451
|
+
mujoco.mj_step(model, data);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// After-step callbacks
|
|
457
|
+
for (const cb of afterStepCallbacks.current) {
|
|
458
|
+
cb(model, data);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
onStepRef.current?.(data.time);
|
|
462
|
+
}, -1);
|
|
463
|
+
|
|
464
|
+
// --- API Methods ---
|
|
465
|
+
|
|
466
|
+
const reset = useCallback(() => {
|
|
467
|
+
const model = mjModelRef.current;
|
|
468
|
+
const data = mjDataRef.current;
|
|
469
|
+
if (!model || !data) return;
|
|
470
|
+
|
|
471
|
+
gizmoAnimRef.current.active = false;
|
|
472
|
+
mujoco.mj_resetData(model, data);
|
|
473
|
+
|
|
474
|
+
const homeJoints = configRef.current.homeJoints;
|
|
475
|
+
if (homeJoints) {
|
|
476
|
+
for (let i = 0; i < homeJoints.length; i++) {
|
|
477
|
+
data.ctrl[i] = homeJoints[i];
|
|
478
|
+
if (model.actuator_trnid[2 * i + 1] === 1) {
|
|
479
|
+
const jointId = model.actuator_trnid[2 * i];
|
|
480
|
+
if (jointId >= 0 && jointId < model.njnt) {
|
|
481
|
+
const qposAdr = model.jnt_qposadr[jointId];
|
|
482
|
+
data.qpos[qposAdr] = homeJoints[i];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
configRef.current.onReset?.(model, data);
|
|
489
|
+
mujoco.mj_forward(model, data);
|
|
490
|
+
|
|
491
|
+
if (ikTargetRef.current) {
|
|
492
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
493
|
+
}
|
|
494
|
+
firstIkEnableRef.current = true;
|
|
495
|
+
ikEnabledRef.current = false;
|
|
496
|
+
}, [mujoco, syncGizmoToSite]);
|
|
497
|
+
|
|
498
|
+
const setIkEnabled = useCallback((enabled: boolean) => {
|
|
499
|
+
ikEnabledRef.current = enabled;
|
|
500
|
+
const data = mjDataRef.current;
|
|
501
|
+
if (enabled && data && !gizmoAnimRef.current.active && ikTargetRef.current) {
|
|
502
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
503
|
+
firstIkEnableRef.current = false;
|
|
504
|
+
}
|
|
505
|
+
}, [syncGizmoToSite]);
|
|
506
|
+
|
|
507
|
+
const syncTargetToSite = useCallback(() => {
|
|
508
|
+
const data = mjDataRef.current;
|
|
509
|
+
const target = ikTargetRef.current;
|
|
510
|
+
if (data && target) syncGizmoToSite(data, siteIdRef.current, target);
|
|
511
|
+
}, [syncGizmoToSite]);
|
|
512
|
+
|
|
513
|
+
const solveIK = useCallback(
|
|
514
|
+
(pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null => {
|
|
515
|
+
return ikSolveFnRef.current(pos, quat, currentQ);
|
|
516
|
+
},
|
|
517
|
+
[]
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
const moveTarget = useCallback(
|
|
521
|
+
(pos: THREE.Vector3, duration = 0) => {
|
|
522
|
+
if (!ikEnabledRef.current) setIkEnabled(true);
|
|
523
|
+
const target = ikTargetRef.current;
|
|
524
|
+
if (!target) return;
|
|
525
|
+
|
|
526
|
+
const targetPos = pos.clone();
|
|
527
|
+
const targetRot = new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI, 0, 0));
|
|
528
|
+
|
|
529
|
+
if (duration > 0) {
|
|
530
|
+
const ga = gizmoAnimRef.current;
|
|
531
|
+
ga.active = true;
|
|
532
|
+
ga.startPos.copy(target.position);
|
|
533
|
+
ga.endPos.copy(targetPos);
|
|
534
|
+
ga.startRot.copy(target.quaternion);
|
|
535
|
+
ga.endRot.copy(targetRot);
|
|
536
|
+
ga.startTime = performance.now();
|
|
537
|
+
ga.duration = duration;
|
|
538
|
+
} else {
|
|
539
|
+
gizmoAnimRef.current.active = false;
|
|
540
|
+
target.position.copy(targetPos);
|
|
541
|
+
target.quaternion.copy(targetRot);
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
[setIkEnabled]
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const setSpeed = useCallback((multiplier: number) => {
|
|
548
|
+
speedRef.current = multiplier;
|
|
549
|
+
}, []);
|
|
550
|
+
|
|
551
|
+
const togglePause = useCallback((): boolean => {
|
|
552
|
+
pausedRef.current = !pausedRef.current;
|
|
553
|
+
return pausedRef.current;
|
|
554
|
+
}, []);
|
|
555
|
+
|
|
556
|
+
// spec 1.1: declarative pause
|
|
557
|
+
const setPaused = useCallback((p: boolean) => {
|
|
558
|
+
pausedRef.current = p;
|
|
559
|
+
}, []);
|
|
560
|
+
|
|
561
|
+
// spec 1.2: single-step mode
|
|
562
|
+
const step = useCallback((n = 1) => {
|
|
563
|
+
stepsToRunRef.current = n;
|
|
564
|
+
}, []);
|
|
565
|
+
|
|
566
|
+
// spec 1.3: simulation time access
|
|
567
|
+
const getTime = useCallback((): number => {
|
|
568
|
+
return mjDataRef.current?.time ?? 0;
|
|
569
|
+
}, []);
|
|
570
|
+
|
|
571
|
+
const getTimestep = useCallback((): number => {
|
|
572
|
+
return mjModelRef.current?.opt?.timestep ?? 0.002;
|
|
573
|
+
}, []);
|
|
574
|
+
|
|
575
|
+
// spec 4.1: state snapshot save/restore
|
|
576
|
+
const saveState = useCallback((): StateSnapshot => {
|
|
577
|
+
const data = mjDataRef.current;
|
|
578
|
+
if (!data) return { time: 0, qpos: new Float64Array(0), qvel: new Float64Array(0), ctrl: new Float64Array(0), act: new Float64Array(0), qfrc_applied: new Float64Array(0) };
|
|
579
|
+
return {
|
|
580
|
+
time: data.time,
|
|
581
|
+
qpos: new Float64Array(data.qpos),
|
|
582
|
+
qvel: new Float64Array(data.qvel),
|
|
583
|
+
ctrl: new Float64Array(data.ctrl),
|
|
584
|
+
act: new Float64Array(data.act),
|
|
585
|
+
qfrc_applied: new Float64Array(data.qfrc_applied),
|
|
586
|
+
};
|
|
587
|
+
}, []);
|
|
588
|
+
|
|
589
|
+
const restoreState = useCallback((snapshot: StateSnapshot) => {
|
|
590
|
+
const model = mjModelRef.current;
|
|
591
|
+
const data = mjDataRef.current;
|
|
592
|
+
if (!model || !data) return;
|
|
593
|
+
data.time = snapshot.time;
|
|
594
|
+
data.qpos.set(snapshot.qpos);
|
|
595
|
+
data.qvel.set(snapshot.qvel);
|
|
596
|
+
data.ctrl.set(snapshot.ctrl);
|
|
597
|
+
if (snapshot.act.length > 0) data.act.set(snapshot.act);
|
|
598
|
+
data.qfrc_applied.set(snapshot.qfrc_applied);
|
|
599
|
+
mujoco.mj_forward(model, data);
|
|
600
|
+
}, [mujoco]);
|
|
601
|
+
|
|
602
|
+
// spec 4.3: qpos/qvel direct set/get
|
|
603
|
+
const setQpos = useCallback((values: Float64Array | number[]) => {
|
|
604
|
+
const model = mjModelRef.current;
|
|
605
|
+
const data = mjDataRef.current;
|
|
606
|
+
if (!model || !data) return;
|
|
607
|
+
const arr = values instanceof Float64Array ? values : new Float64Array(values);
|
|
608
|
+
data.qpos.set(arr.subarray(0, Math.min(arr.length, model.nq)));
|
|
609
|
+
mujoco.mj_forward(model, data);
|
|
610
|
+
}, [mujoco]);
|
|
611
|
+
|
|
612
|
+
const setQvel = useCallback((values: Float64Array | number[]) => {
|
|
613
|
+
const data = mjDataRef.current;
|
|
614
|
+
if (!data) return;
|
|
615
|
+
const arr = values instanceof Float64Array ? values : new Float64Array(values);
|
|
616
|
+
data.qvel.set(arr.subarray(0, Math.min(arr.length, mjModelRef.current?.nv ?? 0)));
|
|
617
|
+
}, []);
|
|
618
|
+
|
|
619
|
+
const getQpos = useCallback((): Float64Array => {
|
|
620
|
+
return mjDataRef.current ? new Float64Array(mjDataRef.current.qpos) : new Float64Array(0);
|
|
621
|
+
}, []);
|
|
622
|
+
|
|
623
|
+
const getQvel = useCallback((): Float64Array => {
|
|
624
|
+
return mjDataRef.current ? new Float64Array(mjDataRef.current.qvel) : new Float64Array(0);
|
|
625
|
+
}, []);
|
|
626
|
+
|
|
627
|
+
// spec 3.1: ctrl set/get
|
|
628
|
+
const setCtrl = useCallback((nameOrValues: string | Record<string, number>, value?: number) => {
|
|
629
|
+
const model = mjModelRef.current;
|
|
630
|
+
const data = mjDataRef.current;
|
|
631
|
+
if (!model || !data) return;
|
|
632
|
+
|
|
633
|
+
if (typeof nameOrValues === 'string') {
|
|
634
|
+
// Single actuator by name
|
|
635
|
+
const id = findActuatorByName(model, nameOrValues);
|
|
636
|
+
if (id >= 0 && value !== undefined) data.ctrl[id] = value;
|
|
637
|
+
} else {
|
|
638
|
+
// Batch: { name: value, ... }
|
|
639
|
+
for (const [name, val] of Object.entries(nameOrValues)) {
|
|
640
|
+
const id = findActuatorByName(model, name);
|
|
641
|
+
if (id >= 0) data.ctrl[id] = val;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}, []);
|
|
645
|
+
|
|
646
|
+
const getCtrl = useCallback((): Float64Array => {
|
|
647
|
+
return mjDataRef.current ? new Float64Array(mjDataRef.current.ctrl) : new Float64Array(0);
|
|
648
|
+
}, []);
|
|
649
|
+
|
|
650
|
+
// spec 8.1: force/torque API
|
|
651
|
+
const applyForce = useCallback((bodyName: string, force: THREE.Vector3, point?: THREE.Vector3) => {
|
|
652
|
+
const model = mjModelRef.current;
|
|
653
|
+
const data = mjDataRef.current;
|
|
654
|
+
if (!model || !data) return;
|
|
655
|
+
const bodyId = findBodyByName(model, bodyName);
|
|
656
|
+
if (bodyId < 0) return;
|
|
657
|
+
|
|
658
|
+
_applyForce[0] = force.x; _applyForce[1] = force.y; _applyForce[2] = force.z;
|
|
659
|
+
_applyTorque[0] = 0; _applyTorque[1] = 0; _applyTorque[2] = 0;
|
|
660
|
+
if (point) {
|
|
661
|
+
_applyPoint[0] = point.x; _applyPoint[1] = point.y; _applyPoint[2] = point.z;
|
|
662
|
+
} else {
|
|
663
|
+
const i3 = bodyId * 3;
|
|
664
|
+
_applyPoint[0] = data.xpos[i3]; _applyPoint[1] = data.xpos[i3 + 1]; _applyPoint[2] = data.xpos[i3 + 2];
|
|
665
|
+
}
|
|
666
|
+
mujoco.mj_applyFT(model, data, _applyForce, _applyTorque, _applyPoint, bodyId, data.qfrc_applied);
|
|
667
|
+
}, [mujoco]);
|
|
668
|
+
|
|
669
|
+
const applyTorqueApi = useCallback((bodyName: string, torque: THREE.Vector3) => {
|
|
670
|
+
const model = mjModelRef.current;
|
|
671
|
+
const data = mjDataRef.current;
|
|
672
|
+
if (!model || !data) return;
|
|
673
|
+
const bodyId = findBodyByName(model, bodyName);
|
|
674
|
+
if (bodyId < 0) return;
|
|
675
|
+
|
|
676
|
+
_applyForce[0] = 0; _applyForce[1] = 0; _applyForce[2] = 0;
|
|
677
|
+
_applyTorque[0] = torque.x; _applyTorque[1] = torque.y; _applyTorque[2] = torque.z;
|
|
678
|
+
const i3 = bodyId * 3;
|
|
679
|
+
_applyPoint[0] = data.xpos[i3]; _applyPoint[1] = data.xpos[i3 + 1]; _applyPoint[2] = data.xpos[i3 + 2];
|
|
680
|
+
mujoco.mj_applyFT(model, data, _applyForce, _applyTorque, _applyPoint, bodyId, data.qfrc_applied);
|
|
681
|
+
}, [mujoco]);
|
|
682
|
+
|
|
683
|
+
const setExternalForce = useCallback((bodyName: string, force: THREE.Vector3, torque: THREE.Vector3) => {
|
|
684
|
+
const model = mjModelRef.current;
|
|
685
|
+
const data = mjDataRef.current;
|
|
686
|
+
if (!model || !data) return;
|
|
687
|
+
const bodyId = findBodyByName(model, bodyName);
|
|
688
|
+
if (bodyId < 0) return;
|
|
689
|
+
const i6 = bodyId * 6;
|
|
690
|
+
data.xfrc_applied[i6] = torque.x; data.xfrc_applied[i6 + 1] = torque.y; data.xfrc_applied[i6 + 2] = torque.z;
|
|
691
|
+
data.xfrc_applied[i6 + 3] = force.x; data.xfrc_applied[i6 + 4] = force.y; data.xfrc_applied[i6 + 5] = force.z;
|
|
692
|
+
}, []);
|
|
693
|
+
|
|
694
|
+
const applyGeneralizedForce = useCallback((values: Float64Array | number[]) => {
|
|
695
|
+
const data = mjDataRef.current;
|
|
696
|
+
if (!data) return;
|
|
697
|
+
const nv = mjModelRef.current?.nv ?? 0;
|
|
698
|
+
for (let i = 0; i < Math.min(values.length, nv); i++) {
|
|
699
|
+
data.qfrc_applied[i] += values[i];
|
|
700
|
+
}
|
|
701
|
+
}, []);
|
|
702
|
+
|
|
703
|
+
// spec 2.1: sensor data
|
|
704
|
+
const getSensorData = useCallback((name: string): Float64Array | null => {
|
|
705
|
+
const model = mjModelRef.current;
|
|
706
|
+
const data = mjDataRef.current;
|
|
707
|
+
if (!model || !data) return null;
|
|
708
|
+
const id = findSensorByName(model, name);
|
|
709
|
+
if (id < 0) return null;
|
|
710
|
+
const adr = model.sensor_adr[id];
|
|
711
|
+
const dim = model.sensor_dim[id];
|
|
712
|
+
return new Float64Array(data.sensordata.subarray(adr, adr + dim));
|
|
713
|
+
}, []);
|
|
714
|
+
|
|
715
|
+
// spec 2.4: contacts
|
|
716
|
+
const getContacts = useCallback((): ContactInfo[] => {
|
|
717
|
+
const model = mjModelRef.current;
|
|
718
|
+
const data = mjDataRef.current;
|
|
719
|
+
if (!model || !data) return [];
|
|
720
|
+
const contacts: ContactInfo[] = [];
|
|
721
|
+
const ncon = data.ncon;
|
|
722
|
+
for (let i = 0; i < ncon; i++) {
|
|
723
|
+
try {
|
|
724
|
+
const c = (data.contact as { get(i: number): { geom1: number; geom2: number; pos: Float64Array; dist: number } }).get(i);
|
|
725
|
+
contacts.push({
|
|
726
|
+
geom1: c.geom1,
|
|
727
|
+
geom1Name: getName(model, model.name_geomadr[c.geom1]),
|
|
728
|
+
geom2: c.geom2,
|
|
729
|
+
geom2Name: getName(model, model.name_geomadr[c.geom2]),
|
|
730
|
+
pos: [c.pos[0], c.pos[1], c.pos[2]],
|
|
731
|
+
depth: c.dist,
|
|
732
|
+
});
|
|
733
|
+
} catch {
|
|
734
|
+
break; // WASM contact access can fail
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return contacts;
|
|
738
|
+
}, []);
|
|
739
|
+
|
|
740
|
+
// spec 5.1: model introspection
|
|
741
|
+
const getBodies = useCallback((): BodyInfo[] => {
|
|
742
|
+
const model = mjModelRef.current;
|
|
743
|
+
if (!model) return [];
|
|
744
|
+
const result: BodyInfo[] = [];
|
|
745
|
+
for (let i = 0; i < model.nbody; i++) {
|
|
746
|
+
result.push({
|
|
747
|
+
id: i,
|
|
748
|
+
name: getName(model, model.name_bodyadr[i]),
|
|
749
|
+
mass: model.body_mass[i],
|
|
750
|
+
parentId: model.body_parentid[i],
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
return result;
|
|
754
|
+
}, []);
|
|
755
|
+
|
|
756
|
+
const getJoints = useCallback((): JointInfo[] => {
|
|
757
|
+
const model = mjModelRef.current;
|
|
758
|
+
if (!model) return [];
|
|
759
|
+
const result: JointInfo[] = [];
|
|
760
|
+
for (let i = 0; i < model.njnt; i++) {
|
|
761
|
+
const type = model.jnt_type[i];
|
|
762
|
+
const limited = model.jnt_limited ? model.jnt_limited[i] !== 0 : false;
|
|
763
|
+
result.push({
|
|
764
|
+
id: i,
|
|
765
|
+
name: getName(model, model.name_jntadr[i]),
|
|
766
|
+
type,
|
|
767
|
+
typeName: JOINT_TYPE_NAMES[type] ?? `unknown(${type})`,
|
|
768
|
+
range: [model.jnt_range[2 * i], model.jnt_range[2 * i + 1]],
|
|
769
|
+
limited,
|
|
770
|
+
bodyId: model.jnt_bodyid[i],
|
|
771
|
+
qposAdr: model.jnt_qposadr[i],
|
|
772
|
+
dofAdr: model.jnt_dofadr[i],
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
return result;
|
|
776
|
+
}, []);
|
|
777
|
+
|
|
778
|
+
const getGeoms = useCallback((): GeomInfo[] => {
|
|
779
|
+
const model = mjModelRef.current;
|
|
780
|
+
if (!model) return [];
|
|
781
|
+
const result: GeomInfo[] = [];
|
|
782
|
+
for (let i = 0; i < model.ngeom; i++) {
|
|
783
|
+
const type = model.geom_type[i];
|
|
784
|
+
result.push({
|
|
785
|
+
id: i,
|
|
786
|
+
name: getName(model, model.name_geomadr[i]),
|
|
787
|
+
type,
|
|
788
|
+
typeName: GEOM_TYPE_NAMES[type] ?? `unknown(${type})`,
|
|
789
|
+
size: [model.geom_size[3 * i], model.geom_size[3 * i + 1], model.geom_size[3 * i + 2]],
|
|
790
|
+
bodyId: model.geom_bodyid[i],
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
return result;
|
|
794
|
+
}, []);
|
|
795
|
+
|
|
796
|
+
const getSites = useCallback((): SiteInfo[] => {
|
|
797
|
+
const model = mjModelRef.current;
|
|
798
|
+
if (!model) return [];
|
|
799
|
+
const result: SiteInfo[] = [];
|
|
800
|
+
for (let i = 0; i < model.nsite; i++) {
|
|
801
|
+
result.push({
|
|
802
|
+
id: i,
|
|
803
|
+
name: getName(model, model.name_siteadr[i]),
|
|
804
|
+
bodyId: model.site_bodyid ? model.site_bodyid[i] : -1,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
return result;
|
|
808
|
+
}, []);
|
|
809
|
+
|
|
810
|
+
const getActuatorsApi = useCallback((): ActuatorInfo[] => {
|
|
811
|
+
const model = mjModelRef.current;
|
|
812
|
+
if (!model) return [];
|
|
813
|
+
const result: ActuatorInfo[] = [];
|
|
814
|
+
for (let i = 0; i < model.nu; i++) {
|
|
815
|
+
const hasRange = model.actuator_ctrlrange[2 * i] < model.actuator_ctrlrange[2 * i + 1];
|
|
816
|
+
result.push({
|
|
817
|
+
id: i,
|
|
818
|
+
name: getName(model, model.name_actuatoradr[i]),
|
|
819
|
+
range: hasRange
|
|
820
|
+
? [model.actuator_ctrlrange[2 * i], model.actuator_ctrlrange[2 * i + 1]]
|
|
821
|
+
: [-Infinity, Infinity],
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
return result;
|
|
825
|
+
}, []);
|
|
826
|
+
|
|
827
|
+
const getSensors = useCallback((): SensorInfo[] => {
|
|
828
|
+
const model = mjModelRef.current;
|
|
829
|
+
if (!model) return [];
|
|
830
|
+
const result: SensorInfo[] = [];
|
|
831
|
+
for (let i = 0; i < model.nsensor; i++) {
|
|
832
|
+
const type = model.sensor_type[i];
|
|
833
|
+
result.push({
|
|
834
|
+
id: i,
|
|
835
|
+
name: getName(model, model.name_sensoradr[i]),
|
|
836
|
+
type,
|
|
837
|
+
typeName: SENSOR_TYPE_NAMES[type] ?? `unknown(${type})`,
|
|
838
|
+
dim: model.sensor_dim[i],
|
|
839
|
+
adr: model.sensor_adr[i],
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
return result;
|
|
843
|
+
}, []);
|
|
844
|
+
|
|
845
|
+
// spec 5.3: model options
|
|
846
|
+
const getModelOption = useCallback((): ModelOptions => {
|
|
847
|
+
const model = mjModelRef.current;
|
|
848
|
+
if (!model?.opt) return { timestep: 0.002, gravity: [0, 0, -9.81], integrator: 0 };
|
|
849
|
+
return {
|
|
850
|
+
timestep: model.opt.timestep,
|
|
851
|
+
gravity: [model.opt.gravity[0], model.opt.gravity[1], model.opt.gravity[2]],
|
|
852
|
+
integrator: model.opt.integrator,
|
|
853
|
+
};
|
|
854
|
+
}, []);
|
|
855
|
+
|
|
856
|
+
const setGravity = useCallback((g: [number, number, number]) => {
|
|
857
|
+
const model = mjModelRef.current;
|
|
858
|
+
if (!model?.opt?.gravity) return;
|
|
859
|
+
model.opt.gravity[0] = g[0];
|
|
860
|
+
model.opt.gravity[1] = g[1];
|
|
861
|
+
model.opt.gravity[2] = g[2];
|
|
862
|
+
}, []);
|
|
863
|
+
|
|
864
|
+
const setTimestepApi = useCallback((dt: number) => {
|
|
865
|
+
const model = mjModelRef.current;
|
|
866
|
+
if (!model?.opt) return;
|
|
867
|
+
model.opt.timestep = dt;
|
|
868
|
+
}, []);
|
|
869
|
+
|
|
870
|
+
// spec 7.1: physics raycast
|
|
871
|
+
const raycast = useCallback((origin: THREE.Vector3, direction: THREE.Vector3, maxDist = 100): RayHit | null => {
|
|
872
|
+
const model = mjModelRef.current;
|
|
873
|
+
const data = mjDataRef.current;
|
|
874
|
+
if (!model || !data) return null;
|
|
875
|
+
|
|
876
|
+
_rayPnt[0] = origin.x; _rayPnt[1] = origin.y; _rayPnt[2] = origin.z;
|
|
877
|
+
const dir = direction.clone().normalize();
|
|
878
|
+
_rayVec[0] = dir.x; _rayVec[1] = dir.y; _rayVec[2] = dir.z;
|
|
879
|
+
_rayGeomId[0] = -1;
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
const dist = mujoco.mj_ray(model, data, _rayPnt, _rayVec, null, 1, -1, _rayGeomId);
|
|
883
|
+
if (dist < 0 || dist > maxDist) return null;
|
|
884
|
+
const geomId = _rayGeomId[0];
|
|
885
|
+
const bodyId = geomId >= 0 ? model.geom_bodyid[geomId] : -1;
|
|
886
|
+
return {
|
|
887
|
+
point: new THREE.Vector3(
|
|
888
|
+
origin.x + dir.x * dist,
|
|
889
|
+
origin.y + dir.y * dist,
|
|
890
|
+
origin.z + dir.z * dist,
|
|
891
|
+
),
|
|
892
|
+
bodyId,
|
|
893
|
+
geomId,
|
|
894
|
+
distance: dist,
|
|
895
|
+
};
|
|
896
|
+
} catch {
|
|
897
|
+
return null; // mj_ray may not be available in all WASM builds
|
|
898
|
+
}
|
|
899
|
+
}, [mujoco]);
|
|
900
|
+
|
|
901
|
+
// spec 4.2: keyframe improvements
|
|
902
|
+
const applyKeyframe = useCallback((nameOrIndex: string | number) => {
|
|
903
|
+
const model = mjModelRef.current;
|
|
904
|
+
const data = mjDataRef.current;
|
|
905
|
+
if (!model || !data) return;
|
|
906
|
+
|
|
907
|
+
let keyId: number;
|
|
908
|
+
if (typeof nameOrIndex === 'number') {
|
|
909
|
+
keyId = nameOrIndex;
|
|
910
|
+
} else {
|
|
911
|
+
keyId = findKeyframeByName(model, nameOrIndex);
|
|
912
|
+
}
|
|
913
|
+
if (keyId < 0 || keyId >= model.nkey) {
|
|
914
|
+
console.warn(`applyKeyframe: keyframe "${nameOrIndex}" not found`);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const nq = model.nq;
|
|
919
|
+
const nu = model.nu;
|
|
920
|
+
const qposOffset = keyId * nq;
|
|
921
|
+
for (let i = 0; i < nq; i++) data.qpos[i] = model.key_qpos[qposOffset + i];
|
|
922
|
+
const ctrlOffset = keyId * nu;
|
|
923
|
+
for (let i = 0; i < nu; i++) data.ctrl[i] = model.key_ctrl[ctrlOffset + i];
|
|
924
|
+
|
|
925
|
+
// Also restore qvel if available (spec 4.2)
|
|
926
|
+
if (model.key_qvel) {
|
|
927
|
+
const qvelOffset = keyId * model.nv;
|
|
928
|
+
for (let i = 0; i < model.nv; i++) data.qvel[i] = model.key_qvel[qvelOffset + i];
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
mujoco.mj_forward(model, data);
|
|
932
|
+
|
|
933
|
+
if (ikTargetRef.current) {
|
|
934
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
935
|
+
}
|
|
936
|
+
}, [mujoco, syncGizmoToSite]);
|
|
937
|
+
|
|
938
|
+
const getKeyframeNames = useCallback((): string[] => {
|
|
939
|
+
const model = mjModelRef.current;
|
|
940
|
+
if (!model) return [];
|
|
941
|
+
const names: string[] = [];
|
|
942
|
+
for (let i = 0; i < model.nkey; i++) {
|
|
943
|
+
names.push(getName(model, model.name_keyadr[i]));
|
|
944
|
+
}
|
|
945
|
+
return names;
|
|
946
|
+
}, []);
|
|
947
|
+
|
|
948
|
+
const getKeyframeCount = useCallback((): number => {
|
|
949
|
+
return mjModelRef.current?.nkey ?? 0;
|
|
950
|
+
}, []);
|
|
951
|
+
|
|
952
|
+
// spec 9.1: runtime model swap
|
|
953
|
+
const loadSceneApi = useCallback(async (newConfig: SceneConfig): Promise<void> => {
|
|
954
|
+
try {
|
|
955
|
+
// Clean up current model
|
|
956
|
+
mjModelRef.current?.delete();
|
|
957
|
+
mjDataRef.current?.delete();
|
|
958
|
+
mjModelRef.current = null;
|
|
959
|
+
mjDataRef.current = null;
|
|
960
|
+
setStatus('loading');
|
|
961
|
+
|
|
962
|
+
const result = await loadScene(mujoco, newConfig);
|
|
963
|
+
mjModelRef.current = result.mjModel;
|
|
964
|
+
mjDataRef.current = result.mjData;
|
|
965
|
+
siteIdRef.current = result.siteId;
|
|
966
|
+
gripperIdRef.current = result.gripperId;
|
|
967
|
+
configRef.current = newConfig;
|
|
968
|
+
|
|
969
|
+
if (ikTargetRef.current) {
|
|
970
|
+
syncGizmoToSite(result.mjData, result.siteId, ikTargetRef.current);
|
|
971
|
+
}
|
|
972
|
+
setStatus('ready');
|
|
973
|
+
} catch (e) {
|
|
974
|
+
setStatus('error');
|
|
975
|
+
throw e;
|
|
976
|
+
}
|
|
977
|
+
}, [mujoco, syncGizmoToSite]);
|
|
978
|
+
|
|
979
|
+
const getGizmoStats = useCallback((): { pos: THREE.Vector3; rot: THREE.Euler } | null => {
|
|
980
|
+
const target = ikTargetRef.current;
|
|
981
|
+
if (!ikCalculatingRef.current || !target) return null;
|
|
982
|
+
return {
|
|
983
|
+
pos: target.position.clone(),
|
|
984
|
+
rot: new THREE.Euler().setFromQuaternion(target.quaternion),
|
|
985
|
+
};
|
|
986
|
+
}, []);
|
|
987
|
+
|
|
988
|
+
const getCanvasSnapshot = useCallback(
|
|
989
|
+
(width?: number, height?: number, mimeType = 'image/jpeg'): string => {
|
|
990
|
+
if (width && height) {
|
|
991
|
+
const tempCanvas = document.createElement('canvas');
|
|
992
|
+
tempCanvas.width = width;
|
|
993
|
+
tempCanvas.height = height;
|
|
994
|
+
const ctx = tempCanvas.getContext('2d');
|
|
995
|
+
if (ctx) {
|
|
996
|
+
ctx.drawImage(gl.domElement, 0, 0, width, height);
|
|
997
|
+
return tempCanvas.toDataURL(mimeType, mimeType === 'image/jpeg' ? 0.8 : undefined);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return gl.domElement.toDataURL(mimeType, mimeType === 'image/jpeg' ? 0.8 : undefined);
|
|
1001
|
+
},
|
|
1002
|
+
[gl]
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
const project2DTo3D = useCallback(
|
|
1006
|
+
(x: number, y: number, cameraPos: THREE.Vector3, lookAt: THREE.Vector3): { point: THREE.Vector3; bodyId: number; geomId: number } | null => {
|
|
1007
|
+
const virtCam = (camera as THREE.PerspectiveCamera).clone();
|
|
1008
|
+
virtCam.position.copy(cameraPos);
|
|
1009
|
+
virtCam.lookAt(lookAt);
|
|
1010
|
+
virtCam.updateMatrixWorld();
|
|
1011
|
+
virtCam.updateProjectionMatrix();
|
|
1012
|
+
const ndc = new THREE.Vector2(x * 2 - 1, -(y * 2 - 1));
|
|
1013
|
+
const raycaster = new THREE.Raycaster();
|
|
1014
|
+
raycaster.setFromCamera(ndc, virtCam);
|
|
1015
|
+
const objects: THREE.Object3D[] = [];
|
|
1016
|
+
const scene = (camera as THREE.PerspectiveCamera).parent;
|
|
1017
|
+
if (scene) {
|
|
1018
|
+
scene.traverse((c) => {
|
|
1019
|
+
if ((c as THREE.Mesh).isMesh) objects.push(c);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
const hits = raycaster.intersectObjects(objects);
|
|
1023
|
+
if (hits.length > 0) {
|
|
1024
|
+
const hitObj = hits[0].object;
|
|
1025
|
+
// Find geomId from the hit object's userData
|
|
1026
|
+
const geomId = hitObj.userData.geomID !== undefined ? hitObj.userData.geomID : -1;
|
|
1027
|
+
// Walk up to find bodyId
|
|
1028
|
+
let obj = hitObj;
|
|
1029
|
+
while (obj && obj.userData.bodyID === undefined && obj.parent) {
|
|
1030
|
+
obj = obj.parent;
|
|
1031
|
+
}
|
|
1032
|
+
const bodyId = obj && obj.userData.bodyID !== undefined ? obj.userData.bodyID : -1;
|
|
1033
|
+
return { point: hits[0].point, bodyId, geomId };
|
|
1034
|
+
}
|
|
1035
|
+
return null;
|
|
1036
|
+
},
|
|
1037
|
+
[camera, gl]
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
// --- Domain randomization (spec 10.3) ---
|
|
1041
|
+
|
|
1042
|
+
const setBodyMass = useCallback((name: string, mass: number): void => {
|
|
1043
|
+
const model = mjModelRef.current;
|
|
1044
|
+
if (!model) return;
|
|
1045
|
+
const id = findBodyByName(model, name);
|
|
1046
|
+
if (id < 0) return;
|
|
1047
|
+
model.body_mass[id] = mass;
|
|
1048
|
+
}, []);
|
|
1049
|
+
|
|
1050
|
+
const setGeomFriction = useCallback((name: string, friction: [number, number, number]): void => {
|
|
1051
|
+
const model = mjModelRef.current;
|
|
1052
|
+
if (!model) return;
|
|
1053
|
+
const id = findGeomByName(model, name);
|
|
1054
|
+
if (id < 0) return;
|
|
1055
|
+
model.geom_friction[id * 3] = friction[0];
|
|
1056
|
+
model.geom_friction[id * 3 + 1] = friction[1];
|
|
1057
|
+
model.geom_friction[id * 3 + 2] = friction[2];
|
|
1058
|
+
}, []);
|
|
1059
|
+
|
|
1060
|
+
const setGeomSize = useCallback((name: string, size: [number, number, number]): void => {
|
|
1061
|
+
const model = mjModelRef.current;
|
|
1062
|
+
if (!model) return;
|
|
1063
|
+
const id = findGeomByName(model, name);
|
|
1064
|
+
if (id < 0) return;
|
|
1065
|
+
model.geom_size[id * 3] = size[0];
|
|
1066
|
+
model.geom_size[id * 3 + 1] = size[1];
|
|
1067
|
+
model.geom_size[id * 3 + 2] = size[2];
|
|
1068
|
+
}, []);
|
|
1069
|
+
|
|
1070
|
+
const getCameraState = useCallback((): { position: THREE.Vector3; target: THREE.Vector3 } => {
|
|
1071
|
+
return { position: camera.position.clone(), target: orbitTargetRef.current.clone() };
|
|
1072
|
+
}, [camera]);
|
|
1073
|
+
|
|
1074
|
+
const moveCameraTo = useCallback(
|
|
1075
|
+
(position: THREE.Vector3, target: THREE.Vector3, durationMs: number): Promise<void> => {
|
|
1076
|
+
return new Promise((resolve) => {
|
|
1077
|
+
const ca = cameraAnimRef.current;
|
|
1078
|
+
ca.active = true;
|
|
1079
|
+
ca.startTime = performance.now();
|
|
1080
|
+
ca.duration = durationMs;
|
|
1081
|
+
ca.startPos.copy(camera.position);
|
|
1082
|
+
ca.startRot.copy(camera.quaternion);
|
|
1083
|
+
ca.startTarget.copy(orbitTargetRef.current);
|
|
1084
|
+
ca.endPos.copy(position);
|
|
1085
|
+
ca.endTarget.copy(target);
|
|
1086
|
+
const dummyCam = (camera as THREE.PerspectiveCamera).clone();
|
|
1087
|
+
dummyCam.position.copy(position);
|
|
1088
|
+
dummyCam.lookAt(target);
|
|
1089
|
+
ca.endRot.copy(dummyCam.quaternion);
|
|
1090
|
+
ca.resolve = resolve;
|
|
1091
|
+
setTimeout(resolve, durationMs + 100);
|
|
1092
|
+
});
|
|
1093
|
+
},
|
|
1094
|
+
[camera]
|
|
1095
|
+
);
|
|
1096
|
+
|
|
1097
|
+
// --- Assemble API ---
|
|
1098
|
+
const api = useMemo<MujocoSimAPI>(
|
|
1099
|
+
() => ({
|
|
1100
|
+
get status() { return status; },
|
|
1101
|
+
config,
|
|
1102
|
+
reset,
|
|
1103
|
+
setSpeed,
|
|
1104
|
+
togglePause,
|
|
1105
|
+
setPaused,
|
|
1106
|
+
step,
|
|
1107
|
+
getTime,
|
|
1108
|
+
getTimestep,
|
|
1109
|
+
applyKeyframe,
|
|
1110
|
+
saveState,
|
|
1111
|
+
restoreState,
|
|
1112
|
+
setQpos,
|
|
1113
|
+
setQvel,
|
|
1114
|
+
getQpos,
|
|
1115
|
+
getQvel,
|
|
1116
|
+
setCtrl,
|
|
1117
|
+
getCtrl,
|
|
1118
|
+
applyForce,
|
|
1119
|
+
applyTorque: applyTorqueApi,
|
|
1120
|
+
setExternalForce,
|
|
1121
|
+
applyGeneralizedForce,
|
|
1122
|
+
getSensorData,
|
|
1123
|
+
getContacts,
|
|
1124
|
+
getBodies,
|
|
1125
|
+
getJoints,
|
|
1126
|
+
getGeoms,
|
|
1127
|
+
getSites,
|
|
1128
|
+
getActuators: getActuatorsApi,
|
|
1129
|
+
getSensors,
|
|
1130
|
+
getModelOption,
|
|
1131
|
+
setGravity,
|
|
1132
|
+
setTimestep: setTimestepApi,
|
|
1133
|
+
raycast,
|
|
1134
|
+
getKeyframeNames,
|
|
1135
|
+
getKeyframeCount,
|
|
1136
|
+
loadScene: loadSceneApi,
|
|
1137
|
+
setIkEnabled,
|
|
1138
|
+
moveTarget,
|
|
1139
|
+
syncTargetToSite,
|
|
1140
|
+
solveIK,
|
|
1141
|
+
getGizmoStats,
|
|
1142
|
+
getCanvasSnapshot,
|
|
1143
|
+
project2DTo3D,
|
|
1144
|
+
getCameraState,
|
|
1145
|
+
moveCameraTo,
|
|
1146
|
+
setBodyMass,
|
|
1147
|
+
setGeomFriction,
|
|
1148
|
+
setGeomSize,
|
|
1149
|
+
mjModelRef,
|
|
1150
|
+
mjDataRef,
|
|
1151
|
+
}),
|
|
1152
|
+
[
|
|
1153
|
+
status, config, reset, setSpeed, togglePause, setPaused, step,
|
|
1154
|
+
getTime, getTimestep, applyKeyframe, saveState, restoreState,
|
|
1155
|
+
setQpos, setQvel, getQpos, getQvel, setCtrl, getCtrl,
|
|
1156
|
+
applyForce, applyTorqueApi, setExternalForce, applyGeneralizedForce,
|
|
1157
|
+
getSensorData, getContacts, getBodies, getJoints, getGeoms, getSites,
|
|
1158
|
+
getActuatorsApi, getSensors, getModelOption, setGravity, setTimestepApi,
|
|
1159
|
+
raycast, getKeyframeNames, getKeyframeCount, loadSceneApi,
|
|
1160
|
+
setIkEnabled, moveTarget, syncTargetToSite, solveIK, getGizmoStats,
|
|
1161
|
+
getCanvasSnapshot, project2DTo3D, getCameraState, moveCameraTo,
|
|
1162
|
+
setBodyMass, setGeomFriction, setGeomSize,
|
|
1163
|
+
]
|
|
1164
|
+
);
|
|
1165
|
+
const apiRef = useRef(api);
|
|
1166
|
+
apiRef.current = api;
|
|
1167
|
+
|
|
1168
|
+
const contextValue = useMemo<MujocoSimContextValue>(
|
|
1169
|
+
() => ({
|
|
1170
|
+
api,
|
|
1171
|
+
mjModelRef,
|
|
1172
|
+
mjDataRef,
|
|
1173
|
+
mujocoRef,
|
|
1174
|
+
configRef,
|
|
1175
|
+
siteIdRef,
|
|
1176
|
+
gripperIdRef,
|
|
1177
|
+
ikEnabledRef,
|
|
1178
|
+
ikCalculatingRef,
|
|
1179
|
+
pausedRef,
|
|
1180
|
+
speedRef,
|
|
1181
|
+
substepsRef,
|
|
1182
|
+
ikTargetRef,
|
|
1183
|
+
genericIkRef,
|
|
1184
|
+
ikSolveFnRef,
|
|
1185
|
+
firstIkEnableRef,
|
|
1186
|
+
gizmoAnimRef,
|
|
1187
|
+
cameraAnimRef,
|
|
1188
|
+
onSelectionRef,
|
|
1189
|
+
beforeStepCallbacks,
|
|
1190
|
+
afterStepCallbacks,
|
|
1191
|
+
status,
|
|
1192
|
+
}),
|
|
1193
|
+
[api, status]
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
return (
|
|
1197
|
+
<MujocoSimContext.Provider value={contextValue}>
|
|
1198
|
+
{children}
|
|
1199
|
+
</MujocoSimContext.Provider>
|
|
1200
|
+
);
|
|
1201
|
+
}
|