facett-core 0.1.7

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()
}

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 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(egui::RawInput::default(), |ctx| {
                egui::CentralPanel::default().show(ctx, |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}");
    }
}