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> 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 { 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() 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, fallbackObjectId: string, camera: THREE.Camera, objectRefs: ObjectRefMap, grabbableObjectIds: Set, ) { const seenObjectIds = new Set() 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(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>()) const anchorRef = useRef(null) const anchorSphereRef = useRef(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() objectRefs.current.set(objectId, objectRef) } return objectRef } const handleHover = useCallback((event: ThreeEvent, 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 ( <> {renderedObjects.map((object) => ( onPointerDown(object.instanceId, event)} /> ))} ) }