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    Rendered { width, height, rgba }
100}
101
102fn draw_edge(
103    ctx: &mut RenderContext,
104    idx: &std::collections::HashMap<&str, (f64, f64)>,
105    e: &GraphEdge,
106    zoom: f32,
107) {
108    draw_edge_styled(ctx, idx, e, zoom, 1.4);
109}
110
111/// One PCB-style cubic from the right edge of `from` to the left edge of `to`,
112/// with a mid break + a small arrowhead — the same shape nornir's `draw_edge`
113/// paints. (Dashing is approximated by skipping the stroke for `dashed` edges in
114/// this spike — see the LOD/edge-styling stub.)
115fn draw_edge_styled(
116    ctx: &mut RenderContext,
117    idx: &std::collections::HashMap<&str, (f64, f64)>,
118    e: &GraphEdge,
119    zoom: f32,
120    base_w: f32,
121) {
122    let (Some(&(ax0, ay)), Some(&(bx0, by))) = (idx.get(e.from.as_str()), idx.get(e.to.as_str()))
123    else {
124        return;
125    };
126    let half = (BOX_W * 0.5 * zoom) as f64;
127    let a = Point::new(ax0 + half, ay);
128    let b = Point::new(bx0 - half, by);
129    let midx = (a.x + b.x) * 0.5;
130
131    let mut path = BezPath::new();
132    path.push(PathEl::MoveTo(a));
133    path.push(PathEl::CurveTo(Point::new(midx, a.y), Point::new(midx, b.y), b));
134
135    let w = (base_w * zoom.clamp(0.5, 2.0)) as f64;
136    ctx.set_stroke(Stroke::new(w).with_caps(Cap::Round).with_join(Join::Round));
137    ctx.set_paint(to_alpha(e.color));
138    if e.dashed {
139        // Cheap dash: sample the cubic and stroke alternate spans (mirrors the
140        // egui dashed-cubic). Kept simple for the spike.
141        let mut dash = BezPath::new();
142        let seg = 0..=20;
143        let mut on = true;
144        let mut last = a;
145        for i in seg {
146            let t = i as f64 / 20.0;
147            let p = cubic_point(a, Point::new(midx, a.y), Point::new(midx, b.y), b, t);
148            if on {
149                dash.push(PathEl::MoveTo(last));
150                dash.push(PathEl::LineTo(p));
151            }
152            last = p;
153            on = !on;
154        }
155        ctx.stroke_path(&dash);
156    } else {
157        ctx.stroke_path(&path);
158    }
159
160    // Arrowhead at the callee, along the cubic's exit tangent. The last control
161    // point is (midx, b.y), so the curve enters `b` travelling from there → a
162    // horizontal approach toward the node (dy = 0).
163    let dir = {
164        let (dx, dy) = (b.x - midx, 0.0_f64);
165        let len = (dx * dx + dy * dy).sqrt().max(1e-3);
166        (dx / len, dy / len)
167    };
168    let perp = (-dir.1, dir.0);
169    let head = (6.0 * zoom.clamp(0.6, 1.6)) as f64;
170    let mut ah = BezPath::new();
171    ah.push(PathEl::MoveTo(b));
172    ah.push(PathEl::LineTo(Point::new(
173        b.x - dir.0 * head + perp.0 * head * 0.5,
174        b.y - dir.1 * head + perp.1 * head * 0.5,
175    )));
176    ah.push(PathEl::MoveTo(b));
177    ah.push(PathEl::LineTo(Point::new(
178        b.x - dir.0 * head - perp.0 * head * 0.5,
179        b.y - dir.1 * head - perp.1 * head * 0.5,
180    )));
181    ctx.stroke_path(&ah);
182}
183
184/// A point on a cubic Bézier at parameter `t`.
185fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
186    let mt = 1.0 - t;
187    let a = mt * mt * mt;
188    let b = 3.0 * mt * mt * t;
189    let c = 3.0 * mt * t * t;
190    let d = t * t * t;
191    Point::new(
192        a * p0.x + b * p1.x + c * p2.x + d * p3.x,
193        a * p0.y + b * p1.y + c * p2.y + d * p3.y,
194    )
195}