facett-core 0.1.7

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Shared spatial navigation + focus** (§13 FOC-2, §14 NAV-1) — the *one*
//! pan/zoom model (`Navigable`) reused by map/graph/plot/canvas, and the *one*
//! spatial focus rule (`nearest_in_direction`) reused by pane and form
//! navigation (COH-3). Pan/zoom is expressed as an `egui::emath::TSTransform`
//! (scale + offset), so a host can also use it to transform an overlaid guest
//! layer (§5 CMP-4).

use egui::{Pos2, Rect, Vec2, emath::TSTransform, vec2};
use serde::{Deserialize, Serialize};

/// A compass direction for directional focus / pane moves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Dir4 {
    Left,
    Right,
    Up,
    Down,
}

/// The shared pan/zoom model (NAV-1). Stores a scale + translation as a
/// `TSTransform` mapping **scene → screen**. Map, graph and plot all drive this
/// with the same gestures, so they feel identical (NAV-2).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Navigable {
    /// Uniform zoom (scene units → screen px).
    pub scale: f32,
    /// Scene-origin offset in screen px.
    pub offset: [f32; 2],
    /// Zoom clamp.
    pub min_scale: f32,
    pub max_scale: f32,
}

impl Default for Navigable {
    fn default() -> Self {
        Self { scale: 1.0, offset: [0.0, 0.0], min_scale: 0.05, max_scale: 64.0 }
    }
}

impl Navigable {
    /// The scene→screen transform.
    pub fn transform(&self) -> TSTransform {
        TSTransform::new(Vec2::new(self.offset[0], self.offset[1]), self.scale)
    }

    /// Pan by a screen-space delta (drag / arrows).
    pub fn pan(&mut self, delta: Vec2) {
        self.offset[0] += delta.x;
        self.offset[1] += delta.y;
    }

    /// **Zoom toward a screen point** by factor `k` (>1 zooms in), keeping the
    /// scene point under the cursor fixed (the natural map/plot feel, NAV-1).
    pub fn zoom_to(&mut self, k: f32, screen_pivot: Pos2) {
        let new_scale = (self.scale * k).clamp(self.min_scale, self.max_scale);
        let actual_k = new_scale / self.scale;
        // Keep `screen_pivot` fixed: offset' = pivot - (pivot - offset) * actual_k
        self.offset[0] = screen_pivot.x - (screen_pivot.x - self.offset[0]) * actual_k;
        self.offset[1] = screen_pivot.y - (screen_pivot.y - self.offset[1]) * actual_k;
        self.scale = new_scale;
    }

    /// **Fit** a scene bounding box into a screen viewport with a margin
    /// fraction (FitToView). Centres + scales so the whole bbox is visible.
    pub fn fit(&mut self, scene_bbox: Rect, viewport: Rect, margin: f32) {
        if scene_bbox.width() <= 0.0 || scene_bbox.height() <= 0.0 {
            return;
        }
        let m = margin.clamp(0.0, 0.45);
        let avail = viewport.size() * (1.0 - 2.0 * m);
        let sx = avail.x / scene_bbox.width();
        let sy = avail.y / scene_bbox.height();
        let s = sx.min(sy).clamp(self.min_scale, self.max_scale);
        self.scale = s;
        // Centre the bbox in the viewport.
        let bbox_center_scaled = scene_bbox.center().to_vec2() * s;
        let vp_center = viewport.center().to_vec2();
        self.offset = [vp_center.x - bbox_center_scaled.x, vp_center.y - bbox_center_scaled.y];
    }

    /// Map a scene point to screen.
    pub fn to_screen(&self, scene: Pos2) -> Pos2 {
        self.transform().mul_pos(scene)
    }
}

