mujoco-react 10.9.0 → 11.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.
@@ -3,6 +3,10 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
+ import { captureSimulationState, restoreSimulationState } from './engineState';
7
+ import { getActuatorInfo, writeActuatorControl } from './SceneLoader';
8
+ import type { ActuatorControlValue, MujocoIntegrator } from '../types';
9
+
6
10
  import { useFrame, useThree } from '@react-three/fiber';
7
11
  import {
8
12
  createContext,
@@ -544,6 +548,7 @@ interface MujocoSimProviderProps {
544
548
  // Declarative physics config props
545
549
  gravity?: [number, number, number];
546
550
  timestep?: number;
551
+ integrator?: MujocoIntegrator;
547
552
  substeps?: number;
548
553
  paused?: boolean;
549
554
  speed?: number;
@@ -563,6 +568,7 @@ export function MujocoSimProvider({
563
568
  onSelection,
564
569
  gravity,
565
570
  timestep,
571
+ integrator,
566
572
  substeps,
567
573
  paused,
568
574
  speed,
@@ -627,6 +633,16 @@ export function MujocoSimProvider({
627
633
  model.opt.gravity[2] = gravity[2];
628
634
  }, [gravity]);
629
635
 
636
+ const integratorRef = useRef(integrator);
637
+ integratorRef.current = integrator;
638
+ const applyIntegrator = (model: MujocoModel) => {
639
+ const value = integratorRef.current;
640
+ if (value !== undefined) model.opt.integrator = { Euler: 0, RK4: 1, implicit: 2, implicitfast: 3, discrete: 4 }[value];
641
+ };
642
+ useEffect(() => {
643
+ if (mjModelRef.current) applyIntegrator(mjModelRef.current);
644
+ }, [integrator]);
645
+
630
646
  // Sync timestep prop
631
647
  useEffect(() => {
632
648
  if (timestep === undefined) return;
@@ -683,12 +699,13 @@ export function MujocoSimProvider({
683
699
  try {
684
700
  const result = await loadScene(mujoco, buildMergedConfig(config));
685
701
  if (disposed) {
686
- result.mjModel.delete();
687
702
  result.mjData.delete();
703
+ result.mjModel.delete();
688
704
  return;
689
705
  }
690
706
 
691
707
  mujocoRef.current = mujoco;
708
+ applyIntegrator(result.mjModel);
692
709
  mjModelRef.current = result.mjModel;
693
710
  mjDataRef.current = result.mjData;
694
711
  physicsAccumulatorRef.current = 0;
@@ -717,8 +734,8 @@ export function MujocoSimProvider({
717
734
 
718
735
  return () => {
719
736
  disposed = true;
720
- mjModelRef.current?.delete();
721
737
  mjDataRef.current?.delete();
738
+ mjModelRef.current?.delete();
722
739
  mjModelRef.current = null;
723
740
  mjDataRef.current = null;
724
741
  physicsAccumulatorRef.current = 0;
@@ -927,27 +944,16 @@ export function MujocoSimProvider({
927
944
  const saveState = useCallback((): StateSnapshot => {
928
945
  const data = mjDataRef.current;
929
946
  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) };
930
- return {
931
- time: data.time,
932
- qpos: new Float64Array(data.qpos),
933
- qvel: new Float64Array(data.qvel),
934
- ctrl: new Float64Array(data.ctrl),
935
- act: new Float64Array(data.act),
936
- qfrc_applied: new Float64Array(data.qfrc_applied),
937
- };
947
+ return captureSimulationState(mujocoRef.current, mjModelRef.current!, data);
938
948
  }, []);
939
949
 
940
950
  const restoreState = useCallback((snapshot: StateSnapshot) => {
941
951
  const model = mjModelRef.current;
942
952
  const data = mjDataRef.current;
943
953
  if (!model || !data) return;
944
- data.time = snapshot.time;
945
- data.qpos.set(snapshot.qpos);
946
- data.qvel.set(snapshot.qvel);
947
- data.ctrl.set(snapshot.ctrl);
948
- if (snapshot.act.length > 0) data.act.set(snapshot.act);
949
- data.qfrc_applied.set(snapshot.qfrc_applied);
950
- mujocoRef.current.mj_forward(model, data);
954
+ restoreSimulationState(mujocoRef.current, model, data, snapshot);
955
+ physicsAccumulatorRef.current = 0;
956
+ interpolationStateRef.current.valid = false;
951
957
  }, [mujoco]);
952
958
 
953
959
  const setQpos = useCallback((values: Float64Array | number[]) => {
@@ -974,18 +980,18 @@ export function MujocoSimProvider({
974
980
  return mjDataRef.current ? new Float64Array(mjDataRef.current.qvel) : new Float64Array(0);
975
981
  }, []);
976
982
 
977
- const setCtrl = useCallback((nameOrValues: string | Record<string, number>, value?: number) => {
983
+ const setCtrl = useCallback((nameOrValues: string | Record<string, ActuatorControlValue>, value?: ActuatorControlValue) => {
978
984
  const model = mjModelRef.current;
979
985
  const data = mjDataRef.current;
980
986
  if (!model || !data) return;
981
987
 
982
988
  if (typeof nameOrValues === 'string') {
983
989
  const id = findActuatorByName(model, nameOrValues);
984
- if (id >= 0 && value !== undefined) data.ctrl[id] = value;
990
+ if (value !== undefined) writeActuatorControl(model, data, id, value);
985
991
  } else {
986
992
  for (const [name, val] of Object.entries(nameOrValues)) {
987
993
  const id = findActuatorByName(model, name);
988
- if (id >= 0) data.ctrl[id] = val;
994
+ writeActuatorControl(model, data, id, val);
989
995
  }
990
996
  }
991
997
  }, []);
@@ -1152,17 +1158,7 @@ export function MujocoSimProvider({
1152
1158
  const getActuatorsApi = useCallback((): ActuatorInfo[] => {
1153
1159
  const model = mjModelRef.current;
1154
1160
  if (!model) return [];
1155
- const result: ActuatorInfo[] = [];
1156
- for (let i = 0; i < model.nu; i++) {
1157
- const hasRange = model.actuator_ctrlrange[2 * i] < model.actuator_ctrlrange[2 * i + 1];
1158
- result.push({
1159
- id: i,
1160
- name: getName(model, model.name_actuatoradr[i]),
1161
- range: hasRange
1162
- ? [model.actuator_ctrlrange[2 * i], model.actuator_ctrlrange[2 * i + 1]]
1163
- : [-Infinity, Infinity],
1164
- });
1165
- }
1161
+ const result = Array.from({ length: model.nactuator }, (_, id) => getActuatorInfo(model, id));
1166
1162
  return result;
1167
1163
  }, []);
1168
1164
 
@@ -1466,8 +1462,8 @@ export function MujocoSimProvider({
1466
1462
  const loadSceneApi = useCallback(async (newConfig: SceneConfig): Promise<void> => {
1467
1463
  const gen = ++loadGenRef.current;
1468
1464
  try {
1469
- mjModelRef.current?.delete();
1470
1465
  mjDataRef.current?.delete();
1466
+ mjModelRef.current?.delete();
1471
1467
  mjModelRef.current = null;
1472
1468
  mjDataRef.current = null;
1473
1469
  setStatus('loading');
@@ -1475,12 +1471,13 @@ export function MujocoSimProvider({
1475
1471
  const result = await loadScene(mujoco, buildMergedConfig(newConfig));
1476
1472
 
1477
1473
  if (gen !== loadGenRef.current) {
1478
- result.mjModel.delete();
1479
1474
  result.mjData.delete();
1475
+ result.mjModel.delete();
1480
1476
  return;
1481
1477
  }
1482
1478
 
1483
- mjModelRef.current = result.mjModel;
1479
+ applyIntegrator(result.mjModel);
1480
+ mjModelRef.current = result.mjModel;
1484
1481
  mjDataRef.current = result.mjData;
1485
1482
  physicsAccumulatorRef.current = 0;
1486
1483
  interpolationStateRef.current.valid = false;
@@ -3,6 +3,8 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
+ import { initializeDataThreads } from './engineState';
7
+
6
8
  import type {
7
9
  ActuatedJointInfo,
8
10
  ActuatorInfo,
@@ -54,7 +56,7 @@ export function findSiteByName(mjModel: MujocoModel, name: string): number {
54
56
  * Find an actuator by name in the MuJoCo model. Returns -1 if not found.
55
57
  */
56
58
  export function findActuatorByName(mjModel: MujocoModel, name: string): number {
57
- for (let i = 0; i < mjModel.nu; i++) {
59
+ for (let i = 0; i < mjModel.nactuator; i++) {
58
60
  if (getName(mjModel, mjModel.name_actuatoradr[i]).includes(name)) return i;
59
61
  }
60
62
  return -1;
@@ -138,7 +140,7 @@ export function findTendonByName(mjModel: MujocoModel, name: string): number {
138
140
  * Returns -1 for non-joint transmissions and multi-DOF joints.
139
141
  */
140
142
  export function getActuatedScalarQposAdr(mjModel: MujocoModel, actuatorId: number): number {
141
- if (actuatorId < 0 || actuatorId >= mjModel.nu) return -1;
143
+ if (actuatorId < 0 || actuatorId >= mjModel.nactuator) return -1;
142
144
 
143
145
  // mjTRN_JOINT=0, mjTRN_JOINTINPARENT=1. Other transmission types don't map ctrl to a single qpos.
144
146
  const trnType = mjModel.actuator_trntype?.[actuatorId];
@@ -166,7 +168,7 @@ function isScalarJoint(mjModel: MujocoModel, jointId: number): boolean {
166
168
  }
167
169
 
168
170
  function getActuatorJointId(mjModel: MujocoModel, actuatorId: number): number {
169
- if (actuatorId < 0 || actuatorId >= mjModel.nu) return -1;
171
+ if (actuatorId < 0 || actuatorId >= mjModel.nactuator) return -1;
170
172
  const trnType = mjModel.actuator_trntype?.[actuatorId];
171
173
  if (trnType !== undefined && trnType !== 0 && trnType !== 1) return -1;
172
174
  const jointId = mjModel.actuator_trnid[2 * actuatorId];
@@ -189,15 +191,35 @@ function getJointInfo(mjModel: MujocoModel, jointId: number): JointInfo {
189
191
  };
190
192
  }
191
193
 
192
- function getActuatorInfo(mjModel: MujocoModel, actuatorId: number): ActuatorInfo {
193
- const hasRange = mjModel.actuator_ctrlrange[2 * actuatorId] < mjModel.actuator_ctrlrange[2 * actuatorId + 1];
194
- return {
195
- id: actuatorId,
196
- name: getName(mjModel, mjModel.name_actuatoradr[actuatorId]),
197
- range: hasRange
198
- ? [mjModel.actuator_ctrlrange[2 * actuatorId], mjModel.actuator_ctrlrange[2 * actuatorId + 1]]
199
- : unlimitedRange(),
200
- };
194
+ export function getActuatorInfo(mjModel: MujocoModel, actuatorId: number): ActuatorInfo {
195
+ const ctrlAdr = mjModel.actuator_ctrladr[actuatorId];
196
+ const ctrlCount = mjModel.actuator_ctrlnum[actuatorId];
197
+ const ranges = Array.from({ length: ctrlCount }, (_, input): [number, number] => {
198
+ const adr = ctrlAdr + input;
199
+ const lo = mjModel.actuator_ctrlrange[2 * adr], hi = mjModel.actuator_ctrlrange[2 * adr + 1];
200
+ return lo < hi ? [lo, hi] : [-Infinity, Infinity];
201
+ });
202
+ return { id: actuatorId, name: getName(mjModel, mjModel.name_actuatoradr[actuatorId]),
203
+ ctrlAdr, ctrlCount, ranges, range: ranges[0] ?? [-Infinity, Infinity] };
204
+ }
205
+
206
+ /** Resolve one input; scalar-only callers reject ambiguous multi-input actuators. */
207
+ export function getActuatorControlAddress(model: MujocoModel, id: number, input?: number): number {
208
+ if (id < 0) return -1;
209
+ const count = model.actuator_ctrlnum[id];
210
+ if (input === undefined && count !== 1) throw new Error('This actuator requires an explicit input index or a complete control vector');
211
+ const index = input ?? 0;
212
+ if (!Number.isInteger(index) || index < 0 || index >= count) throw new Error('Actuator input index out of range');
213
+ return model.actuator_ctrladr[id] + index;
214
+ }
215
+
216
+ export function writeActuatorControl(model: MujocoModel, data: MujocoData, id: number, value: number | readonly number[]): void {
217
+ if (id < 0) return;
218
+ const values = typeof value === 'number' ? [value] : value;
219
+ if (values.length !== model.actuator_ctrlnum[id] || !values.every(Number.isFinite)) {
220
+ throw new Error('Actuator control must contain one finite value per input');
221
+ }
222
+ data.ctrl.set(values, model.actuator_ctrladr[id]);
201
223
  }
202
224
 
203
225
  function includesResourceName(names: readonly string[], name: string): boolean {
@@ -251,7 +273,7 @@ function orderedActuatorIdsFromSelector(
251
273
  .filter((id) => id >= 0 && getActuatorJointId(mjModel, id) >= 0);
252
274
  }
253
275
  const ids: number[] = [];
254
- for (let i = 0; i < mjModel.nu; i++) {
276
+ for (let i = 0; i < mjModel.nactuator; i++) {
255
277
  if (getActuatorJointId(mjModel, i) < 0) continue;
256
278
  const info = getActuatorInfo(mjModel, i);
257
279
  if (matchesSelector(info, selector)) ids.push(i);
@@ -295,9 +317,9 @@ function unique(values: number[]): number[] {
295
317
  }
296
318
 
297
319
  function findActuatorForJoint(mjModel: MujocoModel, jointId: number, preferredActuatorIds?: number[]): number {
298
- const search = preferredActuatorIds ?? Array.from({ length: mjModel.nu }, (_, i) => i);
320
+ const search = preferredActuatorIds ?? Array.from({ length: mjModel.nactuator }, (_, i) => i);
299
321
  for (const actuatorId of search) {
300
- if (getActuatorJointId(mjModel, actuatorId) === jointId) return actuatorId;
322
+ if (mjModel.actuator_ctrlnum[actuatorId] === 1 && getActuatorJointId(mjModel, actuatorId) === jointId) return actuatorId;
301
323
  }
302
324
  return -1;
303
325
  }
@@ -325,12 +347,12 @@ function buildControlGroup(
325
347
  if (actuatorId >= 0) {
326
348
  const actuator = getActuatorInfo(mjModel, actuatorId);
327
349
  actuators.push(actuator);
328
- ctrlAdr.push(actuatorId);
350
+ ctrlAdr.push(getActuatorControlAddress(mjModel, actuatorId));
329
351
  joints.push({
330
352
  ...joint,
331
353
  actuatorId,
332
354
  actuatorName: actuator.name,
333
- ctrlAdr: actuatorId,
355
+ ctrlAdr: getActuatorControlAddress(mjModel, actuatorId),
334
356
  ctrlRange: actuator.range,
335
357
  });
336
358
  } else {
@@ -372,15 +394,15 @@ function buildControlGroup(
372
394
 
373
395
  export function getActuatedJoints(mjModel: MujocoModel): ActuatedJointInfo[] {
374
396
  const result: ActuatedJointInfo[] = [];
375
- for (let actuatorId = 0; actuatorId < mjModel.nu; actuatorId++) {
397
+ for (let actuatorId = 0; actuatorId < mjModel.nactuator; actuatorId++) {
376
398
  const jointId = getActuatorJointId(mjModel, actuatorId);
377
- if (jointId < 0) continue;
399
+ if (jointId < 0 || mjModel.actuator_ctrlnum[actuatorId] !== 1) continue;
378
400
  const actuator = getActuatorInfo(mjModel, actuatorId);
379
401
  result.push({
380
402
  ...getJointInfo(mjModel, jointId),
381
403
  actuatorId,
382
404
  actuatorName: actuator.name,
383
- ctrlAdr: actuatorId,
405
+ ctrlAdr: getActuatorControlAddress(mjModel, actuatorId),
384
406
  ctrlRange: actuator.range,
385
407
  });
386
408
  }
@@ -388,8 +410,8 @@ export function getActuatedJoints(mjModel: MujocoModel): ActuatedJointInfo[] {
388
410
  }
389
411
 
390
412
  export function getControlMap(mjModel: MujocoModel): ControlGroupInfo {
391
- const actuatorIds = Array.from({ length: mjModel.nu }, (_, i) => i)
392
- .filter((id) => getActuatorJointId(mjModel, id) >= 0);
413
+ const actuatorIds = Array.from({ length: mjModel.nactuator }, (_, i) => i)
414
+ .filter((id) => mjModel.actuator_ctrlnum[id] === 1 && getActuatorJointId(mjModel, id) >= 0);
393
415
  const jointIds = actuatorIds.map((id) => getActuatorJointId(mjModel, id));
394
416
  return buildControlGroup(mjModel, jointIds, actuatorIds) ?? createContiguousControlGroup(mjModel, 0);
395
417
  }
@@ -400,6 +422,7 @@ export function resolveControlGroup(
400
422
  ): ControlGroupInfo | null {
401
423
  if (selector.actuators) {
402
424
  const actuatorIds = orderedActuatorIdsFromSelector(mjModel, selector.actuators);
425
+ for (const id of actuatorIds) getActuatorControlAddress(mjModel, id);
403
426
  const jointIds = actuatorIds.map((id) => getActuatorJointId(mjModel, id));
404
427
  return buildControlGroup(mjModel, jointIds, actuatorIds);
405
428
  }
@@ -422,6 +445,9 @@ export function resolveControlGroup(
422
445
  }
423
446
 
424
447
  export function createContiguousControlGroup(mjModel: MujocoModel, count: number): ControlGroupInfo {
448
+ if (count > 0 && (mjModel.nactuator !== mjModel.nu || Array.from(mjModel.actuator_ctrlnum).some((n) => n !== 1))) {
449
+ throw new Error('Legacy numJoints control groups require scalar-input actuators; use named controls');
450
+ }
425
451
  const n = Math.max(0, Math.min(count, mjModel.nq, mjModel.nu));
426
452
  const joints: ControlJointInfo[] = [];
427
453
  const actuators: ActuatorInfo[] = [];
@@ -837,14 +863,18 @@ async function loadSceneFromFiles(
837
863
  onProgress?.('Loading model...');
838
864
  const mjModel = loadModelFromPath(mujoco, `/working/${config.sceneFile}`);
839
865
  const mjData = new mujoco.MjData(mjModel);
840
- applyInitialPose(mjModel, mjData, config);
841
- mujoco.mj_forward(mjModel, mjData);
866
+ try {
867
+ initializeDataThreads(mujoco, mjData);
868
+ applyInitialPose(mjModel, mjData, config);
869
+ mujoco.mj_forward(mjModel, mjData);
842
870
 
843
- return { mjModel, mjData };
871
+ return { mjModel, mjData };
872
+ } catch (error) { mjData.delete(); mjModel.delete(); throw error; }
844
873
  }
845
874
 
846
875
  function applyInitialPose(mjModel: MujocoModel, mjData: MujocoData, config: SceneConfig) {
847
876
  if (!config.homeJoints) return;
877
+ if (config.homeJoints.length && (mjModel.nactuator !== mjModel.nu || Array.from(mjModel.actuator_ctrlnum).some((n) => n !== 1))) throw new Error('homeJoints requires scalar-input actuators; use a keyframe for multi-input models');
848
878
  const homeCount = Math.min(config.homeJoints.length, Math.max(mjModel.nu, mjModel.nq));
849
879
  for (let i = 0; i < homeCount; i++) {
850
880
  if (i < mjModel.nu) mjData.ctrl[i] = config.homeJoints[i];
@@ -946,14 +976,17 @@ export async function loadScene(
946
976
  onProgress?.('Loading model...');
947
977
  const mjModel = loadModelFromPath(mujoco, `/working/${config.sceneFile}`);
948
978
  const mjData = new mujoco.MjData(mjModel);
979
+ try {
980
+ initializeDataThreads(mujoco, mjData);
949
981
 
950
- // 6. Set initial pose — set both ctrl and qpos so robot starts at home.
951
- // If homeJoints is not provided, keep raw MuJoCo defaults.
952
- applyInitialPose(mjModel, mjData, config);
982
+ // 6. Set initial pose — set both ctrl and qpos so robot starts at home.
983
+ // If homeJoints is not provided, keep raw MuJoCo defaults.
984
+ applyInitialPose(mjModel, mjData, config);
953
985
 
954
- mujoco.mj_forward(mjModel, mjData);
986
+ mujoco.mj_forward(mjModel, mjData);
955
987
 
956
- return { mjModel, mjData };
988
+ return { mjModel, mjData };
989
+ } catch (error) { mjData.delete(); mjModel.delete(); throw error; }
957
990
  }
958
991
 
959
992
  /**
@@ -0,0 +1,58 @@
1
+ import type { MujocoModule, MujocoModel, MujocoData, StateSnapshot } from '../types';
2
+
3
+ const threadCounts = new WeakMap<MujocoModule, number>();
4
+ export function configureModuleThreads(module: MujocoModule, count: number) {
5
+ threadCounts.set(module, count);
6
+ }
7
+ export function initializeDataThreads(module: MujocoModule, data: MujocoData) {
8
+ const count = threadCounts.get(module) ?? 0;
9
+ if (count > 0) module.mju_threadpool(data, count);
10
+ }
11
+
12
+ export function captureSimulationState(
13
+ module: MujocoModule,
14
+ model: MujocoModel,
15
+ data: MujocoData,
16
+ ): StateSnapshot {
17
+ const signature = module.mjtState.mjSTATE_INTEGRATION.value;
18
+ const buffer = new module.DoubleBuffer(module.mj_stateSize(model, signature));
19
+ try {
20
+ module.mj_getState(model, data, buffer, signature);
21
+ return {
22
+ time: data.time,
23
+ qpos: new Float64Array(data.qpos),
24
+ qvel: new Float64Array(data.qvel),
25
+ ctrl: new Float64Array(data.ctrl),
26
+ act: new Float64Array(data.act),
27
+ qfrc_applied: new Float64Array(data.qfrc_applied),
28
+ integration: { signature, values: new Float64Array(buffer.GetView()), model },
29
+ };
30
+ } finally {
31
+ buffer.delete();
32
+ }
33
+ }
34
+
35
+ export function restoreSimulationState(
36
+ module: MujocoModule,
37
+ model: MujocoModel,
38
+ data: MujocoData,
39
+ snapshot: StateSnapshot,
40
+ ) {
41
+ const integration = snapshot.integration;
42
+ if (
43
+ integration &&
44
+ (integration.model !== model ||
45
+ integration.values.length !== module.mj_stateSize(model, integration.signature))
46
+ ) {
47
+ throw new Error('Snapshot belongs to a different model; capture a new snapshot after loading a scene');
48
+ }
49
+ for (const key of ['qpos', 'qvel', 'ctrl', 'act', 'qfrc_applied'] as const) {
50
+ if (snapshot[key].length !== data[key].length)
51
+ throw new Error(`Snapshot ${key} size does not match model`);
52
+ }
53
+ if (integration) module.mj_setState(model, data, Array.from(integration.values), integration.signature);
54
+ // Preserve editable legacy fields; the packed state supplies mocap/history/etc.
55
+ data.time = snapshot.time;
56
+ for (const key of ['qpos', 'qvel', 'ctrl', 'act', 'qfrc_applied'] as const) data[key].set(snapshot[key]);
57
+ module.mj_forward(model, data);
58
+ }
@@ -5,12 +5,12 @@
5
5
 
6
6
  import { useMemo } from 'react';
7
7
  import { useMujocoContext } from '../core/MujocoSimProvider';
8
- import { getName } from '../core/SceneLoader';
8
+ import { getActuatorInfo } from '../core/SceneLoader';
9
9
  import type { ActuatorInfo } from '../types';
10
10
 
11
11
  /**
12
12
  * Returns a stable array of actuator metadata for building control UIs.
13
- * Computed once when the model loads. Consumer reads/writes data.ctrl[id] directly.
13
+ * Computed once when the model loads. Use ctrlAdr and ctrlCount to address controls.
14
14
  */
15
15
  export function useActuators(): ActuatorInfo[] {
16
16
  const { mjModelRef, status } = useMujocoContext();
@@ -20,17 +20,7 @@ export function useActuators(): ActuatorInfo[] {
20
20
  const model = mjModelRef.current;
21
21
  if (!model) return [];
22
22
 
23
- const actuators: ActuatorInfo[] = [];
24
- for (let i = 0; i < model.nu; i++) {
25
- const name = getName(model, model.name_actuatoradr[i]);
26
- const lo = model.actuator_ctrlrange[i * 2];
27
- const hi = model.actuator_ctrlrange[i * 2 + 1];
28
- const hasRange = lo < hi;
29
- const range: [number, number] = hasRange
30
- ? [lo, hi]
31
- : [-Infinity, Infinity];
32
- actuators.push({ id: i, name, range });
33
- }
23
+ const actuators = Array.from({ length: model.nactuator }, (_, id) => getActuatorInfo(model, id));
34
24
  return actuators;
35
25
  }, [status, mjModelRef]);
36
26
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { useCallback, useEffect, useId, useMemo, useRef } from 'react';
11
- import { findActuatorByName } from '../core/SceneLoader';
11
+ import { findActuatorByName, getActuatorControlAddress } from '../core/SceneLoader';
12
12
  import { useMujocoContext } from '../core/MujocoSimProvider';
13
13
  import { useControlWriter, type ControlWriterOptions } from './useControlWriter';
14
14
  import type { Actuators } from '../types';
@@ -150,7 +150,7 @@ export function useControlGroup<K extends Actuators>(
150
150
  if (id < 0) {
151
151
  console.warn(`[mujoco-react] useControlGroup: actuator "${name}" was not found.`);
152
152
  }
153
- return id;
153
+ return getActuatorControlAddress(model, id);
154
154
  });
155
155
  // eslint-disable-next-line react-hooks/exhaustive-deps
156
156
  }, [mjModelRef, status, namesKey]);
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { useEffect, useRef, useMemo } from 'react';
9
9
  import { useMujocoContext } from '../core/MujocoSimProvider';
10
- import { findActuatorByName } from '../core/SceneLoader';
10
+ import { findActuatorByName, getActuatorControlAddress } from '../core/SceneLoader';
11
11
  import type { Actuators, CtrlHandle } from '../types';
12
12
 
13
13
  /**
@@ -16,7 +16,7 @@ import type { Actuators, CtrlHandle } from '../types';
16
16
  * Returns a `CtrlHandle` with `read()` and `write()` methods that
17
17
  * operate directly on `data.ctrl` without causing React re-renders.
18
18
  */
19
- export function useCtrl(name: Actuators): CtrlHandle {
19
+ export function useCtrl(name: Actuators, input?: number): CtrlHandle {
20
20
  const { mjModelRef, mjDataRef, status } = useMujocoContext();
21
21
  const actuatorIdRef = useRef(-1);
22
22
  const rangeRef = useRef<[number, number]>([0, 0]);
@@ -25,14 +25,15 @@ export function useCtrl(name: Actuators): CtrlHandle {
25
25
  const model = mjModelRef.current;
26
26
  if (!model || status !== 'ready') return;
27
27
  const id = findActuatorByName(model, name);
28
- actuatorIdRef.current = id;
28
+ const adr = getActuatorControlAddress(model, id, input);
29
+ actuatorIdRef.current = adr;
29
30
  if (id >= 0) {
30
31
  rangeRef.current = [
31
- model.actuator_ctrlrange[id * 2],
32
- model.actuator_ctrlrange[id * 2 + 1],
32
+ model.actuator_ctrlrange[adr * 2],
33
+ model.actuator_ctrlrange[adr * 2 + 1],
33
34
  ];
34
35
  }
35
- }, [name, status, mjModelRef]);
36
+ }, [name, input, status, mjModelRef]);
36
37
 
37
38
  return useMemo<CtrlHandle>(() => ({
38
39
  read() {
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { useEffect, useRef } from 'react';
9
9
  import { useMujocoContext, useBeforePhysicsStep } from '../core/MujocoSimProvider';
10
- import { findActuatorByName } from '../core/SceneLoader';
10
+ import { findActuatorByName, getActuatorControlAddress } from '../core/SceneLoader';
11
11
 
12
12
  interface GamepadConfig {
13
13
  /** Map gamepad axis index to actuator name. */
@@ -43,10 +43,10 @@ export function useGamepad(config: GamepadConfig) {
43
43
  axisCacheRef.current.clear();
44
44
  buttonCacheRef.current.clear();
45
45
  for (const [idx, name] of Object.entries(config.axes ?? {})) {
46
- axisCacheRef.current.set(Number(idx), findActuatorByName(model, name));
46
+ axisCacheRef.current.set(Number(idx), getActuatorControlAddress(model, findActuatorByName(model, name)));
47
47
  }
48
48
  for (const [idx, name] of Object.entries(config.buttons ?? {})) {
49
- buttonCacheRef.current.set(Number(idx), findActuatorByName(model, name));
49
+ buttonCacheRef.current.set(Number(idx), getActuatorControlAddress(model, findActuatorByName(model, name)));
50
50
  }
51
51
  }, [config.axes, config.buttons, status, mjModelRef]);
52
52
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { useEffect, useRef } from 'react';
9
9
  import { useMujocoContext, useBeforePhysicsStep } from '../core/MujocoSimProvider';
10
- import { findActuatorByName } from '../core/SceneLoader';
10
+ import { findActuatorByName, getActuatorControlAddress } from '../core/SceneLoader';
11
11
  import type { KeyboardTeleopConfig } from '../types';
12
12
 
13
13
  /**
@@ -37,7 +37,7 @@ export function useKeyboardTeleop(config: KeyboardTeleopConfig) {
37
37
  const cache = new Map<string, number>();
38
38
  for (const binding of Object.values(config.bindings)) {
39
39
  if (!cache.has(binding.actuator)) {
40
- cache.set(binding.actuator, findActuatorByName(model, binding.actuator));
40
+ cache.set(binding.actuator, getActuatorControlAddress(model, findActuatorByName(model, binding.actuator)));
41
41
  }
42
42
  }
43
43
  actuatorCacheRef.current = cache;
package/src/index.ts CHANGED
@@ -313,6 +313,7 @@ export type {
313
313
  GeomInfo,
314
314
  SiteInfo,
315
315
  ActuatorInfo,
316
+ ActuatorControlValue,
316
317
  SensorInfo,
317
318
  CameraInfo,
318
319
  // Contacts
@@ -324,6 +325,7 @@ export type {
324
325
  ImagePointProjectionResult,
325
326
  // Model options
326
327
  ModelOptions,
328
+ MujocoIntegrator,
327
329
  // Trajectory
328
330
  TrajectoryFrame,
329
331
  TrajectoryData,
@@ -0,0 +1,24 @@
1
+ import type { MujocoModel } from '../types';
2
+
3
+ /** MuJoCo flex topology uses vertex indices local to each flex. */
4
+ export function getFlexTopology(
5
+ model: MujocoModel,
6
+ id: number,
7
+ ): { kind: 'mesh' | 'lines' | 'points'; indices: number[] } {
8
+ const dim = model.flex_dim[id];
9
+ if (dim === 3) {
10
+ const start = model.flex_shelldataadr[id];
11
+ return {
12
+ kind: 'mesh',
13
+ indices: Array.from(model.flex_shell.subarray(start, start + 3 * model.flex_shellnum[id])),
14
+ };
15
+ }
16
+ if (dim === 1 || dim === 2) {
17
+ const start = model.flex_elemdataadr[id];
18
+ return {
19
+ kind: dim === 2 ? 'mesh' : 'lines',
20
+ indices: Array.from(model.flex_elem.subarray(start, start + (dim + 1) * model.flex_elemnum[id])),
21
+ };
22
+ }
23
+ return { kind: 'points', indices: [] };
24
+ }