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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* IkController — composable IK controller plugin.
|
|
6
|
+
* Extracts all IK logic from MujocoSimProvider into an opt-in component.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
10
|
+
import { useFrame } from '@react-three/fiber';
|
|
11
|
+
import * as THREE from 'three';
|
|
12
|
+
import { createController } from '../core/createController';
|
|
13
|
+
import { IkContext, type IkContextValue } from '../core/IkContext';
|
|
14
|
+
import { useMujocoSim, useBeforePhysicsStep } from '../core/MujocoSimProvider';
|
|
15
|
+
import { GenericIK } from '../core/GenericIK';
|
|
16
|
+
import { findSiteByName } from '../core/SceneLoader';
|
|
17
|
+
import type { IkConfig, IKSolveFn, MujocoData } from '../types';
|
|
18
|
+
|
|
19
|
+
// Preallocated temp for syncGizmoToSite
|
|
20
|
+
const _syncMat4 = new THREE.Matrix4();
|
|
21
|
+
|
|
22
|
+
function syncGizmoToSite(data: MujocoData, siteId: number, target: THREE.Group) {
|
|
23
|
+
if (siteId === -1) return;
|
|
24
|
+
const sitePos = data.site_xpos.subarray(siteId * 3, siteId * 3 + 3);
|
|
25
|
+
const siteMat = data.site_xmat.subarray(siteId * 9, siteId * 9 + 9);
|
|
26
|
+
target.position.set(sitePos[0], sitePos[1], sitePos[2]);
|
|
27
|
+
_syncMat4.set(
|
|
28
|
+
siteMat[0], siteMat[1], siteMat[2], 0,
|
|
29
|
+
siteMat[3], siteMat[4], siteMat[5], 0,
|
|
30
|
+
siteMat[6], siteMat[7], siteMat[8], 0,
|
|
31
|
+
0, 0, 0, 1,
|
|
32
|
+
);
|
|
33
|
+
target.quaternion.setFromRotationMatrix(_syncMat4);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function IkControllerImpl({
|
|
37
|
+
config,
|
|
38
|
+
children,
|
|
39
|
+
}: {
|
|
40
|
+
config: IkConfig;
|
|
41
|
+
children?: React.ReactNode;
|
|
42
|
+
}) {
|
|
43
|
+
const { mjModelRef, mjDataRef, mujocoRef, configRef, resetCallbacks, status } =
|
|
44
|
+
useMujocoSim();
|
|
45
|
+
|
|
46
|
+
// All IK state lives here, NOT in the provider
|
|
47
|
+
const ikEnabledRef = useRef(false);
|
|
48
|
+
const ikCalculatingRef = useRef(false);
|
|
49
|
+
const ikTargetRef = useRef<THREE.Group>(new THREE.Group());
|
|
50
|
+
const siteIdRef = useRef(-1);
|
|
51
|
+
const genericIkRef = useRef<GenericIK>(new GenericIK(mujocoRef.current));
|
|
52
|
+
const firstIkEnableRef = useRef(true);
|
|
53
|
+
|
|
54
|
+
const needsInitialSync = useRef(true);
|
|
55
|
+
|
|
56
|
+
const gizmoAnimRef = useRef({
|
|
57
|
+
active: false,
|
|
58
|
+
startPos: new THREE.Vector3(),
|
|
59
|
+
endPos: new THREE.Vector3(),
|
|
60
|
+
startRot: new THREE.Quaternion(),
|
|
61
|
+
endRot: new THREE.Quaternion(),
|
|
62
|
+
startTime: 0,
|
|
63
|
+
duration: 1000,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Resolve site ID when model loads or config changes
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const model = mjModelRef.current;
|
|
69
|
+
if (!model || status !== 'ready') {
|
|
70
|
+
siteIdRef.current = -1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
siteIdRef.current = findSiteByName(model, config.siteName);
|
|
74
|
+
const data = mjDataRef.current;
|
|
75
|
+
if (data && ikTargetRef.current) {
|
|
76
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
77
|
+
}
|
|
78
|
+
}, [config.siteName, status, mjModelRef, mjDataRef]);
|
|
79
|
+
|
|
80
|
+
// IK solve function — use custom solver if provided, otherwise built-in GenericIK
|
|
81
|
+
const ikSolveFn = useCallback(
|
|
82
|
+
(pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null => {
|
|
83
|
+
if (config.ikSolveFn) return config.ikSolveFn(pos, quat, currentQ);
|
|
84
|
+
const model = mjModelRef.current;
|
|
85
|
+
const data = mjDataRef.current;
|
|
86
|
+
if (!model || !data || siteIdRef.current === -1) return null;
|
|
87
|
+
return genericIkRef.current.solve(
|
|
88
|
+
model,
|
|
89
|
+
data,
|
|
90
|
+
siteIdRef.current,
|
|
91
|
+
config.numJoints,
|
|
92
|
+
pos,
|
|
93
|
+
quat,
|
|
94
|
+
currentQ,
|
|
95
|
+
{
|
|
96
|
+
damping: config.damping,
|
|
97
|
+
maxIterations: config.maxIterations,
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
[config.ikSolveFn, config.numJoints, config.damping, config.maxIterations, mjModelRef, mjDataRef],
|
|
102
|
+
);
|
|
103
|
+
const ikSolveFnRef = useRef<IKSolveFn>(ikSolveFn);
|
|
104
|
+
ikSolveFnRef.current = ikSolveFn;
|
|
105
|
+
|
|
106
|
+
// Gizmo animation + one-time initial sync in useFrame
|
|
107
|
+
useFrame(() => {
|
|
108
|
+
// Ensure the gizmo is positioned at the site after the first physics step
|
|
109
|
+
if (needsInitialSync.current && siteIdRef.current !== -1) {
|
|
110
|
+
const data = mjDataRef.current;
|
|
111
|
+
if (data && ikTargetRef.current) {
|
|
112
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
113
|
+
needsInitialSync.current = false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ga = gizmoAnimRef.current;
|
|
118
|
+
const target = ikTargetRef.current;
|
|
119
|
+
if (!ga.active || !target) return;
|
|
120
|
+
|
|
121
|
+
const now = performance.now();
|
|
122
|
+
const elapsed = now - ga.startTime;
|
|
123
|
+
const t = Math.min(elapsed / ga.duration, 1.0);
|
|
124
|
+
const ease = 1 - Math.pow(1 - t, 3);
|
|
125
|
+
target.position.lerpVectors(ga.startPos, ga.endPos, ease);
|
|
126
|
+
target.quaternion.slerpQuaternions(ga.startRot, ga.endRot, ease);
|
|
127
|
+
if (t >= 1.0) ga.active = false;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// IK solve in physics loop
|
|
131
|
+
useBeforePhysicsStep((model, data) => {
|
|
132
|
+
if (!ikEnabledRef.current) {
|
|
133
|
+
ikCalculatingRef.current = false;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const target = ikTargetRef.current;
|
|
137
|
+
if (!target) return;
|
|
138
|
+
|
|
139
|
+
ikCalculatingRef.current = true;
|
|
140
|
+
const numJoints = config.numJoints;
|
|
141
|
+
const currentQ: number[] = [];
|
|
142
|
+
for (let i = 0; i < numJoints; i++) currentQ.push(data.qpos[i]);
|
|
143
|
+
const solution = ikSolveFnRef.current(target.position, target.quaternion, currentQ);
|
|
144
|
+
if (solution) {
|
|
145
|
+
for (let i = 0; i < numJoints; i++) data.ctrl[i] = solution[i];
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Reset callback — sync gizmo and reset IK state
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const cb = () => {
|
|
152
|
+
const data = mjDataRef.current;
|
|
153
|
+
if (data && ikTargetRef.current) {
|
|
154
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
155
|
+
}
|
|
156
|
+
gizmoAnimRef.current.active = false;
|
|
157
|
+
firstIkEnableRef.current = true;
|
|
158
|
+
ikEnabledRef.current = false;
|
|
159
|
+
needsInitialSync.current = true;
|
|
160
|
+
};
|
|
161
|
+
resetCallbacks.current.add(cb);
|
|
162
|
+
return () => {
|
|
163
|
+
resetCallbacks.current.delete(cb);
|
|
164
|
+
};
|
|
165
|
+
}, [resetCallbacks, mjDataRef]);
|
|
166
|
+
|
|
167
|
+
// --- API methods ---
|
|
168
|
+
|
|
169
|
+
const setIkEnabled = useCallback(
|
|
170
|
+
(enabled: boolean) => {
|
|
171
|
+
ikEnabledRef.current = enabled;
|
|
172
|
+
const data = mjDataRef.current;
|
|
173
|
+
if (enabled && data && !gizmoAnimRef.current.active && ikTargetRef.current) {
|
|
174
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
175
|
+
firstIkEnableRef.current = false;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
[mjDataRef],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const syncTargetToSiteApi = useCallback(() => {
|
|
182
|
+
const data = mjDataRef.current;
|
|
183
|
+
const target = ikTargetRef.current;
|
|
184
|
+
if (data && target) syncGizmoToSite(data, siteIdRef.current, target);
|
|
185
|
+
}, [mjDataRef]);
|
|
186
|
+
|
|
187
|
+
const solveIK = useCallback(
|
|
188
|
+
(pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null => {
|
|
189
|
+
return ikSolveFnRef.current(pos, quat, currentQ);
|
|
190
|
+
},
|
|
191
|
+
[],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const moveTarget = useCallback(
|
|
195
|
+
(pos: THREE.Vector3, duration = 0) => {
|
|
196
|
+
if (!ikEnabledRef.current) setIkEnabled(true);
|
|
197
|
+
const target = ikTargetRef.current;
|
|
198
|
+
if (!target) return;
|
|
199
|
+
|
|
200
|
+
const targetPos = pos.clone();
|
|
201
|
+
const targetRot = new THREE.Quaternion().setFromEuler(
|
|
202
|
+
new THREE.Euler(Math.PI, 0, 0),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (duration > 0) {
|
|
206
|
+
const ga = gizmoAnimRef.current;
|
|
207
|
+
ga.active = true;
|
|
208
|
+
ga.startPos.copy(target.position);
|
|
209
|
+
ga.endPos.copy(targetPos);
|
|
210
|
+
ga.startRot.copy(target.quaternion);
|
|
211
|
+
ga.endRot.copy(targetRot);
|
|
212
|
+
ga.startTime = performance.now();
|
|
213
|
+
ga.duration = duration;
|
|
214
|
+
} else {
|
|
215
|
+
gizmoAnimRef.current.active = false;
|
|
216
|
+
target.position.copy(targetPos);
|
|
217
|
+
target.quaternion.copy(targetRot);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
[setIkEnabled],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const getGizmoStats = useCallback(
|
|
224
|
+
(): { pos: THREE.Vector3; rot: THREE.Euler } | null => {
|
|
225
|
+
const target = ikTargetRef.current;
|
|
226
|
+
if (!ikCalculatingRef.current || !target) return null;
|
|
227
|
+
return {
|
|
228
|
+
pos: target.position.clone(),
|
|
229
|
+
rot: new THREE.Euler().setFromQuaternion(target.quaternion),
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
[],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const contextValue = useMemo<IkContextValue>(
|
|
236
|
+
() => ({
|
|
237
|
+
ikEnabledRef,
|
|
238
|
+
ikCalculatingRef,
|
|
239
|
+
ikTargetRef,
|
|
240
|
+
siteIdRef,
|
|
241
|
+
setIkEnabled,
|
|
242
|
+
moveTarget,
|
|
243
|
+
syncTargetToSite: syncTargetToSiteApi,
|
|
244
|
+
solveIK,
|
|
245
|
+
getGizmoStats,
|
|
246
|
+
}),
|
|
247
|
+
[setIkEnabled, moveTarget, syncTargetToSiteApi, solveIK, getGizmoStats],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return <IkContext.Provider value={contextValue}>{children}</IkContext.Provider>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const IkController = createController<IkConfig>(
|
|
254
|
+
{
|
|
255
|
+
name: 'IkController',
|
|
256
|
+
defaultConfig: {
|
|
257
|
+
damping: 0.01,
|
|
258
|
+
maxIterations: 50,
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
IkControllerImpl,
|
|
262
|
+
);
|
|
@@ -8,6 +8,7 @@ import { useFrame, useThree } from '@react-three/fiber';
|
|
|
8
8
|
import { useEffect, useRef } from 'react';
|
|
9
9
|
import * as THREE from 'three';
|
|
10
10
|
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
11
|
+
import { useIk } from '../core/IkContext';
|
|
11
12
|
import { findSiteByName } from '../core/SceneLoader';
|
|
12
13
|
import type { IkGizmoProps } from '../types';
|
|
13
14
|
|
|
@@ -20,25 +21,18 @@ const _scale = new THREE.Vector3(1, 1, 1);
|
|
|
20
21
|
/**
|
|
21
22
|
* IkGizmo — drei PivotControls that tracks a MuJoCo site.
|
|
22
23
|
*
|
|
24
|
+
* Must be rendered inside an `<IkController>`.
|
|
25
|
+
*
|
|
23
26
|
* Props:
|
|
24
|
-
* - `siteName` — MuJoCo site to track. Defaults to
|
|
27
|
+
* - `siteName` — MuJoCo site to track. Defaults to the IkController's configured site.
|
|
25
28
|
* - `scale` — Gizmo handle scale. Default: 0.18.
|
|
26
29
|
* - `onDrag` — Custom drag callback `(pos, quat) => void`.
|
|
27
|
-
* When omitted, dragging enables IK and writes to the
|
|
30
|
+
* When omitted, dragging enables IK and writes to the IK target.
|
|
28
31
|
* When provided, the consumer handles what happens during drag.
|
|
29
|
-
*
|
|
30
|
-
* Multiple gizmos can be rendered — each tracks its own site.
|
|
31
|
-
* Zero gizmos is fine — programmatic IK control works via the provider API.
|
|
32
|
-
*
|
|
33
|
-
* Uses a tiny invisible mesh as child instead of axesHelper — PivotControls
|
|
34
|
-
* computes an anchor offset from children's bounding box, and axesHelper's
|
|
35
|
-
* (0→0.15) bounds would shift the handles away from the TCP origin.
|
|
36
32
|
*/
|
|
37
33
|
export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
|
|
38
|
-
const {
|
|
39
|
-
|
|
40
|
-
api, ikEnabledRef, status,
|
|
41
|
-
} = useMujocoSim();
|
|
34
|
+
const { mjModelRef, mjDataRef, status } = useMujocoSim();
|
|
35
|
+
const { ikTargetRef, siteIdRef, ikEnabledRef, setIkEnabled } = useIk();
|
|
42
36
|
|
|
43
37
|
const wrapperRef = useRef<THREE.Group>(null);
|
|
44
38
|
const pivotRef = useRef<THREE.Group>(null);
|
|
@@ -46,25 +40,23 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
|
|
|
46
40
|
const localSiteIdRef = useRef(-1);
|
|
47
41
|
const { controls } = useThree();
|
|
48
42
|
|
|
49
|
-
// Resolve the site ID from siteName (
|
|
43
|
+
// Resolve the site ID from siteName (only when an explicit siteName override is given)
|
|
50
44
|
useEffect(() => {
|
|
51
45
|
const model = mjModelRef.current;
|
|
52
|
-
if (!model || status !== 'ready') {
|
|
46
|
+
if (!model || status !== 'ready' || !siteName) {
|
|
53
47
|
localSiteIdRef.current = -1;
|
|
54
48
|
return;
|
|
55
49
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
} else {
|
|
59
|
-
// Default: use the provider's siteIdRef (from SceneConfig.tcpSiteName)
|
|
60
|
-
localSiteIdRef.current = siteIdRef.current;
|
|
61
|
-
}
|
|
62
|
-
}, [siteName, status, mjModelRef, siteIdRef]);
|
|
50
|
+
localSiteIdRef.current = findSiteByName(model, siteName);
|
|
51
|
+
}, [siteName, status, mjModelRef]);
|
|
63
52
|
|
|
64
53
|
// Every frame: sync the visual wrapper to the tracked site (when not dragging)
|
|
65
54
|
useFrame(() => {
|
|
66
55
|
const data = mjDataRef.current;
|
|
67
|
-
|
|
56
|
+
// Read IkController's siteIdRef directly in useFrame — avoids useEffect timing
|
|
57
|
+
// issues (React runs child effects before parent effects, so reading siteIdRef
|
|
58
|
+
// in a useEffect would see -1 before IkController resolves it).
|
|
59
|
+
const sid = siteName ? localSiteIdRef.current : siteIdRef.current;
|
|
68
60
|
if (!data || sid < 0 || !wrapperRef.current) return;
|
|
69
61
|
|
|
70
62
|
if (!draggingRef.current) {
|
|
@@ -107,7 +99,7 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
|
|
|
107
99
|
draggingRef.current = true;
|
|
108
100
|
if (!onDrag) {
|
|
109
101
|
// Default: enable IK so the robot follows
|
|
110
|
-
if (!ikEnabledRef.current)
|
|
102
|
+
if (!ikEnabledRef.current) setIkEnabled(true);
|
|
111
103
|
}
|
|
112
104
|
if (controls) (controls as unknown as { enabled: boolean }).enabled = false;
|
|
113
105
|
}}
|
|
@@ -126,7 +118,7 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
|
|
|
126
118
|
// Custom: consumer handles the drag
|
|
127
119
|
onDrag(_pos.clone(), _quat.clone());
|
|
128
120
|
} else {
|
|
129
|
-
// Default: write to
|
|
121
|
+
// Default: write to IK target
|
|
130
122
|
const target = ikTargetRef.current;
|
|
131
123
|
if (target) {
|
|
132
124
|
target.position.copy(_pos);
|
|
@@ -12,120 +12,10 @@
|
|
|
12
12
|
* Note: light_directional does NOT exist in WASM — use light_type instead.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
import * as THREE from 'three';
|
|
17
|
-
import { useThree } from '@react-three/fiber';
|
|
18
|
-
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
15
|
+
import { useSceneLights } from '../hooks/useSceneLights';
|
|
19
16
|
import type { SceneLightsProps } from '../types';
|
|
20
17
|
|
|
21
18
|
export function SceneLights({ intensity = 1.0 }: SceneLightsProps) {
|
|
22
|
-
|
|
23
|
-
const { scene } = useThree();
|
|
24
|
-
const lightsRef = useRef<THREE.Light[]>([]);
|
|
25
|
-
const targetsRef = useRef<THREE.Object3D[]>([]);
|
|
26
|
-
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
const model = mjModelRef.current;
|
|
29
|
-
if (!model || status !== 'ready') return;
|
|
30
|
-
|
|
31
|
-
// Clean up previous lights
|
|
32
|
-
for (const light of lightsRef.current) {
|
|
33
|
-
scene.remove(light);
|
|
34
|
-
light.dispose();
|
|
35
|
-
}
|
|
36
|
-
for (const t of targetsRef.current) scene.remove(t);
|
|
37
|
-
lightsRef.current = [];
|
|
38
|
-
targetsRef.current = [];
|
|
39
|
-
|
|
40
|
-
const nlight = model.nlight ?? 0;
|
|
41
|
-
if (nlight === 0) return;
|
|
42
|
-
|
|
43
|
-
for (let i = 0; i < nlight; i++) {
|
|
44
|
-
// Check if light is active
|
|
45
|
-
const active = model.light_active ? model.light_active[i] : 1;
|
|
46
|
-
if (!active) continue;
|
|
47
|
-
|
|
48
|
-
// light_type: 0 = directional, 1 = spot (no light_directional in WASM)
|
|
49
|
-
const lightType = model.light_type ? model.light_type[i] : 0;
|
|
50
|
-
const isDirectional = lightType === 0;
|
|
51
|
-
const castShadow = model.light_castshadow ? model.light_castshadow[i] !== 0 : false;
|
|
52
|
-
|
|
53
|
-
// Read intensity from model if available, otherwise use prop
|
|
54
|
-
const mjIntensity = model.light_intensity ? model.light_intensity[i] : 1.0;
|
|
55
|
-
const finalIntensity = intensity * mjIntensity;
|
|
56
|
-
|
|
57
|
-
// Read diffuse color
|
|
58
|
-
const dr = model.light_diffuse ? model.light_diffuse[3 * i] : 1;
|
|
59
|
-
const dg = model.light_diffuse ? model.light_diffuse[3 * i + 1] : 1;
|
|
60
|
-
const db = model.light_diffuse ? model.light_diffuse[3 * i + 2] : 1;
|
|
61
|
-
const color = new THREE.Color(dr, dg, db);
|
|
62
|
-
|
|
63
|
-
// Read position and direction
|
|
64
|
-
const px = model.light_pos[3 * i];
|
|
65
|
-
const py = model.light_pos[3 * i + 1];
|
|
66
|
-
const pz = model.light_pos[3 * i + 2];
|
|
67
|
-
const dx = model.light_dir[3 * i];
|
|
68
|
-
const dy = model.light_dir[3 * i + 1];
|
|
69
|
-
const dz = model.light_dir[3 * i + 2];
|
|
70
|
-
|
|
71
|
-
if (isDirectional) {
|
|
72
|
-
const light = new THREE.DirectionalLight(color, finalIntensity);
|
|
73
|
-
light.position.set(px, py, pz);
|
|
74
|
-
light.target.position.set(px + dx, py + dy, pz + dz);
|
|
75
|
-
light.castShadow = castShadow;
|
|
76
|
-
if (castShadow) {
|
|
77
|
-
light.shadow.mapSize.width = 1024;
|
|
78
|
-
light.shadow.mapSize.height = 1024;
|
|
79
|
-
light.shadow.camera.near = 0.1;
|
|
80
|
-
light.shadow.camera.far = 50;
|
|
81
|
-
const d = 5;
|
|
82
|
-
light.shadow.camera.left = -d;
|
|
83
|
-
light.shadow.camera.right = d;
|
|
84
|
-
light.shadow.camera.top = d;
|
|
85
|
-
light.shadow.camera.bottom = -d;
|
|
86
|
-
}
|
|
87
|
-
scene.add(light);
|
|
88
|
-
scene.add(light.target);
|
|
89
|
-
lightsRef.current.push(light);
|
|
90
|
-
targetsRef.current.push(light.target);
|
|
91
|
-
} else {
|
|
92
|
-
// Spot light
|
|
93
|
-
const cutoff = model.light_cutoff ? model.light_cutoff[i] : 45;
|
|
94
|
-
const exponent = model.light_exponent ? model.light_exponent[i] : 10;
|
|
95
|
-
const angle = (cutoff * Math.PI) / 180;
|
|
96
|
-
const light = new THREE.SpotLight(color, finalIntensity, 0, angle, exponent / 128);
|
|
97
|
-
light.position.set(px, py, pz);
|
|
98
|
-
light.target.position.set(px + dx, py + dy, pz + dz);
|
|
99
|
-
light.castShadow = castShadow;
|
|
100
|
-
|
|
101
|
-
if (model.light_attenuation) {
|
|
102
|
-
const att1 = model.light_attenuation[3 * i + 1]; // linear
|
|
103
|
-
const att2 = model.light_attenuation[3 * i + 2]; // quadratic
|
|
104
|
-
light.decay = att2 > 0 ? 2 : (att1 > 0 ? 1 : 0);
|
|
105
|
-
light.distance = att1 > 0 ? 1 / att1 : 0;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (castShadow) {
|
|
109
|
-
light.shadow.mapSize.width = 512;
|
|
110
|
-
light.shadow.mapSize.height = 512;
|
|
111
|
-
}
|
|
112
|
-
scene.add(light);
|
|
113
|
-
scene.add(light.target);
|
|
114
|
-
lightsRef.current.push(light);
|
|
115
|
-
targetsRef.current.push(light.target);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return () => {
|
|
120
|
-
for (const light of lightsRef.current) {
|
|
121
|
-
scene.remove(light);
|
|
122
|
-
light.dispose();
|
|
123
|
-
}
|
|
124
|
-
for (const t of targetsRef.current) scene.remove(t);
|
|
125
|
-
lightsRef.current = [];
|
|
126
|
-
targetsRef.current = [];
|
|
127
|
-
};
|
|
128
|
-
}, [status, mjModelRef, scene, intensity]);
|
|
129
|
-
|
|
19
|
+
useSceneLights(intensity);
|
|
130
20
|
return null;
|
|
131
21
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { useFrame } from '@react-three/fiber';
|
|
7
|
+
import type { ThreeElements } from '@react-three/fiber';
|
|
7
8
|
import { useEffect, useMemo, useRef } from 'react';
|
|
8
9
|
import * as THREE from 'three';
|
|
9
10
|
import { GeomBuilder } from '../rendering/GeomBuilder';
|
|
@@ -13,21 +14,22 @@ import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* SceneRenderer — creates and syncs MuJoCo body meshes every frame.
|
|
16
|
-
*
|
|
17
|
+
* Accepts standard R3F group props (position, rotation, scale, visible, etc.).
|
|
17
18
|
*/
|
|
18
|
-
export function SceneRenderer() {
|
|
19
|
+
export function SceneRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
|
|
19
20
|
const { mjModelRef, mjDataRef, mujocoRef, onSelectionRef, status } = useMujocoSim();
|
|
20
21
|
const groupRef = useRef<THREE.Group>(null);
|
|
21
22
|
const bodyRefs = useRef<(THREE.Group | null)[]>([]);
|
|
22
23
|
const prevModelRef = useRef<MujocoModel | null>(null);
|
|
23
24
|
|
|
24
25
|
const geomBuilder = useMemo(() => {
|
|
26
|
+
if (status !== 'ready') return null;
|
|
25
27
|
return new GeomBuilder(mujocoRef.current);
|
|
26
|
-
}, [mujocoRef
|
|
28
|
+
}, [status, mujocoRef]);
|
|
27
29
|
|
|
28
30
|
// Build body groups when model loads
|
|
29
31
|
useEffect(() => {
|
|
30
|
-
if (status !== 'ready') return;
|
|
32
|
+
if (status !== 'ready' || !geomBuilder) return;
|
|
31
33
|
const model = mjModelRef.current;
|
|
32
34
|
const group = groupRef.current;
|
|
33
35
|
if (!model || !group) return;
|
|
@@ -84,18 +86,21 @@ export function SceneRenderer() {
|
|
|
84
86
|
|
|
85
87
|
return (
|
|
86
88
|
<group
|
|
89
|
+
{...props}
|
|
87
90
|
ref={groupRef}
|
|
88
91
|
onDoubleClick={(e) => {
|
|
92
|
+
if (typeof props.onDoubleClick === 'function') props.onDoubleClick(e);
|
|
89
93
|
e.stopPropagation();
|
|
90
94
|
let obj: THREE.Object3D | null = e.object;
|
|
91
95
|
while (obj && obj.userData.bodyID === undefined && obj.parent) {
|
|
92
96
|
obj = obj.parent;
|
|
93
97
|
}
|
|
94
|
-
|
|
98
|
+
const bodyID = obj?.userData.bodyID;
|
|
99
|
+
if (typeof bodyID === 'number' && bodyID > 0) {
|
|
95
100
|
const model = mjModelRef.current;
|
|
96
|
-
if (model && onSelectionRef.current) {
|
|
97
|
-
const name = getName(model, model.name_bodyadr[
|
|
98
|
-
onSelectionRef.current(
|
|
101
|
+
if (model && bodyID < model.nbody && onSelectionRef.current) {
|
|
102
|
+
const name = getName(model, model.name_bodyadr[bodyID]);
|
|
103
|
+
onSelectionRef.current(bodyID, name);
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
}}
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
* SelectionHighlight — highlight a selected body with emissive color (spec 6.5)
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { useThree } from '@react-three/fiber';
|
|
10
|
-
import * as THREE from 'three';
|
|
8
|
+
import { useSelectionHighlight } from '../hooks/useSelectionHighlight';
|
|
11
9
|
import type { SelectionHighlightProps } from '../types';
|
|
12
10
|
|
|
13
11
|
/**
|
|
@@ -19,51 +17,6 @@ export function SelectionHighlight({
|
|
|
19
17
|
color = '#ff4444',
|
|
20
18
|
emissiveIntensity = 0.3,
|
|
21
19
|
}: SelectionHighlightProps) {
|
|
22
|
-
|
|
23
|
-
const prevMeshesRef = useRef<{ mesh: THREE.Mesh; originalEmissive: THREE.Color; originalIntensity: number }[]>([]);
|
|
24
|
-
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
// Restore previous highlights
|
|
27
|
-
for (const entry of prevMeshesRef.current) {
|
|
28
|
-
const mat = entry.mesh.material as THREE.MeshStandardMaterial;
|
|
29
|
-
if (mat.emissive) {
|
|
30
|
-
mat.emissive.copy(entry.originalEmissive);
|
|
31
|
-
mat.emissiveIntensity = entry.originalIntensity;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
prevMeshesRef.current = [];
|
|
35
|
-
|
|
36
|
-
if (bodyId === null || bodyId < 0) return;
|
|
37
|
-
|
|
38
|
-
// Find all meshes belonging to this body
|
|
39
|
-
const highlightColor = new THREE.Color(color);
|
|
40
|
-
scene.traverse((obj) => {
|
|
41
|
-
if (obj.userData.bodyID === bodyId && (obj as THREE.Mesh).isMesh) {
|
|
42
|
-
const mesh = obj as THREE.Mesh;
|
|
43
|
-
const mat = mesh.material as THREE.MeshStandardMaterial;
|
|
44
|
-
if (mat.emissive) {
|
|
45
|
-
prevMeshesRef.current.push({
|
|
46
|
-
mesh,
|
|
47
|
-
originalEmissive: mat.emissive.clone(),
|
|
48
|
-
originalIntensity: mat.emissiveIntensity ?? 0,
|
|
49
|
-
});
|
|
50
|
-
mat.emissive.copy(highlightColor);
|
|
51
|
-
mat.emissiveIntensity = emissiveIntensity;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
return () => {
|
|
57
|
-
for (const entry of prevMeshesRef.current) {
|
|
58
|
-
const mat = entry.mesh.material as THREE.MeshStandardMaterial;
|
|
59
|
-
if (mat.emissive) {
|
|
60
|
-
mat.emissive.copy(entry.originalEmissive);
|
|
61
|
-
mat.emissiveIntensity = entry.originalIntensity;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
prevMeshesRef.current = [];
|
|
65
|
-
};
|
|
66
|
-
}, [bodyId, color, emissiveIntensity, scene]);
|
|
67
|
-
|
|
20
|
+
useSelectionHighlight(bodyId, { color, emissiveIntensity });
|
|
68
21
|
return null;
|
|
69
22
|
}
|