mujoco-react 8.2.1 → 8.4.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,9 +3,27 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
- import { MujocoData, MujocoModel, MujocoModule } from '../types';
6
+ import type {
7
+ ActuatedJointInfo,
8
+ ActuatorInfo,
9
+ ControlGroupInfo,
10
+ ControlGroupSelector,
11
+ ControlJointInfo,
12
+ JointInfo,
13
+ MujocoData,
14
+ MujocoModel,
15
+ MujocoModule,
16
+ ResourceSelector,
17
+ } from '../types';
7
18
  import { SceneConfig, SceneObject, XmlPatch } from '../types';
8
19
 
20
+ const JOINT_TYPE_NAMES: Record<number, string> = {
21
+ 0: 'free',
22
+ 1: 'ball',
23
+ 2: 'slide',
24
+ 3: 'hinge',
25
+ };
26
+
9
27
  /**
10
28
  * Reads a null-terminated C string from MuJoCo's WASM memory.
11
29
  */
@@ -120,6 +138,330 @@ export function getActuatedScalarQposAdr(mjModel: MujocoModel, actuatorId: numbe
120
138
  return mjModel.jnt_qposadr[jointId];
121
139
  }
122
140
 
141
+ function getScalarJointDim(jointType: number): 0 | 1 {
142
+ return jointType === 2 || jointType === 3 ? 1 : 0;
143
+ }
144
+
145
+ function unlimitedRange(): [number, number] {
146
+ return [-Infinity, Infinity];
147
+ }
148
+
149
+ function isScalarJoint(mjModel: MujocoModel, jointId: number): boolean {
150
+ return jointId >= 0 && jointId < mjModel.njnt && getScalarJointDim(mjModel.jnt_type[jointId]) === 1;
151
+ }
152
+
153
+ function getActuatorJointId(mjModel: MujocoModel, actuatorId: number): number {
154
+ if (actuatorId < 0 || actuatorId >= mjModel.nu) return -1;
155
+ const trnType = mjModel.actuator_trntype?.[actuatorId];
156
+ if (trnType !== undefined && trnType !== 0 && trnType !== 1) return -1;
157
+ const jointId = mjModel.actuator_trnid[2 * actuatorId];
158
+ return isScalarJoint(mjModel, jointId) ? jointId : -1;
159
+ }
160
+
161
+ function getJointInfo(mjModel: MujocoModel, jointId: number): JointInfo {
162
+ const type = mjModel.jnt_type[jointId];
163
+ const range: [number, number] = [mjModel.jnt_range[2 * jointId], mjModel.jnt_range[2 * jointId + 1]];
164
+ return {
165
+ id: jointId,
166
+ name: getName(mjModel, mjModel.name_jntadr[jointId]),
167
+ type,
168
+ typeName: JOINT_TYPE_NAMES[type] ?? `unknown(${type})`,
169
+ range,
170
+ limited: range[0] < range[1],
171
+ bodyId: mjModel.jnt_bodyid[jointId],
172
+ qposAdr: mjModel.jnt_qposadr[jointId],
173
+ dofAdr: mjModel.jnt_dofadr[jointId],
174
+ };
175
+ }
176
+
177
+ function getActuatorInfo(mjModel: MujocoModel, actuatorId: number): ActuatorInfo {
178
+ const hasRange = mjModel.actuator_ctrlrange[2 * actuatorId] < mjModel.actuator_ctrlrange[2 * actuatorId + 1];
179
+ return {
180
+ id: actuatorId,
181
+ name: getName(mjModel, mjModel.name_actuatoradr[actuatorId]),
182
+ range: hasRange
183
+ ? [mjModel.actuator_ctrlrange[2 * actuatorId], mjModel.actuator_ctrlrange[2 * actuatorId + 1]]
184
+ : unlimitedRange(),
185
+ };
186
+ }
187
+
188
+ function includesResourceName(names: readonly string[], name: string): boolean {
189
+ return names.includes(name);
190
+ }
191
+
192
+ function matchesSelector<TInfo extends { name: string }, TName extends string>(
193
+ info: TInfo,
194
+ selector: ResourceSelector<TInfo, TName>
195
+ ): boolean {
196
+ if (typeof selector === 'string') return info.name === selector;
197
+ if (selector instanceof RegExp) return selector.test(info.name);
198
+ if (Array.isArray(selector)) return includesResourceName(selector, info.name);
199
+ if (typeof selector === 'function') return selector(info);
200
+ return false;
201
+ }
202
+
203
+ function orderedJointIdsFromSelector(
204
+ mjModel: MujocoModel,
205
+ selector: ResourceSelector<JointInfo, string>
206
+ ): number[] {
207
+ if (typeof selector === 'string') {
208
+ const id = findJointByName(mjModel, selector);
209
+ return id >= 0 && isScalarJoint(mjModel, id) ? [id] : [];
210
+ }
211
+ if (Array.isArray(selector)) {
212
+ return selector
213
+ .map((name) => findJointByName(mjModel, name))
214
+ .filter((id) => id >= 0 && isScalarJoint(mjModel, id));
215
+ }
216
+ const ids: number[] = [];
217
+ for (let i = 0; i < mjModel.njnt; i++) {
218
+ if (!isScalarJoint(mjModel, i)) continue;
219
+ const info = getJointInfo(mjModel, i);
220
+ if (matchesSelector(info, selector)) ids.push(i);
221
+ }
222
+ return ids;
223
+ }
224
+
225
+ function orderedActuatorIdsFromSelector(
226
+ mjModel: MujocoModel,
227
+ selector: ResourceSelector<ActuatorInfo, string>
228
+ ): number[] {
229
+ if (typeof selector === 'string') {
230
+ const id = findActuatorByName(mjModel, selector);
231
+ return id >= 0 && getActuatorJointId(mjModel, id) >= 0 ? [id] : [];
232
+ }
233
+ if (Array.isArray(selector)) {
234
+ return selector
235
+ .map((name) => findActuatorByName(mjModel, name))
236
+ .filter((id) => id >= 0 && getActuatorJointId(mjModel, id) >= 0);
237
+ }
238
+ const ids: number[] = [];
239
+ for (let i = 0; i < mjModel.nu; i++) {
240
+ if (getActuatorJointId(mjModel, i) < 0) continue;
241
+ const info = getActuatorInfo(mjModel, i);
242
+ if (matchesSelector(info, selector)) ids.push(i);
243
+ }
244
+ return ids;
245
+ }
246
+
247
+ function inferScalarJointChain(mjModel: MujocoModel, bodyId: number): number[] {
248
+ if (bodyId < 0 || bodyId >= mjModel.nbody) return [];
249
+ const chainByBody: number[][] = [];
250
+ let current = bodyId;
251
+ const seen = new Set<number>();
252
+
253
+ while (current >= 0 && current < mjModel.nbody && !seen.has(current)) {
254
+ seen.add(current);
255
+ const joints: number[] = [];
256
+ const jointCount = mjModel.body_jntnum[current] ?? 0;
257
+ const jointStart = mjModel.body_jntadr[current] ?? -1;
258
+ for (let i = 0; i < jointCount; i++) {
259
+ const jointId = jointStart + i;
260
+ if (isScalarJoint(mjModel, jointId)) joints.push(jointId);
261
+ }
262
+ if (joints.length) chainByBody.push(joints);
263
+ const parent = mjModel.body_parentid[current];
264
+ if (parent === current) break;
265
+ current = parent;
266
+ }
267
+
268
+ return chainByBody.reverse().flat();
269
+ }
270
+
271
+ function unique(values: number[]): number[] {
272
+ const seen = new Set<number>();
273
+ const result: number[] = [];
274
+ for (const value of values) {
275
+ if (seen.has(value)) continue;
276
+ seen.add(value);
277
+ result.push(value);
278
+ }
279
+ return result;
280
+ }
281
+
282
+ function findActuatorForJoint(mjModel: MujocoModel, jointId: number, preferredActuatorIds?: number[]): number {
283
+ const search = preferredActuatorIds ?? Array.from({ length: mjModel.nu }, (_, i) => i);
284
+ for (const actuatorId of search) {
285
+ if (getActuatorJointId(mjModel, actuatorId) === jointId) return actuatorId;
286
+ }
287
+ return -1;
288
+ }
289
+
290
+ function buildControlGroup(
291
+ mjModel: MujocoModel,
292
+ jointIds: number[],
293
+ preferredActuatorIds?: number[]
294
+ ): ControlGroupInfo | null {
295
+ const ids = unique(jointIds).filter((id) => isScalarJoint(mjModel, id));
296
+ if (!ids.length) return null;
297
+
298
+ const joints: ControlJointInfo[] = [];
299
+ const actuators: ActuatorInfo[] = [];
300
+ const qposAdr: number[] = [];
301
+ const dofAdr: number[] = [];
302
+ const ctrlAdr: number[] = [];
303
+
304
+ for (const jointId of ids) {
305
+ const actuatorId = findActuatorForJoint(mjModel, jointId, preferredActuatorIds);
306
+ const joint = getJointInfo(mjModel, jointId);
307
+ qposAdr.push(joint.qposAdr);
308
+ dofAdr.push(joint.dofAdr);
309
+
310
+ if (actuatorId >= 0) {
311
+ const actuator = getActuatorInfo(mjModel, actuatorId);
312
+ actuators.push(actuator);
313
+ ctrlAdr.push(actuatorId);
314
+ joints.push({
315
+ ...joint,
316
+ actuatorId,
317
+ actuatorName: actuator.name,
318
+ ctrlAdr: actuatorId,
319
+ ctrlRange: actuator.range,
320
+ });
321
+ } else {
322
+ joints.push({
323
+ ...joint,
324
+ actuatorId: null,
325
+ actuatorName: null,
326
+ ctrlAdr: null,
327
+ ctrlRange: null,
328
+ });
329
+ }
330
+ }
331
+
332
+ return {
333
+ joints,
334
+ actuators,
335
+ qposAdr,
336
+ dofAdr,
337
+ ctrlAdr,
338
+ readQpos(data: MujocoData) {
339
+ return new Float64Array(qposAdr.map((adr) => data.qpos[adr] ?? 0));
340
+ },
341
+ readCtrl(data: MujocoData) {
342
+ return new Float64Array(joints.map((joint) => joint.ctrlAdr === null ? 0 : data.ctrl[joint.ctrlAdr] ?? 0));
343
+ },
344
+ writeQpos(data: MujocoData, values: ArrayLike<number>) {
345
+ for (let i = 0; i < Math.min(values.length, qposAdr.length); i++) {
346
+ data.qpos[qposAdr[i]] = values[i];
347
+ }
348
+ },
349
+ writeCtrl(data: MujocoData, values: ArrayLike<number>) {
350
+ for (let i = 0; i < Math.min(values.length, joints.length); i++) {
351
+ const adr = joints[i].ctrlAdr;
352
+ if (adr !== null) data.ctrl[adr] = values[i];
353
+ }
354
+ },
355
+ };
356
+ }
357
+
358
+ export function getActuatedJoints(mjModel: MujocoModel): ActuatedJointInfo[] {
359
+ const result: ActuatedJointInfo[] = [];
360
+ for (let actuatorId = 0; actuatorId < mjModel.nu; actuatorId++) {
361
+ const jointId = getActuatorJointId(mjModel, actuatorId);
362
+ if (jointId < 0) continue;
363
+ const actuator = getActuatorInfo(mjModel, actuatorId);
364
+ result.push({
365
+ ...getJointInfo(mjModel, jointId),
366
+ actuatorId,
367
+ actuatorName: actuator.name,
368
+ ctrlAdr: actuatorId,
369
+ ctrlRange: actuator.range,
370
+ });
371
+ }
372
+ return result;
373
+ }
374
+
375
+ export function getControlMap(mjModel: MujocoModel): ControlGroupInfo {
376
+ const actuatorIds = Array.from({ length: mjModel.nu }, (_, i) => i)
377
+ .filter((id) => getActuatorJointId(mjModel, id) >= 0);
378
+ const jointIds = actuatorIds.map((id) => getActuatorJointId(mjModel, id));
379
+ return buildControlGroup(mjModel, jointIds, actuatorIds) ?? createContiguousControlGroup(mjModel, 0);
380
+ }
381
+
382
+ export function resolveControlGroup(
383
+ mjModel: MujocoModel,
384
+ selector: ControlGroupSelector
385
+ ): ControlGroupInfo | null {
386
+ if (selector.actuators) {
387
+ const actuatorIds = orderedActuatorIdsFromSelector(mjModel, selector.actuators);
388
+ const jointIds = actuatorIds.map((id) => getActuatorJointId(mjModel, id));
389
+ return buildControlGroup(mjModel, jointIds, actuatorIds);
390
+ }
391
+
392
+ if (selector.joints) {
393
+ return buildControlGroup(mjModel, orderedJointIdsFromSelector(mjModel, selector.joints));
394
+ }
395
+
396
+ if (selector.siteName) {
397
+ const siteId = findSiteByName(mjModel, selector.siteName);
398
+ const bodyId = siteId >= 0 ? (mjModel.site_bodyid?.[siteId] ?? -1) : -1;
399
+ return buildControlGroup(mjModel, inferScalarJointChain(mjModel, bodyId));
400
+ }
401
+
402
+ if (selector.bodyName) {
403
+ return buildControlGroup(mjModel, inferScalarJointChain(mjModel, findBodyByName(mjModel, selector.bodyName)));
404
+ }
405
+
406
+ return getControlMap(mjModel);
407
+ }
408
+
409
+ export function createContiguousControlGroup(mjModel: MujocoModel, count: number): ControlGroupInfo {
410
+ const n = Math.max(0, Math.min(count, mjModel.nq, mjModel.nu));
411
+ const joints: ControlJointInfo[] = [];
412
+ const actuators: ActuatorInfo[] = [];
413
+ const qposAdr: number[] = [];
414
+ const dofAdr: number[] = [];
415
+ const ctrlAdr: number[] = [];
416
+
417
+ for (let i = 0; i < n; i++) {
418
+ qposAdr.push(i);
419
+ dofAdr.push(i);
420
+ ctrlAdr.push(i);
421
+ const jointId = Array.from({ length: mjModel.njnt }, (_, id) => id)
422
+ .find((id) => mjModel.jnt_qposadr[id] === i);
423
+ const actuator = getActuatorInfo(mjModel, i);
424
+ actuators.push(actuator);
425
+ joints.push({
426
+ ...(jointId !== undefined ? getJointInfo(mjModel, jointId) : {
427
+ id: i,
428
+ name: `qpos${i}`,
429
+ type: 3,
430
+ typeName: 'hinge',
431
+ range: unlimitedRange(),
432
+ limited: false,
433
+ bodyId: -1,
434
+ qposAdr: i,
435
+ dofAdr: i,
436
+ }),
437
+ actuatorId: i,
438
+ actuatorName: actuator.name,
439
+ ctrlAdr: i,
440
+ ctrlRange: actuator.range,
441
+ });
442
+ }
443
+
444
+ return {
445
+ joints,
446
+ actuators,
447
+ qposAdr,
448
+ dofAdr,
449
+ ctrlAdr,
450
+ readQpos(data: MujocoData) {
451
+ return new Float64Array(qposAdr.map((adr) => data.qpos[adr] ?? 0));
452
+ },
453
+ readCtrl(data: MujocoData) {
454
+ return new Float64Array(ctrlAdr.map((adr) => data.ctrl[adr] ?? 0));
455
+ },
456
+ writeQpos(data: MujocoData, values: ArrayLike<number>) {
457
+ for (let i = 0; i < Math.min(values.length, qposAdr.length); i++) data.qpos[qposAdr[i]] = values[i];
458
+ },
459
+ writeCtrl(data: MujocoData, values: ArrayLike<number>) {
460
+ for (let i = 0; i < Math.min(values.length, ctrlAdr.length); i++) data.ctrl[ctrlAdr[i]] = values[i];
461
+ },
462
+ };
463
+ }
464
+
123
465
  /**
124
466
  * Convert a SceneObject config to MuJoCo XML.
125
467
  */
