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.
@@ -100,6 +100,26 @@ export function findTendonByName(mjModel: MujocoModel, name: string): number {
100
100
  return -1;
101
101
  }
102
102
 
103
+ /**
104
+ * Return qpos address for actuators that directly target a scalar joint.
105
+ * Returns -1 for non-joint transmissions and multi-DOF joints.
106
+ */
107
+ export function getActuatedScalarQposAdr(mjModel: MujocoModel, actuatorId: number): number {
108
+ if (actuatorId < 0 || actuatorId >= mjModel.nu) return -1;
109
+
110
+ // mjTRN_JOINT=0, mjTRN_JOINTINPARENT=1. Other transmission types don't map ctrl to a single qpos.
111
+ const trnType = mjModel.actuator_trntype?.[actuatorId];
112
+ if (trnType !== undefined && trnType !== 0 && trnType !== 1) return -1;
113
+
114
+ const jointId = mjModel.actuator_trnid[2 * actuatorId];
115
+ if (jointId < 0 || jointId >= mjModel.njnt) return -1;
116
+
117
+ const jntType = mjModel.jnt_type[jointId];
118
+ if (jntType !== 2 && jntType !== 3) return -1; // slide=2, hinge=3
119
+
120
+ return mjModel.jnt_qposadr[jointId];
121
+ }
122
+
103
123
  /**
104
124
  * Convert a SceneObject config to MuJoCo XML.
105
125
  */
