PageSourceSearch

https://spaceethics.vercel.app/src/js/ui/raycaster.js

js spaceethics.vercel.app collected 2026-10-03 07:47:00 UTC 17,219 bytes, 402 lines download raw bytes

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.