facett-core 0.1.15

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Graph edge rendering** (§18) — straight, spline (cubic Bézier / Catmull-Rom
//! sampled), and fat/soft (variable width, feathered) edges, with widths/softness
//! from the theme. **Device** = crisp hairlines, no glow (gated by
//! [`EffectsPolicy`](crate::look::EffectsPolicy)). The sampling is pure so the
//! path is snapshot-stable.

use egui::{Color32, Painter, Pos2, Stroke, pos2};

use crate::look::EffectsPolicy;

/// Edge geometry styles (EDGE-1).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EdgeStyle {
    Straight,
    /// Cubic-Bézier spline with the control points lifted off the chord.
    Spline,
    /// Variable-width feathered ribbon (soft), optional additive glow.
    Soft,
}

/// Sample a cubic Bézier `p0→p3` at `n+1` points (Catmull-Rom-ish smoothness via
/// control points derived from the chord). Pure → deterministic path.
pub fn spline_points(p0: Pos2, p3: Pos2, curvature: f32, n: usize) -> Vec<Pos2> {
    let n = n.max(1);
    let chord = p3 - p0;
    // Lift control points perpendicular to the chord for a smooth arc.
    let perp = egui::vec2(-chord.y, chord.x).normalized() * chord.length() * curvature;
    let c1 = p0 + chord * (1.0 / 3.0) + perp;
    let c2 = p0 + chord * (2.0 / 3.0) + perp;
    (0..=n)
        .map(|i| {
            let t = i as f32 / n as f32;
            cubic(p0, c1, c2, p3, t)
        })
        .collect()
}

/// The point at arc-length `target` along the sampled poly-line `pts` (linear walk;
/// clamps to the ends). The ONE shared arc-length walk — the flow-dash marcher
/// (`facett-pipeline`), the flow-sim CPU advect mirror + fallback
/// (`facett-flowsim`), and the WGSL `sample_polyline` GPU twin all resolve a screen
/// position from an arc-length offset through this (REUSE, not a re-rolled walk).
#[must_use]
pub fn point_at_arclen(pts: &[Pos2], target: f32) -> Pos2 {
    if pts.is_empty() {
        return Pos2::ZERO;
    }
    if target <= 0.0 {
        return pts[0];
    }
    let mut acc = 0.0;
    for w in pts.windows(2) {
        let seg = w[1] - w[0];
        let l = seg.length();
        if acc + l >= target {
            let f = if l > 1e-6 { (target - acc) / l } else { 0.0 };
            return w[0] + seg * f;
        }
        acc += l;
    }
    *pts.last().unwrap()
}

/// The total arc-length of the sampled poly-line `pts` (Σ segment lengths).
#[must_use]
pub fn arclen_total(pts: &[Pos2]) -> f32 {
    pts.windows(2).map(|w| (w[1] - w[0]).length()).sum()
}

/// The point at **fraction** `s ∈ [0,1]` of the poly-line's total arc-length — the
/// CPU twin of the WGSL `sample_polyline(start,end,s)`. `s` is clamped to `[0,1]`.
#[must_use]
pub fn point_at_frac(pts: &[Pos2], s: f32) -> Pos2 {
    point_at_arclen(pts, s.clamp(0.0, 1.0) * arclen_total(pts))
}

fn cubic(p0: Pos2, c1: Pos2, c2: Pos2, p3: Pos2, t: f32) -> Pos2 {
    let u = 1.0 - t;
    let w0 = u * u * u;
    let w1 = 3.0 * u * u * t;
    let w2 = 3.0 * u * t * t;
    let w3 = t * t * t;
    pos2(
        w0 * p0.x + w1 * c1.x + w2 * c2.x + w3 * p3.x,
        w0 * p0.y + w1 * c1.y + w2 * c2.y + w3 * p3.y,
    )
}

