├── .gitignore ├── CustomDragControls.tsx ├── DraggableRigidBody.tsx ├── README.md └── showcase.gif /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /CustomDragControls.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as THREE from 'three' 3 | import { useThree } from '@react-three/fiber' 4 | import { useGesture, DragConfig } from '@use-gesture/react' 5 | import { ForwardRefComponent } from '@react-three/drei/helpers/ts-utils' 6 | 7 | const initialModelPosition = new THREE.Vector3() 8 | const mousePosition2D = new THREE.Vector2() 9 | const mousePosition3D = new THREE.Vector3() 10 | const dragOffset = new THREE.Vector3() 11 | const dragPlaneNormal = new THREE.Vector3() 12 | const dragPlane = new THREE.Plane() 13 | 14 | type ControlsProto = { 15 | enabled: boolean 16 | } 17 | 18 | export type CustomDragControlsProps = { 19 | /** If autoTransform is true, automatically apply the local transform on drag, true */ 20 | autoTransform?: boolean 21 | /** The matrix to control */ 22 | matrix?: THREE.Matrix4 23 | /** Lock the drag to a specific axis */ 24 | axisLock?: 'x' | 'y' | 'z' 25 | /** Limits */ 26 | dragLimits?: [[number, number] | undefined, [number, number] | undefined, [number, number] | undefined] 27 | /** Hover event */ 28 | onHover?: (hovering: boolean) => void 29 | /** Drag start event */ 30 | onDragStart?: (origin: THREE.Vector3) => void 31 | /** Drag event */ 32 | onDrag?: ( 33 | localMatrix: THREE.Matrix4, 34 | deltaLocalMatrix: THREE.Matrix4, 35 | worldMatrix: THREE.Matrix4, 36 | deltaWorldMatrix: THREE.Matrix4 37 | ) => void /** Drag end event */ 38 | onDragEnd?: () => void 39 | children: React.ReactNode 40 | dragConfig?: DragConfig 41 | preventOverlap?: boolean /** If preventOverlap is true, other overlapping CustomDragControls will not be dragged*/ 42 | } 43 | 44 | export const CustomDragControls: ForwardRefComponent = React.forwardRef< 45 | THREE.Group, 46 | CustomDragControlsProps 47 | >( 48 | ( 49 | { 50 | autoTransform = true, 51 | matrix, 52 | axisLock, 53 | dragLimits, 54 | onHover, 55 | onDragStart, 56 | onDrag, 57 | onDragEnd, 58 | children, 59 | dragConfig, 60 | ...props 61 | }, 62 | fRef 63 | ) => { 64 | const defaultControls = useThree((state) => (state as any).controls) as ControlsProto | undefined 65 | const { camera, size, raycaster, invalidate } = useThree() 66 | const ref = React.useRef(null!) 67 | 68 | const bind = useGesture( 69 | { 70 | onHover: ({ hovering }) => onHover && onHover(hovering ?? false), 71 | onDragStart: ({ event }) => { 72 | if (defaultControls) defaultControls.enabled = false 73 | const { point } = event as any 74 | 75 | ref.current.matrix.decompose(initialModelPosition, new THREE.Quaternion(), new THREE.Vector3()) 76 | mousePosition3D.copy(point) 77 | dragOffset.copy(mousePosition3D).sub(initialModelPosition) 78 | 79 | onDragStart && onDragStart(initialModelPosition) 80 | invalidate() 81 | }, 82 | onDrag: ({ xy: [dragX, dragY], intentional, event }) => { 83 | if (!intentional) return 84 | 85 | if(props.preventOverlap === true){ 86 | event.stopPropagation(); 87 | } 88 | 89 | const normalizedMouseX = ((dragX - size.left) / size.width) * 2 - 1 90 | const normalizedMouseY = -((dragY - size.top) / size.height) * 2 + 1 91 | 92 | mousePosition2D.set(normalizedMouseX, normalizedMouseY) 93 | raycaster.setFromCamera(mousePosition2D, camera) 94 | 95 | if (!axisLock) { 96 | camera.getWorldDirection(dragPlaneNormal).negate() 97 | } else { 98 | switch (axisLock) { 99 | case 'x': 100 | dragPlaneNormal.set(1, 0, 0) 101 | break 102 | case 'y': 103 | dragPlaneNormal.set(0, 1, 0) 104 | break 105 | case 'z': 106 | dragPlaneNormal.set(0, 0, 1) 107 | break 108 | } 109 | } 110 | 111 | dragPlane.setFromNormalAndCoplanarPoint(dragPlaneNormal, mousePosition3D) 112 | raycaster.ray.intersectPlane(dragPlane, mousePosition3D) 113 | 114 | const previousLocalMatrix = ref.current.matrix.clone() 115 | const previousWorldMatrix = ref.current.matrixWorld.clone() 116 | 117 | const intendedNewPosition = new THREE.Vector3( 118 | mousePosition3D.x - dragOffset.x, 119 | mousePosition3D.y - dragOffset.y, 120 | mousePosition3D.z - dragOffset.z 121 | ) 122 | 123 | if (dragLimits) { 124 | intendedNewPosition.x = dragLimits[0] 125 | ? Math.max(Math.min(intendedNewPosition.x, dragLimits[0][1]), dragLimits[0][0]) 126 | : intendedNewPosition.x 127 | intendedNewPosition.y = dragLimits[1] 128 | ? Math.max(Math.min(intendedNewPosition.y, dragLimits[1][1]), dragLimits[1][0]) 129 | : intendedNewPosition.y 130 | intendedNewPosition.z = dragLimits[2] 131 | ? Math.max(Math.min(intendedNewPosition.z, dragLimits[2][1]), dragLimits[2][0]) 132 | : intendedNewPosition.z 133 | } 134 | 135 | if (autoTransform) { 136 | ref.current.matrix.setPosition(intendedNewPosition) 137 | 138 | const deltaLocalMatrix = ref.current.matrix.clone().multiply(previousLocalMatrix.invert()) 139 | const deltaWorldMatrix = ref.current.matrix.clone().multiply(previousWorldMatrix.invert()) 140 | 141 | onDrag && onDrag(ref.current.matrix, deltaLocalMatrix, ref.current.matrixWorld, deltaWorldMatrix) 142 | } else { 143 | const tempMatrix = new THREE.Matrix4().copy(ref.current.matrix) 144 | tempMatrix.setPosition(intendedNewPosition) 145 | 146 | const deltaLocalMatrix = tempMatrix.clone().multiply(previousLocalMatrix.invert()) 147 | const deltaWorldMatrix = tempMatrix.clone().multiply(previousWorldMatrix.invert()) 148 | 149 | onDrag && onDrag(tempMatrix, deltaLocalMatrix, ref.current.matrixWorld, deltaWorldMatrix) 150 | } 151 | invalidate() 152 | }, 153 | onDragEnd: () => { 154 | if (defaultControls) defaultControls.enabled = true 155 | 156 | onDragEnd && onDragEnd() 157 | invalidate() 158 | }, 159 | }, 160 | { 161 | drag: { 162 | filterTaps: true, 163 | threshold: 1, 164 | ...(typeof dragConfig === 'object' ? dragConfig : {}), 165 | }, 166 | } 167 | ) 168 | 169 | React.useImperativeHandle(fRef, () => ref.current, []) 170 | 171 | React.useLayoutEffect(() => { 172 | if (!matrix) return 173 | 174 | // If the matrix is a real matrix4 it means that the user wants to control the gizmo 175 | // In that case it should just be set, as a bare prop update would merely copy it 176 | ref.current.matrix = matrix 177 | }, [matrix]) 178 | 179 | return ( 180 | 181 | {children} 182 | 183 | ) 184 | } 185 | ) 186 | -------------------------------------------------------------------------------- /DraggableRigidBody.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { useThree, useFrame, ThreeElements, GroupProps } from '@react-three/fiber'; 3 | import { RapierRigidBody, RigidBody, RigidBodyProps, useSpringJoint } from '@react-three/rapier'; 4 | import React, { useState, useRef, ReactElement, useImperativeHandle, forwardRef } from 'react'; 5 | import { CustomDragControls, CustomDragControlsProps } from './CustomDragControls'; 6 | 7 | export const DEFAULT_SPRING_JOINT_CONFIG = { 8 | restLength: 0, 9 | stiffness: 500, 10 | damping: 0, 11 | collisionGroups: 2 12 | } 13 | 14 | export interface DraggableRigidBodyProps { 15 | groupProps?: GroupProps, /** set position coordinates here */ 16 | 17 | boundingBox?: [ 18 | [number, number] | undefined, 19 | [number, number] | undefined, 20 | [number, number] | undefined 21 | ] /** x, y and z min and max drag coordinates */ 22 | 23 | dragControlsProps?: Partial, 24 | rigidBodyProps?: RigidBodyProps, 25 | 26 | visibleMesh: ReactElement, 27 | invisibleMesh?: ReactElement, /** defaults to visibleMesh, invisibleMesh is used for the DragControls */ 28 | 29 | enableSpringJoint?: boolean, /** enables wobbly physics */ 30 | 31 | jointConfig?: { 32 | restLength?: number, 33 | stiffness?: number, 34 | damping?: number, 35 | springJointCollisionGroups?: number, 36 | } /** rapier SpringJoint props */ 37 | } 38 | 39 | // Interfaccia per il tipo di ref che vogliamo esporre 40 | interface DraggableRigidBodyRef { 41 | getInvisibleMesh: () => THREE.Mesh | null; 42 | getVisibleMesh: () => THREE.Mesh | null; 43 | } 44 | 45 | const DraggableRigidBody = forwardRef( 46 | (props, ref) => { 47 | 48 | const [isDragging, setIsDragging] = useState(false) 49 | const { scene } = useThree(); 50 | 51 | const rigidBodyRef = useRef(null); 52 | const jointRigidBodyRef = useRef(null); 53 | 54 | const meshRef = useRef(null); 55 | const invisibleDragControlsMeshRef = useRef(null); 56 | 57 | useImperativeHandle(ref, () => ({ 58 | getInvisibleMesh: () => invisibleDragControlsMeshRef.current, 59 | getVisibleMesh: () => meshRef.current, 60 | })); 61 | 62 | useSpringJoint( 63 | jointRigidBodyRef, 64 | rigidBodyRef, 65 | [ 66 | [0, 0, 0], 67 | [0, 0, 0], 68 | props.jointConfig?.restLength ?? DEFAULT_SPRING_JOINT_CONFIG.restLength, 69 | props.jointConfig?.stiffness ?? DEFAULT_SPRING_JOINT_CONFIG.stiffness, 70 | props.jointConfig?.damping ?? DEFAULT_SPRING_JOINT_CONFIG.damping, 71 | ] 72 | ); 73 | 74 | useFrame(() => { 75 | // removes unwanted joint movement when not dragged 76 | if ( 77 | jointRigidBodyRef.current && 78 | !jointRigidBodyRef.current.isSleeping() && 79 | !isDragging 80 | ) { 81 | jointRigidBodyRef.current.setLinvel({ x: 0, y: 0, z: 0 }, false) 82 | jointRigidBodyRef.current.setAngvel({ x: 0, y: 0, z: 0 }, false) 83 | } 84 | 85 | if ( 86 | !invisibleDragControlsMeshRef.current || !meshRef.current || 87 | isDragging || 88 | rigidBodyRef.current?.bodyType() === 2 || 89 | rigidBodyRef.current?.isSleeping() 90 | ) return; 91 | 92 | /** 93 | * ? this code syncs the invisible mesh to the visible one 94 | * ? when it's moving without user input (after user stops 95 | * ? dragging or RigidBody is moving) 96 | */ 97 | 98 | // updates position and rotation without influence from parent objects 99 | const pmV = meshRef.current?.parent; 100 | const pmI = invisibleDragControlsMeshRef.current?.parent; 101 | 102 | if (!pmV || !pmI) return; 103 | 104 | scene.attach(meshRef.current); 105 | scene.attach(invisibleDragControlsMeshRef.current); 106 | 107 | const pos = meshRef.current.position; 108 | invisibleDragControlsMeshRef.current.position.set(pos.x, pos.y, pos.z); 109 | invisibleDragControlsMeshRef.current.setRotationFromEuler(meshRef.current.rotation); 110 | 111 | pmV.attach(meshRef.current); 112 | pmI.attach(invisibleDragControlsMeshRef.current); 113 | }) 114 | 115 | const getBoxedPosition = (position: THREE.Vector3) => { 116 | if (!props.boundingBox) return position; 117 | 118 | const box = props.boundingBox; 119 | 120 | if (box[0]) { 121 | position.setX(Math.min(Math.max(box[0][0], position.x), box[0][1])); 122 | } 123 | 124 | if (box[1]) { 125 | position.setY(Math.min(Math.max(box[1][0], position.y), box[1][1])); 126 | } 127 | 128 | if (box[2]) { 129 | position.setZ(Math.min(Math.max(box[2][0], position.z), box[2][1])); 130 | } 131 | 132 | return position; 133 | } 134 | 135 | const startDragging = () => { 136 | 137 | setIsDragging(true) 138 | 139 | if (jointRigidBodyRef.current) { 140 | jointRigidBodyRef.current.setBodyType(2, true); 141 | jointRigidBodyRef.current.wakeUp() 142 | return; 143 | } 144 | 145 | if (!rigidBodyRef.current) return; 146 | rigidBodyRef.current.setBodyType(2, true); 147 | rigidBodyRef.current.wakeUp() 148 | } 149 | 150 | const onDrag = () => { 151 | if (!isDragging || !rigidBodyRef.current || !invisibleDragControlsMeshRef.current) return; 152 | 153 | // skip update if RigidBody type is not updated 154 | if (!props.enableSpringJoint && rigidBodyRef.current.bodyType() !== 2) return; 155 | if (props.enableSpringJoint && jointRigidBodyRef.current && jointRigidBodyRef.current.bodyType() !== 2) return; 156 | 157 | // update position 158 | const position = new THREE.Vector3() 159 | invisibleDragControlsMeshRef.current.getWorldPosition(position) 160 | 161 | if (jointRigidBodyRef.current) { 162 | jointRigidBodyRef.current.setNextKinematicTranslation(position) 163 | return 164 | } 165 | 166 | rigidBodyRef.current.setNextKinematicTranslation(getBoxedPosition(position)) 167 | } 168 | 169 | const stopDragging = () => { 170 | if (jointRigidBodyRef.current) { 171 | jointRigidBodyRef.current.setBodyType(0, true); 172 | setIsDragging(false) 173 | return; 174 | } 175 | 176 | if (!rigidBodyRef.current) return; 177 | rigidBodyRef.current.setBodyType(0, true); 178 | setIsDragging(false) 179 | } 180 | 181 | return ( 182 | 183 | 184 | { 185 | props.enableSpringJoint && 186 | ( 187 | // we use 2 colliders with a joint for the "wobbly effect", this RigidBody is on another collisionGroups 188 | 190 | 191 | 192 | 193 | 194 | 195 | ) 196 | } 197 | 198 | {/* handle mouse movements */} 199 | 205 | {React.cloneElement(props.invisibleMesh ?? props.visibleMesh, { ref: invisibleDragControlsMeshRef, key: 'invisible', visible: false })} 206 | 207 | 208 | {/* handle physics */} 209 | 215 | {React.cloneElement(props.visibleMesh, { ref: meshRef, key: 'visible' })} 216 | 217 | 218 | 219 | ) 220 | } 221 | ); 222 | 223 | export default DraggableRigidBody; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **Draggable RigidBody Component** 2 | 3 | A flexible drag-and-drop component for **[drei](https://github.com/pmndrs/drei)** + **[react-three-rapier](https://github.com/pmndrs/react-three-rapier)** 4 | 5 | ![demo](./showcase.gif) 6 | 7 | ## **Features** 8 | 9 | - **Drag-and-Drop Physics:** Implements realistic, physics-based dragging behavior using `react-three-rapier` physics engine and `DragControls` from `drei` 10 | - **Customizable Bounding Box:** Restrict object movement within defined boundaries. 11 | - **Wobbly effect** Optional spring joints provide "wobbly" effects for objects during and after drag events. 12 | - **Invisible Mesh for Control:** Uses a hidden mesh to improve the precision and fluidity of dragging. 13 | - **Flexible Configuration:** Easily configurable parameters for drag limits, joint stiffness, damping, and more. 14 | 15 | --- 16 | 17 | ## **Installation** 18 | 19 | Simply download and import `DraggableRigidBody.tsx` and `CustomDragControls.tsx` into your project 20 | 21 | ## **Important** 22 | 23 | The component utilizes a modified version of [DragControls](https://drei.docs.pmnd.rs/gizmos/drag-controls#dragcontrols) that fixes overlaps (check [this issue](https://github.com/pmndrs/drei/issues/2097) and [this PR](https://github.com/pmndrs/drei/pull/2098) for additional info) 24 | 25 | ```tsx 26 | const DraggableRigidBodyProps: Partial = { 27 | dragControlsProps: { 28 | preventOverlap: true, 29 | }, 30 | }; 31 | ``` 32 | 33 | ## **Usage** 34 | 35 | Here's an example of how you can use the `DraggableRigidBody` component in your React Three Fiber scene: 36 | 37 | ```tsx 38 | function MyScene() { 39 | const DraggableRigidBodyProps: Partial = { 40 | rigidBodyProps: { 41 | gravityScale: 3.5, 42 | linearDamping: 5, 43 | angularDamping: 0.2, 44 | }, 45 | boundingBox: [ 46 | [-8, 8], 47 | [0.5, 8], 48 | [-8, 8], 49 | ], 50 | dragControlsProps: { 51 | preventOverlap: true, 52 | }, 53 | }; 54 | 55 | return ( 56 | 57 | 58 | 62 | 63 | 64 | 65 | } 66 | /> 67 | 68 | 69 | ); 70 | } 71 | ``` 72 | 73 | ### **Props** 74 | 75 | | Prop Name | Type | Description | 76 | | ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | 77 | | `visibleMesh` | `ReactElement` | The mesh visible in the scene. | 78 | | `groupProps` | `GroupProps` | Set position, rotation. | 79 | | `boundingBox` | `[[number, number], ...]` | Define min/max boundaries for dragging on the X, Y, and Z axes. | 80 | | `dragControlsProps` | `Partial` | Customize drag control behavior, check [Drei docs](https://drei.docs.pmnd.rs/gizmos/drag-controls#dragcontrols) + `preventOverlap` set to true to avoid overlaps. | 81 | | `rigidBodyProps` | `RigidBodyProps` | Pass properties to the RigidBody component (e.g., mass, friction). | 82 | | `invisibleMesh` | `ReactElement` | A mesh used for precise drag control but hidden in the scene. Defaults to `visibleMesh`. | 83 | | `enableSpringJoint` | `boolean` | Enable a spring joint for elastic drag behavior. | 84 | | `jointConfig` | `{ restLength, stiffness, damping, springJointCollisionGroups }` | Configure spring joint properties (rest length, stiffness, and damping). | 85 | 86 | 87 | 88 | 89 | 90 | # WARNING 91 | This is a work in progress! Take it as an example for your projects :) -------------------------------------------------------------------------------- /showcase.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niccolofanton/DraggableRigidBody/d01459d6b110fe2ac9050a9706659da6469cce69/showcase.gif --------------------------------------------------------------------------------