/// **Spatial focus** (FOC-2): among `candidates` (their rects), pick the nearest
/// focusable in direction `dir` from `current`. Picks by directional projection +
/// perpendicular penalty — the standard "nearest in that direction" rule, not tab
/// order. Returns the index into `candidates`, or `None` if nothing lies that way.
pub fn nearest_in_direction(current: Rect, candidates: &[Rect], dir: Dir4) -> Option<usize> {
    let from = current.center();
    let mut best: Option<(usize, f32)> = None;
    for (i, &r) in candidates.iter().enumerate() {
        let to = r.center();
        let d = to - from;
        // Must lie predominantly in the requested direction.
        let (along, across) = match dir {
            Dir4::Left => (-d.x, d.y.abs()),
            Dir4::Right => (d.x, d.y.abs()),
            Dir4::Up => (-d.y, d.x.abs()),
            Dir4::Down => (d.y, d.x.abs()),
        };
        if along <= 0.5 {
            continue; // not in this direction (or same rect)
        }
        // Cost: distance along + a penalty for being off-axis. Lower is better.
        let cost = along + across * 2.0;
        if best.map(|(_, c)| cost < c).unwrap_or(true) {
            best = Some((i, cost));
        }
    }
    best.map(|(i, _)| i)
}

/// Lay out hint badge anchor points for a set of focusable rects (FOC-3): the
/// top-left inset of each rect, where a which-key label is painted. Pure so a
/// snapshot test can assert positions.
pub fn hint_anchors(rects: &[Rect]) -> Vec<Pos2> {
    rects.iter().map(|r| r.left_top() + vec2(4.0, 4.0)).collect()
}

#[cfg(test)]
mod tests {
    use egui::pos2;

    use super::*;

    #[test]
    fn zoom_keeps_the_pivot_point_fixed() {
        let mut nav = Navigable::default();
        let pivot = pos2(200.0, 150.0);
        // The scene point currently under the pivot.
        let before = inverse(&nav, pivot);
        nav.zoom_to(2.0, pivot);
        let after = inverse(&nav, pivot);
        assert!((before - after).length() < 1e-3, "scene point under cursor must stay put");
        assert_eq!(nav.scale, 2.0);
    }

    fn inverse(nav: &Navigable, screen: Pos2) -> Pos2 {
        // screen = offset + scene*scale → scene = (screen - offset)/scale
        pos2((screen.x - nav.offset[0]) / nav.scale, (screen.y - nav.offset[1]) / nav.scale)
    }

    #[test]
    fn zoom_clamps_to_range() {
        let mut nav = Navigable::default();
        for _ in 0..100 {
            nav.zoom_to(2.0, pos2(0.0, 0.0));
        }
        assert!(nav.scale <= nav.max_scale + 1e-3);
        for _ in 0..100 {
            nav.zoom_to(0.5, pos2(0.0, 0.0));
        }
        assert!(nav.scale >= nav.min_scale - 1e-3);
    }

    #[test]
    fn fit_centres_and_scales_a_bbox() {
        let mut nav = Navigable::default();
        let bbox = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0));
        let vp = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 400.0));
        nav.fit(bbox, vp, 0.1);
        // The bbox centre maps to the viewport centre.
        let c = nav.to_screen(bbox.center());
        assert!((c - vp.center()).length() < 1.0, "bbox centre → viewport centre");
        // And it fits with margin (scale ~ (400*0.8)/100 = 3.2).
        assert!((nav.scale - 3.2).abs() < 0.1, "fit scale, got {}", nav.scale);
    }

    #[test]
    fn spatial_focus_picks_nearest_in_direction_not_tab_order() {
        // current at centre; one candidate to the right (close), one far up.
        let current = Rect::from_center_size(pos2(100.0, 100.0), vec2(40.0, 20.0));
        let right = Rect::from_center_size(pos2(180.0, 105.0), vec2(40.0, 20.0));
        let up = Rect::from_center_size(pos2(100.0, 20.0), vec2(40.0, 20.0));
        let cands = [up, right];
        assert_eq!(nearest_in_direction(current, &cands, Dir4::Right), Some(1));
        assert_eq!(nearest_in_direction(current, &cands, Dir4::Up), Some(0));
        // Nothing to the left.
        assert_eq!(nearest_in_direction(current, &cands, Dir4::Left), None);
    }

    #[test]
    fn spatial_focus_prefers_on_axis_over_diagonal() {
        let current = Rect::from_center_size(pos2(0.0, 0.0), vec2(10.0, 10.0));
        let straight = Rect::from_center_size(pos2(100.0, 0.0), vec2(10.0, 10.0));
        let diagonal = Rect::from_center_size(pos2(90.0, 90.0), vec2(10.0, 10.0));
        let cands = [diagonal, straight];
        assert_eq!(nearest_in_direction(current, &cands, Dir4::Right), Some(1), "straight beats diagonal");
    }
}