1// ui/raycaster.js â hover tooltip + click-to-select with multi-selection support 2// 3// HOVER â small tooltip near cursor ("name (dist)") 4// CLICK â creates/removes a selection (ring + infobox + SVG connector) 5// locked to the clicked 3D object; tracked every frame 6// Empty click â clears all selections 7// 8// Exports (consumed by main.js): 9// raycaster â THREE.Raycaster instance (used by other modules) 10// pixelToWorldSize() â helper for hit threshold 11// updateSelectionHighlight() â called every frame in onTick 12 13import { THREE, scene, camera, controls} from '../modules/sim.js'; 14import { 15 showTooltip, hideTooltip, 16 addSelection, clearAllSelections, 17 updateSelectionsEachFrame, 18} from './infobox.js'; 19import { isRulerActive, handleRulerClick } from './ruler.js'; 20import { createPointsMaterial } from '../modules/materialFactory.js'; 21import { routeHover as _dmRouteHover, get as _dmGet } from '../modules/datasetManager.js'; 22import { setHoverColor as _setHoverColor } from '../modules/overlayEngine.js'; 23import { query as _shQuery } from '../modules/spatialHash.js'; 24import { getActivePlanetMeshes } from '../modules/exoplanets.js'; 25import { getVisibleLabelSprites } from '../modules/labelBus.js'; 26 27// âââ Raycaster ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 28export const raycaster = new THREE.Raycaster(); 29const _mouse = new THREE.Vector2(); 30 31export function pixelToWorldSize(pixelWidth, distAU) { 32 const cam = camera; 33 const vFOV = cam.fov * (Math.PI / 180); 34 return pixelWidth * (2 * Math.tan(vFOV / 2) * distAU) / window.innerHeight; 35} 36 37// âââ Hover highlight dot (placed at 3D hit point) ââââââââââââââââââââââââââââ 38const _hlGeo = new THREE.BufferGeometry(); 39_hlGeo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(3), 3)); 40const _hlPts = new THREE.Points( 41 _hlGeo, 42 createPointsMaterial({ 43 color: 0xa0a0b4, size: 10, sizeAttenuation: false, 44 depthTest: false, transparent: true, opacity: 0.85, overlay: true, 45 }) 46); 47_hlPts.renderOrder = 50; 48_hlPts.frustumCulled = false; 49_hlPts.visible = false; 50scene.add(_hlPts); 51 52// âââ NaN-safe bounding sphere install ââââââââââââââââââââââââââââââââââââââââ 53// Lazily installs a per-geometry computeBoundingSphere override the first time 54// the raycaster encounters a _isPointCloud geometry. The override always returns 55// a huge fixed sphere (1e9 AU), which: 56// 1. Prevents console.error spam from NaN positions (no positions are read) 57// 2. Eliminates the O(N) position scan Three.js would otherwise do each frame 58// 3. Ensures the sphere check in Points.raycast() always passes â per-point 59// testing runs (accurate); at stellar scale the camera is already inside any 60// reasonable bounding sphere so there is no performance regression 61// The flag `_safeBS` is set on the geometry so the override is installed once. 62function _installSafeBoundingSphere(geo) { 63 if (geo._safeBS) return; 64 geo.computeBoundingSphere = function () { 65 if (!this.boundingSphere) this.boundingSphere = new THREE.Sphere(); 66 this.boundingSphere.set(new THREE.Vector3(0, 0, 0), 1e9); 67 }; 68 geo._safeBS = true; 69} 70 71// âââ Hit testing ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 72function _findHit(clientX, clientY) { 73 _mouse.x = (clientX / window.innerWidth) * 2 - 1; 74 _mouse.y = -(clientY / window.innerHeight) * 2 + 1; 75 76 const cam = camera; 77 // Use cameraâOrbitControls.target distance so threshold stays accurate 78 // when zoomed close to a planet (camâSun â« camâplanet surface). 79 const distAU = controls?.target 80 ? cam.position.distanceTo(controls.target) 81 : cam.position.length(); 82 raycaster.params.Points.threshold = pixelToWorldSize(15, distAU); 83 raycaster.setFromCamera(_mouse, cam); 84 // THREE.Sprite.raycast() requires raycaster.camera (not set by setFromCamera). 85 // Without it, sprite.raycast() logs console.error then throws TypeError on 86 // `null.matrixWorld`, killing the hover RAF callback every frame a sprite is 87 // in the candidate list. Introduced in v1.34 when planet label sprites were added. 88 raycaster.camera = cam; 89 90 // SpatialHash O(1) candidate lookup; falls back to scene.traverse at stellar scale. 91 let candidates = _shQuery(raycaster.ray, cam, distAU); 92 if (!candidates) { 93 // Fallback: traverse (distAU > 1000 AU â star catalog, bounded count) 94 candidates = []; 95 scene.traverse(o => { 96 if (!o.visible || !o.userData) return; 97 if (o.isLine || o.isSprite) return; // sprites handled by getVisibleLabelSprites() below 98 if (o.userData._isPointCloud) { candidates.push(o); return; } 99 const name = o.userData.nameSet; 100 if (typeof name === 'string' && name.length > 0 && name !== 'DefaultName') candidates.push(o); 101 }); 102 } 103 104 // Always supplement with active exoplanet system planet meshes â they are not in 105 // the SpatialHash (dynamically positioned orbit children), so the hash misses them. 106 const _planetMeshes = getActivePlanetMeshes(); 107 if (_planetMeshes.length) { 108 for (const pm of _planetMeshes) { 109 if (pm.visible && !candidates.includes(pm)) candidates.push(pm); 110 } 111 } 112 113 // LabelBus sprites â clickable labels for planets, landmarks, etc. 114 const _labelSprites = getVisibleLabelSprites(); 115 for (const ls of _labelSprites) { 116 if (!candidates.includes(ls)) candidates.push(ls); 117 } 118 // Ensure every _isPointCloud geometry has a NaN-safe computeBoundingSphere 119 // override installed. This is done lazily (once per geometry) and prevents the 120 // console.error spam from NaN positions that would freeze the browser tab. 121 // We still null boundingSphere so Three.js calls computeBoundingSphere() to 122 // keep live-position clouds (landings, launchsites, satCloud) raycastable. 123 for (const o of candidates) { 124 if (o.userData._isPointCloud && o.geometry) { 125 _installSafeBoundingSphere(o.geometry); 126 o.geometry.boundingSphere = null; 127 } 128 } 129 130 const hits = raycaster.intersectObjects(candidates); 131 if (!hits.length) return null; 132 133 // Prefer surface objects over large body meshes when hits are close. 134 // Surface sites sit just above the sphere; the body mesh intercepts first. 135 // Generic: any mesh with _isLargeBody flag is deprioritized. 136 let hit = hits[0]; 137 if (hit.object.userData._isLargeBody && hits.length > 1) { 138 const thresh = hit.distance * 1.05; 139 const better = hits.find(h => 140 h.distance < thresh && !h.object.userData._isLargeBody 141 && (h.object.userData.nameSet || h.object.userData._isPointCloud)); 142 if (better) hit = better; 143 } 144 145 const ud = hit.object.userData._isPointCloud 146 ? hit.object.userData._pointDataArray?.[hit.index] 147 : hit.object.userData; 148 if (!ud?.nameSet) return null; 149 150 return { 151 ud, 152 point : hit.point, 153 distFromSun : hit.point.length(), 154 object : hit.object, 155 index : hit.index, 156 }; 157} 158 159// âââ UI guard helper ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 160function _inUI(el) { 161 if (!el) return false;
162 if (el.classList?.contains('ui-interface')) return true; 163 return _inUI(el.parentNode); 164} 165 166// âââ Selection lock â trail/vector persists after click even when mouse moves off ââ 167let _lockName = null; // locked object name (set on click, cleared on empty-click) 168let _lockDsId = null; // locked dataset ID (null â legacy path) 169 170function _applyHoverRouting(dsId, name) { 171 // ALL hover routing goes through DatasetManager. 172 // The universal bridge in motionCore auto-registers trail+vector+label 173 // for any descriptor with getPositionMs. 174 _dmRouteHover(dsId, name); 175} 176 177// âââ Hover (RAF-throttled) ââââââââââââââââââââââââââââââââââââââââââââââââââââ 178let _rafPending = false; 179let _lastMX = 0, _lastMY = 0; 180 181document.addEventListener('mousemove', e => { 182 if (_inUI(e.target)) { 183 hideTooltip(); 184 _hlPts.visible = false; 185 return; 186 } 187 _lastMX = e.clientX; 188 _lastMY = e.clientY; 189 if (_rafPending) return; 190 _rafPending = true; 191 requestAnimationFrame(() => { 192 _rafPending = false; 193 const result = _findHit(_lastMX, _lastMY); 194 if (result) { 195 const name = result.ud.nameSet !== 'DefaultName' ? result.ud.nameSet : 'Unknown'; 196 const nPlan = result.ud.planets?.length; 197 const extra = nPlan ? `${nPlan} planet${nPlan !== 1 ? 's' : ''}` : undefined; 198 showTooltip(name, result.distFromSun, _lastMX, _lastMY, extra); 199 _hlGeo.attributes.position.setXYZ(0, result.point.x, result.point.y, result.point.z); 200 _hlGeo.attributes.position.needsUpdate = true; 201 _hlPts.visible = true; 202 // Route to the owning dataset (if registered with DatasetManager) 203 const dsId = result.object.userData._datasetId; 204 _applyHoverRouting(dsId ?? null, name); 205 // Extract color from hit object material â trail/vector inherit it 206 const _mat = result.object.material; 207 if (_mat?.color?.getHex) _setHoverColor(_mat.color.getHex()); 208 } else { 209 hideTooltip(); 210 _hlPts.visible = false; 211 // If a selection lock is active, keep the trail for the selected object 212 if (_lockName) { 213 _applyHoverRouting(_lockDsId, _lockName); 214 } else { 215 _dmRouteHover(null, null); // clears all DatasetManager-registered hover 216 _dmRouteHover(null, null); 217 } 218 } 219 }); 220}); 221 222// âââ Drag guard âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 223let _mdX = 0, _mdY = 0; 224document.addEventListener('mousedown', e => { _mdX = e.clientX; _mdY = e.clientY; }); 225 226// âââ Click â add / remove selection ââââââââââââââââââââââââââââââââââââââââââ 227document.addEventListener('click', e => { 228 if (_inUI(e.target)) return; 229 const dx = e.clientX - _mdX, dy = e.clientY - _mdY; 230 if (dx * dx + dy * dy > 25) return; // orbit drag, not a click 231 232 const result = _findHit(e.clientX, e.clientY); 233 if (isRulerActive()) { 234 const pt = result?.point ?? (() => { 235 const cam = camera; 236 const dist = controls?.target ? cam.position.distanceTo(controls.target) : cam.position.length(); 237 const mx = (e.clientX / window.innerWidth) * 2 - 1; 238 const my = -(e.clientY / window.innerHeight) * 2 + 1; 239 raycaster.setFromCamera(new THREE.Vector2(mx, my), cam); 240 return raycaster.ray.at(dist, new THREE.Vector3()); 241 })(); 242 // Pass hitRef so ruler can track the clicked object dynamically 243 const rulerHitRef = result ? { object: result.object, index: result.index } : null; 244 handleRulerClick(rulerHitRef, pt); 245 return; 246 } 247 if (result) { 248 // ââ Label click â immediate fly-to target object position âââââââââââââ 249 if (result.ud._isLabel && result.ud._getPosFn && window.flyTo) { 250 const raw = result.ud._getPosFn(); 251 if (raw) { 252 const tgt = Array.isArray(raw) ? new THREE.Vector3(raw[0], raw[1], raw[2]) : raw.clone(); 253 const cam = camera; 254 const arrival = Math.max(1e-5, tgt.distanceTo(cam.position) * 0.02); 255 window.flyTo(tgt, arrival); 256 } 257 return; 258 } 259 260 // Populate ud._card via DatasetManager.buildCard() if available 261 const _dsId = result.object.userData?._datasetId; 262 if (_dsId && !result.ud._card) { 263 const _desc = _dmGet(_dsId); 264 if (_desc?.buildCard) { 265 const _built = _desc.buildCard(result.ud.nameSet); 266 if (_built) result.ud._card = _built; 267 } 268 } 269 270 // Auto-inject onSelect flyTo for datasets that don't set one themselves. 271 // Sets a fly-to callback to power the infobox "â¶ Fly to" button. 272 // _autoFly=false so the click only shows the infobox; user explicitly clicks "Fly to". 273 if (!result.ud.onSelect && result.point && window.flyTo) { 274 const _hitPos = result.point.clone(); 275 const _cam = camera;
276 // Arrive at 2% of camera-to-object distance (nice zoom-in without overshooting) 277 const _camDist = _hitPos.distanceTo(_cam.position); 278 const _arrival = Math.max(1e-5, _camDist * 0.02); 279 result.ud.onSelect = () => window.flyTo(_hitPos, _arrival); 280 result.ud._autoFly = false; 281 } 282 283 // Lock hover state so trail+vector persist after mouse moves away 284 const _selName = result.ud.nameSet !== 'DefaultName' ? result.ud.nameSet : 'Unknown'; 285 _lockName = _selName; 286 _lockDsId = _dsId ?? null; 287 _applyHoverRouting(_lockDsId, _lockName); 288 289 addSelection(result.ud, { object: result.object, index: result.index }, result.distFromSun); 290 // Auto-fly disabled â user navigates via infobox "Fly to" button 291 // result.ud.onSelect is preserved for the button to call later 292 result.ud._autoFly = false; 293 } else { 294 // Clear selection lock and hover overlay 295 _lockName = null; 296 _lockDsId = null; 297 _dmRouteHover(null, null); 298 clearAllSelections(); 299 } 300}); 301 302// âââ Per-frame update (called from main.js onTick) âââââââââââââââââââââââââââ 303export function updateSelectionHighlight(_distAU) { 304 const cam = camera; 305 updateSelectionsEachFrame(cam); 306} 307 308// âââ Query selection lock state (used by main.js to always show hover overlay for selected objects) 309export function hasSelectionLock() { return _lockName !== null; } 310 311// âââ Clear selection lock (called when infobox X is pressed) âââââââââââââââââ 312export function clearSelectionLock() { 313 _lockName = null; 314 _lockDsId = null; 315 _dmRouteHover(null, null); 316} 317 318// âââ VR ray helpers (called from vrControls each XR frame) âââââââââââââââââââ 319// 320// Both functions accept a world-space ray (origin + normalised direction). 321// They reuse the shared raycaster + _hlPts highlight dot. 322// handleVRRayHover â hover highlight only (called every frame) 323// handleVRRaySelect â full click equivalent (called on trigger press) 324 325function _vrCandidates() { 326 const cam = camera; 327 const distAU = cam.position.length() || 1; 328 329 let list = _shQuery(raycaster.ray, cam, distAU); 330 if (!list) { 331 list = []; 332 scene.traverse(o => { 333 if (!o.visible || !o.userData) return; 334 if (o.isLine || o.isSprite) return; // sprites handled by getVisibleLabelSprites() 335 if (o.userData._isPointCloud) { list.push(o); return; } 336 const name = o.userData.nameSet; 337 if (typeof name === 'string' && name.length > 0 && name !== 'DefaultName') list.push(o); 338 }); 339 } 340 for (const o of list) { 341 if (o.userData._isPointCloud && o.geometry) { 342 _installSafeBoundingSphere(o.geometry); 343 o.geometry.boundingSphere = null; 344 } 345 } 346 return list; 347} 348 349export function handleVRRayHover(origin, direction) { 350 const cam = camera; 351 const distAU = cam.position.length() || 1; 352 raycaster.params.Points.threshold = pixelToWorldSize(8, Math.max(distAU, 0.001)); 353 raycaster.set(origin, direction); 354 raycaster.camera = cam; 355 356 const hits = raycaster.intersectObjects(_vrCandidates()); 357 if (!hits.length) { 358 _hlPts.visible = false; 359 _dmRouteHover(null, null); 360 return null; 361 } 362 363 const hit = hits[0]; 364 _hlGeo.attributes.position.setXYZ(0, hit.point.x, hit.point.y, hit.point.z); 365 _hlGeo.attributes.position.needsUpdate = true; 366 _hlPts.visible = true; 367 368 const ud = hit.object.userData._isPointCloud 369 ? hit.object.userData._pointDataArray?.[hit.index] 370 : hit.object.userData; 371 if (ud?.nameSet) { 372 const dsId = hit.object.userData._datasetId; 373 if (dsId) { 374 _dmRouteHover(dsId, ud.nameSet); 375 } 376 } 377 return ud ?? null; 378} 379 380export function handleVRRaySelect(origin, direction) { 381 const cam = camera; 382 const distAU = cam.position.length() || 1; 383 raycaster.params.Points.threshold = pixelToWorldSize(8, Math.max(distAU, 0.001)); 384 raycaster.set(origin, direction); 385 raycaster.camera = cam; 386 387 const hits = raycaster.intersectObjects(_vrCandidates()); 388 if (!hits.length) { clearAllSelections(); return; } 389 390 const hit = hits[0]; 391 const ud = hit.object.userData._isPointCloud 392 ? hit.object.userData._pointDataArray?.[hit.index] 393 : hit.object.userData; 394 if (!ud?.nameSet) { clearAllSelections(); return; } 395 396 _hlGeo.attributes.position.setXYZ(0, hit.point.x, hit.point.y, hit.point.z); 397 _hlGeo.attributes.position.needsUpdate = true; 398 _hlPts.visible = true; 399 400 addSelection(ud, { object: hit.object, index: hit.index }, hit.point.length()); 401 if (typeof ud.onSelect === 'function') ud.onSelect(); 402}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.