@@ -153,6 +495,16 @@ interface LoadResult {
153
495
  mjData: MujocoData;
154
496
  }
155
497
 
498
+ function loadModelFromPath(mujoco: MujocoModule, path: string): MujocoModel {
499
+ if (mujoco.MjModel.from_xml_path) {
500
+ return mujoco.MjModel.from_xml_path(path);
501
+ }
502
+ if (mujoco.MjModel.loadFromXML) {
503
+ return mujoco.MjModel.loadFromXML(path);
504
+ }
505
+ throw new Error('MuJoCo WASM module does not expose an XML path loader');
506
+ }
507
+
156
508
  /**
157
509
  * Config-driven scene loader — replaces the old RobotLoader + patchSingleRobot approach.
158
510
  */
@@ -260,7 +612,7 @@ export async function loadScene(
260
612
 
261
613
  // 5. Load model
262
614
  onProgress?.('Loading model...');
263
- const mjModel = mujoco.MjModel.loadFromXML(`/working/${config.sceneFile}`);
615
+ const mjModel = loadModelFromPath(mujoco, `/working/${config.sceneFile}`);
264
616
  const mjData = new mujoco.MjData(mjModel);
265
617
 
266
618
  // 6. Set initial pose — set both ctrl and qpos so robot starts at home.
@@ -9,7 +9,7 @@
9
9
  import { useCallback, useEffect, useRef } from 'react';
10
10
  import { useMujocoContext, useAfterPhysicsStep } from '../core/MujocoSimProvider';
11
11
  import { findBodyByName, getName } from '../core/SceneLoader';
12
- import { getContact } from '../types';
12
+ import { getContact, withContacts } from '../types';
13
13
  import type { Bodies, ContactInfo, MujocoModel } from '../types';
14
14
 
15
15
  // Cache geom names per model to avoid cross-model id collisions.
@@ -77,24 +77,26 @@ export function useContacts(
77
77
  const contacts: ContactInfo[] = [];
78
78
  const filterBody = bodyIdRef.current;
79
79
 
80
- for (let i = 0; i < ncon; i++) {
81
- const c = getContact(data, i);
82
- if (!c) break;
83
- // Filter by body if specified
84
- if (filterBody >= 0) {
85
- const b1 = model.geom_bodyid[c.geom1];
86
- const b2 = model.geom_bodyid[c.geom2];
87
- if (b1 !== filterBody && b2 !== filterBody) continue;
80
+ withContacts(data, (contactArray) => {
81
+ for (let i = 0; i < ncon; i++) {
82
+ const c = getContact(contactArray, i);
83
+ if (!c) break;
84
+ // Filter by body if specified
85
+ if (filterBody >= 0) {
86
+ const b1 = model.geom_bodyid[c.geom1];
87
+ const b2 = model.geom_bodyid[c.geom2];
88
+ if (b1 !== filterBody && b2 !== filterBody) continue;
89
+ }
90
+ contacts.push({
91
+ geom1: c.geom1,
92
+ geom1Name: getGeomNameCached(model, c.geom1),
93
+ geom2: c.geom2,
94
+ geom2Name: getGeomNameCached(model, c.geom2),
95
+ pos: [c.pos[0], c.pos[1], c.pos[2]],
96
+ depth: c.dist,
97
+ });
88
98
  }
89
- contacts.push({
90
- geom1: c.geom1,
91
- geom1Name: getGeomNameCached(model, c.geom1),
92
- geom2: c.geom2,
93
- geom2Name: getGeomNameCached(model, c.geom2),
94
- pos: [c.pos[0], c.pos[1], c.pos[2]],
95
- depth: c.dist,
96
- });
97
- }
99
+ });
98
100
  contactsRef.current = contacts;
99
101
  callbackRef.current?.(contacts);
100
102
  });
@@ -9,8 +9,8 @@ import * as THREE from 'three';
9
9
  import { createControllerHook } from '../core/createController';
10
10
  import { useMujocoContext, useBeforePhysicsStep } from '../core/MujocoSimProvider';
11
11
  import { GenericIK } from '../core/GenericIK';
12
- import { findSiteByName } from '../core/SceneLoader';
13
- import type { IkConfig, IkContextValue, IKSolveFn, MujocoData } from '../types';
12
+ import { createContiguousControlGroup, findSiteByName, resolveControlGroup } from '../core/SceneLoader';
13
+ import type { ControlGroupInfo, IkConfig, IkContextValue, IKSolveFn, MujocoData } from '../types';
14
14
 
15
15
  // Preallocated temp for syncGizmoToSite
16
16
  const _syncMat4 = new THREE.Matrix4();
@@ -40,6 +40,7 @@ export const useIkController = createControllerHook<IkConfig, IkContextValue>(
40
40
  const ikCalculatingRef = useRef(false);
41
41
  const ikTargetRef = useRef<THREE.Group>(new THREE.Group());
42
42
  const siteIdRef = useRef(-1);
43
+ const controlGroupRef = useRef<ControlGroupInfo | null>(null);
43
44
  const genericIkRef = useRef<GenericIK>(new GenericIK(mujocoRef.current));
44
45
  const firstIkEnableRef = useRef(true);
45
46
  const needsInitialSync = useRef(true);
@@ -54,23 +55,32 @@ export const useIkController = createControllerHook<IkConfig, IkContextValue>(
54
55
  duration: 1000,
55
56
  });
56
57
 
57
- // Resolve site ID when model loads or config changes
58
+ // Resolve site ID and model-aware control group when model loads or config changes.
58
59
  useEffect(() => {
59
60
  if (!config) {
60
61
  siteIdRef.current = -1;
62
+ controlGroupRef.current = null;
61
63
  return;
62
64
  }
63
65
  const model = mjModelRef.current;
64
66
  if (!model || status !== 'ready') {
65
67
  siteIdRef.current = -1;
68
+ controlGroupRef.current = null;
66
69
  return;
67
70
  }
68
71
  siteIdRef.current = findSiteByName(model, config.siteName);
72
+ controlGroupRef.current = config.numJoints !== undefined
73
+ ? createContiguousControlGroup(model, config.numJoints)
74
+ : resolveControlGroup(model, {
75
+ siteName: config.siteName,
76
+ joints: config.joints,
77
+ actuators: config.actuators,
78
+ });
69
79
  const data = mjDataRef.current;
70
80
  if (data && ikTargetRef.current) {
71
81
  syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
72
82
  }
73
- }, [config?.siteName, status, mjModelRef, mjDataRef, config]);
83
+ }, [config?.siteName, config?.numJoints, config?.joints, config?.actuators, status, mjModelRef, mjDataRef, config]);
74
84
 
