mujoco-react 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,14 +14,12 @@ import {
14
14
  useState,
15
15
  } from 'react';
16
16
  import * as THREE from 'three';
17
- import { MujocoData, MujocoModel, MujocoModule } from '../types';
18
- import { GenericIK } from './GenericIK';
17
+ import { MujocoData, MujocoModel, MujocoModule, getContact } from '../types';
19
18
  import {
20
19
  ActuatorInfo,
21
20
  BodyInfo,
22
21
  ContactInfo,
23
22
  GeomInfo,
24
- IKSolveFn,
25
23
  JointInfo,
26
24
  ModelOptions,
27
25
  MujocoSimAPI,
@@ -39,6 +37,7 @@ import {
39
37
  findGeomByName,
40
38
  findSensorByName,
41
39
  findActuatorByName,
40
+ getActuatedScalarQposAdr,
42
41
  getName,
43
42
  } from './SceneLoader';
44
43
 
@@ -47,7 +46,6 @@ const JOINT_TYPE_NAMES = ['free', 'ball', 'slide', 'hinge'];
47
46
  // ---- Geom type names ----
48
47
  const GEOM_TYPE_NAMES = ['plane', 'hfield', 'sphere', 'capsule', 'ellipsoid', 'cylinder', 'box', 'mesh'];
49
48
  // ---- Sensor type names (subset — MuJoCo has many) ----
50
- // Sensor type names matching mjtSensor enum in mujoco WASM (mujoco-js 0.0.7)
51
49
  const SENSOR_TYPE_NAMES: Record<number, string> = {
52
50
  0: 'touch', 1: 'accelerometer', 2: 'velocimeter', 3: 'gyro',
53
51
  4: 'force', 5: 'torque', 6: 'magnetometer', 7: 'rangefinder',
@@ -72,6 +70,8 @@ const _applyPoint = new Float64Array(3);
72
70
  const _rayPnt = new Float64Array(3);
73
71
  const _rayVec = new Float64Array(3);
74
72
  const _rayGeomId = new Int32Array(1);
73
+ const _projRaycaster = new THREE.Raycaster();
74
+ const _projNdc = new THREE.Vector2();
75
75
 
76
76
  // ---- Internal context types ----
77
77
 
@@ -81,43 +81,15 @@ export interface MujocoSimContextValue {
81
81
  mjDataRef: React.RefObject<MujocoData | null>;
82
82
  mujocoRef: React.RefObject<MujocoModule>;
83
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
84
  pausedRef: React.RefObject<boolean>;
89
85
  speedRef: React.RefObject<number>;
90
86
  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
87
  onSelectionRef: React.RefObject<
117
88
  ((bodyId: number, name: string) => void) | undefined
118
89
  >;
119
90
  beforeStepCallbacks: React.RefObject<Set<PhysicsStepCallback>>;
120
91
  afterStepCallbacks: React.RefObject<Set<PhysicsStepCallback>>;
92
+ resetCallbacks: React.RefObject<Set<() => void>>;
121
93
  status: 'loading' | 'ready' | 'error';
122
94
  }
123
95
 
@@ -162,13 +134,12 @@ interface MujocoSimProviderProps {
162
134
  onError?: (error: Error) => void;
163
135
  onStep?: (time: number) => void;
164
136
  onSelection?: (bodyId: number, name: string) => void;
165
- // Declarative physics config props (spec 1.1)
137
+ // Declarative physics config props
166
138
  gravity?: [number, number, number];
167
139
  timestep?: number;
168
140
  substeps?: number;
169
141
  paused?: boolean;
170
142
  speed?: number;
171
- interpolate?: boolean;
172
143
  children: React.ReactNode;
173
144
  }
174
145
 
@@ -185,7 +156,6 @@ export function MujocoSimProvider({
185
156
  substeps,
186
157
  paused,
187
158
  speed,
188
- interpolate,
189
159
  children,
190
160
  }: MujocoSimProviderProps) {
191
161
  const { gl, camera } = useThree();
@@ -196,21 +166,11 @@ export function MujocoSimProvider({
196
166
  const mjDataRef = useRef<MujocoData | null>(null);
197
167
  const mujocoRef = useRef<MujocoModule>(mujoco);
198
168
  const configRef = useRef<SceneConfig>(config);
199
- const siteIdRef = useRef(-1);
200
- const gripperIdRef = useRef(-1);
201
- const ikEnabledRef = useRef(false);
202
- const ikCalculatingRef = useRef(false);
203
169
  const pausedRef = useRef(paused ?? false);
204
170
  const speedRef = useRef(speed ?? 1);
205
171
  const substepsRef = useRef(substeps ?? 1);
206
- const interpolateRef = useRef(interpolate ?? false);
207
- const firstIkEnableRef = useRef(true);
208
- const stepsToRunRef = useRef(0); // for single-step mode (spec 1.2)
209
-
210
- // Interpolation state (spec 11.1)
211
- const prevXposRef = useRef<Float64Array | null>(null);
212
- const prevXquatRef = useRef<Float64Array | null>(null);
213
- const interpAlphaRef = useRef(0);
172
+ const stepsToRunRef = useRef(0);
173
+ const loadGenRef = useRef(0);
214
174
 
215
175
  const onSelectionRef = useRef(onSelection);
216
176
  onSelectionRef.current = onSelection;
@@ -219,6 +179,7 @@ export function MujocoSimProvider({
219
179
 
220
180
  const beforeStepCallbacks = useRef(new Set<PhysicsStepCallback>());
221
181
  const afterStepCallbacks = useRef(new Set<PhysicsStepCallback>());
182
+ const resetCallbacks = useRef(new Set<() => void>());
222
183
 
223
184
  configRef.current = config;
224
185
 
@@ -226,9 +187,8 @@ export function MujocoSimProvider({
226
187
  useEffect(() => { pausedRef.current = paused ?? false; }, [paused]);
227
188
  useEffect(() => { speedRef.current = speed ?? 1; }, [speed]);
228
189
  useEffect(() => { substepsRef.current = substeps ?? 1; }, [substeps]);
229
- useEffect(() => { interpolateRef.current = interpolate ?? false; }, [interpolate]);
230
190
 
231
- // Sync gravity prop (spec 1.1)
191
+ // Sync gravity prop
232
192
  useEffect(() => {
233
193
  if (!gravity) return;
234
194
  const model = mjModelRef.current;
@@ -238,7 +198,7 @@ export function MujocoSimProvider({
238
198
  model.opt.gravity[2] = gravity[2];
239
199
  }, [gravity]);
240
200
 
241
- // Sync timestep prop (spec 1.1)
201
+ // Sync timestep prop
242
202
  useEffect(() => {
243
203
  if (timestep === undefined) return;
244
204
  const model = mjModelRef.current;
@@ -246,66 +206,6 @@ export function MujocoSimProvider({
246
206
  model.opt.timestep = timestep;
247
207
  }, [timestep]);
248
208
 
249
- const ikTargetRef = useRef<THREE.Group>(new THREE.Group());
250
- const genericIkRef = useRef<GenericIK>(new GenericIK(mujoco));
251
-
252
- const gizmoAnimRef = useRef({
253
- active: false,
254
- startPos: new THREE.Vector3(),
255
- endPos: new THREE.Vector3(),
256
- startRot: new THREE.Quaternion(),
257
- endRot: new THREE.Quaternion(),
258
- startTime: 0,
259
- duration: 1000,
260
- });
261
-
262
- const cameraAnimRef = useRef({
263
- active: false,
264
- startPos: new THREE.Vector3(),
265
- endPos: new THREE.Vector3(),
266
- startRot: new THREE.Quaternion(),
267
- endRot: new THREE.Quaternion(),
268
- startTarget: new THREE.Vector3(),
269
- endTarget: new THREE.Vector3(),
270
- startTime: 0,
271
- duration: 0,
272
- resolve: null as (() => void) | null,
273
- });
274
-
275
- const orbitTargetRef = useRef(new THREE.Vector3(0, 0, 0));
276
-
277
- // --- Helper: sync gizmo to actual MuJoCo site position ---
278
- const syncGizmoToSite = useCallback((data: MujocoData, siteId: number, target: THREE.Group) => {
279
- if (siteId === -1) return;
280
- const sitePos = data.site_xpos.subarray(siteId * 3, siteId * 3 + 3);
281
- const siteMat = data.site_xmat.subarray(siteId * 9, siteId * 9 + 9);
282
- target.position.set(sitePos[0], sitePos[1], sitePos[2]);
283
- const m = new THREE.Matrix4().set(
284
- siteMat[0], siteMat[1], siteMat[2], 0,
285
- siteMat[3], siteMat[4], siteMat[5], 0,
286
- siteMat[6], siteMat[7], siteMat[8], 0,
287
- 0, 0, 0, 1
288
- );
289
- target.quaternion.setFromRotationMatrix(m);
290
- }, []);
291
-
292
- // IK solve function
293
- const ikSolveFn = useCallback(
294
- (pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null => {
295
- const model = mjModelRef.current;
296
- const data = mjDataRef.current;
297
- if (!model || !data || siteIdRef.current === -1) return null;
298
- return genericIkRef.current.solve(
299
- model, data, siteIdRef.current,
300
- configRef.current.numArmJoints ?? 7,
301
- pos, quat, currentQ
302
- );
303
- },
304
- []
305
- );
306
- const ikSolveFnRef = useRef<IKSolveFn>(ikSolveFn);
307
- ikSolveFnRef.current = ikSolveFn;
308
-
309
209
  // --- Load scene on mount ---
310
210
  useEffect(() => {
311
211
  let disposed = false;
@@ -321,8 +221,6 @@ export function MujocoSimProvider({
321
221
 
322
222
  mjModelRef.current = result.mjModel;
323
223
  mjDataRef.current = result.mjData;
324
- siteIdRef.current = result.siteId;
325
- gripperIdRef.current = result.gripperId;
326
224
 
327
225
  // Apply declarative physics props after load
328
226
  if (gravity && result.mjModel.opt?.gravity) {
@@ -334,10 +232,6 @@ export function MujocoSimProvider({
334
232
  result.mjModel.opt.timestep = timestep;
335
233
  }
336
234
 
337
- if (ikTargetRef.current) {
338
- syncGizmoToSite(result.mjData, result.siteId, ikTargetRef.current);
339
- }
340
-
341
235
  setStatus('ready');
342
236
  } catch (e: unknown) {
343
237
  if (!disposed) {
@@ -374,51 +268,12 @@ export function MujocoSimProvider({
374
268
  }, [status]);
375
269
 
376
270
  // --- Physics step (priority -1) ---
377
- useFrame((state) => {
271
+ useFrame((_state, delta) => {
378
272
  const model = mjModelRef.current;
379
273
  const data = mjDataRef.current;
380
274
  if (!model || !data) return;
381
275
 
382
- // Gizmo animation
383
- const ga = gizmoAnimRef.current;
384
- const target = ikTargetRef.current;
385
- if (ga.active && target) {
386
- const now = performance.now();
387
- const elapsed = now - ga.startTime;
388
- const t = Math.min(elapsed / ga.duration, 1.0);
389
- const ease = 1 - Math.pow(1 - t, 3);
390
- target.position.lerpVectors(ga.startPos, ga.endPos, ease);
391
- target.quaternion.slerpQuaternions(ga.startRot, ga.endRot, ease);
392
- if (t >= 1.0) ga.active = false;
393
- }
394
-
395
- // Camera animation
396
- const ca = cameraAnimRef.current;
397
- if (ca.active) {
398
- const now = performance.now();
399
- const progress = Math.min((now - ca.startTime) / ca.duration, 1.0);
400
- const ease =
401
- progress < 0.5
402
- ? 4 * progress * progress * progress
403
- : 1 - Math.pow(-2 * progress + 2, 3) / 2;
404
- camera.position.lerpVectors(ca.startPos, ca.endPos, ease);
405
- camera.quaternion.slerpQuaternions(ca.startRot, ca.endRot, ease);
406
- orbitTargetRef.current.lerpVectors(ca.startTarget, ca.endTarget, ease);
407
- const orbitControls = state.controls as { target?: THREE.Vector3 };
408
- if (orbitControls?.target) {
409
- orbitControls.target.copy(orbitTargetRef.current);
410
- }
411
- if (progress >= 1.0) {
412
- ca.active = false;
413
- camera.position.copy(ca.endPos);
414
- camera.quaternion.copy(ca.endRot);
415
- orbitTargetRef.current.copy(ca.endTarget);
416
- ca.resolve?.();
417
- ca.resolve = null;
418
- }
419
- }
420
-
421
- // Check single-step mode (spec 1.2)
276
+ // Check single-step mode
422
277
  const shouldStep = !pausedRef.current || stepsToRunRef.current > 0;
423
278
  if (!shouldStep) return;
424
279
 
@@ -432,31 +287,17 @@ export function MujocoSimProvider({
432
287
  cb(model, data);
433
288
  }
434
289
 
435
- // IK
436
- if (ikEnabledRef.current && target) {
437
- ikCalculatingRef.current = true;
438
- const numArm = configRef.current.numArmJoints ?? 7;
439
- const currentQ: number[] = [];
440
- for (let i = 0; i < numArm; i++) currentQ.push(data.qpos[i]);
441
- const solution = ikSolveFnRef.current(target.position, target.quaternion, currentQ);
442
- if (solution) {
443
- for (let i = 0; i < numArm; i++) data.ctrl[i] = solution[i];
444
- }
445
- } else {
446
- ikCalculatingRef.current = false;
447
- }
448
-
449
- // Step physics with substeps (spec 1.1)
290
+ // Step physics with substeps
450
291
  const numSubsteps = substepsRef.current;
451
292
  if (stepsToRunRef.current > 0) {
452
- // Single-step mode (spec 1.2)
453
293
  for (let s = 0; s < stepsToRunRef.current; s++) {
454
294
  mujoco.mj_step(model, data);
455
295
  }
456
296
  stepsToRunRef.current = 0;
457
297
  } else {
458
298
  const startSimTime = data.time;
459
- const frameTime = (1.0 / 60.0) * speedRef.current;
299
+ const clampedDelta = Math.min(delta, 1 / 15); // cap to avoid spiral of death
300
+ const frameTime = clampedDelta * speedRef.current;
460
301
  while (data.time - startSimTime < frameTime) {
461
302
  for (let s = 0; s < numSubsteps; s++) {
462
303
  mujoco.mj_step(model, data);
@@ -479,19 +320,16 @@ export function MujocoSimProvider({
479
320
  const data = mjDataRef.current;
480
321
  if (!model || !data) return;
481
322
 
482
- gizmoAnimRef.current.active = false;
483
323
  mujoco.mj_resetData(model, data);
484
324
 
485
325
  const homeJoints = configRef.current.homeJoints;
486
326
  if (homeJoints) {
487
- for (let i = 0; i < homeJoints.length; i++) {
327
+ const homeCount = Math.min(homeJoints.length, model.nu);
328
+ for (let i = 0; i < homeCount; i++) {
488
329
  data.ctrl[i] = homeJoints[i];
489
- if (model.actuator_trnid[2 * i + 1] === 1) {
490
- const jointId = model.actuator_trnid[2 * i];
491
- if (jointId >= 0 && jointId < model.njnt) {
492
- const qposAdr = model.jnt_qposadr[jointId];
493
- data.qpos[qposAdr] = homeJoints[i];
494
- }
330
+ const qposAdr = getActuatedScalarQposAdr(model, i);
331
+ if (qposAdr !== -1) {
332
+ data.qpos[qposAdr] = homeJoints[i];
495
333
  }
496
334
  }
497
335
  }
@@ -499,61 +337,11 @@ export function MujocoSimProvider({
499
337
  configRef.current.onReset?.(model, data);
500
338
  mujoco.mj_forward(model, data);
501
339
 
502
- if (ikTargetRef.current) {
503
- syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
504
- }
505
- firstIkEnableRef.current = true;
506
- ikEnabledRef.current = false;
507
- }, [mujoco, syncGizmoToSite]);
508
-
509
- const setIkEnabled = useCallback((enabled: boolean) => {
510
- ikEnabledRef.current = enabled;
511
- const data = mjDataRef.current;
512
- if (enabled && data && !gizmoAnimRef.current.active && ikTargetRef.current) {
513
- syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
514
- firstIkEnableRef.current = false;
340
+ // Notify composable plugins (e.g. IkController)
341
+ for (const cb of resetCallbacks.current) {
342
+ cb();
515
343
  }
516
- }, [syncGizmoToSite]);
517
-
518
- const syncTargetToSite = useCallback(() => {
519
- const data = mjDataRef.current;
520
- const target = ikTargetRef.current;
521
- if (data && target) syncGizmoToSite(data, siteIdRef.current, target);
522
- }, [syncGizmoToSite]);
523
-
524
- const solveIK = useCallback(
525
- (pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null => {
526
- return ikSolveFnRef.current(pos, quat, currentQ);
527
- },
528
- []
529
- );
530
-
531
- const moveTarget = useCallback(
532
- (pos: THREE.Vector3, duration = 0) => {
533
- if (!ikEnabledRef.current) setIkEnabled(true);
534
- const target = ikTargetRef.current;
535
- if (!target) return;
536
-
537
- const targetPos = pos.clone();
538
- const targetRot = new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI, 0, 0));
539
-
540
- if (duration > 0) {
541
- const ga = gizmoAnimRef.current;
542
- ga.active = true;
543
- ga.startPos.copy(target.position);
544
- ga.endPos.copy(targetPos);
545
- ga.startRot.copy(target.quaternion);
546
- ga.endRot.copy(targetRot);
547
- ga.startTime = performance.now();
548
- ga.duration = duration;
549
- } else {
550
- gizmoAnimRef.current.active = false;
551
- target.position.copy(targetPos);
552
- target.quaternion.copy(targetRot);
553
- }
554
- },
555
- [setIkEnabled]
556
- );
344
+ }, [mujoco]);
557
345
 
558
346
  const setSpeed = useCallback((multiplier: number) => {
559
347
  speedRef.current = multiplier;
@@ -564,17 +352,14 @@ export function MujocoSimProvider({
564
352
  return pausedRef.current;
565
353
  }, []);
566
354
 
567
- // spec 1.1: declarative pause
568
355
  const setPaused = useCallback((p: boolean) => {
569
356
  pausedRef.current = p;
570
357
  }, []);
571
358
 
572
- // spec 1.2: single-step mode
573
359
  const step = useCallback((n = 1) => {
574
360
  stepsToRunRef.current = n;
575
361
  }, []);
576
362
 
577
- // spec 1.3: simulation time access
578
363
  const getTime = useCallback((): number => {
579
364
  return mjDataRef.current?.time ?? 0;
580
365
  }, []);
@@ -583,7 +368,6 @@ export function MujocoSimProvider({
583
368
  return mjModelRef.current?.opt?.timestep ?? 0.002;
584
369
  }, []);
585
370
 
586
- // spec 4.1: state snapshot save/restore
587
371
  const saveState = useCallback((): StateSnapshot => {
588
372
  const data = mjDataRef.current;
589
373
  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) };
@@ -610,7 +394,6 @@ export function MujocoSimProvider({
610
394
  mujoco.mj_forward(model, data);
611
395
  }, [mujoco]);
612
396
 
613
- // spec 4.3: qpos/qvel direct set/get
614
397
  const setQpos = useCallback((values: Float64Array | number[]) => {
615
398
  const model = mjModelRef.current;
616
399
  const data = mjDataRef.current;
@@ -635,18 +418,15 @@ export function MujocoSimProvider({
635
418
  return mjDataRef.current ? new Float64Array(mjDataRef.current.qvel) : new Float64Array(0);
636
419
  }, []);
637
420
 
638
- // spec 3.1: ctrl set/get
639
421
  const setCtrl = useCallback((nameOrValues: string | Record<string, number>, value?: number) => {
640
422
  const model = mjModelRef.current;
641
423
  const data = mjDataRef.current;
642
424
  if (!model || !data) return;
643
425
 
644
426
  if (typeof nameOrValues === 'string') {
645
- // Single actuator by name
646
427
  const id = findActuatorByName(model, nameOrValues);
647
428
  if (id >= 0 && value !== undefined) data.ctrl[id] = value;
648
429
  } else {
649
- // Batch: { name: value, ... }
650
430
  for (const [name, val] of Object.entries(nameOrValues)) {
651
431
  const id = findActuatorByName(model, name);
652
432
  if (id >= 0) data.ctrl[id] = val;
@@ -658,7 +438,6 @@ export function MujocoSimProvider({
658
438
  return mjDataRef.current ? new Float64Array(mjDataRef.current.ctrl) : new Float64Array(0);
659
439
  }, []);
660
440
 
661
- // spec 8.1: force/torque API
662
441
  const applyForce = useCallback((bodyName: string, force: THREE.Vector3, point?: THREE.Vector3) => {
663
442
  const model = mjModelRef.current;
664
443
  const data = mjDataRef.current;
@@ -711,7 +490,6 @@ export function MujocoSimProvider({
711
490
  }
712
491
  }, []);
713
492
 
714
- // spec 2.1: sensor data
715
493
  const getSensorData = useCallback((name: string): Float64Array | null => {
716
494
  const model = mjModelRef.current;
717
495
  const data = mjDataRef.current;
@@ -723,7 +501,6 @@ export function MujocoSimProvider({
723
501
  return new Float64Array(data.sensordata.subarray(adr, adr + dim));
724
502
  }, []);
725
503
 
726
- // spec 2.4: contacts
727
504
  const getContacts = useCallback((): ContactInfo[] => {
728
505
  const model = mjModelRef.current;
729
506
  const data = mjDataRef.current;
@@ -731,24 +508,20 @@ export function MujocoSimProvider({
731
508
  const contacts: ContactInfo[] = [];
732
509
  const ncon = data.ncon;
733
510
  for (let i = 0; i < ncon; i++) {
734
- try {
735
- const c = (data.contact as { get(i: number): { geom1: number; geom2: number; pos: Float64Array; dist: number } }).get(i);
736
- contacts.push({
737
- geom1: c.geom1,
738
- geom1Name: getName(model, model.name_geomadr[c.geom1]),
739
- geom2: c.geom2,
740
- geom2Name: getName(model, model.name_geomadr[c.geom2]),
741
- pos: [c.pos[0], c.pos[1], c.pos[2]],
742
- depth: c.dist,
743
- });
744
- } catch {
745
- break; // WASM contact access can fail
746
- }
511
+ const c = getContact(data, i);
512
+ if (!c) break;
513
+ contacts.push({
514
+ geom1: c.geom1,
515
+ geom1Name: getName(model, model.name_geomadr[c.geom1]),
516
+ geom2: c.geom2,
517
+ geom2Name: getName(model, model.name_geomadr[c.geom2]),
518
+ pos: [c.pos[0], c.pos[1], c.pos[2]],
519
+ depth: c.dist,
520
+ });
747
521
  }
748
522
  return contacts;
749
523
  }, []);
750
524
 
751
- // spec 5.1: model introspection
752
525
  const getBodies = useCallback((): BodyInfo[] => {
753
526
  const model = mjModelRef.current;
754
527
  if (!model) return [];
@@ -853,7 +626,6 @@ export function MujocoSimProvider({
853
626
  return result;
854
627
  }, []);
855
628
 
856
- // spec 5.3: model options
857
629
  const getModelOption = useCallback((): ModelOptions => {
858
630
  const model = mjModelRef.current;
859
631
  if (!model?.opt) return { timestep: 0.002, gravity: [0, 0, -9.81], integrator: 0 };
@@ -878,7 +650,6 @@ export function MujocoSimProvider({
878
650
  model.opt.timestep = dt;
879
651
  }, []);
880
652
 
881
- // spec 7.1: physics raycast
882
653
  const raycast = useCallback((origin: THREE.Vector3, direction: THREE.Vector3, maxDist = 100): RayHit | null => {
883
654
  const model = mjModelRef.current;
884
655
  const data = mjDataRef.current;
@@ -905,11 +676,10 @@ export function MujocoSimProvider({
905
676
  distance: dist,
906
677
  };
907
678
  } catch {
908
- return null; // mj_ray may not be available in all WASM builds
679
+ return null;
909
680
  }
910
681
  }, [mujoco]);
911
682
 
912
- // spec 4.2: keyframe improvements
913
683
  const applyKeyframe = useCallback((nameOrIndex: string | number) => {
914
684
  const model = mjModelRef.current;
915
685
  const data = mjDataRef.current;
@@ -933,7 +703,6 @@ export function MujocoSimProvider({
933
703
  const ctrlOffset = keyId * nu;
934
704
  for (let i = 0; i < nu; i++) data.ctrl[i] = model.key_ctrl[ctrlOffset + i];
935
705
 
936
- // Also restore qvel if available (spec 4.2)
937
706
  if (model.key_qvel) {
938
707
  const qvelOffset = keyId * model.nv;
939
708
  for (let i = 0; i < model.nv; i++) data.qvel[i] = model.key_qvel[qvelOffset + i];
@@ -941,10 +710,11 @@ export function MujocoSimProvider({
941
710
 
942
711
  mujoco.mj_forward(model, data);
943
712
 
944
- if (ikTargetRef.current) {
945
- syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
713
+ // Notify composable plugins
714
+ for (const cb of resetCallbacks.current) {
715
+ cb();
946
716
  }
947
- }, [mujoco, syncGizmoToSite]);
717
+ }, [mujoco]);
948
718
 
949
719
  const getKeyframeNames = useCallback((): string[] => {
950
720
  const model = mjModelRef.current;
@@ -960,10 +730,9 @@ export function MujocoSimProvider({
960
730
  return mjModelRef.current?.nkey ?? 0;
961
731
  }, []);
962
732
 
963
- // spec 9.1: runtime model swap
964
733
  const loadSceneApi = useCallback(async (newConfig: SceneConfig): Promise<void> => {
734
+ const gen = ++loadGenRef.current;
965
735
  try {
966
- // Clean up current model
967
736
  mjModelRef.current?.delete();
968
737
  mjDataRef.current?.delete();
969
738
  mjModelRef.current = null;
@@ -971,30 +740,24 @@ export function MujocoSimProvider({
971
740
  setStatus('loading');
972
741
 
973
742
  const result = await loadScene(mujoco, newConfig);
743
+
744
+ if (gen !== loadGenRef.current) {
745
+ result.mjModel.delete();
746
+ result.mjData.delete();
747
+ return;
748
+ }
749
+
974
750
  mjModelRef.current = result.mjModel;
975
751
  mjDataRef.current = result.mjData;
976
- siteIdRef.current = result.siteId;
977
- gripperIdRef.current = result.gripperId;
978
752
  configRef.current = newConfig;
979
753
 
980
- if (ikTargetRef.current) {
981
- syncGizmoToSite(result.mjData, result.siteId, ikTargetRef.current);
982
- }
983
754
  setStatus('ready');
984
755
  } catch (e) {
756
+ if (gen !== loadGenRef.current) return;
985
757
  setStatus('error');
986
758
  throw e;
987
759
  }
988
- }, [mujoco, syncGizmoToSite]);
989
-
990
- const getGizmoStats = useCallback((): { pos: THREE.Vector3; rot: THREE.Euler } | null => {
991
- const target = ikTargetRef.current;
992
- if (!ikCalculatingRef.current || !target) return null;
993
- return {
994
- pos: target.position.clone(),
995
- rot: new THREE.Euler().setFromQuaternion(target.quaternion),
996
- };
997
- }, []);
760
+ }, [mujoco]);
998
761
 
999
762
  const getCanvasSnapshot = useCallback(
1000
763
  (width?: number, height?: number, mimeType = 'image/jpeg'): string => {
@@ -1020,9 +783,8 @@ export function MujocoSimProvider({
1020
783
  virtCam.lookAt(lookAt);
1021
784
  virtCam.updateMatrixWorld();
1022
785
  virtCam.updateProjectionMatrix();
1023
- const ndc = new THREE.Vector2(x * 2 - 1, -(y * 2 - 1));
1024
- const raycaster = new THREE.Raycaster();
1025
- raycaster.setFromCamera(ndc, virtCam);
786
+ _projNdc.set(x * 2 - 1, -(y * 2 - 1));
787
+ _projRaycaster.setFromCamera(_projNdc, virtCam);
1026
788
  const objects: THREE.Object3D[] = [];
1027
789
  const scene = (camera as THREE.PerspectiveCamera).parent;
1028
790
  if (scene) {
@@ -1030,12 +792,10 @@ export function MujocoSimProvider({
1030
792
  if ((c as THREE.Mesh).isMesh) objects.push(c);
1031
793
  });
1032
794
  }
1033
- const hits = raycaster.intersectObjects(objects);
795
+ const hits = _projRaycaster.intersectObjects(objects);
1034
796
  if (hits.length > 0) {
1035
797
  const hitObj = hits[0].object;
1036
- // Find geomId from the hit object's userData
1037
798
  const geomId = hitObj.userData.geomID !== undefined ? hitObj.userData.geomID : -1;
1038
- // Walk up to find bodyId
1039
799
  let obj = hitObj;
1040
800
  while (obj && obj.userData.bodyID === undefined && obj.parent) {
1041
801
  obj = obj.parent;
@@ -1048,7 +808,7 @@ export function MujocoSimProvider({
1048
808
  [camera, gl]
1049
809
  );
1050
810
 
1051
- // --- Domain randomization (spec 10.3) ---
811
+ // --- Domain randomization ---
1052
812
 
1053
813
  const setBodyMass = useCallback((name: string, mass: number): void => {
1054
814
  const model = mjModelRef.current;
@@ -1078,33 +838,6 @@ export function MujocoSimProvider({
1078
838
  model.geom_size[id * 3 + 2] = size[2];
1079
839
  }, []);
1080
840
 
1081
- const getCameraState = useCallback((): { position: THREE.Vector3; target: THREE.Vector3 } => {
1082
- return { position: camera.position.clone(), target: orbitTargetRef.current.clone() };
1083
- }, [camera]);
1084
-
1085
- const moveCameraTo = useCallback(
1086
- (position: THREE.Vector3, target: THREE.Vector3, durationMs: number): Promise<void> => {
1087
- return new Promise((resolve) => {
1088
- const ca = cameraAnimRef.current;
1089
- ca.active = true;
1090
- ca.startTime = performance.now();
1091
- ca.duration = durationMs;
1092
- ca.startPos.copy(camera.position);
1093
- ca.startRot.copy(camera.quaternion);
1094
- ca.startTarget.copy(orbitTargetRef.current);
1095
- ca.endPos.copy(position);
1096
- ca.endTarget.copy(target);
1097
- const dummyCam = (camera as THREE.PerspectiveCamera).clone();
1098
- dummyCam.position.copy(position);
1099
- dummyCam.lookAt(target);
1100
- ca.endRot.copy(dummyCam.quaternion);
1101
- ca.resolve = resolve;
1102
- setTimeout(resolve, durationMs + 100);
1103
- });
1104
- },
1105
- [camera]
1106
- );
1107
-
1108
841
  // --- Assemble API ---
1109
842
  const api = useMemo<MujocoSimAPI>(
1110
843
  () => ({
@@ -1145,15 +878,8 @@ export function MujocoSimProvider({
1145
878
  getKeyframeNames,
1146
879
  getKeyframeCount,
1147
880
  loadScene: loadSceneApi,
1148
- setIkEnabled,
1149
- moveTarget,
1150
- syncTargetToSite,
1151
- solveIK,
1152
- getGizmoStats,
1153
881
  getCanvasSnapshot,
1154
882
  project2DTo3D,
1155
- getCameraState,
1156
- moveCameraTo,
1157
883
  setBodyMass,
1158
884
  setGeomFriction,
1159
885
  setGeomSize,
@@ -1168,8 +894,7 @@ export function MujocoSimProvider({
1168
894
  getSensorData, getContacts, getBodies, getJoints, getGeoms, getSites,
1169
895
  getActuatorsApi, getSensors, getModelOption, setGravity, setTimestepApi,
1170
896
  raycast, getKeyframeNames, getKeyframeCount, loadSceneApi,
1171
- setIkEnabled, moveTarget, syncTargetToSite, solveIK, getGizmoStats,
1172
- getCanvasSnapshot, project2DTo3D, getCameraState, moveCameraTo,
897
+ getCanvasSnapshot, project2DTo3D,
1173
898
  setBodyMass, setGeomFriction, setGeomSize,
1174
899
  ]
1175
900
  );
@@ -1183,22 +908,13 @@ export function MujocoSimProvider({
1183
908
  mjDataRef,
1184
909
  mujocoRef,
1185
910
  configRef,
1186
- siteIdRef,
1187
- gripperIdRef,
1188
- ikEnabledRef,
1189
- ikCalculatingRef,
1190
911
  pausedRef,
1191
912
  speedRef,
1192
913
  substepsRef,
1193
- ikTargetRef,
1194
- genericIkRef,
1195
- ikSolveFnRef,
1196
- firstIkEnableRef,
1197
- gizmoAnimRef,
1198
- cameraAnimRef,
1199
914
  onSelectionRef,
1200
915
  beforeStepCallbacks,
1201
916
  afterStepCallbacks,
917
+ resetCallbacks,
1202
918
  status,
1203
919
  }),
1204
920
  [api, status]