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.
@@ -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 `SceneConfig.tcpSiteName`.
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 provider's IK target.
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
- ikTargetRef, mjModelRef, mjDataRef, siteIdRef,
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 (or fall back to provider's tcpSiteName)
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
- if (siteName) {
57
- localSiteIdRef.current = findSiteByName(model, siteName);
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
- const sid = localSiteIdRef.current;
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) api.setIkEnabled(true);
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 provider's IK target
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 { useEffect, useRef } from 'react';
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
- const { mjModelRef, status } = useMujocoSim();
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
- * Replaces RenderSystem.initScene() + RenderSystem.update() body loop.
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.current]);
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
- if (obj && obj.userData.bodyID !== undefined && obj.userData.bodyID > 0) {
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[obj.userData.bodyID]);
98
- onSelectionRef.current(obj.userData.bodyID, name);
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 { useEffect, useRef } from 'react';
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
- const { scene } = useThree();
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
  }