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.
- package/README.md +206 -42
- package/dist/index.d.ts +175 -95
- package/dist/index.js +1137 -771
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ContactMarkers.tsx +12 -19
- package/src/components/Debug.tsx +168 -33
- package/src/components/DragInteraction.tsx +1 -1
- package/src/components/IkController.tsx +262 -0
- package/src/components/IkGizmo.tsx +17 -25
- package/src/components/SceneLights.tsx +2 -112
- package/src/components/SceneRenderer.tsx +8 -6
- package/src/components/SelectionHighlight.tsx +2 -49
- package/src/components/TendonRenderer.tsx +90 -26
- package/src/components/TrajectoryPlayer.tsx +14 -10
- package/src/core/IkContext.tsx +40 -0
- package/src/core/MujocoProvider.tsx +12 -4
- package/src/core/MujocoSimProvider.tsx +55 -331
- package/src/core/SceneLoader.ts +44 -11
- package/src/core/createController.tsx +91 -0
- package/src/hooks/useCameraAnimation.ts +102 -0
- package/src/hooks/useContacts.ts +52 -22
- package/src/hooks/useJointState.ts +18 -2
- package/src/hooks/useSceneLights.ts +117 -0
- package/src/hooks/useSelectionHighlight.ts +65 -0
- package/src/index.ts +16 -1
- package/src/types.ts +59 -22
package/src/core/SceneLoader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
248
|
-
const
|
|
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
|
+
}
|
package/src/hooks/useContacts.ts
CHANGED
|
@@ -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
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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';
|