facett-core 0.1.5

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **3D label "gyro" stabilization** (§19) — fix labels bobbing while the scene
//! rotates. The label's 3D anchor is projected to **screen space**, then the text
//! is drawn **billboarded** (upright, camera-facing) — never rotated with the
//! scene (GYRO-1). Vertical jitter is eliminated by **stable sub-pixel placement**
//! (consistent rounding) + optional **temporal smoothing** of the projected
//! anchor (GYRO-2). The galley is cached once per label by the caller (no
//! per-frame relayout).

use egui::Pos2;

/// Snap a screen position to a stable sub-pixel grid so a label doesn't jitter
/// vertically as the projected anchor wobbles by sub-pixel amounts each frame
/// (GYRO-2). `subpixel` = fraction of a pixel to snap to (e.g. 0.5 → half-pixel).
pub fn stable_snap(p: Pos2, subpixel: f32) -> Pos2 {
    let q = if subpixel <= 0.0 { 1.0 } else { 1.0 / subpixel };
    Pos2::new((p.x * q).round() / q, (p.y * q).round() / q)
}

/// One-pole temporal smoothing of a projected anchor toward its new position
/// (GYRO-2): `alpha` ∈ [0,1], higher = snappier. Removes high-frequency wobble
/// while still following rotation. Deterministic given the prior + target.
pub fn smooth_anchor(prev: Pos2, target: Pos2, alpha: f32) -> Pos2 {
    let a = alpha.clamp(0.0, 1.0);
    Pos2::new(prev.x + (target.x - prev.x) * a, prev.y + (target.y - prev.y) * a)
}

/// The **billboarded** screen placement for a label: take the projected anchor,
/// smooth it toward `prev`, then snap to the sub-pixel grid. The text is always
/// drawn upright at this point (the caller paints with a fixed `Align2`), so it
/// never rotates with the scene (GYRO-1) and never bobs (GYRO-2).
pub fn billboard_placement(projected: Pos2, prev: Option<Pos2>, smoothing: f32, subpixel: f32) -> Pos2 {
    let smoothed = match prev {
        Some(p) => smooth_anchor(p, projected, smoothing),
        None => projected,
    };
    stable_snap(smoothed, subpixel)
}

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

    use super::*;

    #[test]
    fn stable_snap_quantises_to_subpixel_grid() {
        // Two anchors a tiny fraction apart snap to the same half-pixel cell → no
        // visible bob between frames.
        let a = stable_snap(pos2(100.04, 50.02), 0.5);
        let b = stable_snap(pos2(100.06, 49.98), 0.5);
        assert_eq!(a, b, "sub-half-pixel wobble snaps to the same position (no jitter)");
        assert_eq!(a, pos2(100.0, 50.0));
    }

    #[test]
    fn smoothing_follows_but_damps_a_jump() {
        let prev = pos2(0.0, 0.0);
        let target = pos2(10.0, 0.0);
        let mid = smooth_anchor(prev, target, 0.5);
        assert_eq!(mid, pos2(5.0, 0.0), "half-way toward target at alpha 0.5");
        // alpha 1.0 jumps fully; 0.0 stays put.
        assert_eq!(smooth_anchor(prev, target, 1.0), target);
        assert_eq!(smooth_anchor(prev, target, 0.0), prev);
    }

    #[test]
    fn rotating_anchor_does_not_bob_after_stabilization() {
        // Simulate a label anchor wobbling vertically by sub-pixel amounts each
        // frame (the bug). After stabilization the y-position is constant.
        let mut prev = pos2(200.0, 100.0);
        let mut ys = Vec::new();
        for i in 0..20 {
            // tiny vertical wobble ± < half a pixel
            let wobble = ((i as f32) * 0.7).sin() * 0.3;
            let projected = pos2(200.0, 100.0 + wobble);
            let placed = billboard_placement(projected, Some(prev), 0.6, 0.5);
            prev = placed;
            ys.push(placed.y);
        }
        // All placements snapped to the same y → zero bob.
        assert!(ys.iter().all(|&y| (y - ys[0]).abs() < 1e-6), "stabilized label must not bob: {ys:?}");
        assert_eq!(ys[0], 100.0);
    }

    #[test]
    fn placement_with_no_prior_is_just_the_snapped_projection() {
        let placed = billboard_placement(pos2(50.4, 25.6), None, 0.6, 0.5);
        assert_eq!(placed, stable_snap(pos2(50.4, 25.6), 0.5));
    }
}