/ src / features / tutorial / utils / projection.ts
projection.ts
  1  /**
  2   * Projection Utilities for Tutorial Elements
  3   *
  4   * These utilities help place 2D/3D elements (dots, text, etc.) so they appear
  5   * visually aligned with 3D objects from the camera's perspective.
  6   */
  7  
  8  /**
  9   * Project a 3D point onto a plane at a given Z depth along the camera ray.
 10   * Camera is assumed to be at origin (0,0,0), so the ray goes from origin through the point.
 11   *
 12   * Uses similar triangles: at targetZ, the X,Y coordinates scale proportionally.
 13   *
 14   * Example: A node at [10, 5, -100] projected to Z=-30 becomes [3, 1.5, -30]
 15   * because scale = -30/-100 = 0.3
 16   *
 17   * @param point - The 3D point to project [x, y, z]
 18   * @param targetZ - The Z plane to project onto (e.g., -30)
 19   * @returns The projected point [x', y', targetZ]
 20   */
 21  export function projectToZPlane(
 22    point: [number, number, number],
 23    targetZ: number
 24  ): [number, number, number] {
 25    const [x, y, z] = point;
 26  
 27    // Edge case: if point is at targetZ already, return as-is
 28    if (Math.abs(z - targetZ) < 0.001) {
 29      return [x, y, targetZ];
 30    }
 31  
 32    // Edge case: point at camera (z=0) - can't project
 33    if (Math.abs(z) < 0.001) {
 34      return [x, y, targetZ];
 35    }
 36  
 37    // Similar triangles: x'/x = y'/y = targetZ/z
 38    const scale = targetZ / z;
 39    return [x * scale, y * scale, targetZ];
 40  }
 41  
 42  /**
 43   * Project a 3D point onto a plane, with an optional offset from the projected position.
 44   * Useful for placing text labels near nodes but slightly offset.
 45   *
 46   * @param point - The 3D point to project [x, y, z]
 47   * @param targetZ - The Z plane to project onto
 48   * @param offset - Offset to add after projection [dx, dy, dz]
 49   * @returns The projected and offset point
 50   */
 51  export function projectToZPlaneWithOffset(
 52    point: [number, number, number],
 53    targetZ: number,
 54    offset: [number, number, number]
 55  ): [number, number, number] {
 56    const projected = projectToZPlane(point, targetZ);
 57    return [
 58      projected[0] + offset[0],
 59      projected[1] + offset[1],
 60      projected[2] + offset[2],
 61    ];
 62  }
 63  
 64  /**
 65   * Calculate the midpoint between two projected positions.
 66   * Useful for placing text labels between two nodes.
 67   *
 68   * @param pointA - First 3D point
 69   * @param pointB - Second 3D point
 70   * @param targetZ - The Z plane to project onto
 71   * @returns The midpoint of the two projected positions
 72   */
 73  export function projectMidpointToZPlane(
 74    pointA: [number, number, number],
 75    pointB: [number, number, number],
 76    targetZ: number
 77  ): [number, number, number] {
 78    const projA = projectToZPlane(pointA, targetZ);
 79    const projB = projectToZPlane(pointB, targetZ);
 80    return [
 81      (projA[0] + projB[0]) / 2,
 82      (projA[1] + projB[1]) / 2,
 83      targetZ,
 84    ];
 85  }
 86  
 87  // Hit sphere radius from DreamNode3D (sphereGeometry args={[12, 8, 8]})
 88  const HIT_SPHERE_RADIUS = 12;
 89  
 90  /**
 91   * Calculate edge positions for golden dot travel between two nodes.
 92   *
 93   * Instead of starting at node centers, the dot starts at the edge of the
 94   * start node's hit sphere (pointing toward the destination) and ends at
 95   * the edge of the end node's hit sphere (coming from the source).
 96   *
 97   * This ensures:
 98   * 1. The slow easing animation happens outside the node's visual footprint
 99   * 2. The dot appears to "launch from" and "land on" nodes naturally
100   * 3. Hit detection still works (positions are slightly inside the boundary)
101   *
102   * @param fromNodePos - 3D position of the starting node
103   * @param toNodePos - 3D position of the ending node
104   * @param insetFactor - How far inside the hit sphere to place the point (0-1, default 0.9 = just inside edge)
105   * @returns Object with from and to edge positions
106   */
107  export function calculateEdgePositions(
108    fromNodePos: [number, number, number],
109    toNodePos: [number, number, number],
110    insetFactor: number = 0.9
111  ): {
112    from: [number, number, number];
113    to: [number, number, number];
114  } {
115    // Calculate direction vector from start to end
116    const dx = toNodePos[0] - fromNodePos[0];
117    const dy = toNodePos[1] - fromNodePos[1];
118    const dz = toNodePos[2] - fromNodePos[2];
119  
120    // Normalize the direction
121    const length = Math.sqrt(dx * dx + dy * dy + dz * dz);
122    if (length < 0.001) {
123      // Nodes are at same position, return as-is
124      return { from: fromNodePos, to: toNodePos };
125    }
126  
127    const nx = dx / length;
128    const ny = dy / length;
129    const nz = dz / length;
130  
131    // Calculate edge offset (slightly inside the hit sphere boundary)
132    const edgeOffset = HIT_SPHERE_RADIUS * insetFactor;
133  
134    // Start position: from node center + direction * offset (toward destination)
135    const from: [number, number, number] = [
136      fromNodePos[0] + nx * edgeOffset,
137      fromNodePos[1] + ny * edgeOffset,
138      fromNodePos[2] + nz * edgeOffset,
139    ];
140  
141    // End position: to node center - direction * offset (coming from source)
142    const to: [number, number, number] = [
143      toNodePos[0] - nx * edgeOffset,
144      toNodePos[1] - ny * edgeOffset,
145      toNodePos[2] - nz * edgeOffset,
146    ];
147  
148    return { from, to };
149  }
150  
151  /**
152   * Combined utility: Calculate edge positions and project to Z plane.
153   *
154   * This is the main function to use for golden dot animations - it handles
155   * both the edge offset calculation AND the perspective projection in one call.
156   *
157   * @param fromNodePos - 3D position of the starting node
158   * @param toNodePos - 3D position of the ending node
159   * @param targetZ - The Z plane to project onto (e.g., -30)
160   * @param insetFactor - How far inside the hit sphere (default 0.9)
161   * @returns Object with projected from and to positions at node edges
162   */
163  export function calculateProjectedEdgePositions(
164    fromNodePos: [number, number, number],
165    toNodePos: [number, number, number],
166    targetZ: number,
167    insetFactor: number = 0.9
168  ): {
169    from: [number, number, number];
170    to: [number, number, number];
171  } {
172    // First calculate edge positions in 3D space
173    const edgePositions = calculateEdgePositions(fromNodePos, toNodePos, insetFactor);
174  
175    // Then project both to the target Z plane
176    return {
177      from: projectToZPlane(edgePositions.from, targetZ),
178      to: projectToZPlane(edgePositions.to, targetZ),
179    };
180  }
181  
182  /**
183   * Calculate position for text label next to a node.
184   *
185   * Projects the node position to the target Z plane and applies an offset
186   * to position the text beside (not on top of) the node.
187   *
188   * @param nodePos - 3D position of the node
189   * @param targetZ - The Z plane to project onto (e.g., -30)
190   * @param offsetDirection - Direction to offset: 'right', 'left', 'above', 'below'
191   * @param offsetAmount - Distance to offset from projected node center
192   * @returns Position for the text label
193   */
194  export function calculateTextPositionNextToNode(
195    nodePos: [number, number, number],
196    targetZ: number,
197    offsetDirection: 'right' | 'left' | 'above' | 'below' = 'right',
198    offsetAmount: number = 15
199  ): [number, number, number] {
200    // Project node position to target Z plane
201    const projected = projectToZPlane(nodePos, targetZ);
202  
203    // Apply offset based on direction
204    switch (offsetDirection) {
205      case 'right':
206        return [projected[0] + offsetAmount, projected[1], projected[2]];
207      case 'left':
208        return [projected[0] - offsetAmount, projected[1], projected[2]];
209      case 'above':
210        return [projected[0], projected[1] + offsetAmount, projected[2]];
211      case 'below':
212        return [projected[0], projected[1] - offsetAmount, projected[2]];
213    }
214  }