graph-explorer-render 0.1.0

WebGL2/wgpu renderer for graph-explorer — nodes, edges, halos and labels.
Documentation
//! Pure hit-test geometry: does a world point fall inside a node's drawn shape?
//! Lives here (not in graph-explorer-wasm) so it is testable without wasm.

use graph_explorer_style::interner::IdKind;

/// One thing on screen that a pointer can hit. Identity is the interned
/// `NodeIndex`; `kind` was classified once at intern time, replacing the
/// per-pick string prefix tests.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HitTarget {
    pub index: u32,
    pub kind: IdKind,
    pub pos: [f32; 2],
    pub radius: f32,
    /// 0 circle, 1 square, 2 diamond — matches `NodeInstanceData::shape`.
    pub shape: u32,
    pub opacity: f32,
}

/// Targets fainter than this are mid-fade-out; clicking a ghost would surprise.
const MIN_HITTABLE_OPACITY: f32 = 0.05;

/// Whether `point` (world space) falls inside `t`'s drawn shape, widened by
/// `tolerance` world units. The shape maths must match what the renderer draws,
/// or the clickable area disagrees with the visible one.
pub fn contains(t: &HitTarget, point: [f32; 2], tolerance: f32) -> bool {
    let r = t.radius + tolerance;
    let dx = point[0] - t.pos[0];
    let dy = point[1] - t.pos[1];
    match t.shape {
        1 => dx.abs() <= r && dy.abs() <= r,      // square
        // A diamond's edge runs at 45 degrees, so |dx|+|dy| overshoots true
        // perpendicular distance by sqrt(2). Scale the slop to match, or the
        // documented tolerance is only tolerance/sqrt(2) px for diamonds.
        2 => dx.abs() + dy.abs() <= t.radius + tolerance * std::f32::consts::SQRT_2,
        _ => (dx * dx + dy * dy).sqrt() <= r,     // circle (default)
    }
}

/// The topmost hittable target at `point`, or `None` — returned as a slot
/// into `targets` so the caller resolves identity however it likes.
///
/// Iterates in reverse so later-drawn (visually on top) targets win. Loading
/// placeholders are inert by design; near-invisible targets are leaving the
/// scene (mid-fade ghosts — clicking one would surprise).
pub fn pick(targets: &[HitTarget], point: [f32; 2], tolerance: f32) -> Option<usize> {
    targets
        .iter()
        .enumerate()
        .rev()
        .find(|(_, t)| {
            t.kind != IdKind::Loading
                && t.opacity >= MIN_HITTABLE_OPACITY
                && contains(t, point, tolerance)
        })
        .map(|(i, _)| i)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn t(index: u32, pos: [f32; 2], radius: f32, shape: u32) -> HitTarget {
        HitTarget { index, kind: IdKind::Node, pos, radius, shape, opacity: 1.0 }
    }

    #[test]
    fn circle_containment() {
        let c = t(0, [0.0, 0.0], 10.0, 0);
        assert!(contains(&c, [0.0, 0.0], 0.0));
        assert!(contains(&c, [9.9, 0.0], 0.0));
        assert!(!contains(&c, [10.1, 0.0], 0.0));
        assert!(!contains(&c, [8.0, 8.0], 0.0), "corner is outside a circle");
    }

    #[test]
    fn square_containment() {
        let s = t(0, [0.0, 0.0], 10.0, 1);
        assert!(contains(&s, [8.0, 8.0], 0.0), "corner IS inside a square");
        assert!(contains(&s, [-9.9, 9.9], 0.0));
        assert!(!contains(&s, [10.1, 0.0], 0.0));
    }

    #[test]
    fn diamond_containment() {
        let d = t(0, [0.0, 0.0], 10.0, 2);
        assert!(contains(&d, [9.9, 0.0], 0.0));
        assert!(contains(&d, [4.0, 4.0], 0.0));
        assert!(!contains(&d, [8.0, 8.0], 0.0), "|dx|+|dy| > r is outside a diamond");
    }

    #[test]
    fn tolerance_widens_the_target() {
        let c = t(0, [0.0, 0.0], 10.0, 0);
        assert!(!contains(&c, [12.0, 0.0], 0.0));
        assert!(contains(&c, [12.0, 0.0], 4.0), "tolerance makes it hittable");
    }

    #[test]
    fn diamond_tolerance_is_perpendicular_distance() {
        let d = t(0, [0.0, 0.0], 10.0, 2);
        // Straight out along +x, the edge is at x == radius, so `tolerance`
        // away perpendicular from that corner... use the 45-degree edge, where
        // the naive `radius + tolerance` test under-widens: the point on the
        // outward normal 4.0 away from the edge midpoint (5,5).
        let n = std::f32::consts::FRAC_1_SQRT_2; // outward normal (n, n)
        // 3.9px perpendicular from the edge midpoint (5,5): inside a 4px slop.
        // The old `radius + tolerance` form only reached 2.83px and missed it.
        assert!(contains(&d, [5.0 + 3.9 * n, 5.0 + 3.9 * n], 4.0));
        assert!(!contains(&d, [5.0 + 4.1 * n, 5.0 + 4.1 * n], 4.0), "beyond the slop is outside");
    }

    #[test]
    fn offset_position_is_respected() {
        let c = t(0, [100.0, -50.0], 5.0, 0);
        assert!(contains(&c, [102.0, -50.0], 0.0));
        assert!(!contains(&c, [0.0, 0.0], 0.0));
    }

    #[test]
    fn topmost_wins_on_overlap() {
        // Later in the list == drawn later == on top; pick returns its slot.
        let targets = vec![t(7, [0.0, 0.0], 10.0, 0), t(8, [0.0, 0.0], 10.0, 0)];
        assert_eq!(pick(&targets, [0.0, 0.0], 0.0), Some(1));
    }

    #[test]
    fn misses_return_none() {
        let targets = vec![t(0, [0.0, 0.0], 5.0, 0)];
        assert_eq!(pick(&targets, [100.0, 100.0], 0.0), None);
    }

    #[test]
    fn loading_placeholders_are_not_hittable() {
        let mut loading = t(0, [0.0, 0.0], 10.0, 0);
        loading.kind = IdKind::Loading;
        assert_eq!(pick(&[loading], [0.0, 0.0], 0.0), None);
    }

    #[test]
    fn other_synthetic_targets_are_hittable() {
        // Contract from the interner docs: only `Loading` is pick-inert.
        // An unrecognized `"__"`-prefixed id (OtherSynthetic) hit-tests as an
        // ordinary node, exactly as the old string code treated it.
        let mut synth = t(3, [0.0, 0.0], 10.0, 0);
        synth.kind = IdKind::OtherSynthetic;
        assert_eq!(pick(&[synth], [0.0, 0.0], 0.0), Some(0));
    }

    #[test]
    fn faded_targets_are_not_hittable() {
        let mut ghost = t(0, [0.0, 0.0], 10.0, 0);
        ghost.opacity = 0.02;
        assert_eq!(pick(&[ghost], [0.0, 0.0], 0.0), None);
    }
}