/ src / features / tutorial / utils / hit-detection.ts
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  }