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/README.md +287 -48
- package/dist/index.d.ts +215 -135
- package/dist/index.js +1176 -795
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/src/components/ContactMarkers.tsx +19 -22
- package/src/components/Debug.tsx +173 -36
- package/src/components/DragInteraction.tsx +5 -3
- package/src/components/FlexRenderer.tsx +3 -2
- 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 +13 -8
- package/src/components/SelectionHighlight.tsx +2 -49
- package/src/components/TendonRenderer.tsx +93 -28
- package/src/components/TrajectoryPlayer.tsx +14 -10
- package/src/core/IkContext.tsx +40 -0
- package/src/core/MujocoCanvas.tsx +1 -5
- package/src/core/MujocoPhysics.tsx +79 -0
- package/src/core/MujocoProvider.tsx +12 -4
- package/src/core/MujocoSimProvider.tsx +56 -340
- package/src/core/SceneLoader.ts +45 -18
- 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 +18 -1
- package/src/types.ts +53 -26
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
|
*/
|
|
@@ -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
|
-
|
|
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.
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
|
248
|
-
const
|
|
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
|
+
}
|
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
|
@@ -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';
|