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"