mujoco-react 0.2.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.
@@ -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
  */
@@ -174,7 +194,13 @@ export async function loadScene(
174
194
  for (const patch of config.xmlPatches ?? []) {
175
195
  if (fname.endsWith(patch.target) || fname === patch.target) {
176
196
  if (patch.replace) {
177
- text = text.replace(patch.replace[0], patch.replace[1]);
197
+ const [from, to] = patch.replace;
198
+ if (text.includes(from)) {
199
+ text = text.replace(from, to);
200
+ } else {
201
+ const preview = from.length > 80 ? `${from.slice(0, 80)}...` : from;
202
+ console.warn(`XML patch replace pattern not found in ${fname}: "${preview}"`);
203
+ }
178
204
  }
179
205
  if (patch.inject && patch.injectAfter) {
180
206
  const idx = text.indexOf(patch.injectAfter);
@@ -183,7 +209,14 @@ export async function loadScene(
183
209
  const tagEnd = text.indexOf('>', idx + patch.injectAfter.length);
184
210
  if (tagEnd !== -1) {
185
211
  text = text.slice(0, tagEnd + 1) + patch.inject + text.slice(tagEnd + 1);
212
+ } else {
213
+ console.warn(`XML patch inject failed in ${fname}: could not find tag end after "${patch.injectAfter}"`);
186
214
  }
215
+ } else {
216
+ const preview = patch.injectAfter.length > 80
217
+ ? `${patch.injectAfter.slice(0, 80)}...`
218
+ : patch.injectAfter;
219
+ console.warn(`XML patch inject anchor not found in ${fname}: "${preview}"`);
187
220
  }
188
221
  }
189
222
  }
@@ -212,16 +245,15 @@ export async function loadScene(
212
245
  const siteId = findSiteByName(mjModel, config.tcpSiteName ?? 'tcp');
213
246
  const gripperId = findActuatorByName(mjModel, config.gripperActuatorName ?? 'gripper');
214
247
 
215
- // 7. Set initial pose
248
+ // 7. Set initial pose — set both ctrl and qpos so robot starts at home.
249
+ // If homeJoints is not provided, keep raw MuJoCo defaults.
216
250
  if (config.homeJoints) {
217
- for (let i = 0; i < config.homeJoints.length; i++) {
251
+ const homeCount = Math.min(config.homeJoints.length, mjModel.nu);
252
+ for (let i = 0; i < homeCount; i++) {
218
253
  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
- }
254
+ const qposAdr = getActuatedScalarQposAdr(mjModel, i);
255
+ if (qposAdr !== -1) {
256
+ mjData.qpos[qposAdr] = config.homeJoints[i];
225
257
  }
226
258
  }
227
259
  }
@@ -244,8 +276,9 @@ function scanDependencies(
244
276
  const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
245
277
 
246
278
  const compiler = xmlDoc.querySelector('compiler');
247
- const meshDir = compiler?.getAttribute('meshdir') || '';
248
- const textureDir = compiler?.getAttribute('texturedir') || '';
279
+ const assetDir = compiler?.getAttribute('assetdir') || '';
280
+ const meshDir = compiler?.getAttribute('meshdir') || assetDir;
281
+ const textureDir = compiler?.getAttribute('texturedir') || assetDir;
249
282
  const currentDir = currentFile.includes('/')
250
283
  ? currentFile.substring(0, currentFile.lastIndexOf('/') + 1)
251
284
  : '';
@@ -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
@@ -20,6 +20,15 @@ export {
20
20
  findTendonByName,
21
21
  } from './core/SceneLoader';
22
22
 
23
+ // Controller factory
24
+ export { createController } from './core/createController';
25
+ export type { ControllerOptions, ControllerComponent } from './core/createController';
26
+
27
+ // IK controller plugin
28
+ export { IkController } from './components/IkController';
29
+ export { useIk } from './core/IkContext';
30
+ export type { IkContextValue } from './core/IkContext';
31
+
23
32
  // Components
24
33
  export { SceneRenderer } from './components/SceneRenderer';
25
34
  export { IkGizmo } from './components/IkGizmo';
@@ -49,6 +58,10 @@ export { useTrajectoryRecorder } from './hooks/useTrajectoryRecorder';
49
58
  export { useGamepad } from './hooks/useGamepad';
50
59
  export { useVideoRecorder } from './hooks/useVideoRecorder';
51
60
  export { useCtrlNoise } from './hooks/useCtrlNoise';
61
+ export { useSelectionHighlight } from './hooks/useSelectionHighlight';
62
+ export { useSceneLights } from './hooks/useSceneLights';
63
+ export { useCameraAnimation } from './hooks/useCameraAnimation';
64
+ export type { CameraAnimationAPI } from './hooks/useCameraAnimation';
52
65
 
53
66
  // Types
54
67
  export type {
@@ -59,6 +72,7 @@ export type {
59
72
  SceneMarker,
60
73
  PhysicsConfig,
61
74
  // IK
75
+ IkConfig,
62
76
  IKSolveFn,
63
77
  // Callbacks
64
78
  PhysicsStepCallback,
@@ -105,4 +119,5 @@ export type {
105
119
  } from './types';
106
120
 
107
121
  // Re-export MuJoCo types for convenience
108
- export type { MujocoModule, MujocoModel, MujocoData } from './types';
122
+ export type { MujocoModule, MujocoModel, MujocoData, MujocoContact, MujocoContactArray } from './types';
123
+ export { getContact } from './types';