Skip to main content

qualia_core_db/render/
navigation.rs

1//! GPU/CPU node picking helpers and camera fly-to for PR-C11 navigation.
2
3use crate::render::camera::CameraState;
4use crate::render::telemetry::ObserverStandpoint;
5use crate::tensor::buffer_export::read_tensor_at;
6
7/// Background sentinel in the R32Uint picking attachment.
8pub const PICK_SENTINEL: u32 = u32::MAX;
9
10/// Frames to interpolate camera when framing a selected node.
11pub const FLY_TO_FRAMES: u32 = 24;
12
13/// Epistemic q below this threshold is treated as collapsed (matches WGSL `Q_COLLAPSED_EPS`).
14pub const Q_COLLAPSED_EPS: f32 = 0.001;
15
16/// Active camera interpolation toward a selected node.
17#[derive(Clone, Copy, Debug, Default, PartialEq)]
18pub struct CameraFlyTo {
19    pub target: CameraState,
20    pub remaining: u32,
21}
22
23impl CameraFlyTo {
24    #[inline]
25    pub fn is_active(&self) -> bool {
26        self.remaining > 0
27    }
28
29    pub fn start_toward(target: CameraState) -> Self {
30        Self {
31            target: target.clamped(),
32            remaining: FLY_TO_FRAMES,
33        }
34    }
35
36    /// Advance one frame; returns updated camera state.
37    pub fn advance(&mut self, current: CameraState) -> CameraState {
38        if self.remaining == 0 {
39            return current.clamped();
40        }
41        let t = 1.0 - (self.remaining as f32 / FLY_TO_FRAMES as f32);
42        let blended = lerp_camera(current, self.target, t.clamp(0.0, 1.0));
43        self.remaining = self.remaining.saturating_sub(1);
44        blended
45    }
46}
47
48/// Compute orbit parameters that frame a world-space node (maps to node focal point).
49#[inline]
50pub fn camera_frame_node(node: [f32; 3]) -> CameraState {
51    let [x, y, z] = node;
52    let dist = (x * x + y * y + z * z).sqrt().max(0.05);
53    let yaw = x.atan2(z);
54    let pitch = (y / dist).clamp(-1.0, 1.0).asin();
55    let zoom = (dist * 1.75).clamp(0.35, 48.0);
56    CameraState { yaw, pitch, zoom }.clamped()
57}
58
59#[inline]
60pub fn lerp_camera(a: CameraState, b: CameraState, t: f32) -> CameraState {
61    CameraState {
62        yaw: a.yaw + (b.yaw - a.yaw) * t,
63        pitch: a.pitch + (b.pitch - a.pitch) * t,
64        zoom: a.zoom + (b.zoom - a.zoom) * t,
65    }
66    .clamped()
67}
68
69/// Canvas2D fallback pick — nearest projected node within hit radius (px).
70pub fn cpu_pick_node_at(
71    tensor: &[u8],
72    canvas_w: f64,
73    canvas_h: f64,
74    pick_x: f64,
75    pick_y: f64,
76    yaw: f32,
77    standpoint: &ObserverStandpoint,
78) -> Option<u32> {
79    let count = crate::tensor::buffer_export::tensor_node_count(tensor).ok()?;
80    if count == 0 {
81        return None;
82    }
83
84    let mut best: Option<(u32, f64)> = None;
85    for i in 0..count {
86        let Ok(t) = read_tensor_at(tensor, i) else {
87            continue;
88        };
89        if !standpoint.temporal_visible(t.t) {
90            continue;
91        }
92        let (px, py, _) = project_xyz_canvas(t.x, t.y, t.z, canvas_w, canvas_h, yaw as f64);
93        let dx = px - pick_x;
94        let dy = py - pick_y;
95        let hit_r = 8.0 + t.alpha as f64 * 6.0;
96        let d2 = dx * dx + dy * dy;
97        if d2 > hit_r * hit_r {
98            continue;
99        }
100        if best.map_or(true, |(_, bd)| d2 < bd) {
101            best = Some((i as u32, d2));
102        }
103    }
104    best.map(|(idx, _)| idx)
105}
106
107#[inline]
108fn project_xyz_canvas(x: f32, y: f32, z: f32, w: f64, h: f64, yaw: f64) -> (f64, f64, f32) {
109    let cx = yaw.cos() as f32;
110    let sx = yaw.sin() as f32;
111    let xr = x * cx + z * sx;
112    let zr = -x * sx + z * cx;
113    let depth = (1.0 / (1.0 + zr * 0.35)).clamp(0.2, 1.0);
114    let scale = 0.42 * w.min(h) * depth as f64;
115    let px = w * 0.5 + xr as f64 * scale;
116    let py = h * 0.5 - y as f64 * scale;
117    (px, py, depth)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn camera_frame_node_produces_finite_orbit() {
126        let cam = camera_frame_node([1.0, 0.5, -2.0]);
127        assert!(cam.yaw.is_finite());
128        assert!(cam.pitch.is_finite());
129        assert!(cam.zoom.is_finite());
130    }
131
132    #[test]
133    fn fly_to_converges_toward_target() {
134        let target = camera_frame_node([0.0, 1.0, 2.0]);
135        let mut fly = CameraFlyTo::start_toward(target);
136        let mut cam = CameraState::default();
137        for _ in 0..FLY_TO_FRAMES {
138            cam = fly.advance(cam);
139        }
140        assert!((cam.yaw - target.yaw).abs() < 0.05);
141    }
142}