diff --git a/webclient/src/components/ObjectEditorView.tsx b/webclient/src/components/ObjectEditorView.tsx index 8038a0d..8528020 100644 --- a/webclient/src/components/ObjectEditorView.tsx +++ b/webclient/src/components/ObjectEditorView.tsx @@ -1,11 +1,11 @@ import { observer } from "mobx-react-lite"; import { runInAction } from "mobx"; import type { ObjectInstance, R3, Runtime } from "../types"; -import { useMemo, useRef, type RefObject } from "react"; +import { useMemo, useRef, useEffect, useCallback, type RefObject } from "react"; import type { Group } from "three"; -import { Euler, Quaternion, Vector3 } from "three"; -import { TransformControls, useHelper } from "@react-three/drei"; -import { BoxHelper } from "three"; +import { Euler, Quaternion, Vector3, BoxHelper, type LineBasicMaterial } from "three"; +import { TransformControls } from "@react-three/drei"; +import { useThree, useFrame } from "@react-three/fiber"; import { state } from "../state"; import { nextObjectEditMode } from "../state/worldEditorState"; import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal"; @@ -39,13 +39,44 @@ const SelectionOverlay = observer(function ({ objectId, ref, onTransformChange, [ref], ); - useHelper(isSelected ? groupRef : { current: null }, BoxHelper, 'white'); + // BoxHelper — manually disposed because BoxHelper has no dispose() of its own + const scene = useThree(s => s.scene); + const helperRef = useRef(null); + useEffect(() => { + const target = groupRef.current; + if (!isSelected || !target) + return; + const helper = new BoxHelper(target, 'white'); + scene.add(helper); + helperRef.current = helper; + return () => { + scene.remove(helper); + helper.geometry.dispose(); + (helper.material as LineBasicMaterial).dispose(); + helperRef.current = null; + }; + }, [isSelected, groupRef, scene]); + useFrame(() => helperRef.current?.update()); + + // TransformControls — drei never calls dispose() on unmount, leaking all gizmo + // geometries. Callback ref ignores null so tcRef.current survives React's + // ref-clear-on-unmount; effect cleanup then calls dispose() with the live instance. + const tcRef = useRef<{ dispose(): void } | null>(null); + const setTcRef = useCallback((tc: { dispose(): void } | null) => { + if (tc !== null) + tcRef.current = tc; + }, []); + useEffect(() => () => { + tcRef.current?.dispose(); + tcRef.current = null; + }, [isSelected]); if (!isSelected || selectionMode === undefined || !groupRef.current) return null; return ( } mode={selectionMode} onChange={onTransformChange}