@@ -120,8 +140,6 @@ function sceneObjectToXml(obj: SceneObject): string {
120
140
  interface LoadResult {
121
141
  mjModel: MujocoModel;
122
142
  mjData: MujocoData;
123
- siteId: number;
124
- gripperId: number;
125
143
  }
126
144
 
127
145
  /**
@@ -174,7 +192,13 @@ export async function loadScene(
174
192
  for (const patch of config.xmlPatches ?? []) {
175
193
  if (fname.endsWith(patch.target) || fname === patch.target) {
176
194
  if (patch.replace) {
177
- text = text.replace(patch.replace[0], patch.replace[1]);
195
+ const [from, to] = patch.replace;
196
+ if (text.includes(from)) {
197
+ text = text.replace(from, to);
198
+ } else {
199
+ const preview = from.length > 80 ? `${from.slice(0, 80)}...` : from;
200
+ console.warn(`XML patch replace pattern not found in ${fname}: "${preview}"`);
201
+ }
178
202
  }
179
203
  if (patch.inject && patch.injectAfter) {
180
204
  const idx = text.indexOf(patch.injectAfter);
@@ -183,7 +207,14 @@ export async function loadScene(
183
207
  const tagEnd = text.indexOf('>', idx + patch.injectAfter.length);
184
208
  if (tagEnd !== -1) {
185
209
  text = text.slice(0, tagEnd + 1) + patch.inject + text.slice(tagEnd + 1);
210
+ } else {
211
+ console.warn(`XML patch inject failed in ${fname}: could not find tag end after "${patch.injectAfter}"`);
186
212
  }
213
+ } else {
214
+ const preview = patch.injectAfter.length > 80
215
+ ? `${patch.injectAfter.slice(0, 80)}...`
216
+ : patch.injectAfter;
217
+ console.warn(`XML patch inject anchor not found in ${fname}: "${preview}"`);
187
218
  }
188
219
  }
189
220
  }
@@ -208,27 +239,22 @@ export async function loadScene(
208
239
  const mjModel = mujoco.MjModel.loadFromXML(`/working/${config.sceneFile}`);
209
240
  const mjData = new mujoco.MjData(mjModel);
210
241
 
211
- // 6. Find TCP site and gripper actuator
212
- const siteId = findSiteByName(mjModel, config.tcpSiteName ?? 'tcp');
213
- const gripperId = findActuatorByName(mjModel, config.gripperActuatorName ?? 'gripper');
214
-
215
- // 7. Set initial pose
242
+ // 6. Set initial pose — set both ctrl and qpos so robot starts at home.
243
+ // If homeJoints is not provided, keep raw MuJoCo defaults.
216
244
  if (config.homeJoints) {
217
- for (let i = 0; i < config.homeJoints.length; i++) {
245
+ const homeCount = Math.min(config.homeJoints.length, mjModel.nu);
246
+ for (let i = 0; i < homeCount; i++) {
218
247
  mjData.ctrl[i] = config.homeJoints[i];
219
- if (mjModel.actuator_trnid[2 * i + 1] === 1) {
220
- const jointId = mjModel.actuator_trnid[2 * i];
221
- if (jointId >= 0 && jointId < mjModel.njnt) {
222
- const qposAdr = mjModel.jnt_qposadr[jointId];
223
- mjData.qpos[qposAdr] = config.homeJoints[i];
224
- }
248
+ const qposAdr = getActuatedScalarQposAdr(mjModel, i);
249
+ if (qposAdr !== -1) {
250
+ mjData.qpos[qposAdr] = config.homeJoints[i];
225
251
  }
226
252
  }
227
253
  }
228
254
 
229
255
  mujoco.mj_forward(mjModel, mjData);
230
256
 
231
- return { mjModel, mjData, siteId, gripperId };
257
+ return { mjModel, mjData };
232
258
  }
233
259
 
234
260
  /**
@@ -244,8 +270,9 @@ function scanDependencies(
244
270
  const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
245
271
 
246
272
  const compiler = xmlDoc.querySelector('compiler');
247
- const meshDir = compiler?.getAttribute('meshdir') || '';
248
- const textureDir = compiler?.getAttribute('texturedir') || '';
273
+ const assetDir = compiler?.getAttribute('assetdir') || '';
274
+ const meshDir = compiler?.getAttribute('meshdir') || assetDir;
275
+ const textureDir = compiler?.getAttribute('texturedir') || assetDir;
249
276
  const currentDir = currentFile.includes('/')
250
277
  ? currentFile.substring(0, currentFile.lastIndexOf('/') + 1)
251
278
  : '';
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * createController — typed factory for BYOC (Bring Your Own Controller) plugins.
6
+ */
7
+
8
+ import { useMemo, useRef } from 'react';
9
+
10
+ /** Shallow-compare two plain objects by own enumerable keys. */
11
+ function shallowEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
12
+ const keysA = Object.keys(a);
13
+ const keysB = Object.keys(b);
14
+ if (keysA.length !== keysB.length) return false;
15
+ for (const key of keysA) {
16
+ if (a[key] !== b[key]) return false;
17
+ }
18
+ return true;
19
+ }
20
+
21
+ export interface ControllerOptions<TConfig> {
22
+ /** Unique name for this controller (used as displayName). */
23
+ name: string;
24
+ /** Default values merged under user-supplied config. */
25
+ defaultConfig?: Partial<TConfig>;
26
+ }
27
+
28
+ export type ControllerComponent<TConfig> = React.FC<{
29
+ config?: Partial<TConfig>;
30
+ children?: React.ReactNode;
31
+ }> & {
32
+ controllerName: string;
33
+ defaultConfig: Partial<TConfig>;
34
+ };
35
+
36
+ /**
37
+ * Factory that produces a typed controller component.
38
+ *
39
+ * Controllers are React components that plug into the MuJoCo simulation tree.
40
+ * Inside `Impl`, use any hooks (`useMujocoSim`, `useBeforePhysicsStep`, etc.)
41
+ * to interact with the physics engine.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * const MyController = createController<{ speed: number }>(
46
+ * { name: 'my-controller', defaultConfig: { speed: 1.0 } },
47
+ * function MyControllerImpl({ config }) {
48
+ * useBeforePhysicsStep((_model, data) => {
49
+ * data.ctrl[0] = config.speed;
50
+ * });
51
+ * return null;
52
+ * },
53
+ * );
54
+ *
55
+ * // Usage:
56
+ * <MyController config={{ speed: 2.0 }} />
57
+ * ```
58
+ */
59
+ export function createController<TConfig>(
60
+ options: ControllerOptions<TConfig>,
61
+ Impl: React.FC<{ config: TConfig; children?: React.ReactNode }>,
62
+ ): ControllerComponent<TConfig> {
63
+ function Controller({
64
+ config,
65
+ children,
66
+ }: {
67
+ config?: Partial<TConfig>;
68
+ children?: React.ReactNode;
69
+ }) {
70
+ // Stabilise config reference: inline objects get a new identity each render,
71
+ // but the actual values rarely change. Shallow-compare to keep the same ref.
72
+ const configObj = (config ?? {}) as Record<string, unknown>;
73
+ const stableRef = useRef(configObj);
74
+ if (!shallowEqual(stableRef.current, configObj)) {
75
+ stableRef.current = configObj;
76
+ }
77
+ const stableConfig = stableRef.current as Partial<TConfig>;
78
+
79
+ const mergedConfig = useMemo(
80
+ () => ({ ...options.defaultConfig, ...stableConfig }) as TConfig,
81
+ [stableConfig],
82
+ );
83
+ return <Impl config={mergedConfig}>{children}</Impl>;
84
+ }
85
+
86
+ Controller.displayName = options.name;
87
+ Controller.controllerName = options.name;
88
+ Controller.defaultConfig = options.defaultConfig ?? ({} as Partial<TConfig>);
89
+
90
+ return Controller as ControllerComponent<TConfig>;
91
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * useCameraAnimation — composable camera animation hook.
6
+ */
7
+
8
+ import { useCallback, useRef } from 'react';
9
+ import { useFrame, useThree } from '@react-three/fiber';
10
+ import * as THREE from 'three';
11
+
12
+ export interface CameraAnimationAPI {
13
+ getCameraState(): { position: THREE.Vector3; target: THREE.Vector3 };
14
+ moveCameraTo(position: THREE.Vector3, target: THREE.Vector3, durationMs: number): Promise<void>;
15
+ }
16
+
17
+ /**
18
+ * Standalone hook for animated camera transitions.
19
+ *
20
+ * Manages its own `useFrame` callback — drop it into any component inside `<Canvas>`.
21
+ */
22
+ export function useCameraAnimation(): CameraAnimationAPI {
23
+ const { camera } = useThree();
24
+
25
+ const orbitTargetRef = useRef(new THREE.Vector3(0, 0, 0));
26
+
27
+ const cameraAnimRef = useRef({
28
+ active: false,
29
+ startPos: new THREE.Vector3(),
30
+ endPos: new THREE.Vector3(),
31
+ startRot: new THREE.Quaternion(),
32
+ endRot: new THREE.Quaternion(),
33
+ startTarget: new THREE.Vector3(),
34
+ endTarget: new THREE.Vector3(),
35
+ startTime: 0,
36
+ duration: 0,
37
+ resolve: null as (() => void) | null,
38
+ });
39
+
40
+ useFrame((state) => {
41
+ const ca = cameraAnimRef.current;
42
+ if (!ca.active) return;
43
+
44
+ const now = performance.now();
45
+ const progress = Math.min((now - ca.startTime) / ca.duration, 1.0);
46
+ const ease =
47
+ progress < 0.5
48
+ ? 4 * progress * progress * progress
49
+ : 1 - Math.pow(-2 * progress + 2, 3) / 2;
50
+
51
+ camera.position.lerpVectors(ca.startPos, ca.endPos, ease);
52
+ camera.quaternion.slerpQuaternions(ca.startRot, ca.endRot, ease);
53
+ orbitTargetRef.current.lerpVectors(ca.startTarget, ca.endTarget, ease);
54
+
55
+ const orbitControls = state.controls as { target?: THREE.Vector3 };
56
+ if (orbitControls?.target) {
57
+ orbitControls.target.copy(orbitTargetRef.current);
58
+ }
59
+
60
+ if (progress >= 1.0) {
61
+ ca.active = false;
62
+ camera.position.copy(ca.endPos);
63
+ camera.quaternion.copy(ca.endRot);
64
+ orbitTargetRef.current.copy(ca.endTarget);
65
+ ca.resolve?.();
66
+ ca.resolve = null;
67
+ }
68
+ });
69
+
70
+ const getCameraState = useCallback(
71
+ (): { position: THREE.Vector3; target: THREE.Vector3 } => ({
72
+ position: camera.position.clone(),
73
+ target: orbitTargetRef.current.clone(),
74
+ }),
75
+ [camera],
76
+ );
77
+
78
+ const moveCameraTo = useCallback(
79
+ (position: THREE.Vector3, target: THREE.Vector3, durationMs: number): Promise<void> => {
80
+ return new Promise((resolve) => {
81
+ const ca = cameraAnimRef.current;
82
+ ca.active = true;
83
+ ca.startTime = performance.now();
84
+ ca.duration = durationMs;
85
+ ca.startPos.copy(camera.position);
86
+ ca.startRot.copy(camera.quaternion);
87
+ ca.startTarget.copy(orbitTargetRef.current);
88
+ ca.endPos.copy(position);
89
+ ca.endTarget.copy(target);
90
+ const dummyCam = (camera as THREE.PerspectiveCamera).clone();
91
+ dummyCam.position.copy(position);
92
+ dummyCam.lookAt(target);
93
+ ca.endRot.copy(dummyCam.quaternion);
94
+ ca.resolve = resolve;
95
+ setTimeout(resolve, durationMs + 100);
96
+ });
97
+ },
98
+ [camera],
99
+ );
100
+
101
+ return { getCameraState, moveCameraTo };
102
+ }
@@ -9,7 +9,26 @@
9
9
  import { useCallback, useEffect, useRef } from 'react';
10
10
  import { useMujocoSim, useAfterPhysicsStep } from '../core/MujocoSimProvider';
11
11
  import { findBodyByName, getName } from '../core/SceneLoader';
12
- import type { ContactInfo } from '../types';
12
+ import { getContact } from '../types';
13
+ import type { ContactInfo, MujocoModel } from '../types';
14
+
15
+ // Cache geom names per model to avoid cross-model id collisions.
16
+ const geomNameCacheByModel = new WeakMap<MujocoModel, Map<number, string>>();
17
+
18
+ function getGeomNameCached(model: MujocoModel, geomId: number): string {
19
+ let perModel = geomNameCacheByModel.get(model);
20
+ if (!perModel) {
21
+ perModel = new Map<number, string>();
22
+ geomNameCacheByModel.set(model, perModel);
23
+ }
24
+
25
+ let name = perModel.get(geomId);
26
+ if (name === undefined) {
27
+ name = getName(model, model.name_geomadr[geomId]);
28
+ perModel.set(geomId, name);
29
+ }
30
+ return name;
31
+ }
13
32
 
14
33
  /**
15
34
  * Track contacts for a specific body (or all contacts if no body specified).
@@ -20,20 +39,34 @@ export function useContacts(
20
39
  bodyName?: string,
21
40
  callback?: (contacts: ContactInfo[]) => void,
22
41
  ): React.RefObject<ContactInfo[]> {
23
- const { mjModelRef } = useMujocoSim();
42
+ const { mjModelRef, status } = useMujocoSim();
24
43
  const contactsRef = useRef<ContactInfo[]>([]);
25
44
  const bodyIdRef = useRef(-1);
45
+ const bodyResolvedRef = useRef(false);
26
46
  const callbackRef = useRef(callback);
27
47
  callbackRef.current = callback;
28
48
 
29
49
  useEffect(() => {
30
- if (!bodyName) { bodyIdRef.current = -1; return; }
50
+ if (!bodyName) {
51
+ bodyIdRef.current = -1;
52
+ bodyResolvedRef.current = true;
53
+ return;
54
+ }
55
+ bodyResolvedRef.current = false;
56
+ if (status !== 'ready') return;
31
57
  const model = mjModelRef.current;
32
58
  if (!model) return;
33
59
  bodyIdRef.current = findBodyByName(model, bodyName);
34
- }, [bodyName, mjModelRef]);
60
+ bodyResolvedRef.current = true;
61
+ }, [bodyName, status, mjModelRef]);
35
62
 
36
63
  useAfterPhysicsStep((model, data) => {
64
+ // Resolve body id lazily once model exists, to avoid missing the first ready frame.
65
+ if (bodyName && !bodyResolvedRef.current) {
66
+ bodyIdRef.current = findBodyByName(model, bodyName);
67
+ bodyResolvedRef.current = true;
68
+ }
69
+
37
70
  const ncon = data.ncon;
38
71
  if (ncon === 0) {
39
72
  if (contactsRef.current.length > 0) contactsRef.current = [];
@@ -45,25 +78,22 @@ export function useContacts(
45
78
  const filterBody = bodyIdRef.current;
46
79
 
47
80
  for (let i = 0; i < ncon; i++) {
48
- try {
49
- const c = (data.contact as { get(i: number): { geom1: number; geom2: number; pos: Float64Array; dist: number } }).get(i);
50
- // Filter by body if specified
51
- if (filterBody >= 0) {
52
- const b1 = model.geom_bodyid[c.geom1];
53
- const b2 = model.geom_bodyid[c.geom2];
54
- if (b1 !== filterBody && b2 !== filterBody) continue;
55
- }
56
- contacts.push({
57
- geom1: c.geom1,
58
- geom1Name: getName(model, model.name_geomadr[c.geom1]),
59
- geom2: c.geom2,
60
- geom2Name: getName(model, model.name_geomadr[c.geom2]),
61
- pos: [c.pos[0], c.pos[1], c.pos[2]],
62
- depth: c.dist,
63
- });
64
- } catch {
65
- break;
81
+ const c = getContact(data, i);
82
+ if (!c) break;
83
+ // Filter by body if specified
84
+ if (filterBody >= 0) {
85
+ const b1 = model.geom_bodyid[c.geom1];
86
+ const b2 = model.geom_bodyid[c.geom2];
87
+ if (b1 !== filterBody && b2 !== filterBody) continue;
66
88
  }
89
+ contacts.push({
90
+ geom1: c.geom1,
91
+ geom1Name: getGeomNameCached(model, c.geom1),
92
+ geom2: c.geom2,
93
+ geom2Name: getGeomNameCached(model, c.geom2),
94
+ pos: [c.pos[0], c.pos[1], c.pos[2]],
95
+ depth: c.dist,
96
+ });
67
97
  }
68
98
  contactsRef.current = contacts;
69
99
  callbackRef.current?.(contacts);
@@ -27,6 +27,9 @@ export function useJointState(name: string): JointStateResult {
27
27
  const dofDimRef = useRef(1);
28
28
  const positionRef = useRef<number | Float64Array>(0);
29
29
  const velocityRef = useRef<number | Float64Array>(0);
30
+ // Preallocated typed arrays for multi-DOF joints
31
+ const posBufferRef = useRef<Float64Array | null>(null);
32
+ const velBufferRef = useRef<Float64Array | null>(null);
30
33
 
31
34
  useEffect(() => {
32
35
  const model = mjModelRef.current;
@@ -41,6 +44,15 @@ export function useJointState(name: string): JointStateResult {
41
44
  if (type === 0) { qposDimRef.current = 7; dofDimRef.current = 6; }
42
45
  else if (type === 1) { qposDimRef.current = 4; dofDimRef.current = 3; }
43
46
  else { qposDimRef.current = 1; dofDimRef.current = 1; }
47
+
48
+ // Preallocate buffers for multi-DOF joints
49
+ if (qposDimRef.current > 1) {
50
+ posBufferRef.current = new Float64Array(qposDimRef.current);
51
+ velBufferRef.current = new Float64Array(dofDimRef.current);
52
+ } else {
53
+ posBufferRef.current = null;
54
+ velBufferRef.current = null;
55
+ }
44
56
  return;
45
57
  }
46
58
  }
@@ -55,8 +67,12 @@ export function useJointState(name: string): JointStateResult {
55
67
  positionRef.current = data.qpos[qa];
56
68
  velocityRef.current = data.qvel[da];
57
69
  } else {
58
- positionRef.current = new Float64Array(data.qpos.subarray(qa, qa + qposDimRef.current));
59
- velocityRef.current = new Float64Array(data.qvel.subarray(da, da + dofDimRef.current));
70
+ const posBuf = posBufferRef.current!;
71
+ const velBuf = velBufferRef.current!;
72
+ posBuf.set(data.qpos.subarray(qa, qa + qposDimRef.current));
73
+ velBuf.set(data.qvel.subarray(da, da + dofDimRef.current));
74
+ positionRef.current = posBuf;
75
+ velocityRef.current = velBuf;
60
76
  }
61
77
  });
62
78
 
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * useSceneLights — hook form of SceneLights (spec 6.3)
6
+ *
7
+ * Auto-creates Three.js lights from MJCF <light> elements.
8
+ */
9
+
10
+ import { useEffect, useRef } from 'react';
11
+ import * as THREE from 'three';
12
+ import { useThree } from '@react-three/fiber';
13
+ import { useMujocoSim } from '../core/MujocoSimProvider';
14
+
15
+ export function useSceneLights(intensity = 1.0) {
16
+ const { mjModelRef, status } = useMujocoSim();
17
+ const { scene } = useThree();
18
+ const lightsRef = useRef<THREE.Light[]>([]);
19
+ const targetsRef = useRef<THREE.Object3D[]>([]);
20
+
21
+ useEffect(() => {
22
+ const model = mjModelRef.current;
23
+ if (!model || status !== 'ready') return;
24
+
25
+ // Clean up previous lights
26
+ for (const light of lightsRef.current) {
27
+ scene.remove(light);
28
+ light.dispose();
29
+ }
30
+ for (const t of targetsRef.current) scene.remove(t);
31
+ lightsRef.current = [];
32
+ targetsRef.current = [];
33
+
34
+ const nlight = model.nlight ?? 0;
35
+ if (nlight === 0) return;
36
+
37
+ for (let i = 0; i < nlight; i++) {
38
+ const active = model.light_active ? model.light_active[i] : 1;
39
+ if (!active) continue;
40
+
41
+ const lightType = model.light_type ? model.light_type[i] : 0;
42
+ const isDirectional = lightType === 0;
43
+ const castShadow = model.light_castshadow ? model.light_castshadow[i] !== 0 : false;
44
+
45
+ const mjIntensity = model.light_intensity ? model.light_intensity[i] : 1.0;
46
+ const finalIntensity = intensity * mjIntensity;
47
+
48
+ const dr = model.light_diffuse ? model.light_diffuse[3 * i] : 1;
49
+ const dg = model.light_diffuse ? model.light_diffuse[3 * i + 1] : 1;
50
+ const db = model.light_diffuse ? model.light_diffuse[3 * i + 2] : 1;
51
+ const color = new THREE.Color(dr, dg, db);
52
+
53
+ const px = model.light_pos[3 * i];
54
+ const py = model.light_pos[3 * i + 1];
55
+ const pz = model.light_pos[3 * i + 2];
56
+ const dx = model.light_dir[3 * i];
57
+ const dy = model.light_dir[3 * i + 1];
58
+ const dz = model.light_dir[3 * i + 2];
59
+
60
+ if (isDirectional) {
61
+ const light = new THREE.DirectionalLight(color, finalIntensity);
62
+ light.position.set(px, py, pz);
63
+ light.target.position.set(px + dx, py + dy, pz + dz);
64
+ light.castShadow = castShadow;
65
+ if (castShadow) {
66
+ light.shadow.mapSize.width = 1024;
67
+ light.shadow.mapSize.height = 1024;
68
+ light.shadow.camera.near = 0.1;
69
+ light.shadow.camera.far = 50;
70
+ const d = 5;
71
+ light.shadow.camera.left = -d;
72
+ light.shadow.camera.right = d;
73
+ light.shadow.camera.top = d;
74
+ light.shadow.camera.bottom = -d;
75
+ }
76
+ scene.add(light);
77
+ scene.add(light.target);
78
+ lightsRef.current.push(light);
79
+ targetsRef.current.push(light.target);
80
+ } else {
81
+ const cutoff = model.light_cutoff ? model.light_cutoff[i] : 45;
82
+ const exponent = model.light_exponent ? model.light_exponent[i] : 10;
83
+ const angle = (cutoff * Math.PI) / 180;
84
+ const light = new THREE.SpotLight(color, finalIntensity, 0, angle, exponent / 128);
85
+ light.position.set(px, py, pz);
86
+ light.target.position.set(px + dx, py + dy, pz + dz);
87
+ light.castShadow = castShadow;
88
+
89
+ if (model.light_attenuation) {
90
+ const att1 = model.light_attenuation[3 * i + 1];
91
+ const att2 = model.light_attenuation[3 * i + 2];
92
+ light.decay = att2 > 0 ? 2 : (att1 > 0 ? 1 : 0);
93
+ light.distance = att1 > 0 ? 1 / att1 : 0;
94
+ }
95
+
96
+ if (castShadow) {
97
+ light.shadow.mapSize.width = 512;
98
+ light.shadow.mapSize.height = 512;
99
+ }
100
+ scene.add(light);
101
+ scene.add(light.target);
102
+ lightsRef.current.push(light);
103
+ targetsRef.current.push(light.target);
104
+ }
105
+ }
106
+
107
+ return () => {
108
+ for (const light of lightsRef.current) {
109
+ scene.remove(light);
110
+ light.dispose();
111
+ }
112
+ for (const t of targetsRef.current) scene.remove(t);
113
+ lightsRef.current = [];
114
+ targetsRef.current = [];
115
+ };
116
+ }, [status, mjModelRef, scene, intensity]);
117
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * useSelectionHighlight — hook form of SelectionHighlight (spec 6.5)
6
+ *
7
+ * Applies emissive highlight to all meshes belonging to a body.
8
+ * Restores original emissive when bodyId changes or hook unmounts.
9
+ */
10
+
11
+ import { useEffect, useRef } from 'react';
12
+ import { useThree } from '@react-three/fiber';
13
+ import * as THREE from 'three';
14
+
15
+ export function useSelectionHighlight(
16
+ bodyId: number | null,
17
+ options: { color?: string; emissiveIntensity?: number } = {},
18
+ ) {
19
+ const { color = '#ff4444', emissiveIntensity = 0.3 } = options;
20
+ const { scene } = useThree();
21
+ const prevMeshesRef = useRef<{ mesh: THREE.Mesh; originalEmissive: THREE.Color; originalIntensity: number }[]>([]);
22
+
23
+ useEffect(() => {
24
+ // Restore previous highlights
25
+ for (const entry of prevMeshesRef.current) {
26
+ const mat = entry.mesh.material as THREE.MeshStandardMaterial;
27
+ if (mat.emissive) {
28
+ mat.emissive.copy(entry.originalEmissive);
29
+ mat.emissiveIntensity = entry.originalIntensity;
30
+ }
31
+ }
32
+ prevMeshesRef.current = [];
33
+
34
+ if (bodyId === null || bodyId < 0) return;
35
+
36
+ // Find all meshes belonging to this body
37
+ const highlightColor = new THREE.Color(color);
38
+ scene.traverse((obj) => {
39
+ if (obj.userData.bodyID === bodyId && (obj as THREE.Mesh).isMesh) {
40
+ const mesh = obj as THREE.Mesh;
41
+ const mat = mesh.material as THREE.MeshStandardMaterial;
42
+ if (mat.emissive) {
43
+ prevMeshesRef.current.push({
44
+ mesh,
45
+ originalEmissive: mat.emissive.clone(),
46
+ originalIntensity: mat.emissiveIntensity ?? 0,
47
+ });
48
+ mat.emissive.copy(highlightColor);
49
+ mat.emissiveIntensity = emissiveIntensity;
50
+ }
51
+ }
52
+ });
53
+
54
+ return () => {
55
+ for (const entry of prevMeshesRef.current) {
56
+ const mat = entry.mesh.material as THREE.MeshStandardMaterial;
57
+ if (mat.emissive) {
58
+ mat.emissive.copy(entry.originalEmissive);
59
+ mat.emissiveIntensity = entry.originalIntensity;
60
+ }
61
+ }
62
+ prevMeshesRef.current = [];
63
+ };
64
+ }, [bodyId, color, emissiveIntensity, scene]);
65
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  // Core
7
7
  export { MujocoProvider, useMujoco } from './core/MujocoProvider';
8
8
  export { MujocoCanvas } from './core/MujocoCanvas';
9
+ export { MujocoPhysics } from './core/MujocoPhysics';
10
+ export type { MujocoPhysicsProps } from './core/MujocoPhysics';
9
11
  export { MujocoSimProvider, useMujocoSim, useBeforePhysicsStep, useAfterPhysicsStep } from './core/MujocoSimProvider';
10
12
  export {
11
13
  loadScene,
@@ -20,6 +22,15 @@ export {
20
22
  findTendonByName,
21
23
  } from './core/SceneLoader';
22
24
 
25
+ // Controller factory
26
+ export { createController } from './core/createController';
27
+ export type { ControllerOptions, ControllerComponent } from './core/createController';
28
+
29
+ // IK controller plugin
30
+ export { IkController } from './components/IkController';
31
+ export { useIk } from './core/IkContext';
32
+ export type { IkContextValue } from './core/IkContext';
33
+
23
34
  // Components
24
35
  export { SceneRenderer } from './components/SceneRenderer';
25
36
  export { IkGizmo } from './components/IkGizmo';
@@ -49,6 +60,10 @@ export { useTrajectoryRecorder } from './hooks/useTrajectoryRecorder';
49
60
  export { useGamepad } from './hooks/useGamepad';
50
61
  export { useVideoRecorder } from './hooks/useVideoRecorder';
51
62
  export { useCtrlNoise } from './hooks/useCtrlNoise';
63
+ export { useSelectionHighlight } from './hooks/useSelectionHighlight';
64
+ export { useSceneLights } from './hooks/useSceneLights';
65
+ export { useCameraAnimation } from './hooks/useCameraAnimation';
66
+ export type { CameraAnimationAPI } from './hooks/useCameraAnimation';
52
67
 
53
68
  // Types
54
69
  export type {
@@ -59,6 +74,7 @@ export type {
59
74
  SceneMarker,
60
75
  PhysicsConfig,
61
76
  // IK
77
+ IkConfig,
62
78
  IKSolveFn,
63
79
  // Callbacks
64
80
  PhysicsStepCallback,
@@ -105,4 +121,5 @@ export type {
105
121
  } from './types';
106
122
 
107
123
  // Re-export MuJoCo types for convenience
108
- export type { MujocoModule, MujocoModel, MujocoData } from './types';
124
+ export type { MujocoModule, MujocoModel, MujocoData, MujocoContact, MujocoContactArray } from './types';
125
+ export { getContact } from './types';