hit-detection.ts
1 /** 2 * Hit Detection Utilities for Tutorial Elements 3 * 4 * Uses raycasting to detect when a 3D position intersects with node hit spheres. 5 * This allows the golden dot to trigger hover effects when it visually overlaps nodes. 6 */ 7 8 import { Vector3, Raycaster, Mesh, Camera } from 'three'; 9 import { serviceManager } from '../../../core/services/service-manager'; 10 11 // Camera FOV constant (matching drop-handlers.ts) 12 const CAMERA_FOV_RAD = (75 * Math.PI) / 180; 13 14 /** 15 * Check if a 3D position visually intersects with a node's hit sphere. 16 * Projects the position to screen coordinates and raycasts from camera. 17 * 18 * @param position - The 3D position to check [x, y, z] 19 * @param nodeIds - Array of node IDs to check against 20 * @param camera - The Three.js camera for projection 21 * @returns The ID of the first intersected node, or null if no intersection 22 */ 23 export function checkHitSphereIntersection( 24 position: [number, number, number], 25 nodeIds: string[], 26 camera: Camera 27 ): string | null { 28 // Get hit sphere meshes for the specified nodes 29 const hitSpheres: Mesh[] = []; 30 const nodeIdByMesh = new Map<Mesh, string>(); 31 32 for (const nodeId of nodeIds) { 33 const hitSphere = serviceManager.getHitSphere(nodeId); 34 if (hitSphere) { 35 hitSpheres.push(hitSphere); 36 nodeIdByMesh.set(hitSphere, nodeId); 37 } 38 } 39 40 if (hitSpheres.length === 0) { 41 return null; 42 } 43 44 // Get canvas element for coordinate conversion 45 const canvasElement = globalThis.document.querySelector( 46 '.dreamspace-canvas-container canvas' 47 ) as globalThis.HTMLCanvasElement; 48 49 if (!canvasElement) { 50 return null; 51 } 52 53 const rect = canvasElement.getBoundingClientRect(); 54 55 // Project 3D position to screen coordinates 56 const pos3D = new Vector3(position[0], position[1], position[2]); 57 const projected = pos3D.clone().project(camera); 58 59 // Convert from NDC (-1 to 1) to screen coordinates 60 // Note: We don't actually need screen coords, we use NDC directly for raycasting 61 const ndcX = projected.x; 62 const ndcY = projected.y; 63 64 // Calculate ray direction with perspective projection (matching drop-handlers.ts) 65 const aspect = rect.width / rect.height; 66 const tanHalfFov = Math.tan(CAMERA_FOV_RAD / 2); 67 68 const rayDirection = new Vector3( 69 ndcX * tanHalfFov * aspect, 70 ndcY * tanHalfFov, 71 -1 72 ).normalize(); 73 74 // Create raycaster from camera origin along the ray direction 75 const raycaster = new Raycaster(); 76 raycaster.set(new Vector3(0, 0, 0), rayDirection); 77 78 // Check intersections with hit spheres 79 const intersections = raycaster.intersectObjects(hitSpheres); 80 81 if (intersections.length > 0) { 82 const hitMesh = intersections[0].object as Mesh; 83 return nodeIdByMesh.get(hitMesh) ?? null; 84 } 85 86 return null; 87 } 88 89 /** 90 * Hook-style interface for continuous hit detection during animation. 91 * Returns functions to start/stop tracking and get current intersection state. 92 */ 93 export interface HitDetectionState { 94 /** Currently intersected node ID, or null */ 95 intersectedNodeId: string | null; 96 /** Whether the dot is currently inside a hit sphere */ 97 isIntersecting: boolean; 98 } 99 100 /** 101 * Create a hit detection tracker for use in animation frames. 102 * Call update() each frame with the current position. 103 */ 104 export function createHitDetectionTracker(nodeIds: string[]) { 105 let lastIntersectedId: string | null = null; 106 107 return { 108 /** 109 * Update hit detection with current position. 110 * @returns Object with intersection state and change flags 111 */ 112 update( 113 position: [number, number, number], 114 camera: Camera 115 ): { 116 intersectedNodeId: string | null; 117 didEnter: string | null; // Node ID if just entered 118 didExit: string | null; // Node ID if just exited 119 } { 120 const currentIntersectedId = checkHitSphereIntersection(position, nodeIds, camera); 121 122 const didEnter = currentIntersectedId && currentIntersectedId !== lastIntersectedId 123 ? currentIntersectedId 124 : null; 125 const didExit = lastIntersectedId && lastIntersectedId !== currentIntersectedId 126 ? lastIntersectedId 127 : null; 128 129 lastIntersectedId = currentIntersectedId; 130 131 return { 132 intersectedNodeId: currentIntersectedId, 133 didEnter, 134 didExit, 135 }; 136 }, 137 138 /** 139 * Reset the tracker state 140 */ 141 reset() { 142 lastIntersectedId = null; 143 }, 144 145 /** 146 * Get current intersection state without updating 147 */ 148 getCurrentState(): string | null { 149 return lastIntersectedId; 150 } 151 }; 152 }