mujoco-react 10.3.0 → 10.5.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.
@@ -13,7 +13,7 @@ import { useMujocoContext } from '../core/MujocoSimProvider';
13
13
  import { getName } from '../core/SceneLoader';
14
14
  import { CAPTURE_EXCLUDE_KEY } from '../rendering/cameraFrameCapture';
15
15
  import { getContact, withContacts } from '../types';
16
- import type { DebugProps } from '../types';
16
+ import type { CameraFrameCaptureVector3, DebugProps, DebugVirtualCamera } from '../types';
17
17
 
18
18
  const JOINT_COLORS: Record<number, number> = {
19
19
  0: 0xff0000, // free - red
@@ -41,6 +41,170 @@ type CameraDebugObject = THREE.Group & {
41
41
  };
42
42
  };
43
43
 
44
+ function toVector3(
45
+ value: CameraFrameCaptureVector3 | undefined,
46
+ fallback: THREE.Vector3
47
+ ) {
48
+ if (!value) return fallback.clone();
49
+ return value instanceof THREE.Vector3
50
+ ? value.clone()
51
+ : new THREE.Vector3(value[0], value[1], value[2]);
52
+ }
53
+
54
+ function createCameraLabel(text: string, color: THREE.ColorRepresentation) {
55
+ const canvas = document.createElement('canvas');
56
+ canvas.width = 256;
57
+ canvas.height = 64;
58
+ const ctx = canvas.getContext('2d')!;
59
+ ctx.fillStyle = new THREE.Color(color).getStyle();
60
+ ctx.font = 'bold 32px monospace';
61
+ ctx.textAlign = 'center';
62
+ ctx.fillText(text, 128, 42);
63
+ const texture = new THREE.CanvasTexture(canvas);
64
+ const sprite = new THREE.Sprite(
65
+ new THREE.SpriteMaterial({
66
+ map: texture,
67
+ depthTest: false,
68
+ transparent: true,
69
+ })
70
+ );
71
+ sprite.position.set(0, 0.014, 0.01);
72
+ sprite.scale.set(0.05, 0.012, 1);
73
+ sprite.renderOrder = 999;
74
+ return sprite;
75
+ }
76
+
77
+ function createVirtualCameraDebugObject(
78
+ camera: DebugVirtualCamera,
79
+ index: number
80
+ ) {
81
+ const color = camera.color ?? '#ff3d71';
82
+ const aimColor = camera.aimColor ?? '#38bdf8';
83
+ const markerScale = camera.markerScale ?? 1;
84
+ const cameraPosition = toVector3(camera.position, new THREE.Vector3());
85
+ const configuredUp = toVector3(camera.up, new THREE.Vector3(0, 0, 1)).normalize();
86
+ const cameraQuaternion = new THREE.Quaternion();
87
+ const forward = new THREE.Vector3();
88
+
89
+ if (camera.quaternion) {
90
+ if (camera.quaternion instanceof THREE.Quaternion) {
91
+ cameraQuaternion.copy(camera.quaternion);
92
+ } else {
93
+ cameraQuaternion.set(
94
+ camera.quaternion[0],
95
+ camera.quaternion[1],
96
+ camera.quaternion[2],
97
+ camera.quaternion[3]
98
+ );
99
+ }
100
+ forward.set(0, 0, -1).applyQuaternion(cameraQuaternion).normalize();
101
+ } else {
102
+ const target = toVector3(
103
+ camera.lookAt,
104
+ cameraPosition.clone().add(new THREE.Vector3(0, 0, -1))
105
+ );
106
+ forward.copy(target).sub(cameraPosition);
107
+ if (forward.lengthSq() < 1e-8) forward.set(0, 0, -1);
108
+ forward.normalize();
109
+ cameraQuaternion.setFromRotationMatrix(
110
+ new THREE.Matrix4().lookAt(cameraPosition, target, configuredUp)
111
+ );
112
+ }
113
+
114
+ const target = camera.lookAt
115
+ ? toVector3(camera.lookAt, cameraPosition.clone().add(forward))
116
+ : cameraPosition.clone().addScaledVector(forward, 0.4);
117
+ const distanceToTarget = Math.max(target.distanceTo(cameraPosition), 0.001);
118
+ const depth = camera.frustumDepth ?? Math.min(Math.max(distanceToTarget * 0.42, 0.16), 0.45);
119
+ const fov = camera.fov ?? 50;
120
+ const aspect = (camera.width ?? 640) / (camera.height ?? 480);
121
+
122
+ const right = forward.clone().cross(configuredUp);
123
+ if (right.lengthSq() < 1e-8) right.set(1, 0, 0);
124
+ right.normalize();
125
+ const orthogonalUp = right.clone().cross(forward).normalize();
126
+ const frustumHeight = 2 * Math.tan(THREE.MathUtils.degToRad(fov) / 2) * depth;
127
+ const frustumWidth = frustumHeight * aspect;
128
+ const center = cameraPosition.clone().addScaledVector(forward, depth);
129
+ const halfRight = right.clone().multiplyScalar(frustumWidth / 2);
130
+ const halfUp = orthogonalUp.clone().multiplyScalar(frustumHeight / 2);
131
+
132
+ const topLeft = center.clone().sub(halfRight).add(halfUp);
133
+ const topRight = center.clone().add(halfRight).add(halfUp);
134
+ const bottomRight = center.clone().add(halfRight).sub(halfUp);
135
+ const bottomLeft = center.clone().sub(halfRight).sub(halfUp);
136
+ const frustumPoints = [
137
+ cameraPosition, topLeft,
138
+ cameraPosition, topRight,
139
+ cameraPosition, bottomRight,
140
+ cameraPosition, bottomLeft,
141
+ topLeft, topRight,
142
+ topRight, bottomRight,
143
+ bottomRight, bottomLeft,
144
+ bottomLeft, topLeft,
145
+ ];
146
+
147
+ const group = new THREE.Group();
148
+ group.name = camera.name ?? `virtual-camera-${index}`;
149
+ group.renderOrder = 999;
150
+ group.frustumCulled = false;
151
+
152
+ const frustum = new THREE.LineSegments(
153
+ new THREE.BufferGeometry().setFromPoints(frustumPoints),
154
+ new THREE.LineBasicMaterial({
155
+ color,
156
+ transparent: true,
157
+ opacity: 0.9,
158
+ depthTest: false,
159
+ })
160
+ );
161
+ frustum.renderOrder = 999;
162
+ frustum.frustumCulled = false;
163
+ group.add(frustum);
164
+
165
+ const aim = new THREE.LineSegments(
166
+ new THREE.BufferGeometry().setFromPoints([cameraPosition, target]),
167
+ new THREE.LineBasicMaterial({
168
+ color: aimColor,
169
+ transparent: true,
170
+ opacity: 0.95,
171
+ depthTest: false,
172
+ })
173
+ );
174
+ aim.renderOrder = 999;
175
+ aim.frustumCulled = false;
176
+ group.add(aim);
177
+
178
+ const markerGroup = new THREE.Group();
179
+ markerGroup.position.copy(cameraPosition);
180
+ markerGroup.quaternion.copy(cameraQuaternion);
181
+ markerGroup.renderOrder = 999;
182
+ markerGroup.frustumCulled = false;
183
+ markerGroup.add(new THREE.Mesh(
184
+ new THREE.BoxGeometry(0.045 * markerScale, 0.028 * markerScale, 0.022 * markerScale),
185
+ new THREE.MeshBasicMaterial({ color, depthTest: false })
186
+ ));
187
+ const lens = new THREE.Mesh(
188
+ new THREE.BoxGeometry(0.025 * markerScale, 0.018 * markerScale, 0.014 * markerScale),
189
+ new THREE.MeshBasicMaterial({ color: aimColor, depthTest: false })
190
+ );
191
+ lens.position.set(0, 0, -0.021 * markerScale);
192
+ markerGroup.add(lens);
193
+ if (camera.name) markerGroup.add(createCameraLabel(camera.name, color));
194
+ group.add(markerGroup);
195
+
196
+ const targetMarker = new THREE.Mesh(
197
+ new THREE.SphereGeometry(0.018 * markerScale, 16, 10),
198
+ new THREE.MeshBasicMaterial({ color: aimColor, depthTest: false })
199
+ );
200
+ targetMarker.position.copy(target);
201
+ targetMarker.renderOrder = 999;
202
+ targetMarker.frustumCulled = false;
203
+ group.add(targetMarker);
204
+
205
+ return group;
206
+ }
207
+
44
208
  /**
45
209
  * Declarative debug visualization component.
46
210
  * Renders wireframe geoms, site markers, joint axes, contact forces, COM markers, etc.
@@ -50,6 +214,7 @@ export function Debug({
50
214
  showSites = false,
51
215
  showJoints = false,
52
216
  showCameras = false,
217
+ virtualCameras = [],
53
218
  showContacts = false,
54
219
  showCOM = false,
55
220
  showInertia = false,
@@ -69,6 +234,7 @@ export function Debug({
69
234
  const sites: THREE.Object3D[] = [];
70
235
  const joints: THREE.Object3D[] = [];
71
236
  const cameras: CameraDebugObject[] = [];
237
+ const virtualCameraObjects: THREE.Object3D[] = [];
72
238
  const comMarkers: THREE.Object3D[] = [];
73
239
 
74
240
  // Wireframe geoms
@@ -270,6 +436,10 @@ export function Debug({
270
436
  }
271
437
  }
272
438
 
439
+ for (let i = 0; i < virtualCameras.length; i += 1) {
440
+ virtualCameraObjects.push(createVirtualCameraDebugObject(virtualCameras[i], i));
441
+ }
442
+
273
443
  // COM markers
274
444
  if (showCOM) {
275
445
  for (let i = 1; i < model.nbody; i++) {
@@ -281,8 +451,8 @@ export function Debug({
281
451
  }
282
452
  }
283
453
 
284
- return { geoms, sites, joints, cameras, comMarkers };
285
- }, [status, mjModelRef, showGeoms, showSites, showJoints, showCameras, showCOM]);
454
+ return { geoms, sites, joints, cameras, virtualCameraObjects, comMarkers };
455
+ }, [status, mjModelRef, showGeoms, showSites, showJoints, showCameras, virtualCameras, showCOM]);
286
456
 
287
457
  // Add/remove debug objects from scene
288
458
  useEffect(() => {
@@ -294,6 +464,7 @@ export function Debug({
294
464
  ...debugGeometry.sites,
295
465
  ...debugGeometry.joints,
296
466
  ...debugGeometry.cameras,
467
+ ...debugGeometry.virtualCameraObjects,
297
468
  ...debugGeometry.comMarkers,
298
469
  ];
299
470
  for (const obj of allObjects) group.add(obj);
@@ -13,9 +13,13 @@ export interface GenericIKOptions {
13
13
  epsilon: number;
14
14
  posWeight: number;
15
15
  rotWeight: number;
16
+ jointLimits?: ReadonlyArray<readonly [number, number] | null | undefined>;
16
17
  }
17
18
 
18
- const DEFAULTS: GenericIKOptions = {
19
+ type ResolvedGenericIKOptions = Required<Omit<GenericIKOptions, 'jointLimits'>> &
20
+ Pick<GenericIKOptions, 'jointLimits'>;
21
+
22
+ const DEFAULTS: Required<Omit<GenericIKOptions, 'jointLimits'>> = {
19
23
  maxIterations: 50,
20
24
  damping: 0.01,
21
25
  tolerance: 1e-3,
@@ -24,7 +28,7 @@ const DEFAULTS: GenericIKOptions = {
24
28
  rotWeight: 0.3,
25
29
  };
26
30
 
27
- function resolveOptions(opts?: Partial<GenericIKOptions>): GenericIKOptions {
31
+ function resolveOptions(opts?: Partial<GenericIKOptions>): ResolvedGenericIKOptions {
28
32
  return {
29
33
  maxIterations: opts?.maxIterations ?? DEFAULTS.maxIterations,
30
34
  damping: opts?.damping ?? DEFAULTS.damping,
@@ -32,9 +36,17 @@ function resolveOptions(opts?: Partial<GenericIKOptions>): GenericIKOptions {
32
36
  epsilon: opts?.epsilon ?? DEFAULTS.epsilon,
33
37
  posWeight: opts?.posWeight ?? DEFAULTS.posWeight,
34
38
  rotWeight: opts?.rotWeight ?? DEFAULTS.rotWeight,
39
+ jointLimits: opts?.jointLimits,
35
40
  };
36
41
  }
37
42
 
43
+ function clampJoint(value: number, limit: readonly [number, number] | null | undefined) {
44
+ if (!limit) return value;
45
+ const [min, max] = limit;
46
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) return value;
47
+ return Math.max(min, Math.min(max, value));
48
+ }
49
+
38
50
  /**
39
51
  * Generic Damped Least-Squares IK solver.
40
52
  * Uses finite-difference Jacobian via MuJoCo's mj_forward.
@@ -81,7 +93,7 @@ export class GenericIK {
81
93
 
82
94
  // Working joint angles — start from current configuration
83
95
  const q = new Float64Array(n);
84
- for (let i = 0; i < n; i++) q[i] = currentQ[i];
96
+ for (let i = 0; i < n; i++) q[i] = clampJoint(currentQ[i], o.jointLimits?.[i]);
85
97
 
86
98
  // Pre-allocate work arrays
87
99
  const J = new Float64Array(6 * n); // 6×n Jacobian (row-major)
@@ -196,7 +208,7 @@ export class GenericIK {
196
208
  }
197
209
 
198
210
  // Update joints
199
- for (let i = 0; i < n; i++) q[i] += dq[i];
211
+ for (let i = 0; i < n; i++) q[i] = clampJoint(q[i] + dq[i], o.jointLimits?.[i]);
200
212
  }
201
213
 
202
214
  // Restore original qpos
@@ -16,6 +16,7 @@ import {
16
16
  import * as THREE from 'three';
17
17
  import { MujocoData, MujocoModel, MujocoModule, getContact, withContacts } from '../types';
18
18
  import { SceneRenderer } from '../components/SceneRenderer';
19
+ import { CameraViewportProvider } from '../components/CameraView';
19
20
  import {
20
21
  ActuatedJointInfo,
21
22
  ActuatorInfo,
@@ -59,6 +60,7 @@ import {
59
60
  captureCameraFrame,
60
61
  captureCameraFrameBlob,
61
62
  createCameraFrameCaptureSession,
63
+ type CameraFrameCaptureTensorOptions,
62
64
  } from '../rendering/cameraFrameCapture';
63
65
  import {
64
66
  getCameraFrameCaptureSourceTarget,
@@ -1558,6 +1560,35 @@ export function MujocoSimProvider({
1558
1560
  [camera, gl, resolveCameraCaptureOptions, scene]
1559
1561
  );
1560
1562
 
1563
+ const createCameraFrameCaptureSessionApi = useCallback(
1564
+ (options: CameraFrameCaptureOptions = {}) =>
1565
+ createCameraFrameCaptureSession(
1566
+ gl,
1567
+ scene,
1568
+ camera,
1569
+ resolveCameraCaptureOptions(options)
1570
+ ),
1571
+ [camera, gl, resolveCameraCaptureOptions, scene]
1572
+ );
1573
+
1574
+ const captureCameraFrameTensorApi = useCallback(
1575
+ (options: CameraFrameCaptureTensorOptions = {}) => {
1576
+ const resolved: CameraFrameCaptureTensorOptions = {
1577
+ ...resolveCameraCaptureOptions(options),
1578
+ channels: options.channels,
1579
+ layout: options.layout,
1580
+ range: options.range,
1581
+ };
1582
+ const session = createCameraFrameCaptureSession(gl, scene, camera, resolved);
1583
+ try {
1584
+ return session.captureTensor(resolved);
1585
+ } finally {
1586
+ session.dispose();
1587
+ }
1588
+ },
1589
+ [camera, gl, resolveCameraCaptureOptions, scene]
1590
+ );
1591
+
1561
1592
  const recordCameraSequenceApi = useCallback(
1562
1593
  async (
1563
1594
  options: CameraFrameSequenceOptions
@@ -1882,6 +1913,9 @@ export function MujocoSimProvider({
1882
1913
  captureFrameBlob: captureFrameBlobApi,
1883
1914
  captureCameraFrame: captureCameraFrameApi,
1884
1915
  captureCameraFrameBlob: captureCameraFrameBlobApi,
1916
+ captureCameraFrameTensor: captureCameraFrameTensorApi,
1917
+ createCameraFrameCaptureSession: createCameraFrameCaptureSessionApi,
1918
+ resolveCameraCaptureOptions,
1885
1919
  recordCameraSequence: recordCameraSequenceApi,
1886
1920
  project2DTo3D,
1887
1921
  projectImagePointTo3D,
@@ -1903,6 +1937,8 @@ export function MujocoSimProvider({
1903
1937
  loadFromFilesApi, addBodyApi, removeBodyApi, recompileApi,
1904
1938
  getCanvas, getCanvasSnapshot, captureFrameApi, captureFrameBlobApi,
1905
1939
  captureCameraFrameApi, captureCameraFrameBlobApi,
1940
+ captureCameraFrameTensorApi, createCameraFrameCaptureSessionApi,
1941
+ resolveCameraCaptureOptions,
1906
1942
  recordCameraSequenceApi,
1907
1943
  project2DTo3D,
1908
1944
  projectImagePointTo3D,
@@ -1940,7 +1976,7 @@ export function MujocoSimProvider({
1940
1976
  return (
1941
1977
  <MujocoSimContext.Provider value={contextValue}>
1942
1978
  <SceneRenderer renderOptions={renderOptions} />
1943
- {children}
1979
+ <CameraViewportProvider>{children}</CameraViewportProvider>
1944
1980
  </MujocoSimContext.Provider>
1945
1981
  );
1946
1982
  }
@@ -496,9 +496,10 @@ function sceneObjectToXml(obj: SceneObject): string {
496
496
  const solref = obj.solref ? ` solref="${obj.solref}"` : '';
497
497
  const solimp = obj.solimp ? ` solimp="${obj.solimp}"` : '';
498
498
  const condim = obj.condim ? ` condim="${obj.condim}"` : '';
499
+ const contype = obj.contype ?? 1;
500
+ const conaffinity = obj.conaffinity ?? 1;
499
501
  const group = obj.group !== undefined ? ` group="${obj.group}"` : '';
500
- // Always set contype/conaffinity=1 so objects collide regardless of model defaults
501
- return `<body name="${obj.name}" pos="${pos}">${joint}<geom name="${geomName}" type="${obj.type}" size="${size}" rgba="${rgba}" contype="1" conaffinity="1"${mass}${friction}${solref}${solimp}${condim}${group}/></body>`;
502
+ return `<body name="${obj.name}" pos="${pos}">${joint}<geom name="${geomName}" type="${obj.type}" size="${size}" rgba="${rgba}" contype="${contype}" conaffinity="${conaffinity}"${mass}${friction}${solref}${solimp}${condim}${group}/></body>`;
502
503
  }
503
504
 
504
505
  /** Create virtual directory structure for a file path. */
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * Stream a live MuJoCo camera into a DOM `<canvas>`. Each frame the scene is
6
+ * rendered offscreen from the selected camera and blitted into the canvas, so
7
+ * it composites normally in the DOM (works inside opaque panels) and does NOT
8
+ * take over the render loop. Prefer this over `useCameraViewport` for camera
9
+ * tiles embedded in HTML UI; use `useCameraViewport` for transparent overlays
10
+ * on a full-bleed canvas.
11
+ *
12
+ * Uses the async capture path so Gaussian-splat environments render through
13
+ * their dedicated capture renderer — streaming a splat scene at full rate does
14
+ * not disturb the main view's splat sort.
15
+ */
16
+
17
+ import { useEffect, useRef } from 'react';
18
+ import type { RefObject } from 'react';
19
+ import { useFrame } from '@react-three/fiber';
20
+ import { useMujoco } from '../core/MujocoSimProvider';
21
+ import type { CameraFrameCaptureSession } from '../rendering/cameraFrameCapture';
22
+ import type { CameraFrameCaptureOptions } from '../types';
23
+
24
+ export interface CameraStreamOptions extends CameraFrameCaptureOptions {
25
+ /**
26
+ * Optional cap on updates per second. Omit to stream as fast as captures
27
+ * complete (one capture is in flight at a time regardless).
28
+ */
29
+ fps?: number;
30
+ /** Pause updates without unmounting. */
31
+ paused?: boolean;
32
+ }
33
+
34
+ function streamSignature(options: CameraStreamOptions): string {
35
+ return JSON.stringify({
36
+ cameraName: options.cameraName,
37
+ siteName: options.siteName,
38
+ bodyName: options.bodyName,
39
+ width: options.width,
40
+ height: options.height,
41
+ renderIsolation: options.renderIsolation ?? false,
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Render the live scene from a MuJoCo camera/site/body into `canvasRef`'s
47
+ * `<canvas>` every frame (throttled to `fps`). Call inside `<MujocoCanvas>`;
48
+ * the canvas itself can live anywhere in the DOM.
49
+ */
50
+ export function useCameraStream(
51
+ canvasRef: RefObject<HTMLCanvasElement | null>,
52
+ options: CameraStreamOptions
53
+ ) {
54
+ const mujoco = useMujoco();
55
+ const sessionRef = useRef<CameraFrameCaptureSession | null>(null);
56
+ const signatureRef = useRef<string>('');
57
+ const optionsRef = useRef(options);
58
+ optionsRef.current = options;
59
+ const elapsedRef = useRef(0);
60
+ const inFlightRef = useRef(false);
61
+ const mountedRef = useRef(true);
62
+
63
+ useEffect(() => {
64
+ mountedRef.current = true;
65
+ return () => {
66
+ mountedRef.current = false;
67
+ sessionRef.current?.dispose();
68
+ sessionRef.current = null;
69
+ signatureRef.current = '';
70
+ };
71
+ }, []);
72
+
73
+ useFrame((_state, delta) => {
74
+ const api = mujoco.api;
75
+ if (!api || !canvasRef.current) return;
76
+
77
+ const opts = optionsRef.current;
78
+ if (opts.paused) return;
79
+
80
+ if (opts.fps && opts.fps > 0) {
81
+ elapsedRef.current += delta;
82
+ if (elapsedRef.current < 1 / opts.fps) return;
83
+ }
84
+ // One capture in flight at a time — naturally rate-limits to capture speed.
85
+ if (inFlightRef.current) return;
86
+ elapsedRef.current = 0;
87
+
88
+ const signature = streamSignature(opts);
89
+ if (!sessionRef.current || signatureRef.current !== signature) {
90
+ sessionRef.current?.dispose();
91
+ sessionRef.current = api.createCameraFrameCaptureSession(opts);
92
+ signatureRef.current = signature;
93
+ }
94
+ const session = sessionRef.current;
95
+
96
+ inFlightRef.current = true;
97
+ session
98
+ .captureAsync(api.resolveCameraCaptureOptions(opts))
99
+ .then((frame) => {
100
+ const canvas = canvasRef.current;
101
+ if (!mountedRef.current || !canvas) return;
102
+ const ctx = canvas.getContext('2d');
103
+ if (!ctx) return;
104
+ if (canvas.width !== frame.width || canvas.height !== frame.height) {
105
+ canvas.width = frame.width;
106
+ canvas.height = frame.height;
107
+ }
108
+ ctx.drawImage(frame.canvas, 0, 0);
109
+ })
110
+ .catch(() => {})
111
+ .finally(() => {
112
+ inFlightRef.current = false;
113
+ });
114
+ });
115
+ }
Binary file
@@ -97,6 +97,9 @@ export const useIkController = createControllerHook<IkConfig, IkContextValue>(
97
97
  {
98
98
  damping: config.damping,
99
99
  epsilon: config.epsilon,
100
+ jointLimits: config.jointLimits ?? controlGroup.joints.map((joint) => (
101
+ joint.limited ? joint.range : joint.ctrlRange
102
+ )),
100
103
  maxIterations: config.maxIterations,
101
104
  posWeight: config.posWeight,
102
105
  rotWeight: config.rotWeight,