Skip to main content

facett_graphview/
cpu.rs

1//! The **CPU rasterizer** (`vello_cpu`) — the reference render path. Builds a
2//! vello_cpu scene from a [`GraphModel`] + [`Decorations`] under a [`Camera`] and
3//! rasterizes it to a straight-RGBA8 pixel buffer (ready for a PNG or an egui
4//! texture). vello_cpu is multithreaded-SIMD software rendering: the proof that
5//! "vello" is not GPU-only.
6//!
7//! Geometry mirrors nornir's `draw_graph`: nodes are rounded rects with a kind
8//! fill + a ring stroke; edges are PCB-style cubics (mid-break) with a little
9//! arrowhead; decorations add ring colours / emphasis edges.
10
11use vello_cpu::color::{AlphaColor, Srgb};
12use vello_cpu::kurbo::{BezPath, Cap, Join, PathEl, Point, RoundedRect, Shape, Stroke};
13use vello_cpu::{Pixmap, RenderContext};
14
15use crate::model::{BOX_H, BOX_W, Camera, Color, Decorations, GraphEdge, GraphModel};
16
17/// A rendered frame: straight (un-premultiplied) RGBA8, row-major, `w × h`.
18pub struct Rendered {
19    pub width: u32,
20    pub height: u32,
21    /// `width * height * 4` bytes, `[r, g, b, a]` per pixel, NOT premultiplied.
22    pub rgba: Vec<u8>,
23}
24
25fn to_alpha(c: Color) -> AlphaColor<Srgb> {
26    AlphaColor::from_rgba8(c.r, c.g, c.b, c.a)
27}
28
29/// Build + rasterize `model` (decorated, under `camera`) into a `w × h` frame on
30/// the CPU. The single entry point the `CpuVello` backend drives.
31pub fn render(
32    model: &GraphModel,
33    decorations: &Decorations,
34    camera: &Camera,
35    width: u32,
36    height: u32,
37    background: Color,
38) -> Rendered {
39    let mut ctx = RenderContext::new(width as u16, height as u16);
40
41    // Background.
42    ctx.set_paint(to_alpha(background));
43    let full = vello_cpu::kurbo::Rect::new(0.0, 0.0, width as f64, height as f64);
44    ctx.fill_rect(&full);
45
46    // id -> centre (screen px), for edge endpoints.
47    let idx: std::collections::HashMap<&str, (f64, f64)> = model
48        .nodes
49        .iter()
50        .map(|n| {
51            let (x, y) = camera.project(n.pos);
52            (n.id.as_str(), (x as f64, y as f64))
53        })
54        .collect();
55
56    // Edges first (chips draw on top).
57    for e in &model.edges {
58        draw_edge(&mut ctx, &idx, e, camera.zoom);
59    }
60    for e in &decorations.edges {
61        // Emphasis edges: a touch thicker.
62        draw_edge_styled(&mut ctx, &idx, e, camera.zoom, 2.6);
63    }
64
65    // Node chips.
66    let hw = (BOX_W * 0.5 * camera.zoom) as f64;
67    let hh = (BOX_H * 0.5 * camera.zoom) as f64;
68    let radius = (5.0 * camera.zoom) as f64;
69    for n in &model.nodes {
70        let (cx, cy) = camera.project(n.pos);
71        let (cx, cy) = (cx as f64, cy as f64);
72        let rect = RoundedRect::new(cx - hw, cy - hh, cx + hw, cy + hh, radius);
73
74        // Fill.
75        ctx.set_paint(to_alpha(n.fill));
76        ctx.fill_path(&rect.to_path(0.1));
77
78        // Ring: a decoration ring wins over the kind stroke.
79        let deco = decorations.nodes.get(&n.id);
80        let (ring_col, ring_w) = match deco.and_then(|d| d.ring) {
81            Some(rc) => (rc, 2.4_f32 * camera.zoom),
82            None => (n.stroke, 1.4_f32 * camera.zoom),
83        };
84        ctx.set_stroke(Stroke::new(ring_w as f64).with_join(Join::Round));
85        ctx.set_paint(to_alpha(ring_col));
86        ctx.stroke_path(&rect.to_path(0.1));
87    }
88
89    let mut pixmap = Pixmap::new(width as u16, height as u16);
90    ctx.flush();
91    ctx.render_to_pixmap(&mut pixmap);
92
93    let rgba: Vec<u8> = pixmap
94        .take_unpremultiplied()
95        .into_iter()
96        .flat_map(|p| [p.r, p.g, p.b, p.a])
97        .collect();
98
99    // ── graphview legacy CPU render-lane emit ─────────────────────────────────
100    // This vello_cpu lane is distinct from the core `CpuRenderer` lane; it was
101    // uninstrumented. Record that it ran + the node/edge geometry it drew + a
102    // lit-pixel proof so a blank graph frame surfaces as RED.
103    #[cfg(feature = "testmatrix")]
104    {
105        let total = (width as usize) * (height as usize);
106        let lit = rgba.chunks_exact(4).filter(|p| p[3] != 0).count();
107        let geom = model.nodes.len() + model.edges.len();
108        facett_core::testmatrix::emit(
109            "facett-graphview::cpu::render",
110            "cpu_render",
111            (geom == 0 || lit > 0) && rgba.len() == total * 4,
112            &format!("nodes={} edges={} lit_px={lit} total_px={total}", model.nodes.len(), model.edges.len()),
113        );
114    }
115
116    Rendered { width, height, rgba }
117}
118
119fn draw_edge(
120    ctx: &mut RenderContext,
121    idx: &std::collections::HashMap<&str, (f64, f64)>,
122    e: &GraphEdge,
123    zoom: f32,
124) {
125    draw_edge_styled(ctx, idx, e, zoom, 1.4);
126}
127
128/// One PCB-style cubic from the right edge of `from` to the left edge of `to`,
129/// with a mid break + a small arrowhead — the same shape nornir's `draw_edge`
130/// paints. (Dashing is approximated by skipping the stroke for `dashed` edges in
131/// this spike — see the LOD/edge-styling stub.)
132fn draw_edge_styled(
133    ctx: &mut RenderContext,
134    idx: &std::collections::HashMap<&str, (f64, f64)>,
135    e: &GraphEdge,
136    zoom: f32,
137    base_w: f32,
138) {
139    let (Some(&(ax0, ay)), Some(&(bx0, by))) = (idx.get(e.from.as_str()), idx.get(e.to.as_str()))
140    else {
141        return;
142    };
143    let half = (BOX_W * 0.5 * zoom) as f64;
144    let a = Point::new(ax0 + half, ay);
145    let b = Point::new(bx0 - half, by);
146    let midx = (a.x + b.x) * 0.5;
147
148    let mut path = BezPath::new();
149    path.push(PathEl::MoveTo(a));
150    path.push(PathEl::CurveTo(Point::new(midx, a.y), Point::new(midx, b.y), b));
151
152    let w = (base_w * zoom.clamp(0.5, 2.0)) as f64;
153    ctx.set_stroke(Stroke::new(w).with_caps(Cap::Round).with_join(Join::Round));
154    ctx.set_paint(to_alpha(e.color));
155    if e.dashed {
156        // Cheap dash: sample the cubic and stroke alternate spans (mirrors the
157        // egui dashed-cubic). Kept simple for the spike.
158        let mut dash = BezPath::new();
159        let seg = 0..=20;
160        let mut on = true;
161        let mut last = a;
162        for i in seg {
163            let t = i as f64 / 20.0;
164            let p = cubic_point(a, Point::new(midx, a.y), Point::new(midx, b.y), b, t);
165            if on {
166                dash.push(PathEl::MoveTo(last));
167                dash.push(PathEl::LineTo(p));
168            }
169            last = p;
170            on = !on;
171        }
172        ctx.stroke_path(&dash);
173    } else {
174        ctx.stroke_path(&path);
175    }
176
177    // Arrowhead at the callee, along the cubic's exit tangent. The last control
178    // point is (midx, b.y), so the curve enters `b` travelling from there → a
179    // horizontal approach toward the node (dy = 0).
180    let dir = {
181        let (dx, dy) = (b.x - midx, 0.0_f64);
182        let len = (dx * dx + dy * dy).sqrt().max(1e-3);
183        (dx / len, dy / len)
184    };
185    let perp = (-dir.1, dir.0);
186    let head = (6.0 * zoom.clamp(0.6, 1.6)) as f64;
187    let mut ah = BezPath::new();
188    ah.push(PathEl::MoveTo(b));
189    ah.push(PathEl::LineTo(Point::new(
190        b.x - dir.0 * head + perp.0 * head * 0.5,
191        b.y - dir.1 * head + perp.1 * head * 0.5,
192    )));
193    ah.push(PathEl::MoveTo(b));
194    ah.push(PathEl::LineTo(Point::new(
195        b.x - dir.0 * head - perp.0 * head * 0.5,
196        b.y - dir.1 * head - perp.1 * head * 0.5,
197    )));
198    ctx.stroke_path(&ah);
199}
200
201/// A point on a cubic Bézier at parameter `t`.
202fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
203    let mt = 1.0 - t;
204    let a = mt * mt * mt;
205    let b = 3.0 * mt * mt * t;
206    let c = 3.0 * mt * t * t;
207    let d = t * t * t;
208    Point::new(
209        a * p0.x + b * p1.x + c * p2.x + d * p3.x,
210        a * p0.y + b * p1.y + c * p2.y + d * p3.y,
211    )
212}