diff --git a/.storybook/stories/Raycaster.stories.tsx b/.storybook/stories/Raycaster.stories.tsx new file mode 100644 index 000000000..b0b0a938a --- /dev/null +++ b/.storybook/stories/Raycaster.stories.tsx @@ -0,0 +1,85 @@ +import * as THREE from 'three' +import * as React from 'react' + +import { Vector3 } from 'three' +import { Meta, StoryObj } from '@storybook/react' + +import { Setup } from '../Setup' + +import { Raycaster } from '../../src' +import { ComponentProps, useRef } from 'react' +import { useFrame } from '@react-three/fiber' + +export default { + title: 'Abstractions/Raycaster', + component: Raycaster, + decorators: [ + (Story) => ( + + + + ), + ], + argTypes: { + near: { control: { type: 'range', min: 0, max: 15 } }, + far: { control: { type: 'range', min: 0, max: 15 } }, + helper: { control: { type: 'boolean' } }, + }, +} satisfies Meta + +type Story = StoryObj + +function RaycasterScene(props: React.ComponentProps) { + const raycasterRef = useRef(null) + + React.useEffect(() => { + console.log('raycasterRef', raycasterRef) + }) + + return ( + <> + + + + + + + + + ) +} + +export const RaycasterSt = { + render: (args) => , + args: { + origin: [-4, 0, 0], + direction: [1, 0, 0], + near: 1, + far: 8, + helper: true, + }, + + name: 'Default', +} satisfies Story + +const Capsule = ({ + // layers, + ...props +}: ComponentProps<'mesh'>) => { + const meshRef = useRef(null) + + useFrame(({ clock }) => { + if (!meshRef.current) return + meshRef.current.position.y = Math.sin(clock.getElapsedTime() * 0.5 + meshRef.current.position.x) + meshRef.current.rotation.z = Math.sin(clock.getElapsedTime() * 0.5) * Math.PI * 1 + }) + + return ( + + {/* */} + + + + + ) +} diff --git a/package.json b/package.json index aa7360d9e..968925b73 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@babel/runtime": "^7.26.0", + "@gsimone/three-raycaster-helper": "^0.1.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@react-spring/three": "~9.7.5", diff --git a/src/core/Helper.tsx b/src/core/Helper.tsx index 599cb42a3..3e67abbdd 100644 --- a/src/core/Helper.tsx +++ b/src/core/Helper.tsx @@ -1,24 +1,40 @@ +/* eslint react-hooks/exhaustive-deps: 1 */ import * as React from 'react' import { Object3D } from 'three' import { useThree, useFrame } from '@react-three/fiber' import { Falsey } from 'utility-types' -type HelperType = Object3D & { update: () => void; dispose: () => void } -type HelperConstructor = new (...args: any[]) => any -type HelperArgs = T extends [infer _, ...infer R] ? R : never +type HelperConstructor = new (...args: any[]) => Object3D & { update: () => void; dispose?: () => void } +type HelperArgs = T extends [any, ...infer R] ? R : never -export function useHelper( - object3D: React.MutableRefObject | Falsey, - helperConstructor: T, - ...args: HelperArgs> +/** + * Instantiate a `THREE.*Helper` for an existing node and add it to the scene. + * + * Examples: + * + * ```ts + * useHelper(sphereRef, BoxHelper, 'royalblue') + * useHelper(sphereRef, VertexNormalsHelper, 1, 0xff0000) + + * useHelper(raycasterRef, RaycasterHelper, 20) + * ``` + */ +export function useHelper( + /** A ref to the node the helper is instantiate on (type inferred from H's ctor 1st param) */ + nodeRef: React.RefObject[0]> | Falsey, + /** `*Helper` class */ + helperConstructor: H, + /** Rest of arguments for H (types inferred from H's ctor params, omitting first) */ + ...args: HelperArgs> ) { - const helper = React.useRef() + const helperRef = React.useRef>(null!) const scene = useThree((state) => state.scene) + React.useLayoutEffect(() => { - let currentHelper: HelperType = undefined! + let currentHelper: InstanceType = undefined! - if (object3D && object3D?.current && helperConstructor) { - helper.current = currentHelper = new (helperConstructor as any)(object3D.current, ...args) + if (nodeRef && nodeRef?.current && helperConstructor) { + helperRef.current = currentHelper = new helperConstructor(nodeRef.current, ...args) as InstanceType } if (currentHelper) { @@ -26,36 +42,43 @@ export function useHelper( currentHelper.traverse((child) => (child.raycast = () => null)) scene.add(currentHelper) return () => { - helper.current = undefined + helperRef.current = null! scene.remove(currentHelper) currentHelper.dispose?.() } } - }, [scene, helperConstructor, object3D, ...args]) + }, [scene, helperConstructor, nodeRef, args]) - useFrame(() => void helper.current?.update?.()) - return helper + useFrame(() => void helperRef.current?.update?.()) + return helperRef } // -export type HelperProps = { - type: T - args?: HelperArgs> +export type HelperProps = { + /** `*Helper` class */ + type: H + /** Rest of arguments for H (types inferred from H's ctor params, omitting first) */ + args?: HelperArgs> } -export const Helper = ({ +/** + * Instantiate a `THREE.*Helper` for parent node and add it to the scene. + */ + +export const Helper = ({ type: helperConstructor, args = [] as never, -}: HelperProps) => { - const thisRef = React.useRef(null!) +}: HelperProps) => { const parentRef = React.useRef(null!) - React.useLayoutEffect(() => { - parentRef.current = thisRef.current.parent! - }) - useHelper(parentRef, helperConstructor, ...args) - return + return ( + { + parentRef.current = obj?.parent! + }} + /> + ) } diff --git a/src/core/Raycaster.tsx b/src/core/Raycaster.tsx new file mode 100644 index 000000000..a419cc1a2 --- /dev/null +++ b/src/core/Raycaster.tsx @@ -0,0 +1,45 @@ +import * as THREE from 'three' +import * as React from 'react' +import { ComponentProps, forwardRef, useRef, useState } from 'react' +import { useFrame, type Vector3 } from '@react-three/fiber' +// import { RaycasterHelper } from '@gsimone/three-raycaster-helper' +import { RaycasterHelper } from '../tmp/raycaster-helper' + +import { useHelper } from '..' + +type RaycasterProps = Omit, 'args'> & { + origin: Vector3 + direction: Vector3 +} & { + helper?: boolean +} + +function toThreeVec3(v: Vector3) { + return v instanceof THREE.Vector3 ? v : new THREE.Vector3(...(typeof v === 'number' ? [v, v, v] : v)) +} + +export const Raycaster = forwardRef( + ({ origin: _origin, direction: _direction, near, far, helper, ...props }, fref) => { + const origin = toThreeVec3(_origin) + const direction = toThreeVec3(_direction) + + const [r] = useState(() => new THREE.Raycaster(origin, direction)) + + const raycasterRef = useRef(null) + const ref = fref || raycasterRef + const isCallbackRef = typeof ref === 'function' + + const raycasterHelperRef = useHelper(helper && !isCallbackRef && ref, RaycasterHelper) + + // Update the hits with intersection results + useFrame(({ scene }) => { + if (!helper) return + if (!ref || isCallbackRef) return + + if (!raycasterHelperRef.current || !ref.current) return + raycasterHelperRef.current.hits = ref.current.intersectObjects(scene.children) + }) + + return + } +) diff --git a/src/core/index.ts b/src/core/index.ts index 1842b9299..1b98f7057 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -24,6 +24,7 @@ export * from './Svg' export * from './Gltf' export * from './AsciiRenderer' export * from './Splat' +export * from './Raycaster' // Cameras export * from './OrthographicCamera' diff --git a/src/tmp/raycaster-helper.ts b/src/tmp/raycaster-helper.ts new file mode 100644 index 000000000..4efee8ca7 --- /dev/null +++ b/src/tmp/raycaster-helper.ts @@ -0,0 +1,174 @@ +import { + BufferAttribute, + BufferGeometry, + Float32BufferAttribute, + InstancedMesh, + Intersection, + Line, + LineBasicMaterial, + Mesh, + MeshBasicMaterial, + Object3D, + Raycaster, + SphereGeometry, + Vector3, +} from 'three' + +const _o = new Object3D() +const _v = new Vector3() + +class RaycasterHelper extends Object3D { + raycaster: Raycaster + hits: Intersection[] + + origin: Mesh + near: Line + far: Line + + nearToFar: Line + originToNear: Line + + hitPoints: InstancedMesh + + colors = { + near: 0xffffff, + far: 0xffffff, + originToNear: 0x333333, + nearToFar: 0xffffff, + origin: [0x0eec82, 0xff005b], + } + + constructor( + raycaster: Raycaster, + public numberOfHitsToVisualize = 20 + ) { + super() + this.raycaster = raycaster + + this.hits = [] + + this.origin = new Mesh(new SphereGeometry(0.04, 32), new MeshBasicMaterial()) + this.origin.name = 'RaycasterHelper_origin' + this.origin.raycast = () => null + + const size = 0.1 + let geometry = new BufferGeometry() + // prettier-ignore + geometry.setAttribute( 'position', new Float32BufferAttribute( [ + - size, size, 0, + size, size, 0, + size, - size, 0, + - size, - size, 0, + - size, size, 0 + ], 3 ) ); + + this.near = new Line(geometry, new LineBasicMaterial()) + this.near.name = 'RaycasterHelper_near' + this.near.raycast = () => null + + this.far = new Line(geometry, new LineBasicMaterial()) + this.far.name = 'RaycasterHelper_far' + this.far.raycast = () => null + + this.nearToFar = new Line(new BufferGeometry(), new LineBasicMaterial()) + this.nearToFar.name = 'RaycasterHelper_nearToFar' + this.nearToFar.raycast = () => null + + this.nearToFar.geometry.setFromPoints([_v, _v]) + + this.originToNear = new Line(this.nearToFar.geometry.clone(), new LineBasicMaterial()) + this.originToNear.name = 'RaycasterHelper_originToNear' + this.originToNear.raycast = () => null + + this.hitPoints = new InstancedMesh(new SphereGeometry(0.04), new MeshBasicMaterial(), this.numberOfHitsToVisualize) + this.hitPoints.name = 'RaycasterHelper_hits' + this.hitPoints.raycast = () => null + + this.add(this.nearToFar) + this.add(this.originToNear) + + this.add(this.near) + this.add(this.far) + + this.add(this.origin) + this.add(this.hitPoints) + + this.setColors() + } + + setColors = (colors?: Partial) => { + const _colors = { + ...this.colors, + ...colors, + } + + this.near.material.color.set(_colors.near) + this.far.material.color.set(_colors.far) + this.nearToFar.material.color.set(_colors.nearToFar) + this.originToNear.material.color.set(_colors.originToNear) + } + + update = () => { + const origin = this.raycaster.ray.origin + const direction = this.raycaster.ray.direction + + this.origin.position.copy(origin) + + this.near.position.copy(origin).add(direction.clone().multiplyScalar(this.raycaster.near)) + + this.far.position.copy(origin).add(direction.clone().multiplyScalar(this.raycaster.far)) + + this.far.lookAt(origin) + this.near.lookAt(origin) + + let pos = this.nearToFar.geometry.getAttribute('position') as BufferAttribute + pos.set([...this.near.position.toArray(), ...this.far.position.toArray()]) + pos.needsUpdate = true + + pos = this.originToNear.geometry.getAttribute('position') as BufferAttribute + pos.set([...origin.toArray(), ...this.near.position.toArray()]) + pos.needsUpdate = true + + /** + * Update hit points visualization + */ + for (let i = 0; i < this.numberOfHitsToVisualize; i++) { + const hit = this.hits?.[i] + + if (hit) { + const { point } = hit + _o.position.copy(point) + _o.scale.setScalar(1) + } else { + _o.scale.setScalar(0) + } + + _o.updateMatrix() + + this.hitPoints.setMatrixAt(i, _o.matrix) + } + + this.hitPoints.instanceMatrix.needsUpdate = true + + /** + * Update the color of the origin based on wether there are hits. + */ + this.origin.material.color.set(this.hits.length > 0 ? this.colors.origin[0] : this.colors.origin[1]) + } + + dispose = () => { + this.origin.geometry.dispose() + this.origin.material.dispose() + this.near.geometry.dispose() + this.near.material.dispose() + this.far.geometry.dispose() + this.far.material.dispose() + this.nearToFar.geometry.dispose() + this.nearToFar.material.dispose() + this.originToNear.geometry.dispose() + this.originToNear.material.dispose() + this.hitPoints.dispose() + } +} + +export { RaycasterHelper } diff --git a/yarn.lock b/yarn.lock index a2d08b545..f88214bda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2133,6 +2133,15 @@ __metadata: languageName: node linkType: hard +"@gsimone/three-raycaster-helper@npm:^0.1.0": + version: 0.1.0 + resolution: "@gsimone/three-raycaster-helper@npm:0.1.0" + peerDependencies: + three: ^0.139.2 + checksum: 10c0/adefc5b44a449d0ef1540e46454b81b6ff6c267dfb1ad187a1f64da723d6dabc77158958cdd1cb0a63295ecfd9c2e5a4d08577b6821653963e334ef8e4d54b90 + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -2837,6 +2846,7 @@ __metadata: "@eslint/compat": "npm:^1.2.3" "@eslint/eslintrc": "npm:^3.2.0" "@eslint/js": "npm:^9.15.0" + "@gsimone/three-raycaster-helper": "npm:^0.1.0" "@mediapipe/tasks-vision": "npm:0.10.17" "@monogrid/gainmap-js": "npm:^3.0.6" "@playwright/test": "npm:^1.45.2"