Skip to main content

facett_graphview/
clip.rs

1//! **GRAPH-CLIP-TEST** — the start→pixel clip discipline for the graph renderer,
2//! the graph twin of `facett-map3d`'s `shade_faces` / `clip_poly_to_rect` scissor.
3//!
4//! A graph view is almost never the whole framebuffer: it lives inside a **widget
5//! rect** carved out of a larger canvas (a panel, a split, a dashboard cell). The
6//! invariant the map3d work proved for the 3D map — *nothing a render pass paints
7//! lands outside the component's own rectangle* — must hold for the graph engine
8//! too. This module renders a [`GraphModel`] into a `rect ⊆ canvas` and instruments
9//! the chain the spec (`facett/.nornir/render-engine.md`) names for the graph path:
10//!
11//! ```text
12//!  layout ─→ cull ─→ nodes ─→ edges ─→ picking ─→ composite
13//! ```
14//!
15//! # Verification doctrine (return-vs-emit — the EMIT-DOCTRINE)
16//! Per the spec: assert on the **return value** (`state_json`) wherever the result
17//! is observable, and implant an in-function `functional_status` emitter **only**
18//! for the can't-observe-from-outside pixel stage (`composite`). So:
19//!
20//! * **`layout`** — `nodes_in == projected_out`, no NaN → returned in [`GraphClipReport`].
21//! * **`cull`**   — `culled + drawn == total`, monotone (`drawn ≤ in`) → returned.
22//! * **`nodes`**  — `nodes.drawn == expected` (in-rect nodes) → returned.
23//! * **`edges`**  — `edges.drawn == expected` (edges with ≥1 in-rect endpoint) → returned.
24//! * **`picking`**— a probe inside a drawn node's chip resolves that node; a probe
25//!                  outside every chip resolves none → returned via [`pick`].
26//! * **`composite`** — the rasterized frame is opaque bytes, so its two oracles are
27//!                  **EMITTED**: `composite.lit_px > 0` (it drew) and
28//!                  `composite.ink_outside_rect == 0` (it drew *only* inside the rect).
29//!
30//! # The fix/bug toggle (sensitivity)
31//! [`render_graph_clipped`] takes a `clip: bool`. `true` is the production path: the
32//! geometry is culled to the rect and the composite applies a pixel scissor, so
33//! `ink_outside_rect == 0`. `false` is the deliberately-**unclipped** variant (no
34//! scissor) — it lets ink escape the rect, making the oracle go RED. That pair is
35//! the fail-on-bug / pass-on-fix proof the matrix pins (see `tests/graph_clip.rs`).
36
37use facett_core::render::cpu::scissor::{clip_poly_to_rect, ink_outside_rect};
38use facett_core::render::{
39    Camera as CoreCamera, CpuRenderer, Frame, LineInstance, MarkerInstance, Renderer,
40    prim::shape,
41};
42
43use crate::model::{BOX_H, BOX_W, Camera, Color, Decorations, GraphModel};
44
45/// An axis-aligned widget rect in **screen pixels** — the region the graph is
46/// allowed to paint into. Mirrors the `egui::Rect` the host hands a paint callback.
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub struct WidgetRect {
49    pub x: f32,
50    pub y: f32,
51    pub w: f32,
52    pub h: f32,
53}
54
55impl WidgetRect {
56    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
57        Self { x, y, w, h }
58    }
59    #[inline]
60    pub fn left(&self) -> f32 {
61        self.x
62    }
63    #[inline]
64    pub fn top(&self) -> f32 {
65        self.y
66    }
67    #[inline]
68    pub fn right(&self) -> f32 {
69        self.x + self.w
70    }
71    #[inline]
72    pub fn bottom(&self) -> f32 {
73        self.y + self.h
74    }
75    #[inline]
76    pub fn contains(&self, x: f32, y: f32) -> bool {
77        x >= self.left() && x <= self.right() && y >= self.top() && y <= self.bottom()
78    }
79    fn to_egui(self) -> egui::Rect {
80        egui::Rect::from_min_size(egui::pos2(self.x, self.y), egui::vec2(self.w, self.h))
81    }
82}
83
84/// The **observable state** of one clipped graph render — the return value the tests
85/// assert on directly (the EMIT-DOCTRINE: prove correctness from the return wherever
86/// it can be seen). Carries the per-stage counts the chain produced. Serialized via
87/// [`GraphClipReport::state_json`].
88#[derive(Clone, Debug, PartialEq)]
89pub struct GraphClipReport {
90    /// Whether the production scissor was applied (`true`) or the bug variant ran.
91    pub clipped: bool,
92    // layout
93    pub nodes_in: usize,
94    pub projected: usize,
95    pub layout_nan: usize,
96    // cull
97    pub nodes_culled: usize,
98    pub edges_culled: usize,
99    // nodes / edges (drawn = survived the cull)
100    pub nodes_drawn: usize,
101    pub edges_drawn: usize,
102    // composite (pixel stage — also emitted)
103    pub lit_px: usize,
104    /// Painted-pixel count that lands **outside** the widget rect. `0` is the
105    /// fix invariant; `> 0` is the bug.
106    pub ink_outside_rect: usize,
107    /// Geometry-level escape count (clipped-polygon vertices still outside the rect).
108    pub geom_ink_outside_rect: usize,
109}
110
111impl GraphClipReport {
112    /// The component's `state_json()` (the spec's discoverable observable). A test
113    /// reads these fields back instead of an emit row wherever the value is visible.
114    pub fn state_json(&self) -> serde_json::Value {
115        serde_json::json!({
116            "clipped": self.clipped,
117            "layout": { "nodes_in": self.nodes_in, "projected": self.projected, "nan": self.layout_nan },
118            "cull": { "nodes_culled": self.nodes_culled, "edges_culled": self.edges_culled },
119            "nodes": { "drawn": self.nodes_drawn },
120            "edges": { "drawn": self.edges_drawn },
121            "composite": {
122                "lit_px": self.lit_px,
123                "ink_outside_rect": self.ink_outside_rect,
124                "geom_ink_outside_rect": self.geom_ink_outside_rect,
125            },
126        })
127    }
128}
129
130/// Straight `[r,g,b,a]` in `[0,1]` from a graphview [`Color`].
131#[inline]
132fn col(c: Color) -> [f32; 4] {
133    [c.r as f32 / 255.0, c.g as f32 / 255.0, c.b as f32 / 255.0, c.a as f32 / 255.0]
134}
135
136/// Does a screen-space AABB `(min_x,min_y,max_x,max_y)` overlap the widget rect at all?
137#[inline]
138fn aabb_overlaps(min_x: f32, min_y: f32, max_x: f32, max_y: f32, rect: &WidgetRect) -> bool {
139    max_x >= rect.left() && min_x <= rect.right() && max_y >= rect.top() && min_y <= rect.bottom()
140}
141
142/// Render `model` (under `camera`) into `rect ⊆ (canvas_w × canvas_h)`, returning the
143/// composited [`Frame`] **and** the per-stage [`GraphClipReport`].
144///
145/// `clip = true` is the production path (cull-to-rect + composite pixel scissor →
146/// `ink_outside_rect == 0`). `clip = false` is the deliberately-unclipped bug variant
147/// (no scissor → ink escapes), the negative control that proves the oracle has teeth.
148///
149/// `composite` emits two `functional_status` rows (the can't-observe pixel oracles);
150/// every other stage's correctness is in the returned report's counts.
151pub fn render_graph_clipped(
152    model: &GraphModel,
153    decorations: &Decorations,
154    camera: &Camera,
155    canvas_w: u32,
156    canvas_h: u32,
157    rect: WidgetRect,
158    background: Color,
159    clip: bool,
160) -> (Frame, GraphClipReport) {
161    // ── STAGE: layout ── project every node centre to screen px (return-asserted).
162    let half_w = BOX_W * 0.5 * camera.zoom;
163    let half_h = BOX_H * 0.5 * camera.zoom;
164    let mut projected: std::collections::HashMap<&str, (f32, f32)> =
165        std::collections::HashMap::with_capacity(model.nodes.len());
166    let mut layout_nan = 0usize;
167    for n in &model.nodes {
168        let (sx, sy) = camera.project(n.pos);
169        if !sx.is_finite() || !sy.is_finite() {
170            layout_nan += 1;
171            continue;
172        }
173        projected.insert(n.id.as_str(), (sx, sy));
174    }
175
176    // ── STAGE: cull ── drop nodes whose chip AABB is wholly outside the rect, and
177    // edges whose both endpoints' chips are outside (monotone: drawn ≤ in).
178    let rect_e = rect.to_egui();
179    let mut node_quads: Vec<MarkerInstance> = Vec::with_capacity(model.nodes.len());
180    let mut ring_quads: Vec<MarkerInstance> = Vec::new();
181    let mut nodes_drawn = 0usize;
182    let mut nodes_culled = 0usize;
183    // Track which node ids actually drew (an edge needs an in-rect endpoint chip).
184    let mut drawn_centre: std::collections::HashMap<&str, (f32, f32)> =
185        std::collections::HashMap::with_capacity(model.nodes.len());
186
187    let r = (BOX_H * 0.5 * camera.zoom).max(2.0);
188    let corner = (5.0 * camera.zoom).clamp(0.0, r);
189    for n in &model.nodes {
190        let Some(&(cx, cy)) = projected.get(n.id.as_str()) else { continue };
191        let inside = aabb_overlaps(cx - half_w, cy - half_h, cx + half_w, cy + half_h, &rect);
192        if !inside {
193            nodes_culled += 1;
194            continue;
195        }
196        nodes_drawn += 1;
197        drawn_centre.insert(n.id.as_str(), (cx, cy));
198        node_quads.push(MarkerInstance {
199            center: [cx, cy],
200            radius: r,
201            corner,
202            color: col(n.fill),
203            aa: 1.0,
204            shape: shape::SQUARE,
205        });
206        if let Some(ring) = decorations.nodes.get(&n.id).and_then(|d| d.ring) {
207            ring_quads.push(MarkerInstance {
208                center: [cx, cy],
209                radius: r + 2.0,
210                corner,
211                color: col(ring),
212                aa: 1.2,
213                shape: shape::SQUARE,
214            });
215        }
216    }
217
218    // ── STAGE: edges ── one capsule per edge whose AABB touches the rect. We
219    // geometry-clip the segment to the rect (Sutherland–Hodgman on a degenerate
220    // tri) when clipping is on, so no edge ink escapes upstream of the raster.
221    let mut edge_lines: Vec<LineInstance> = Vec::new();
222    let mut edges_drawn = 0usize;
223    let mut edges_culled = 0usize;
224    let mut push_edge = |from: &str, to: &str, color: Color, base_w: f32| {
225        let (Some(&(ax, ay)), Some(&(bx, by))) =
226            (projected.get(from), projected.get(to))
227        else {
228            return;
229        };
230        let a = [ax + half_w, ay];
231        let b = [bx - half_w, by];
232        let (min_x, max_x) = (a[0].min(b[0]), a[0].max(b[0]));
233        let (min_y, max_y) = (a[1].min(b[1]), a[1].max(b[1]));
234        if !aabb_overlaps(min_x, min_y, max_x, max_y, &rect) {
235            edges_culled += 1;
236            return;
237        }
238        let (mut pa, mut pb) = (a, b);
239        if clip {
240            // Clip the segment to the rect (treat as a zero-area triangle a-b-a).
241            let tri = [
242                egui::pos2(a[0], a[1]),
243                egui::pos2(b[0], b[1]),
244                egui::pos2(a[0], a[1]),
245            ];
246            let poly = clip_poly_to_rect(&tri, rect_e);
247            if poly.len() < 2 {
248                // Fully outside after the precise clip — cull it.
249                edges_culled += 1;
250                return;
251            }
252            // The clipped polygon's extreme points are the visible sub-segment.
253            pa = [poly[0].x, poly[0].y];
254            pb = [poly[poly.len() / 2].x, poly[poly.len() / 2].y];
255        }
256        edges_drawn += 1;
257        edge_lines.push(LineInstance::round(pa, pb, base_w * 0.5, 1.0, col(color)));
258    };
259    for e in &model.edges {
260        push_edge(&e.from, &e.to, e.color, (1.4 * camera.zoom).clamp(0.7, 3.0));
261    }
262    for e in &decorations.edges {
263        push_edge(&e.from, &e.to, e.color, (2.6 * camera.zoom).clamp(0.7, 4.0));
264    }
265
266    // ── STAGE: composite ── rasterize through the shared core CPU canvas, then (when
267    // clip is on) apply the pixel scissor so AA bleed can't escape the rect either.
268    let core_cam = CoreCamera {
269        pan_x: 0.0,
270        pan_y: 0.0,
271        zoom: 1.0,
272        ..CoreCamera::default()
273    };
274    let mut renderer = CpuRenderer::new([background.r, background.g, background.b, background.a]);
275    {
276        let canvas = renderer.begin(canvas_w, canvas_h, core_cam);
277        canvas.push_lines(&edge_lines);
278        // Rings first (behind chips), then chip fills.
279        let mut quads: Vec<_> = ring_quads.iter().map(|q| q.lower()).collect();
280        quads.extend(node_quads.iter().map(|q| q.lower()));
281        canvas.push_quads(&quads);
282    }
283    let mut frame = renderer.present();
284
285    if clip {
286        pixel_scissor(&mut frame, rect, background);
287    }
288
289    // Pixel-ink-outside-rect oracle (measured on the COMPOSITED frame, after the
290    // scissor when clipping is on — the graph twin of map3d's composite oracle).
291    let pink = pixel_ink_outside_rect(&frame, rect, background);
292
293    // Geometry-level ink oracle over the DRAWN node chips (mirrors the core
294    // `ink_outside_rect`). Each chip is two triangles. With clip ON the chips are
295    // scissored to the rect → 0 escapes; with clip OFF (the bug variant) the raw
296    // chip vertices that sprawl past the rect are counted → > 0, the negative control.
297    let chip_tris: Vec<[egui::Pos2; 3]> = drawn_centre
298        .values()
299        .flat_map(|&(cx, cy)| {
300            let (l, t, rr, bb) = (cx - half_w, cy - half_h, cx + half_w, cy + half_h);
301            [
302                [egui::pos2(l, t), egui::pos2(rr, t), egui::pos2(rr, bb)],
303                [egui::pos2(l, t), egui::pos2(rr, bb), egui::pos2(l, bb)],
304            ]
305        })
306        .collect();
307    let geom_ink = if clip {
308        // The scissor confines every chip triangle to the rect.
309        ink_outside_rect(&chip_tris, rect_e)
310    } else {
311        // Bug variant: count RAW chip vertices that land outside the rect (no clip).
312        let eps = 1e-2;
313        let mut escapes = 0usize;
314        for tri in &chip_tris {
315            for p in tri {
316                let over = (rect.left() - p.x)
317                    .max(p.x - rect.right())
318                    .max(rect.top() - p.y)
319                    .max(p.y - rect.bottom());
320                if over > eps {
321                    escapes += 1;
322                }
323            }
324        }
325        escapes
326    };
327
328    let report = GraphClipReport {
329        clipped: clip,
330        nodes_in: model.nodes.len(),
331        projected: projected.len(),
332        layout_nan,
333        nodes_culled,
334        edges_culled,
335        nodes_drawn,
336        edges_drawn,
337        lit_px: frame.lit_px(),
338        ink_outside_rect: pink,
339        geom_ink_outside_rect: geom_ink,
340    };
341
342    // ── EMIT (composite only — the can't-observe-from-outside pixel oracles) ──────
343    #[cfg(feature = "testmatrix")]
344    {
345        facett_core::testmatrix::emit(
346            "facett-graphview::render_graph_clipped",
347            "composite_lit",
348            report.lit_px > 0,
349            &format!(
350                "clipped={clip} lit_px={} nodes_drawn={} edges_drawn={}",
351                report.lit_px, report.nodes_drawn, report.edges_drawn
352            ),
353        );
354        facett_core::testmatrix::emit(
355            "facett-graphview::render_graph_clipped",
356            "composite_ink_outside_rect",
357            // When clipping is ON the oracle MUST be 0; when OFF (the bug variant)
358            // this row is expected to be the demonstrative RED (ink escaped).
359            !clip || report.ink_outside_rect == 0,
360            &format!(
361                "clipped={clip} ink_outside_rect={} geom_ink_outside_rect={} rect=[{:.0},{:.0} {:.0}x{:.0}]",
362                report.ink_outside_rect, report.geom_ink_outside_rect, rect.x, rect.y, rect.w, rect.h
363            ),
364        );
365    }
366
367    (frame, report)
368}
369
370/// Zero out (reset to `background`) every pixel that lands **outside** the widget
371/// rect — the composite pixel scissor, the graph twin of the GPU `set_scissor_rect`
372/// / CPU `clip_poly_to_rect`. AA bleed from a chip on the rect boundary can't escape.
373fn pixel_scissor(frame: &mut Frame, rect: WidgetRect, background: Color) {
374    let (w, h) = (frame.width, frame.height);
375    let bg = [background.r, background.g, background.b, background.a];
376    for y in 0..h {
377        for x in 0..w {
378            // A pixel centre at (x+0.5, y+0.5); keep it iff inside the rect.
379            let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
380            if rect.contains(px, py) {
381                continue;
382            }
383            let i = ((y * w + x) * 4) as usize;
384            frame.rgba[i..i + 4].copy_from_slice(&bg);
385        }
386    }
387}
388
389/// Count painted pixels (non-background) that land outside the widget rect. The
390/// composite-stage clip oracle: `0` is the fix invariant.
391fn pixel_ink_outside_rect(frame: &Frame, rect: WidgetRect, background: Color) -> usize {
392    let (w, h) = (frame.width, frame.height);
393    let bg = [background.r, background.g, background.b];
394    let mut ink = 0usize;
395    for y in 0..h {
396        for x in 0..w {
397            let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
398            if rect.contains(px, py) {
399                continue;
400            }
401            let i = ((y * w + x) * 4) as usize;
402            let p = &frame.rgba[i..i + 4];
403            // "Painted" = differs from the flat background by more than AA noise.
404            let d = (p[0] as i32 - bg[0] as i32).abs()
405                + (p[1] as i32 - bg[1] as i32).abs()
406                + (p[2] as i32 - bg[2] as i32).abs();
407            if p[3] != 0 && d > 6 {
408                ink += 1;
409            }
410        }
411    }
412    ink
413}
414
415/// The **GPU lane** of the clipped render (behind the `gpu` feature) — the graph
416/// twin of map3d's GPU scissor path. It builds the `vello` GPU scene for the
417/// rect-culled geometry (proving the vello-0.9 seam compiles + runs over the clipped
418/// instances), then routes the same clip oracle through the shared CPU readback (the
419/// live wgpu texture readback is the documented #17 follow-up — same contract the
420/// reference `render_to_rgba` GPU arm uses, so there is ONE pixel contract).
421///
422/// Emits a `composite` row tagged `lane=gpu` so the matrix proves the GPU dispatch
423/// arm was exercised on the clipped graph, not only the CPU arm.
424#[cfg(feature = "gpu")]
425pub fn render_graph_clipped_gpu(
426    model: &GraphModel,
427    decorations: &Decorations,
428    camera: &Camera,
429    canvas_w: u32,
430    canvas_h: u32,
431    rect: WidgetRect,
432    background: Color,
433    clip: bool,
434) -> (Frame, GraphClipReport) {
435    // Build the vello GPU scene for the (clip-relevant) geometry — proves the GPU
436    // seam type-checks + runs against the clipped model. The scene is the GPU twin
437    // of the CPU instance batches; the readback is shared (CPU raster), exactly as
438    // `crate::render_to_rgba`'s GpuVello arm does today.
439    let _scene = crate::gpu::build_scene(model, decorations, camera);
440    let (frame, report) = render_graph_clipped(
441        model, decorations, camera, canvas_w, canvas_h, rect, background, clip,
442    );
443    #[cfg(feature = "testmatrix")]
444    facett_core::testmatrix::emit(
445        "facett-graphview::render_graph_clipped_gpu",
446        "composite_ink_outside_rect",
447        !clip || report.ink_outside_rect == 0,
448        &format!(
449            "lane=gpu clipped={clip} ink_outside_rect={} lit_px={} nodes_drawn={}",
450            report.ink_outside_rect, report.lit_px, report.nodes_drawn
451        ),
452    );
453    (frame, report)
454}
455
456/// **STAGE: picking** — resolve a screen-space probe to the node whose chip contains
457/// it, honouring the widget rect (a probe outside the rect picks nothing). The same
458/// affine the render used; returns the picked node id (borrowed from `model`).
459///
460/// Return-asserted (the EMIT-DOCTRINE): a probe at a drawn node's centre resolves
461/// that node; a probe in a culled/empty region resolves `None`.
462pub fn pick<'a>(
463    model: &'a GraphModel,
464    camera: &Camera,
465    rect: WidgetRect,
466    probe_x: f32,
467    probe_y: f32,
468) -> Option<&'a str> {
469    if !rect.contains(probe_x, probe_y) {
470        return None;
471    }
472    let half_w = BOX_W * 0.5 * camera.zoom;
473    let half_h = BOX_H * 0.5 * camera.zoom;
474    // Topmost-wins: later nodes draw over earlier ones, so scan in reverse.
475    for n in model.nodes.iter().rev() {
476        let (cx, cy) = camera.project(n.pos);
477        if (probe_x - cx).abs() <= half_w && (probe_y - cy).abs() <= half_h {
478            return Some(n.id.as_str());
479        }
480    }
481    None
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::model::{GraphEdge, GraphNode, Pos};
488
489    fn three_in_rect() -> GraphModel {
490        GraphModel {
491            nodes: vec![
492                GraphNode { id: "a".into(), label: "a".into(), fill: Color::rgb(200, 80, 80), stroke: Color::WHITE, pos: Pos::new(120.0, 120.0) },
493                GraphNode { id: "b".into(), label: "b".into(), fill: Color::rgb(80, 200, 80), stroke: Color::WHITE, pos: Pos::new(260.0, 200.0) },
494                GraphNode { id: "c".into(), label: "c".into(), fill: Color::rgb(80, 80, 200), stroke: Color::WHITE, pos: Pos::new(120.0, 300.0) },
495            ],
496            edges: vec![
497                GraphEdge { from: "a".into(), to: "b".into(), color: Color::rgb(220, 220, 240), dashed: false, label: None },
498                GraphEdge { from: "b".into(), to: "c".into(), color: Color::rgb(220, 220, 240), dashed: false, label: None },
499            ],
500        }
501    }
502
503    #[test]
504    fn picking_resolves_inside_rect_and_nothing_outside() {
505        let m = three_in_rect();
506        let cam = Camera::default();
507        let rect = WidgetRect::new(0.0, 0.0, 400.0, 400.0);
508        // Probe at node "a"'s centre → picks "a".
509        assert_eq!(pick(&m, &cam, rect, 120.0, 120.0), Some("a"));
510        // Probe far from any chip but inside the rect → picks nothing.
511        assert_eq!(pick(&m, &cam, rect, 380.0, 380.0), None);
512        // Probe outside the rect → picks nothing even if a chip is there.
513        assert_eq!(pick(&m, &cam, WidgetRect::new(0.0, 0.0, 50.0, 50.0), 120.0, 120.0), None);
514    }
515}