Skip to main content

facett_core/
edges.rs

1//! **Graph edge rendering** (§18) — straight, spline (cubic Bézier / Catmull-Rom
2//! sampled), and fat/soft (variable width, feathered) edges, with widths/softness
3//! from the theme. **Device** = crisp hairlines, no glow (gated by
4//! [`EffectsPolicy`](crate::look::EffectsPolicy)). The sampling is pure so the
5//! path is snapshot-stable.
6
7use egui::{Color32, Painter, Pos2, Stroke, pos2};
8
9use crate::look::EffectsPolicy;
10
11/// Edge geometry styles (EDGE-1).
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum EdgeStyle {
14    Straight,
15    /// Cubic-Bézier spline with the control points lifted off the chord.
16    Spline,
17    /// Variable-width feathered ribbon (soft), optional additive glow.
18    Soft,
19}
20
21/// Sample a cubic Bézier `p0→p3` at `n+1` points (Catmull-Rom-ish smoothness via
22/// control points derived from the chord). Pure → deterministic path.
23pub fn spline_points(p0: Pos2, p3: Pos2, curvature: f32, n: usize) -> Vec<Pos2> {
24    let n = n.max(1);
25    let chord = p3 - p0;
26    // Lift control points perpendicular to the chord for a smooth arc.
27    let perp = egui::vec2(-chord.y, chord.x).normalized() * chord.length() * curvature;
28    let c1 = p0 + chord * (1.0 / 3.0) + perp;
29    let c2 = p0 + chord * (2.0 / 3.0) + perp;
30    (0..=n)
31        .map(|i| {
32            let t = i as f32 / n as f32;
33            cubic(p0, c1, c2, p3, t)
34        })
35        .collect()
36}
37
38fn cubic(p0: Pos2, c1: Pos2, c2: Pos2, p3: Pos2, t: f32) -> Pos2 {
39    let u = 1.0 - t;
40    let w0 = u * u * u;
41    let w1 = 3.0 * u * u * t;
42    let w2 = 3.0 * u * t * t;
43    let w3 = t * t * t;
44    pos2(
45        w0 * p0.x + w1 * c1.x + w2 * c2.x + w3 * p3.x,
46        w0 * p0.y + w1 * c1.y + w2 * c2.y + w3 * p3.y,
47    )
48}
49
50/// Paint an edge per `style`, with `width`/`curvature` from the theme and `glow`
51/// gated by `effects`. Soft edges feather by stacking translucent strokes; under
52/// `EffectsPolicy::None` (Device) `Soft` collapses to a crisp hairline, no glow
53/// (EDGE-2).
54#[allow(clippy::too_many_arguments)] // a painter helper: endpoints + style + theme-derived params
55pub fn draw_edge(
56    painter: &Painter,
57    p0: Pos2,
58    p3: Pos2,
59    style: EdgeStyle,
60    color: Color32,
61    width: f32,
62    curvature: f32,
63    glow: Color32,
64    effects: EffectsPolicy,
65) {
66    let crisp = !effects.allows_decorative_motion(); // Device/Reduced → no glow/feather
67    match style {
68        EdgeStyle::Straight => {
69            painter.line_segment([p0, p3], Stroke::new(width, color));
70        }
71        EdgeStyle::Spline => {
72            let pts = spline_points(p0, p3, curvature, 24);
73            for w in pts.windows(2) {
74                painter.line_segment([w[0], w[1]], Stroke::new(width, color));
75            }
76        }
77        EdgeStyle::Soft => {
78            let pts = spline_points(p0, p3, curvature, 24);
79            if crisp {
80                // Device: crisp hairline, no feather, no glow.
81                for w in pts.windows(2) {
82                    painter.line_segment([w[0], w[1]], Stroke::new(width.max(1.0), color));
83                }
84                return;
85            }
86            // Feathered: a few stacked, widening, fading strokes + a glow underlay.
87            let glow_c = Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), 40);
88            for w in pts.windows(2) {
89                painter.line_segment([w[0], w[1]], Stroke::new(width * 3.0, glow_c));
90            }
91            for layer in 0..3 {
92                let f = layer as f32 / 3.0;
93                let a = (180.0 * (1.0 - f)) as u8;
94                let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), a);
95                let lw = width * (1.0 + f * 1.5);
96                for w in pts.windows(2) {
97                    painter.line_segment([w[0], w[1]], Stroke::new(lw, c));
98                }
99            }
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use egui::pos2;
107
108    use super::*;
109
110    #[test]
111    fn spline_starts_and_ends_at_the_endpoints() {
112        let p0 = pos2(10.0, 10.0);
113        let p3 = pos2(110.0, 60.0);
114        let pts = spline_points(p0, p3, 0.3, 16);
115        assert_eq!(pts.len(), 17);
116        assert!((pts[0] - p0).length() < 1e-3, "starts at p0");
117        assert!((*pts.last().unwrap() - p3).length() < 1e-3, "ends at p3");
118    }
119
120    #[test]
121    fn curvature_lifts_the_midpoint_off_the_chord() {
122        let p0 = pos2(0.0, 0.0);
123        let p3 = pos2(100.0, 0.0);
124        let straight_mid = pos2(50.0, 0.0);
125        let pts = spline_points(p0, p3, 0.4, 16);
126        let mid = pts[pts.len() / 2];
127        assert!((mid - straight_mid).length() > 5.0, "a curved spline bows away from the chord");
128    }
129
130    #[test]
131    fn zero_curvature_is_essentially_straight() {
132        let p0 = pos2(0.0, 0.0);
133        let p3 = pos2(100.0, 0.0);
134        let pts = spline_points(p0, p3, 0.0, 16);
135        let mid = pts[pts.len() / 2];
136        assert!((mid - pos2(50.0, 0.0)).length() < 1e-2, "no curvature → straight");
137    }
138
139    #[test]
140    #[allow(deprecated)]
141    fn soft_edge_renders_without_glow_on_device() {
142        // Device (effects None) → the soft edge collapses to crisp hairlines (no
143        // extra glow vertices). Compare tessellated vertex counts: Full > None.
144        let count = |effects: EffectsPolicy| {
145            let ctx = egui::Context::default();
146            let out = ctx.run(egui::RawInput::default(), |ctx| {
147                egui::CentralPanel::default().show(ctx, |ui| {
148                    let painter = ui.painter().clone();
149                    draw_edge(
150                        &painter,
151                        pos2(50.0, 50.0),
152                        pos2(300.0, 200.0),
153                        EdgeStyle::Soft,
154                        Color32::WHITE,
155                        2.0,
156                        0.3,
157                        Color32::from_rgb(0, 200, 255),
158                        effects,
159                    );
160                });
161            });
162            ctx.tessellate(out.shapes, out.pixels_per_point)
163                .iter()
164                .map(|p| match &p.primitive {
165                    egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
166                    _ => 0,
167                })
168                .sum::<usize>()
169        };
170        let full = count(EffectsPolicy::Full);
171        let device = count(EffectsPolicy::None);
172        assert!(device > 0, "Device still draws the edge");
173        assert!(full > device, "Full glow/feather draws more than Device's crisp hairline: full={full} device={device}");
174    }
175}