import * as THREE from 'three'; import { STATE } from '../core/State.js'; import { updateNavigation } from './Navigation.js'; import { raycaster } from './Interaction.js'; import { getActiveReliefObjects } from '../visualizer/ReliefRenderer.js'; import { updateHUD } from '../ui/HUD.js'; import { renderAxisGizmo } from './AxisGizmo.js'; let rafId = null; /** * Starts the main requestAnimationFrame loop: navigation, raycast selection, * relief HUD per-frame updates, HUD refresh, and scene render. * * Relief index/HUD helpers are injected so H11.2 can relocate them to * ReliefHUDController without touching the loop wiring again. */ export function startRenderLoop({ scene, camera, renderer, controls, computeReliefIndices, fillReliefHUD, }) { stopRenderLoop(); const clock = new THREE.Clock(); const lastCamPos = new THREE.Vector3(); const lastCamRot = new THREE.Euler(); let lastIntersects = []; function frame() { rafId = requestAnimationFrame(frame); if (!STATE.isReady) return; const delta = Math.min(clock.getDelta(), 0.1); if (STATE.engineMode === 'MICRO') { STATE.threads.forEach(t => { if (t.uniforms) t.uniforms.highlightIndex.value = STATE.currentSelectedIndex; }); } const isRelief = (STATE.layoutMode === 'BLANKET' || STATE.layoutMode === 'COMPARE'); const isReliefInteractive = isRelief && STATE.comparisonMode !== 'C'; if (controls.isLocked) { updateNavigation(camera, controls, delta); const crosshair = document.getElementById('hud-crosshair'); const positionChanged = lastCamPos.distanceToSquared(camera.position) > 0.0001; const rotationChanged = Math.abs(lastCamRot.x - camera.rotation.x) + Math.abs(lastCamRot.y - camera.rotation.y) + Math.abs(lastCamRot.z - camera.rotation.z) > 0.0001; let intersects = lastIntersects; if (positionChanged || rotationChanged) { raycaster.setFromCamera(new THREE.Vector2(0, 0), camera); if (isRelief) { const reliefObjects = getActiveReliefObjects(); intersects = raycaster.intersectObjects(reliefObjects, false); } else if (STATE.engineMode === 'MACRO') { if (STATE.macroMeshes && STATE.macroMeshes.points) { intersects = raycaster.intersectObject(STATE.macroMeshes.points); } else { intersects = []; } } else { const meshes = []; STATE.threads.forEach(t => { if (t.pointsMesh && t.pointsMesh.visible) meshes.push(t.pointsMesh); }); intersects = raycaster.intersectObjects(meshes, false); } lastCamPos.copy(camera.position); lastCamRot.copy(camera.rotation); lastIntersects = intersects; } if (STATE.isTargetLocked) { if (crosshair) { crosshair.style.borderColor = 'var(--color-semantic-error)'; const dot = crosshair.querySelector('div'); if (dot) dot.style.background = 'var(--color-semantic-error)'; } if (isReliefInteractive && STATE.reliefPinned) { fillReliefHUD(STATE.reliefPinned.chunkIndex, STATE.reliefPinned.dimIndex, true, STATE.reliefPinned.source); } } else if (intersects.length > 0) { if (crosshair) { crosshair.style.borderColor = 'var(--color-semantic-warning)'; const dot = crosshair.querySelector('div'); if (dot) dot.style.background = 'var(--color-semantic-warning)'; } const hit = intersects[0]; if (isReliefInteractive) { const indices = computeReliefIndices(hit); if (indices) { STATE.reliefHover = indices; if (!STATE.reliefPinned) { STATE.currentSelectedIndex = indices.dimIndex; fillReliefHUD(indices.chunkIndex, indices.dimIndex, false); } else { fillReliefHUD(STATE.reliefPinned.chunkIndex, STATE.reliefPinned.dimIndex, true, STATE.reliefPinned.source); } } } else if (STATE.engineMode === 'MACRO') { if (hit.index !== undefined) { const firstThread = STATE.threads[0]; const len = firstThread && firstThread.embedding ? firstThread.embedding.length : 0; if (len > 0) STATE.currentSelectedIndex = hit.index % len; } } else if (hit.index !== undefined) { STATE.currentSelectedIndex = hit.index; } } else { if (crosshair) { crosshair.style.borderColor = 'var(--color-semantic-success)'; const dot = crosshair.querySelector('div'); if (dot) dot.style.background = 'var(--color-semantic-success)'; } if (isReliefInteractive && STATE.reliefPinned) { fillReliefHUD(STATE.reliefPinned.chunkIndex, STATE.reliefPinned.dimIndex, true, STATE.reliefPinned.source); } else { const hudEl = document.getElementById('relief-hud'); if (hudEl && isRelief) hudEl.style.display = 'none'; const coordsEl = document.getElementById('relief-coords'); if (coordsEl) coordsEl.style.display = 'none'; } } } else { if (isReliefInteractive && STATE.reliefPinned) { fillReliefHUD(STATE.reliefPinned.chunkIndex, STATE.reliefPinned.dimIndex, true, STATE.reliefPinned.source); const coordsEl = document.getElementById('relief-coords'); if (coordsEl) coordsEl.style.display = 'none'; } else { const hudEl = document.getElementById('relief-hud'); if (hudEl) hudEl.style.display = 'none'; const coordsEl = document.getElementById('relief-coords'); if (coordsEl) coordsEl.style.display = 'none'; } } updateHUD(camera); renderer.render(scene, camera); renderAxisGizmo(renderer, camera); } rafId = requestAnimationFrame(frame); } /** Cancels the active animation frame loop, if any. */ export function stopRenderLoop() { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }