Spaces:
Sleeping
Sleeping
| import { Component, createRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react' | |
| import { ThreeEvent, useFrame, useThree } from '@react-three/fiber' | |
| import { RigidBody, type RapierRigidBody } from '@react-three/rapier' | |
| import * as THREE from 'three' | |
| import type { WorldObjectAsset, WorldObjectPhysics, WorldObjectPlacement } from '../../types/world' | |
| import { useDebugStore } from '../../store/debug' | |
| import { SCENE_OBJECT_INSTANCE_ID_KEY, SceneObject, type SceneObjectHandle } from './SceneObject' | |
| import { useObjectGrab } from './useObjectGrab' | |
| import { cameraFocusTarget, pendingFocusId } from '../camera/cameraFocus' | |
| import { getInitialPlacements } from './placements' | |
| const _focusPoint = new THREE.Vector3() | |
| const _hoverObjectCenter = new THREE.Vector3() | |
| const _projectedHoverObjectCenter = new THREE.Vector3() | |
| type ObjectRefMap = Map<string, RefObject<SceneObjectHandle | null>> | |
| interface RenderedObject { | |
| instanceId: string | |
| asset: WorldObjectAsset | |
| position: [number, number, number] | |
| rotation: [number, number, number] | |
| scale: [number, number, number] | |
| physics: WorldObjectPhysics | |
| } | |
| interface Props { | |
| objects: WorldObjectAsset[] | |
| placements?: WorldObjectPlacement[] | |
| } | |
| interface ObjectLoadErrorBoundaryProps { | |
| objectName: string | |
| resetKey: string | |
| children: ReactNode | |
| } | |
| interface ObjectLoadErrorBoundaryState { | |
| hasError: boolean | |
| } | |
| class ObjectLoadErrorBoundary extends Component<ObjectLoadErrorBoundaryProps, ObjectLoadErrorBoundaryState> { | |
| state: ObjectLoadErrorBoundaryState = { hasError: false } | |
| static getDerivedStateFromError(): ObjectLoadErrorBoundaryState { | |
| return { hasError: true } | |
| } | |
| componentDidCatch(error: unknown) { | |
| console.warn(`Skipping object "${this.props.objectName}" because it failed to load.`, error) | |
| } | |
| componentDidUpdate(prevProps: ObjectLoadErrorBoundaryProps) { | |
| if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { | |
| this.setState({ hasError: false }) | |
| } | |
| } | |
| render() { | |
| if (this.state.hasError) return null | |
| return this.props.children | |
| } | |
| } | |
| function resolveRenderedObjects( | |
| objects: WorldObjectAsset[], | |
| placements?: WorldObjectPlacement[], | |
| ): RenderedObject[] { | |
| const assetsById = new Map<string, WorldObjectAsset>() | |
| for (const object of objects) { | |
| assetsById.set(object.id, object) | |
| assetsById.set(object.assetId, object) | |
| assetsById.set(object.baseObjectId, object) | |
| assetsById.set(`${object.sourceWorldSlug}/${object.baseObjectId}`, object) | |
| } | |
| return getInitialPlacements(objects, placements).flatMap((placement) => { | |
| const asset = assetsById.get(placement.assetId ?? placement.objectId) ?? assetsById.get(placement.objectId) | |
| if (!asset) return [] | |
| return [{ | |
| instanceId: placement.instanceId, | |
| asset, | |
| position: placement.position, | |
| rotation: placement.rotation, | |
| scale: placement.scale, | |
| physics: placement.physics ?? 'rigidbody', | |
| }] | |
| }) | |
| } | |
| function objectIdFromIntersectionObject(object: THREE.Object3D) { | |
| let current: THREE.Object3D | null = object | |
| while (current) { | |
| const objectId = current.userData[SCENE_OBJECT_INSTANCE_ID_KEY] | |
| if (typeof objectId === 'string') return objectId | |
| current = current.parent | |
| } | |
| return null | |
| } | |
| function nearestGrabbableObjectId( | |
| event: ThreeEvent<PointerEvent>, | |
| fallbackObjectId: string, | |
| camera: THREE.Camera, | |
| objectRefs: ObjectRefMap, | |
| grabbableObjectIds: Set<string>, | |
| ) { | |
| const seenObjectIds = new Set<string>() | |
| let best: { objectId: string; centerDistanceSq: number; hitDistance: number } | null = null | |
| for (const intersection of event.intersections) { | |
| const objectId = objectIdFromIntersectionObject(intersection.object) | |
| if (!objectId || seenObjectIds.has(objectId) || !grabbableObjectIds.has(objectId)) continue | |
| seenObjectIds.add(objectId) | |
| const handle = objectRefs.get(objectId)?.current | |
| if (!handle) continue | |
| handle.getFocusPoint(_hoverObjectCenter) | |
| _projectedHoverObjectCenter.copy(_hoverObjectCenter).project(camera) | |
| const centerDistanceSq = | |
| (_projectedHoverObjectCenter.x - event.pointer.x) ** 2 + | |
| (_projectedHoverObjectCenter.y - event.pointer.y) ** 2 | |
| const hitDistance = Number.isFinite(intersection.distance) ? intersection.distance : Number.POSITIVE_INFINITY | |
| if ( | |
| !best || | |
| centerDistanceSq < best.centerDistanceSq || | |
| (centerDistanceSq === best.centerDistanceSq && hitDistance < best.hitDistance) | |
| ) { | |
| best = { objectId, centerDistanceSq, hitDistance } | |
| } | |
| } | |
| if (best) return best.objectId | |
| return grabbableObjectIds.has(fallbackObjectId) ? fallbackObjectId : null | |
| } | |
| export function ObjectGrid({ objects, placements }: Props) { | |
| const { camera, gl } = useThree() | |
| const [hoveredObjectId, setHoveredObjectId] = useState<string | null>(null) | |
| const renderedObjects = useMemo(() => resolveRenderedObjects(objects, placements), [objects, placements]) | |
| const objectRenderMode = useDebugStore((s) => s.objectRenderMode) | |
| const objectResetToken = useDebugStore((s) => s.objectResetToken) | |
| const objectRefs = useRef(new Map<string, RefObject<SceneObjectHandle | null>>()) | |
| const anchorRef = useRef<RapierRigidBody>(null) | |
| const anchorSphereRef = useRef<THREE.Mesh>(null) | |
| const grabbableObjectIds = useMemo( | |
| () => new Set(renderedObjects.filter((object) => object.physics === 'rigidbody').map((object) => object.instanceId)), | |
| [renderedObjects], | |
| ) | |
| const isObjectEligible = useCallback((objectId: string) => grabbableObjectIds.has(objectId), [grabbableObjectIds]) | |
| const { activeObjectId, onPointerDown, resetObjects, activeGrabRef, cancelGrab } = useObjectGrab({ | |
| anchorRef, | |
| objectRefs, | |
| isObjectEligible, | |
| }) | |
| useLayoutEffect(() => { | |
| const objectIds = new Set([ | |
| ...renderedObjects.map((object) => object.instanceId), | |
| ]) | |
| if (activeGrabRef.current && !objectIds.has(activeGrabRef.current.objectId)) { | |
| cancelGrab() | |
| } | |
| for (const id of objectRefs.current.keys()) { | |
| if (!objectIds.has(id)) objectRefs.current.delete(id) | |
| } | |
| }, [activeGrabRef, cancelGrab, renderedObjects]) | |
| useEffect(() => { | |
| if (objectResetToken > 0) { | |
| resetObjects() | |
| } | |
| }, [objectResetToken, resetObjects]) | |
| const getObjectRef = (objectId: string) => { | |
| let objectRef = objectRefs.current.get(objectId) | |
| if (!objectRef) { | |
| objectRef = createRef<SceneObjectHandle>() | |
| objectRefs.current.set(objectId, objectRef) | |
| } | |
| return objectRef | |
| } | |
| const handleHover = useCallback((event: ThreeEvent<PointerEvent>, objectId: string, hovering: boolean) => { | |
| const nearestObjectId = hovering | |
| ? nearestGrabbableObjectId(event, objectId, camera, objectRefs.current, grabbableObjectIds) | |
| : null | |
| setHoveredObjectId((current) => { | |
| if (hovering) return nearestObjectId | |
| return current ? null : current | |
| }) | |
| }, [camera, grabbableObjectIds]) | |
| useEffect(() => { | |
| gl.domElement.style.cursor = activeObjectId ? 'move' : hoveredObjectId ? 'grab' : '' | |
| return () => { | |
| gl.domElement.style.cursor = '' | |
| } | |
| }, [activeObjectId, gl.domElement, hoveredObjectId, renderedObjects]) | |
| useFrame(() => { | |
| const id = pendingFocusId.current | |
| if (id) { | |
| pendingFocusId.current = null | |
| const point = objectRefs.current.get(id)?.current?.getFocusPoint(_focusPoint) | |
| if (point) { | |
| cameraFocusTarget.current = point.clone() | |
| } | |
| } | |
| const sphere = anchorSphereRef.current | |
| if (sphere) { | |
| const grab = activeGrabRef.current | |
| if (grab) { | |
| sphere.position.copy(grab.target) | |
| sphere.visible = true | |
| } else { | |
| sphere.visible = false | |
| } | |
| } | |
| }) | |
| if (!renderedObjects.length) return null | |
| return ( | |
| <> | |
| <RigidBody ref={anchorRef} type="kinematicPosition" colliders={false} position={[0, -1000, 0]} /> | |
| <mesh ref={anchorSphereRef} visible={false} renderOrder={10000}> | |
| <sphereGeometry args={[0.025, 10, 10]} /> | |
| <meshBasicMaterial color={0xffffff} depthTest={false} depthWrite={false} toneMapped={false} transparent /> | |
| </mesh> | |
| {renderedObjects.map((object) => ( | |
| <ObjectLoadErrorBoundary key={`${object.instanceId}:${object.asset.assetId}:${object.asset.url}`} objectName={object.asset.name} resetKey={object.asset.url}> | |
| <SceneObject | |
| ref={getObjectRef(object.instanceId)} | |
| key={`${object.instanceId}:${object.asset.assetId}:${object.position.join(',')}:${object.rotation.join(',')}:${object.scale.join(',')}:${object.physics}`} | |
| object={{ ...object.asset, id: object.instanceId }} | |
| position={object.position} | |
| rotation={object.rotation} | |
| scale={object.scale} | |
| physics={object.physics} | |
| renderMode={objectRenderMode} | |
| onHover={handleHover} | |
| onPointerDown={(event) => onPointerDown(object.instanceId, event)} | |
| /> | |
| </ObjectLoadErrorBoundary> | |
| ))} | |
| </> | |
| ) | |
| } | |