facett-core/src/edges.rs
//! **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 mut out = Vec::with_capacity(n.max(1) + 1);
spline_points_into(&mut out, p0, p3, curvature, n);
out
}
/// [`spline_points`] into a caller-owned buffer (cleared first), so a per-frame painter
/// reuses one allocation instead of making one per edge. Same points, same order.
pub fn spline_points_into(out: &mut Vec<Pos2>, p0: Pos2, p3: Pos2, curvature: f32, n: usize) {
out.clear();
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;
out.extend((0..=n).map(|i| {
let t = i as f32 / n as f32;
cubic(p0, c1, c2, p3, t)
}));
}
/// 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]
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 mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
egui::CentralPanel::default().show(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,
);
});
});
out.textures_delta.clear();
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}");
}
}