75
85
  // IK solve function
76
86
  const ikSolveFn = useCallback(
@@ -79,9 +89,10 @@ export const useIkController = createControllerHook<IkConfig, IkContextValue>(
79
89
  if (config.ikSolveFn) return config.ikSolveFn(pos, quat, currentQ);
80
90
  const model = mjModelRef.current;
81
91
  const data = mjDataRef.current;
82
- if (!model || !data || siteIdRef.current === -1) return null;
92
+ const controlGroup = controlGroupRef.current;
93
+ if (!model || !data || !controlGroup || siteIdRef.current === -1) return null;
83
94
  return genericIkRef.current.solve(
84
- model, data, siteIdRef.current, config.numJoints,
95
+ model, data, siteIdRef.current, controlGroup.qposAdr,
85
96
  pos, quat, currentQ,
86
97
  { damping: config.damping, maxIterations: config.maxIterations },
87
98
  );
@@ -126,12 +137,20 @@ export const useIkController = createControllerHook<IkConfig, IkContextValue>(
126
137
  if (!target) return;
127
138
 
128
139
  ikCalculatingRef.current = true;
129
- const numJoints = config.numJoints;
130
- const currentQ: number[] = [];
131
- for (let i = 0; i < numJoints; i++) currentQ.push(data.qpos[i]);
132
- const solution = ikSolveFnRef.current(target.position, target.quaternion, currentQ);
140
+ const controlGroup = controlGroupRef.current;
141
+ if (!controlGroup) return;
142
+
143
+ const currentQ = Array.from(controlGroup.readQpos(data));
144
+ const solution = config.ikSolveFn
145
+ ? config.ikSolveFn(target.position, target.quaternion, currentQ, {
146
+ model,
147
+ data,
148
+ siteId: siteIdRef.current,
149
+ controlGroup,
150
+ })
151
+ : ikSolveFnRef.current(target.position, target.quaternion, currentQ);
133
152
  if (solution) {
134
- for (let i = 0; i < numJoints; i++) data.ctrl[i] = solution[i];
153
+ controlGroup.writeCtrl(data, solution);
135
154
  }
136
155
  });
137
156
 
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  // Core
7
7
  export { MujocoProvider, useMujocoWasm } from './core/MujocoProvider';
8
+ export type { MujocoLoader, MujocoLoaderOptions, MujocoProviderProps, MujocoWasmVariant } from './core/MujocoProvider';
8
9
  export { MujocoCanvas } from './core/MujocoCanvas';
9
10
  export { MujocoPhysics } from './core/MujocoPhysics';
10
11
  export type { MujocoPhysicsProps } from './core/MujocoPhysics';
@@ -20,6 +21,10 @@ export {
20
21
  findGeomByName,
21
22
  findSensorByName,
22
23
  findTendonByName,
24
+ getActuatedJoints,
25
+ getControlMap,
26
+ resolveControlGroup,
27
+ createContiguousControlGroup,
23
28
  } from './core/SceneLoader';
24
29
 
25
30
  // Controller factory
@@ -82,6 +87,11 @@ export type {
82
87
  // Model introspection
83
88
  BodyInfo,
84
89
  JointInfo,
90
+ ActuatedJointInfo,
91
+ ControlJointInfo,
92
+ ControlGroupInfo,
93
+ ControlGroupSelector,
94
+ ResourceSelector,
85
95
  GeomInfo,
86
96
  SiteInfo,
87
97
  ActuatorInfo,
@@ -61,7 +61,7 @@ export class GeomBuilder {
61
61
  const MG = this.mujoco.mjtGeom; // Short alias for MuJoCo Geometry Types enum
62
62
  let geo: THREE.BufferGeometry | null = null;
63
63
 
64
- // The '.value ?? MG.XYZ' pattern handles slightly different versions of the mujoco-js bindings.
64
+ // The '.value ?? MG.XYZ' pattern handles slightly different MuJoCo WASM binding versions.
65
65
  const getVal = (v: unknown) => (v as { value: number })?.value ?? v;
66
66
 
67
67
  if (type === getVal(MG.mjGEOM_PLANE)) {