/// Paint an edge per `style`, with `width`/`curvature` from the theme and `glow`
/// gated by `effects`. Soft edges feather by stacking translucent strokes; under
/// `EffectsPolicy::None` (Device) `Soft` collapses to a crisp hairline, no glow
/// (EDGE-2).
#[allow(clippy::too_many_arguments)] // a painter helper: endpoints + style + theme-derived params
pub fn draw_edge(
    painter: &Painter,
    p0: Pos2,
    p3: Pos2,
    style: EdgeStyle,
    color: Color32,
    width: f32,
    curvature: f32,
    glow: Color32,
    effects: EffectsPolicy,
) {
    let crisp = !effects.allows_decorative_motion(); // Device/Reduced → no glow/feather
    match style {
        EdgeStyle::Straight => {
            painter.line_segment([p0, p3], Stroke::new(width, color));
        }
        EdgeStyle::Spline => {
            let pts = spline_points(p0, p3, curvature, 24);
            for w in pts.windows(2) {
                painter.line_segment([w[0], w[1]], Stroke::new(width, color));
            }
        }
        EdgeStyle::Soft => {
            let pts = spline_points(p0, p3, curvature, 24);
            if crisp {
                // Device: crisp hairline, no feather, no glow.
                for w in pts.windows(2) {
                    painter.line_segment([w[0], w[1]], Stroke::new(width.max(1.0), color));
                }
                return;
            }
            // Feathered: a few stacked, widening, fading strokes + a glow underlay.
            let glow_c = Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), 40);
            for w in pts.windows(2) {
                painter.line_segment([w[0], w[1]], Stroke::new(width * 3.0, glow_c));
            }
            for layer in 0..3 {
                let f = layer as f32 / 3.0;
                let a = (180.0 * (1.0 - f)) as u8;
                let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), a);
                let lw = width * (1.0 + f * 1.5);
                for w in pts.windows(2) {
                    painter.line_segment([w[0], w[1]], Stroke::new(lw, c));
                }
            }
        }
    }
}

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

    use super::*;

    #[test]
    fn spline_starts_and_ends_at_the_endpoints() {
        let p0 = pos2(10.0, 10.0);
        let p3 = pos2(110.0, 60.0);
        let pts = spline_points(p0, p3, 0.3, 16);
        assert_eq!(pts.len(), 17);
        assert!((pts[0] - p0).length() < 1e-3, "starts at p0");
        assert!((*pts.last().unwrap() - p3).length() < 1e-3, "ends at p3");
    }

    #[test]
    fn curvature_lifts_the_midpoint_off_the_chord() {
        let p0 = pos2(0.0, 0.0);
        let p3 = pos2(100.0, 0.0);
        let straight_mid = pos2(50.0, 0.0);
        let pts = spline_points(p0, p3, 0.4, 16);
        let mid = pts[pts.len() / 2];
        assert!((mid - straight_mid).length() > 5.0, "a curved spline bows away from the chord");
    }

    #[test]
    fn point_at_arclen_walks_and_clamps() {
        // A straight 3-point poly-line of total length 100 (two 50-px segments).
        let pts = vec![pos2(0.0, 0.0), pos2(50.0, 0.0), pos2(100.0, 0.0)];
        assert_eq!(arclen_total(&pts), 100.0);
        // Clamps below/above the ends.
        assert!((point_at_arclen(&pts, -5.0) - pts[0]).length() < 1e-4);
        assert!((point_at_arclen(&pts, 999.0) - pts[2]).length() < 1e-4);
        // Midpoint lands at x=25 (quarter) and x=75 (three-quarter) by arc-length.
        assert!((point_at_arclen(&pts, 25.0) - pos2(25.0, 0.0)).length() < 1e-4);
        assert!((point_at_frac(&pts, 0.75) - pos2(75.0, 0.0)).length() < 1e-4);
        // Empty is safe.
        assert_eq!(point_at_arclen(&[], 5.0), Pos2::ZERO);
    }

    #[test]
    fn zero_curvature_is_essentially_straight() {
        let p0 = pos2(0.0, 0.0);
        let p3 = pos2(100.0, 0.0);
        let pts = spline_points(p0, p3, 0.0, 16);
        let mid = pts[pts.len() / 2];
        assert!((mid - pos2(50.0, 0.0)).length() < 1e-2, "no curvature → straight");
    }

    #[test]
    #[allow(deprecated)]
    fn soft_edge_renders_without_glow_on_device() {
        // Device (effects None) → the soft edge collapses to crisp hairlines (no
        // extra glow vertices). Compare tessellated vertex counts: Full > None.
        let count = |effects: EffectsPolicy| {
            let ctx = egui::Context::default();
            let out = ctx.run_ui(egui::RawInput::default(), |ui| {
                egui::CentralPanel::default().show_inside(ui, |ui| {
                    let painter = ui.painter().clone();
                    draw_edge(
                        &painter,
                        pos2(50.0, 50.0),
                        pos2(300.0, 200.0),
                        EdgeStyle::Soft,
                        Color32::WHITE,
                        2.0,
                        0.3,
                        Color32::from_rgb(0, 200, 255),
                        effects,
                    );
                });
            });
            ctx.tessellate(out.shapes, out.pixels_per_point)
                .iter()
                .map(|p| match &p.primitive {
                    egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
                    _ => 0,
                })
                .sum::<usize>()
        };
        let full = count(EffectsPolicy::Full);
        let device = count(EffectsPolicy::None);
        assert!(device > 0, "Device still draws the edge");
        assert!(full > device, "Full glow/feather draws more than Device's crisp hairline: full={full} device={device}");
    }
}