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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mujoco-react",
3
- "version": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "description": "Composable React Three Fiber building blocks for MuJoCo WASM simulations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,13 +34,12 @@
34
34
  "license": "Apache-2.0",
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "https://github.com/noah/mujoco-react"
37
+ "url": "https://github.com/noah-wardlow/mujoco-react"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsup",
41
41
  "dev": "tsup --watch",
42
- "typecheck": "tsc --noEmit",
43
- "prepublishOnly": "npm run build"
42
+ "typecheck": "tsc --noEmit"
44
43
  },
45
44
  "peerDependencies": {
46
45
  "@react-three/drei": ">=9",
@@ -54,9 +53,12 @@
54
53
  "devDependencies": {
55
54
  "@react-three/drei": "^10.7.7",
56
55
  "@react-three/fiber": "^9.5.0",
56
+ "@semantic-release/changelog": "^6.0.3",
57
+ "@semantic-release/git": "^10.0.1",
57
58
  "@types/react": "^19.0.0",
58
59
  "@types/three": "^0.181.0",
59
60
  "react": "^19.2.0",
61
+ "semantic-release": "^25.0.3",
60
62
  "three": "^0.181.0",
61
63
  "tsup": "^8.4.0",
62
64
  "typescript": "~5.8.2"
@@ -10,17 +10,19 @@
10
10
 
11
11
  import { useRef } from 'react';
12
12
  import { useFrame } from '@react-three/fiber';
13
+ import type { ThreeElements } from '@react-three/fiber';
13
14
  import * as THREE from 'three';
14
15
  import { useMujocoSim } from '../core/MujocoSimProvider';
16
+ import { getContact } from '../types';
15
17
 
16
18
  const _dummy = new THREE.Object3D();
17
19
 
18
20
  interface ContactMarkersProps {
19
21
  /** Maximum contacts to render. Default: 100. */
20
22
  maxContacts?: number;
21
- /** Sphere radius. Default: 0.005. */
23
+ /** Sphere radius. Default: 0.008. */
22
24
  radius?: number;
23
- /** Color. Default: '#4f46e5'. */
25
+ /** Color. Default: '#22d3ee'. */
24
26
  color?: string;
25
27
  /** Show markers. Default: true. */
26
28
  visible?: boolean;
@@ -28,10 +30,11 @@ interface ContactMarkersProps {
28
30
 
29
31
  export function ContactMarkers({
30
32
  maxContacts = 100,
31
- radius = 0.005,
32
- color = '#4f46e5',
33
+ radius = 0.008,
34
+ color = '#22d3ee',
33
35
  visible = true,
34
- }: ContactMarkersProps = {}) {
36
+ ...groupProps
37
+ }: ContactMarkersProps & Omit<ThreeElements['group'], 'ref' | 'visible'> = {}) {
35
38
  const { mjDataRef, status } = useMujocoSim();
36
39
  const meshRef = useRef<THREE.InstancedMesh>(null);
37
40
 
@@ -47,18 +50,15 @@ export function ContactMarkers({
47
50
  const count = Math.min(ncon, maxContacts);
48
51
 
49
52
  for (let i = 0; i < count; i++) {
50
- try {
51
- const c = (data.contact as { get(i: number): { pos: Float64Array } | undefined }).get(i);
52
- if (!c) break;
53
- _dummy.position.set(c.pos[0], c.pos[1], c.pos[2]);
54
- _dummy.updateMatrix();
55
- mesh.setMatrixAt(i, _dummy.matrix);
56
- } catch {
57
- // Contact access failed — stop here
53
+ const c = getContact(data, i);
54
+ if (!c) {
58
55
  mesh.count = i;
59
56
  mesh.instanceMatrix.needsUpdate = true;
60
57
  return;
61
58
  }
59
+ _dummy.position.set(c.pos[0], c.pos[1], c.pos[2]);
60
+ _dummy.updateMatrix();
61
+ mesh.setMatrixAt(i, _dummy.matrix);
62
62
  }
63
63
 
64
64
  mesh.count = count;
@@ -68,14 +68,11 @@ export function ContactMarkers({
68
68
  if (status !== 'ready') return null;
69
69
 
70
70
  return (
71
- <instancedMesh ref={meshRef} args={[undefined, undefined, maxContacts]}>
72
- <sphereGeometry args={[radius, 8, 8]} />
73
- <meshStandardMaterial
74
- color={color}
75
- emissive={color}
76
- emissiveIntensity={0.3}
77
- roughness={0.5}
78
- />
79
- </instancedMesh>
71
+ <group {...groupProps}>
72
+ <instancedMesh ref={meshRef} args={[undefined, undefined, maxContacts]} frustumCulled={false} renderOrder={999}>
73
+ <sphereGeometry args={[radius, 8, 8]} />
74
+ <meshBasicMaterial color={color} depthTest={false} />
75
+ </instancedMesh>
76
+ </group>
80
77
  );
81
78
  }
@@ -7,9 +7,11 @@
7
7
 
8
8
  import { useEffect, useMemo, useRef } from 'react';
9
9
  import { useFrame, useThree } from '@react-three/fiber';
10
+ import type { ThreeElements } from '@react-three/fiber';
10
11
  import * as THREE from 'three';
11
12
  import { useMujocoSim } from '../core/MujocoSimProvider';
12
13
  import { getName } from '../core/SceneLoader';
14
+ import { getContact } from '../types';
13
15
  import type { DebugProps } from '../types';
14
16
 
15
17
  const JOINT_COLORS: Record<number, number> = {
@@ -19,6 +21,14 @@ const JOINT_COLORS: Record<number, number> = {
19
21
  3: 0xffff00, // hinge - yellow
20
22
  };
21
23
 
24
+ // Preallocated temps to avoid per-frame GC pressure
25
+ const _v3a = new THREE.Vector3();
26
+ const _v3b = new THREE.Vector3();
27
+ const _quat = new THREE.Quaternion();
28
+ const _contactPos = new THREE.Vector3();
29
+ const _contactNormal = new THREE.Vector3();
30
+ const MAX_CONTACT_ARROWS = 50;
31
+
22
32
  /**
23
33
  * Declarative debug visualization component.
24
34
  * Renders wireframe geoms, site markers, joint axes, contact forces, COM markers, etc.
@@ -31,7 +41,8 @@ export function Debug({
31
41
  showCOM = false,
32
42
  showInertia = false,
33
43
  showTendons = false,
34
- }: DebugProps) {
44
+ ...groupProps
45
+ }: DebugProps & Omit<ThreeElements['group'], 'ref'>) {
35
46
  const { mjModelRef, mjDataRef, status } = useMujocoSim();
36
47
  const { scene } = useThree();
37
48
  const groupRef = useRef<THREE.Group>(null);
@@ -78,27 +89,92 @@ export function Debug({
78
89
  }
79
90
  }
80
91
 
81
- // Site markers
92
+ // Site markers — scale based on site_size if available, else use geom_size of parent body
82
93
  if (showSites) {
94
+ const siteSize = (model as Record<string, unknown>).site_size as Float64Array | undefined;
83
95
  for (let i = 0; i < model.nsite; i++) {
84
- const geometry = new THREE.OctahedronGeometry(0.01);
85
- const mat = new THREE.MeshBasicMaterial({ color: 0xff00ff, transparent: true, opacity: 0.7 });
96
+ // Determine marker radius: use site_size[3*i] if available, else estimate from parent body's geoms
97
+ let radius = 0.008;
98
+ if (siteSize) {
99
+ radius = Math.max(siteSize[3 * i] * 0.5, 0.004);
100
+ } else {
101
+ // Estimate from parent body's geom sizes
102
+ const bodyId = model.site_bodyid[i];
103
+ let maxGeomSize = 0;
104
+ for (let g = 0; g < model.ngeom; g++) {
105
+ if (model.geom_bodyid[g] === bodyId) {
106
+ maxGeomSize = Math.max(maxGeomSize, model.geom_size[3 * g]);
107
+ }
108
+ }
109
+ if (maxGeomSize > 0) radius = maxGeomSize * 0.15;
110
+ }
111
+
112
+ const geometry = new THREE.OctahedronGeometry(radius);
113
+ const mat = new THREE.MeshBasicMaterial({ color: 0xff00ff, depthTest: false });
86
114
  const mesh = new THREE.Mesh(geometry, mat);
115
+ mesh.renderOrder = 999;
116
+ mesh.frustumCulled = false;
87
117
  mesh.userData.siteId = i;
118
+
119
+ // Label
120
+ const canvas = document.createElement('canvas');
121
+ canvas.width = 256;
122
+ canvas.height = 64;
123
+ const ctx = canvas.getContext('2d')!;
124
+ ctx.fillStyle = '#ff00ff';
125
+ ctx.font = 'bold 36px monospace';
126
+ ctx.textAlign = 'center';
127
+ ctx.fillText(getName(model, model.name_siteadr[i]), 128, 42);
128
+ const tex = new THREE.CanvasTexture(canvas);
129
+ const spriteMat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true });
130
+ const sprite = new THREE.Sprite(spriteMat);
131
+ const labelScale = radius * 15;
132
+ sprite.scale.set(labelScale, labelScale * 0.25, 1);
133
+ sprite.position.y = radius * 2;
134
+ sprite.renderOrder = 999;
135
+ mesh.add(sprite);
136
+
88
137
  sites.push(mesh);
89
138
  }
90
139
  }
91
140
 
92
- // Joint axes
141
+ // Joint axes — scale arrow length based on parent body's geom sizes
93
142
  if (showJoints) {
143
+ // Safely check for jnt_pos/jnt_axis on the WASM model
144
+ const jntPos = (model as Record<string, unknown>).jnt_pos as Float64Array | undefined;
145
+ const jntAxis = (model as Record<string, unknown>).jnt_axis as Float64Array | undefined;
146
+
94
147
  for (let i = 0; i < model.njnt; i++) {
95
148
  const type = model.jnt_type[i];
96
149
  const color = JOINT_COLORS[type] ?? 0xffffff;
150
+
151
+ // Scale based on parent body geom size
152
+ const bodyId = model.jnt_bodyid[i];
153
+ let maxGeomSize = 0;
154
+ for (let g = 0; g < model.ngeom; g++) {
155
+ if (model.geom_bodyid[g] === bodyId) {
156
+ maxGeomSize = Math.max(maxGeomSize, model.geom_size[3 * g]);
157
+ }
158
+ }
159
+ const arrowLen = Math.max(maxGeomSize * 0.8, 0.05);
160
+
97
161
  const arrow = new THREE.ArrowHelper(
98
162
  new THREE.Vector3(0, 0, 1), new THREE.Vector3(),
99
- 0.05, color, 0.01, 0.005
163
+ arrowLen, color, arrowLen * 0.25, arrowLen * 0.12
100
164
  );
165
+ // Render on top so arrows show through geometry
166
+ arrow.renderOrder = 999;
167
+ arrow.frustumCulled = false;
168
+ arrow.line.material = new THREE.LineBasicMaterial({ color, depthTest: false });
169
+ (arrow.cone.material as THREE.MeshBasicMaterial).depthTest = false;
170
+ arrow.line.renderOrder = 999;
171
+ arrow.line.frustumCulled = false;
172
+ arrow.cone.renderOrder = 999;
173
+ arrow.cone.frustumCulled = false;
101
174
  arrow.userData.jointId = i;
175
+ arrow.userData.bodyId = bodyId;
176
+ arrow.userData.hasJntPos = !!jntPos;
177
+ arrow.userData.hasJntAxis = !!jntAxis;
102
178
  joints.push(arrow);
103
179
  }
104
180
  }
@@ -144,6 +220,10 @@ export function Debug({
144
220
  const data = mjDataRef.current;
145
221
  if (!model || !data || !debugGeometry) return;
146
222
 
223
+ // Safely grab optional arrays once
224
+ const jntPos = (model as Record<string, unknown>).jnt_pos as Float64Array | undefined;
225
+ const jntAxis = (model as Record<string, unknown>).jnt_axis as Float64Array | undefined;
226
+
147
227
  // Update geom wireframes
148
228
  for (const mesh of debugGeometry.geoms) {
149
229
  const bid = mesh.userData.bodyId;
@@ -157,8 +237,9 @@ export function Debug({
157
237
  // Apply local geom offset
158
238
  const gid = mesh.userData.geomId;
159
239
  const gp = model.geom_pos;
160
- mesh.position.add(new THREE.Vector3(gp[3 * gid], gp[3 * gid + 1], gp[3 * gid + 2])
161
- .applyQuaternion(mesh.quaternion));
240
+ _v3a.set(gp[3 * gid], gp[3 * gid + 1], gp[3 * gid + 2])
241
+ .applyQuaternion(mesh.quaternion);
242
+ mesh.position.add(_v3a);
162
243
  }
163
244
 
164
245
  // Update site markers
@@ -171,6 +252,35 @@ export function Debug({
171
252
  );
172
253
  }
173
254
 
255
+ // Update joint axes
256
+ for (const obj of debugGeometry.joints) {
257
+ const arrow = obj as THREE.ArrowHelper;
258
+ const jid = arrow.userData.jointId;
259
+ const bid = arrow.userData.bodyId;
260
+ const i3 = bid * 3;
261
+ const i4 = bid * 4;
262
+
263
+ _quat.set(
264
+ data.xquat[i4 + 1], data.xquat[i4 + 2],
265
+ data.xquat[i4 + 3], data.xquat[i4]
266
+ );
267
+
268
+ // Position: body origin + local joint anchor (if available)
269
+ arrow.position.set(data.xpos[i3], data.xpos[i3 + 1], data.xpos[i3 + 2]);
270
+ if (jntPos) {
271
+ _v3a.set(jntPos[3 * jid], jntPos[3 * jid + 1], jntPos[3 * jid + 2])
272
+ .applyQuaternion(_quat);
273
+ arrow.position.add(_v3a);
274
+ }
275
+
276
+ // Orient along joint axis in world frame (if available)
277
+ if (jntAxis) {
278
+ _v3a.set(jntAxis[3 * jid], jntAxis[3 * jid + 1], jntAxis[3 * jid + 2])
279
+ .applyQuaternion(_quat).normalize();
280
+ arrow.setDirection(_v3a);
281
+ }
282
+ }
283
+
174
284
  // Update COM markers
175
285
  for (const mesh of debugGeometry.comMarkers) {
176
286
  const bid = mesh.userData.bodyId;
@@ -179,49 +289,76 @@ export function Debug({
179
289
  }
180
290
  });
181
291
 
182
- // Contact force vectors
292
+ // Contact force vectors — pre-created pool to avoid per-frame allocation
183
293
  const contactGroupRef = useRef<THREE.Group>(null);
184
- const contactArrowsRef = useRef<THREE.ArrowHelper[]>([]);
294
+ const contactPoolRef = useRef<THREE.ArrowHelper[]>([]);
295
+ const contactPoolInitRef = useRef(false);
185
296
 
186
- useFrame(() => {
187
- if (!showContacts) return;
188
- const model = mjModelRef.current;
189
- const data = mjDataRef.current;
297
+ // Initialize arrow pool once
298
+ useEffect(() => {
190
299
  const group = contactGroupRef.current;
191
- if (!model || !data || !group) return;
300
+ if (!group || contactPoolInitRef.current) return;
301
+ contactPoolInitRef.current = true;
192
302
 
193
- // Remove old arrows
194
- for (const arrow of contactArrowsRef.current) {
195
- group.remove(arrow);
196
- arrow.dispose();
303
+ const pool: THREE.ArrowHelper[] = [];
304
+ for (let i = 0; i < MAX_CONTACT_ARROWS; i++) {
305
+ const arrow = new THREE.ArrowHelper(
306
+ new THREE.Vector3(0, 1, 0), new THREE.Vector3(), 0.1, 0xff4444, 0.03, 0.015
307
+ );
308
+ arrow.visible = false;
309
+ group.add(arrow);
310
+ pool.push(arrow);
197
311
  }
198
- contactArrowsRef.current = [];
312
+ contactPoolRef.current = pool;
313
+
314
+ return () => {
315
+ for (const arrow of pool) {
316
+ group.remove(arrow);
317
+ arrow.dispose();
318
+ }
319
+ contactPoolRef.current = [];
320
+ contactPoolInitRef.current = false;
321
+ };
322
+ }, [showContacts]);
323
+
324
+ useFrame(() => {
325
+ if (!showContacts) return;
326
+ const data = mjDataRef.current;
327
+ const pool = contactPoolRef.current;
328
+ if (!data || pool.length === 0) return;
199
329
 
200
330
  const ncon = data.ncon;
201
- for (let i = 0; i < Math.min(ncon, 50); i++) {
202
- try {
203
- const c = (data.contact as { get(i: number): { pos: Float64Array; frame: Float64Array; dist: number } }).get(i);
204
- const pos = new THREE.Vector3(c.pos[0], c.pos[1], c.pos[2]);
205
- const normal = new THREE.Vector3(c.frame[0], c.frame[1], c.frame[2]);
206
- const force = Math.abs(c.dist) * 100;
207
- const length = Math.min(force * 0.01, 0.1);
208
- if (length > 0.001) {
209
- const arrow = new THREE.ArrowHelper(normal, pos, length, 0xff4444, length * 0.3, length * 0.15);
210
- group.add(arrow);
211
- contactArrowsRef.current.push(arrow);
212
- }
213
- } catch {
214
- break;
331
+ let arrowIdx = 0;
332
+
333
+ for (let i = 0; i < Math.min(ncon, MAX_CONTACT_ARROWS); i++) {
334
+ const c = getContact(data, i);
335
+ if (!c) break;
336
+ _contactPos.set(c.pos[0], c.pos[1], c.pos[2]);
337
+ _contactNormal.set(c.frame[0], c.frame[1], c.frame[2]);
338
+ const force = Math.abs(c.dist) * 100;
339
+ const length = Math.min(force * 0.01, 0.1);
340
+ if (length > 0.001 && arrowIdx < pool.length) {
341
+ const arrow = pool[arrowIdx];
342
+ arrow.position.copy(_contactPos);
343
+ arrow.setDirection(_contactNormal);
344
+ arrow.setLength(length, length * 0.3, length * 0.15);
345
+ arrow.visible = true;
346
+ arrowIdx++;
215
347
  }
216
348
  }
349
+
350
+ // Hide unused arrows
351
+ for (let i = arrowIdx; i < pool.length; i++) {
352
+ pool[i].visible = false;
353
+ }
217
354
  });
218
355
 
219
356
  if (status !== 'ready') return null;
220
357
 
221
358
  return (
222
- <>
359
+ <group {...groupProps}>
223
360
  <group ref={groupRef} />
224
361
  {showContacts && <group ref={contactGroupRef} />}
225
- </>
362
+ </group>
226
363
  );
227
364
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { useFrame, useThree } from '@react-three/fiber';
7
+ import type { ThreeElements } from '@react-three/fiber';
7
8
  import { useEffect, useRef } from 'react';
8
9
  import * as THREE from 'three';
9
10
  import { useMujocoSim, useBeforePhysicsStep } from '../core/MujocoSimProvider';
@@ -35,7 +36,8 @@ const _mouse = new THREE.Vector2();
35
36
  export function DragInteraction({
36
37
  stiffness = 250,
37
38
  showArrow = true,
38
- }: DragInteractionProps) {
39
+ ...groupProps
40
+ }: DragInteractionProps & Omit<ThreeElements['group'], 'ref'>) {
39
41
  const { mjDataRef, mujocoRef, mjModelRef, status } = useMujocoSim();
40
42
  const { gl, camera, scene, controls } = useThree();
41
43
 
@@ -208,7 +210,7 @@ export function DragInteraction({
208
210
 
209
211
  if (draggingRef.current && bodyIdRef.current > 0) {
210
212
  arrow.visible = true;
211
- const dir = mouseWorldRef.current.clone().sub(grabWorldRef.current);
213
+ const dir = _bodyPos.copy(mouseWorldRef.current).sub(grabWorldRef.current);
212
214
  const len = dir.length();
213
215
  if (len > 0.001) {
214
216
  dir.normalize();
@@ -223,5 +225,5 @@ export function DragInteraction({
223
225
 
224
226
  if (status !== 'ready') return null;
225
227
 
226
- return <group ref={groupRef} />;
228
+ return <group {...groupProps} ref={groupRef} />;
227
229
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { useEffect, useRef } from 'react';
9
9
  import { useFrame } from '@react-three/fiber';
10
+ import type { ThreeElements } from '@react-three/fiber';
10
11
  import * as THREE from 'three';
11
12
  import { useMujocoSim } from '../core/MujocoSimProvider';
12
13
 
@@ -14,7 +15,7 @@ import { useMujocoSim } from '../core/MujocoSimProvider';
14
15
  * Renders MuJoCo flex (deformable) bodies as dynamic meshes.
15
16
  * Vertices are updated every frame from flexvert_xpos.
16
17
  */
17
- export function FlexRenderer() {
18
+ export function FlexRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
18
19
  const { mjModelRef, mjDataRef, status } = useMujocoSim();
19
20
  const groupRef = useRef<THREE.Group>(null);
20
21
  const meshesRef = useRef<THREE.Mesh[]>([]);
@@ -98,5 +99,5 @@ export function FlexRenderer() {
98
99
  });
99
100
 
100
101
  if (status !== 'ready') return null;
101
- return <group ref={groupRef} />;
102
+ return <group {...props} ref={groupRef} />;
102
103
  }