mujoco-react 0.1.0 → 0.3.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
 
@@ -157,11 +129,12 @@ export function useAfterPhysicsStep(callback: PhysicsStepCallback) {
157
129
  interface MujocoSimProviderProps {
158
130
  mujoco: MujocoModule;
159
131
  config: SceneConfig;
132
+ apiRef?: React.ForwardedRef<MujocoSimAPI>;
160
133
  onReady?: (api: MujocoSimAPI) => void;
161
134
  onError?: (error: Error) => void;
162
135
  onStep?: (time: number) => void;
163
136
  onSelection?: (bodyId: number, name: string) => void;
164
- // Declarative physics config props (spec 1.1)
137
+ // Declarative physics config props
165
138
  gravity?: [number, number, number];
166
139
  timestep?: number;
167
140
  substeps?: number;
@@ -174,6 +147,7 @@ interface MujocoSimProviderProps {
174
147
  export function MujocoSimProvider({
175
148
  mujoco,
176
149
  config,
150
+ apiRef: externalApiRef,
177
151
  onReady,
178
152
  onError,
179
153
  onStep,
@@ -194,18 +168,14 @@ export function MujocoSimProvider({
194
168
  const mjDataRef = useRef<MujocoData | null>(null);
195
169
  const mujocoRef = useRef<MujocoModule>(mujoco);
196
170
  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
171
  const pausedRef = useRef(paused ?? false);
202
172
  const speedRef = useRef(speed ?? 1);
203
173
  const substepsRef = useRef(substeps ?? 1);
204
174
  const interpolateRef = useRef(interpolate ?? false);
205
- const firstIkEnableRef = useRef(true);
206
- const stepsToRunRef = useRef(0); // for single-step mode (spec 1.2)
175
+ const stepsToRunRef = useRef(0);
176
+ const loadGenRef = useRef(0);
207
177
 
208
- // Interpolation state (spec 11.1)
178
+ // Interpolation state
209
179
  const prevXposRef = useRef<Float64Array | null>(null);
210
180
  const prevXquatRef = useRef<Float64Array | null>(null);
211
181
  const interpAlphaRef = useRef(0);
@@ -217,6 +187,7 @@ export function MujocoSimProvider({
217
187
 
218
188
  const beforeStepCallbacks = useRef(new Set<PhysicsStepCallback>());
219
189
  const afterStepCallbacks = useRef(new Set<PhysicsStepCallback>());
190
+ const resetCallbacks = useRef(new Set<() => void>());
220
191
 
221
192
  configRef.current = config;
222
193
 
@@ -226,7 +197,7 @@ export function MujocoSimProvider({
226
197
  useEffect(() => { substepsRef.current = substeps ?? 1; }, [substeps]);
227
198
  useEffect(() => { interpolateRef.current = interpolate ?? false; }, [interpolate]);
228
199
 
229
- // Sync gravity prop (spec 1.1)
200
+ // Sync gravity prop
230
201
  useEffect(() => {
231
202
  if (!gravity) return;
232
203
  const model = mjModelRef.current;
@@ -236,7 +207,7 @@ export function MujocoSimProvider({
236
207
  model.opt.gravity[2] = gravity[2];
237
208
  }, [gravity]);
238
209
 
239
- // Sync timestep prop (spec 1.1)
210
+ // Sync timestep prop
240
211
  useEffect(() => {
241
212
  if (timestep === undefined) return;
242
213
  const model = mjModelRef.current;
@@ -244,66 +215,6 @@ export function MujocoSimProvider({
244
215
  model.opt.timestep = timestep;
245
216
  }, [timestep]);
246
217
 
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
218
  // --- Load scene on mount ---
308
219
  useEffect(() => {
309
220
  let disposed = false;
@@ -319,8 +230,6 @@ export function MujocoSimProvider({
319
230
 
320
231
  mjModelRef.current = result.mjModel;
321
232
  mjDataRef.current = result.mjData;
322
- siteIdRef.current = result.siteId;
323
- gripperIdRef.current = result.gripperId;
324
233
 
325
234
  // Apply declarative physics props after load
326
235
  if (gravity && result.mjModel.opt?.gravity) {
@@ -332,10 +241,6 @@ export function MujocoSimProvider({
332
241
  result.mjModel.opt.timestep = timestep;
333
242
  }
334
243
 
335
- if (ikTargetRef.current) {
336
- syncGizmoToSite(result.mjData, result.siteId, ikTargetRef.current);
337
- }
338
-
339
244
  setStatus('ready');
340
245
  } catch (e: unknown) {
341
246
  if (!disposed) {
@@ -355,59 +260,29 @@ export function MujocoSimProvider({
355
260
  };
356
261
  }, [mujoco, config]);
357
262
 
358
- // Fire onReady when status changes to ready
263
+ // Fire onReady and assign external ref when status changes to ready
359
264
  useEffect(() => {
360
- if (status === 'ready' && onReady) {
361
- onReady(apiRef.current);
265
+ if (status === 'ready') {
266
+ const api = apiRef.current;
267
+ if (onReady) onReady(api);
268
+ // Assign the forwarded ref
269
+ if (externalApiRef) {
270
+ if (typeof externalApiRef === 'function') {
271
+ externalApiRef(api);
272
+ } else {
273
+ (externalApiRef as React.MutableRefObject<MujocoSimAPI | null>).current = api;
274
+ }
275
+ }
362
276
  }
363
277
  }, [status]);
364
278
 
365
279
  // --- Physics step (priority -1) ---
366
- useFrame((state) => {
280
+ useFrame(() => {
367
281
  const model = mjModelRef.current;
368
282
  const data = mjDataRef.current;
369
283
  if (!model || !data) return;
370
284
 
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)
285
+ // Check single-step mode
411
286
  const shouldStep = !pausedRef.current || stepsToRunRef.current > 0;
412
287
  if (!shouldStep) return;
413
288
 
@@ -421,24 +296,9 @@ export function MujocoSimProvider({
421
296
  cb(model, data);
422
297
  }
423
298
 
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)
299
+ // Step physics with substeps
439
300
  const numSubsteps = substepsRef.current;
440
301
  if (stepsToRunRef.current > 0) {
441
- // Single-step mode (spec 1.2)
442
302
  for (let s = 0; s < stepsToRunRef.current; s++) {
443
303
  mujoco.mj_step(model, data);
444
304
  }
@@ -468,19 +328,16 @@ export function MujocoSimProvider({
468
328
  const data = mjDataRef.current;
469
329
  if (!model || !data) return;
470
330
 
471
- gizmoAnimRef.current.active = false;
472
331
  mujoco.mj_resetData(model, data);
473
332
 
474
333
  const homeJoints = configRef.current.homeJoints;
475
334
  if (homeJoints) {
476
- for (let i = 0; i < homeJoints.length; i++) {
335
+ const homeCount = Math.min(homeJoints.length, model.nu);
336
+ for (let i = 0; i < homeCount; i++) {
477
337
  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
- }
338
+ const qposAdr = getActuatedScalarQposAdr(model, i);
339
+ if (qposAdr !== -1) {
340
+ data.qpos[qposAdr] = homeJoints[i];
484
341
  }
485
342
  }
486
343
  }
@@ -488,61 +345,11 @@ export function MujocoSimProvider({
488
345
  configRef.current.onReset?.(model, data);
489
346
  mujoco.mj_forward(model, data);
490
347
 
491
- if (ikTargetRef.current) {
492
- syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
348
+ // Notify composable plugins (e.g. IkController)
349
+ for (const cb of resetCallbacks.current) {
350
+ cb();
493
351
  }
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
- );
352
+ }, [mujoco]);
546
353
 
547
354
  const setSpeed = useCallback((multiplier: number) => {
548
355
  speedRef.current = multiplier;
@@ -553,17 +360,14 @@ export function MujocoSimProvider({
553
360
  return pausedRef.current;
554
361
  }, []);
555
362
 
556
- // spec 1.1: declarative pause
557
363
  const setPaused = useCallback((p: boolean) => {
558
364
  pausedRef.current = p;
559
365
  }, []);
560
366
 
561
- // spec 1.2: single-step mode
562
367
  const step = useCallback((n = 1) => {
563
368
  stepsToRunRef.current = n;
564
369
  }, []);
565
370
 
566
- // spec 1.3: simulation time access
567
371
  const getTime = useCallback((): number => {
568
372
  return mjDataRef.current?.time ?? 0;
569
373
  }, []);
@@ -572,7 +376,6 @@ export function MujocoSimProvider({
572
376
  return mjModelRef.current?.opt?.timestep ?? 0.002;
573
377
  }, []);
574
378
 
575
- // spec 4.1: state snapshot save/restore
576
379
  const saveState = useCallback((): StateSnapshot => {
577
380
  const data = mjDataRef.current;
578
381
  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) };
@@ -599,7 +402,6 @@ export function MujocoSimProvider({
599
402
  mujoco.mj_forward(model, data);
600
403
  }, [mujoco]);
601
404
 
602
- // spec 4.3: qpos/qvel direct set/get
603
405
  const setQpos = useCallback((values: Float64Array | number[]) => {
604
406
  const model = mjModelRef.current;
605
407
  const data = mjDataRef.current;
@@ -624,18 +426,15 @@ export function MujocoSimProvider({
624
426
  return mjDataRef.current ? new Float64Array(mjDataRef.current.qvel) : new Float64Array(0);
625
427
  }, []);
626
428
 
627
- // spec 3.1: ctrl set/get
628
429
  const setCtrl = useCallback((nameOrValues: string | Record<string, number>, value?: number) => {
629
430
  const model = mjModelRef.current;
630
431
  const data = mjDataRef.current;
631
432
  if (!model || !data) return;
632
433
 
633
434
  if (typeof nameOrValues === 'string') {
634
- // Single actuator by name
635
435
  const id = findActuatorByName(model, nameOrValues);
636
436
  if (id >= 0 && value !== undefined) data.ctrl[id] = value;
637
437
  } else {
638
- // Batch: { name: value, ... }
639
438
  for (const [name, val] of Object.entries(nameOrValues)) {
640
439
  const id = findActuatorByName(model, name);
641
440
  if (id >= 0) data.ctrl[id] = val;
@@ -647,7 +446,6 @@ export function MujocoSimProvider({
647
446
  return mjDataRef.current ? new Float64Array(mjDataRef.current.ctrl) : new Float64Array(0);
648
447
  }, []);
649
448
 
650
- // spec 8.1: force/torque API
651
449
  const applyForce = useCallback((bodyName: string, force: THREE.Vector3, point?: THREE.Vector3) => {
652
450
  const model = mjModelRef.current;
653
451
  const data = mjDataRef.current;
@@ -700,7 +498,6 @@ export function MujocoSimProvider({
700
498
  }
701
499
  }, []);
702
500
 
703
- // spec 2.1: sensor data
704
501
  const getSensorData = useCallback((name: string): Float64Array | null => {
705
502
  const model = mjModelRef.current;
706
503
  const data = mjDataRef.current;
@@ -712,7 +509,6 @@ export function MujocoSimProvider({
712
509
  return new Float64Array(data.sensordata.subarray(adr, adr + dim));
713
510
  }, []);
714
511
 
715
- // spec 2.4: contacts
716
512
  const getContacts = useCallback((): ContactInfo[] => {
717
513
  const model = mjModelRef.current;
718
514
  const data = mjDataRef.current;
@@ -720,24 +516,20 @@ export function MujocoSimProvider({
720
516
  const contacts: ContactInfo[] = [];
721
517
  const ncon = data.ncon;
722
518
  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
- }
519
+ const c = getContact(data, i);
520
+ if (!c) break;
521
+ contacts.push({
522
+ geom1: c.geom1,
523
+ geom1Name: getName(model, model.name_geomadr[c.geom1]),
524
+ geom2: c.geom2,
525
+ geom2Name: getName(model, model.name_geomadr[c.geom2]),
526
+ pos: [c.pos[0], c.pos[1], c.pos[2]],
527
+ depth: c.dist,
528
+ });
736
529
  }
737
530
  return contacts;
738
531
  }, []);
739
532
 
740
- // spec 5.1: model introspection
741
533
  const getBodies = useCallback((): BodyInfo[] => {
742
534
  const model = mjModelRef.current;
743
535
  if (!model) return [];
@@ -842,7 +634,6 @@ export function MujocoSimProvider({
842
634
  return result;
843
635
  }, []);
844
636
 
845
- // spec 5.3: model options
846
637
  const getModelOption = useCallback((): ModelOptions => {
847
638
  const model = mjModelRef.current;
848
639
  if (!model?.opt) return { timestep: 0.002, gravity: [0, 0, -9.81], integrator: 0 };
@@ -867,7 +658,6 @@ export function MujocoSimProvider({
867
658
  model.opt.timestep = dt;
868
659
  }, []);
869
660
 
870
- // spec 7.1: physics raycast
871
661
  const raycast = useCallback((origin: THREE.Vector3, direction: THREE.Vector3, maxDist = 100): RayHit | null => {
872
662
  const model = mjModelRef.current;
873
663
  const data = mjDataRef.current;
@@ -894,11 +684,10 @@ export function MujocoSimProvider({
894
684
  distance: dist,
895
685
  };
896
686
  } catch {
897
- return null; // mj_ray may not be available in all WASM builds
687
+ return null;
898
688
  }
899
689
  }, [mujoco]);
900
690
 
901
- // spec 4.2: keyframe improvements
902
691
  const applyKeyframe = useCallback((nameOrIndex: string | number) => {
903
692
  const model = mjModelRef.current;
904
693
  const data = mjDataRef.current;
@@ -922,7 +711,6 @@ export function MujocoSimProvider({
922
711
  const ctrlOffset = keyId * nu;
923
712
  for (let i = 0; i < nu; i++) data.ctrl[i] = model.key_ctrl[ctrlOffset + i];
924
713
 
925
- // Also restore qvel if available (spec 4.2)
926
714
  if (model.key_qvel) {
927
715
  const qvelOffset = keyId * model.nv;
928
716
  for (let i = 0; i < model.nv; i++) data.qvel[i] = model.key_qvel[qvelOffset + i];
@@ -930,10 +718,11 @@ export function MujocoSimProvider({
930
718
 
931
719
  mujoco.mj_forward(model, data);
932
720
 
933
- if (ikTargetRef.current) {
934
- syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
721
+ // Notify composable plugins
722
+ for (const cb of resetCallbacks.current) {
723
+ cb();
935
724
  }
936
- }, [mujoco, syncGizmoToSite]);
725
+ }, [mujoco]);
937
726
 
938
727
  const getKeyframeNames = useCallback((): string[] => {
939
728
  const model = mjModelRef.current;
@@ -949,10 +738,9 @@ export function MujocoSimProvider({
949
738
  return mjModelRef.current?.nkey ?? 0;
950
739
  }, []);
951
740
 
952
- // spec 9.1: runtime model swap
953
741
  const loadSceneApi = useCallback(async (newConfig: SceneConfig): Promise<void> => {
742
+ const gen = ++loadGenRef.current;
954
743
  try {
955
- // Clean up current model
956
744
  mjModelRef.current?.delete();
957
745
  mjDataRef.current?.delete();
958
746
  mjModelRef.current = null;
@@ -960,30 +748,24 @@ export function MujocoSimProvider({
960
748
  setStatus('loading');
961
749
 
962
750
  const result = await loadScene(mujoco, newConfig);
751
+
752
+ if (gen !== loadGenRef.current) {
753
+ result.mjModel.delete();
754
+ result.mjData.delete();
755
+ return;
756
+ }
757
+
963
758
  mjModelRef.current = result.mjModel;
964
759
  mjDataRef.current = result.mjData;
965
- siteIdRef.current = result.siteId;
966
- gripperIdRef.current = result.gripperId;
967
760
  configRef.current = newConfig;
968
761
 
969
- if (ikTargetRef.current) {
970
- syncGizmoToSite(result.mjData, result.siteId, ikTargetRef.current);
971
- }
972
762
  setStatus('ready');
973
763
  } catch (e) {
764
+ if (gen !== loadGenRef.current) return;
974
765
  setStatus('error');
975
766
  throw e;
976
767
  }
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
- }, []);
768
+ }, [mujoco]);
987
769
 
988
770
  const getCanvasSnapshot = useCallback(
989
771
  (width?: number, height?: number, mimeType = 'image/jpeg'): string => {
@@ -1009,9 +791,8 @@ export function MujocoSimProvider({
1009
791
  virtCam.lookAt(lookAt);
1010
792
  virtCam.updateMatrixWorld();
1011
793
  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);
794
+ _projNdc.set(x * 2 - 1, -(y * 2 - 1));
795
+ _projRaycaster.setFromCamera(_projNdc, virtCam);
1015
796
  const objects: THREE.Object3D[] = [];
1016
797
  const scene = (camera as THREE.PerspectiveCamera).parent;
1017
798
  if (scene) {
@@ -1019,12 +800,10 @@ export function MujocoSimProvider({
1019
800
  if ((c as THREE.Mesh).isMesh) objects.push(c);
1020
801
  });
1021
802
  }
1022
- const hits = raycaster.intersectObjects(objects);
803
+ const hits = _projRaycaster.intersectObjects(objects);
1023
804
  if (hits.length > 0) {
1024
805
  const hitObj = hits[0].object;
1025
- // Find geomId from the hit object's userData
1026
806
  const geomId = hitObj.userData.geomID !== undefined ? hitObj.userData.geomID : -1;
1027
- // Walk up to find bodyId
1028
807
  let obj = hitObj;
1029
808
  while (obj && obj.userData.bodyID === undefined && obj.parent) {
1030
809
  obj = obj.parent;
@@ -1037,7 +816,7 @@ export function MujocoSimProvider({
1037
816
  [camera, gl]
1038
817
  );
1039
818
 
1040
- // --- Domain randomization (spec 10.3) ---
819
+ // --- Domain randomization ---
1041
820
 
1042
821
  const setBodyMass = useCallback((name: string, mass: number): void => {
1043
822
  const model = mjModelRef.current;
@@ -1067,33 +846,6 @@ export function MujocoSimProvider({
1067
846
  model.geom_size[id * 3 + 2] = size[2];
1068
847
  }, []);
1069
848
 
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
849
  // --- Assemble API ---
1098
850
  const api = useMemo<MujocoSimAPI>(
1099
851
  () => ({
@@ -1134,15 +886,8 @@ export function MujocoSimProvider({
1134
886
  getKeyframeNames,
1135
887
  getKeyframeCount,
1136
888
  loadScene: loadSceneApi,
1137
- setIkEnabled,
1138
- moveTarget,
1139
- syncTargetToSite,
1140
- solveIK,
1141
- getGizmoStats,
1142
889
  getCanvasSnapshot,
1143
890
  project2DTo3D,
1144
- getCameraState,
1145
- moveCameraTo,
1146
891
  setBodyMass,
1147
892
  setGeomFriction,
1148
893
  setGeomSize,
@@ -1157,8 +902,7 @@ export function MujocoSimProvider({
1157
902
  getSensorData, getContacts, getBodies, getJoints, getGeoms, getSites,
1158
903
  getActuatorsApi, getSensors, getModelOption, setGravity, setTimestepApi,
1159
904
  raycast, getKeyframeNames, getKeyframeCount, loadSceneApi,
1160
- setIkEnabled, moveTarget, syncTargetToSite, solveIK, getGizmoStats,
1161
- getCanvasSnapshot, project2DTo3D, getCameraState, moveCameraTo,
905
+ getCanvasSnapshot, project2DTo3D,
1162
906
  setBodyMass, setGeomFriction, setGeomSize,
1163
907
  ]
1164
908
  );
@@ -1172,22 +916,13 @@ export function MujocoSimProvider({
1172
916
  mjDataRef,
1173
917
  mujocoRef,
1174
918
  configRef,
1175
- siteIdRef,
1176
- gripperIdRef,
1177
- ikEnabledRef,
1178
- ikCalculatingRef,
1179
919
  pausedRef,
1180
920
  speedRef,
1181
921
  substepsRef,
1182
- ikTargetRef,
1183
- genericIkRef,
1184
- ikSolveFnRef,
1185
- firstIkEnableRef,
1186
- gizmoAnimRef,
1187
- cameraAnimRef,
1188
922
  onSelectionRef,
1189
923
  beforeStepCallbacks,
1190
924
  afterStepCallbacks,
925
+ resetCallbacks,
1191
926
  status,
1192
927
  }),
1193
928
  [api, status]