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 }