facett-graphview/src/clip.rs
//! **GRAPH-CLIP-TEST** — the start→pixel clip discipline for the graph renderer,
//! the graph twin of `facett-map3d`'s `shade_faces` / `clip_poly_to_rect` scissor.
//!
//! A graph view is almost never the whole framebuffer: it lives inside a **widget
//! rect** carved out of a larger canvas (a panel, a split, a dashboard cell). The
//! invariant the map3d work proved for the 3D map — *nothing a render pass paints
//! lands outside the component's own rectangle* — must hold for the graph engine
//! too. This module renders a [`GraphModel`] into a `rect ⊆ canvas` and instruments
//! the chain the spec (`facett/.nornir/render-engine.md`) names for the graph path:
//!
//! ```text
//! layout ─→ cull ─→ nodes ─→ edges ─→ picking ─→ composite
//! ```
//!
//! # Verification doctrine (return-vs-emit — the EMIT-DOCTRINE)
//! Per the spec: assert on the **return value** (`state_json`) wherever the result
//! is observable, and implant an in-function `functional_status` emitter **only**
//! for the can't-observe-from-outside pixel stage (`composite`). So:
//!
//! * **`layout`** — `nodes_in == projected_out`, no NaN → returned in [`GraphClipReport`].
//! * **`cull`** — `culled + drawn == total`, monotone (`drawn ≤ in`) → returned.
//! * **`nodes`** — `nodes.drawn == expected` (in-rect nodes) → returned.
//! * **`edges`** — `edges.drawn == expected` (edges with ≥1 in-rect endpoint) → returned.
//! * **`picking`**— a probe inside a drawn node's chip resolves that node; a probe
//! outside every chip resolves none → returned via [`pick`].
//! * **`composite`** — the rasterized frame is opaque bytes, so its two oracles are
//! **EMITTED**: `composite.lit_px > 0` (it drew) and
//! `composite.ink_outside_rect == 0` (it drew *only* inside the rect).
//!
//! # The fix/bug toggle (sensitivity)
//! [`render_graph_clipped`] takes a `clip: bool`. `true` is the production path: the
//! geometry is culled to the rect and the composite applies a pixel scissor, so
//! `ink_outside_rect == 0`. `false` is the deliberately-**unclipped** variant (no
//! scissor) — it lets ink escape the rect, making the oracle go RED. That pair is
//! the fail-on-bug / pass-on-fix proof the matrix pins (see `tests/graph_clip.rs`).
use facett_core::render::cpu::scissor::{clip_poly_to_rect, ink_outside_rect};
use facett_core::render::{
Camera as CoreCamera, CpuRenderer, Frame, LineInstance, MarkerInstance, Renderer,
prim::shape,
};
use crate::model::{BOX_H, BOX_W, Camera, Color, Decorations, GraphModel};
/// An axis-aligned widget rect in **screen pixels** — the region the graph is
/// allowed to paint into. Mirrors the `egui::Rect` the host hands a paint callback.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WidgetRect {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl WidgetRect {
pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
Self { x, y, w, h }
}
#[inline]
pub fn left(&self) -> f32 {
self.x
}
#[inline]
pub fn top(&self) -> f32 {
self.y
}
#[inline]
pub fn right(&self) -> f32 {
self.x + self.w
}
#[inline]
pub fn bottom(&self) -> f32 {
self.y + self.h
}
#[inline]
pub fn contains(&self, x: f32, y: f32) -> bool {
x >= self.left() && x <= self.right() && y >= self.top() && y <= self.bottom()
}
fn to_egui(self) -> egui::Rect {
egui::Rect::from_min_size(egui::pos2(self.x, self.y), egui::vec2(self.w, self.h))
}
}
/// The **observable state** of one clipped graph render — the return value the tests
/// assert on directly (the EMIT-DOCTRINE: prove correctness from the return wherever
/// it can be seen). Carries the per-stage counts the chain produced. Serialized via
/// [`GraphClipReport::state_json`].
#[derive(Clone, Debug, PartialEq)]
pub struct GraphClipReport {
/// Whether the production scissor was applied (`true`) or the bug variant ran.
pub clipped: bool,
// layout
pub nodes_in: usize,
pub projected: usize,
pub layout_nan: usize,
// cull
pub nodes_culled: usize,
pub edges_culled: usize,
// nodes / edges (drawn = survived the cull)
pub nodes_drawn: usize,
pub edges_drawn: usize,
// composite (pixel stage — also emitted)
pub lit_px: usize,
/// Painted-pixel count that lands **outside** the widget rect. `0` is the
/// fix invariant; `> 0` is the bug.
pub ink_outside_rect: usize,
/// Geometry-level escape count (clipped-polygon vertices still outside the rect).
pub geom_ink_outside_rect: usize,
}
impl GraphClipReport {
/// The component's `state_json()` (the spec's discoverable observable). A test
/// reads these fields back instead of an emit row wherever the value is visible.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"clipped": self.clipped,
"layout": { "nodes_in": self.nodes_in, "projected": self.projected, "nan": self.layout_nan },
"cull": { "nodes_culled": self.nodes_culled, "edges_culled": self.edges_culled },
"nodes": { "drawn": self.nodes_drawn },
"edges": { "drawn": self.edges_drawn },
"composite": {
"lit_px": self.lit_px,
"ink_outside_rect": self.ink_outside_rect,
"geom_ink_outside_rect": self.geom_ink_outside_rect,
},
})
}
}
/// Straight `[r,g,b,a]` in `[0,1]` from a graphview [`Color`].
#[inline]
fn col(c: Color) -> [f32; 4] {
[c.r as f32 / 255.0, c.g as f32 / 255.0, c.b as f32 / 255.0, c.a as f32 / 255.0]
}
/// Does a screen-space AABB `(min_x,min_y,max_x,max_y)` overlap the widget rect at all?
#[inline]
fn aabb_overlaps(min_x: f32, min_y: f32, max_x: f32, max_y: f32, rect: &WidgetRect) -> bool {
max_x >= rect.left() && min_x <= rect.right() && max_y >= rect.top() && min_y <= rect.bottom()
}
/// Render `model` (under `camera`) into `rect ⊆ (canvas_w × canvas_h)`, returning the
/// composited [`Frame`] **and** the per-stage [`GraphClipReport`].
///
/// `clip = true` is the production path (cull-to-rect + composite pixel scissor →
/// `ink_outside_rect == 0`). `clip = false` is the deliberately-unclipped bug variant
/// (no scissor → ink escapes), the negative control that proves the oracle has teeth.
///
/// `composite` emits two `functional_status` rows (the can't-observe pixel oracles);
/// every other stage's correctness is in the returned report's counts.
#[allow(clippy::too_many_arguments)] // public signature hosts call: kept as is
pub fn render_graph_clipped(
model: &GraphModel,
decorations: &Decorations,
camera: &Camera,
canvas_w: u32,
canvas_h: u32,
rect: WidgetRect,
background: Color,
clip: bool,
) -> (Frame, GraphClipReport) {
// ── STAGE: layout ── project every node centre to screen px (return-asserted).
let half_w = BOX_W * 0.5 * camera.zoom;
let half_h = BOX_H * 0.5 * camera.zoom;
let mut projected: std::collections::HashMap<&str, (f32, f32)> =
std::collections::HashMap::with_capacity(model.nodes.len());
let mut layout_nan = 0usize;
for n in &model.nodes {
let (sx, sy) = camera.project(n.pos);
if !sx.is_finite() || !sy.is_finite() {
layout_nan += 1;
continue;
}
projected.insert(n.id.as_str(), (sx, sy));
}
// ── STAGE: cull ── drop nodes whose chip AABB is wholly outside the rect, and
// edges whose both endpoints' chips are outside (monotone: drawn ≤ in).
let rect_e = rect.to_egui();
let mut node_quads: Vec<MarkerInstance> = Vec::with_capacity(model.nodes.len());
let mut ring_quads: Vec<MarkerInstance> = Vec::new();
let mut nodes_drawn = 0usize;
let mut nodes_culled = 0usize;
// Track which node ids actually drew (an edge needs an in-rect endpoint chip).
let mut drawn_centre: std::collections::HashMap<&str, (f32, f32)> =
std::collections::HashMap::with_capacity(model.nodes.len());
let r = (BOX_H * 0.5 * camera.zoom).max(2.0);
let corner = (5.0 * camera.zoom).clamp(0.0, r);
for n in &model.nodes {
let Some(&(cx, cy)) = projected.get(n.id.as_str()) else { continue };
let inside = aabb_overlaps(cx - half_w, cy - half_h, cx + half_w, cy + half_h, &rect);
if !inside {
nodes_culled += 1;
continue;
}
nodes_drawn += 1;
drawn_centre.insert(n.id.as_str(), (cx, cy));
node_quads.push(MarkerInstance {
center: [cx, cy],
radius: r,
corner,
color: col(n.fill),
aa: 1.0,
shape: shape::SQUARE,
});
if let Some(ring) = decorations.nodes.get(&n.id).and_then(|d| d.ring) {
ring_quads.push(MarkerInstance {
center: [cx, cy],
radius: r + 2.0,
corner,
color: col(ring),
aa: 1.2,
shape: shape::SQUARE,
});
}
}
// ── STAGE: edges ── one capsule per edge whose AABB touches the rect. We
// geometry-clip the segment to the rect (Sutherland–Hodgman on a degenerate
// tri) when clipping is on, so no edge ink escapes upstream of the raster.
let mut edge_lines: Vec<LineInstance> = Vec::new();
let mut edges_drawn = 0usize;
let mut edges_culled = 0usize;
let mut push_edge = |from: &str, to: &str, color: Color, base_w: f32| {
let (Some(&(ax, ay)), Some(&(bx, by))) =
(projected.get(from), projected.get(to))
else {
return;
};
let a = [ax + half_w, ay];
let b = [bx - half_w, by];
let (min_x, max_x) = (a[0].min(b[0]), a[0].max(b[0]));
let (min_y, max_y) = (a[1].min(b[1]), a[1].max(b[1]));
if !aabb_overlaps(min_x, min_y, max_x, max_y, &rect) {
edges_culled += 1;
return;
}
let (mut pa, mut pb) = (a, b);
if clip {
// Clip the segment to the rect (treat as a zero-area triangle a-b-a).
let tri = [
egui::pos2(a[0], a[1]),
egui::pos2(b[0], b[1]),
egui::pos2(a[0], a[1]),
];
let poly = clip_poly_to_rect(&tri, rect_e);
if poly.len() < 2 {
// Fully outside after the precise clip — cull it.
edges_culled += 1;
return;
}
// The clipped polygon's extreme points are the visible sub-segment.
pa = [poly[0].x, poly[0].y];
pb = [poly[poly.len() / 2].x, poly[poly.len() / 2].y];
}
edges_drawn += 1;
edge_lines.push(LineInstance::round(pa, pb, base_w * 0.5, 1.0, col(color)));
};
for e in &model.edges {
push_edge(&e.from, &e.to, e.color, (1.4 * camera.zoom).clamp(0.7, 3.0));
}
for e in &decorations.edges {
push_edge(&e.from, &e.to, e.color, (2.6 * camera.zoom).clamp(0.7, 4.0));
}
// ── STAGE: composite ── rasterize through the shared core CPU canvas, then (when
// clip is on) apply the pixel scissor so AA bleed can't escape the rect either.
let core_cam = CoreCamera {
pan_x: 0.0,
pan_y: 0.0,
zoom: 1.0,
..CoreCamera::default()
};
let mut renderer = CpuRenderer::new([background.r, background.g, background.b, background.a]);
{
let canvas = renderer.begin(canvas_w, canvas_h, core_cam);
canvas.push_lines(&edge_lines);
// Rings first (behind chips), then chip fills.
let mut quads: Vec<_> = ring_quads.iter().map(|q| q.lower()).collect();
quads.extend(node_quads.iter().map(|q| q.lower()));
canvas.push_quads(&quads);
}
let mut frame = renderer.present();
if clip {
pixel_scissor(&mut frame, rect, background);
}
// Pixel-ink-outside-rect oracle (measured on the COMPOSITED frame, after the
// scissor when clipping is on — the graph twin of map3d's composite oracle).
let pink = pixel_ink_outside_rect(&frame, rect, background);
// Geometry-level ink oracle over the DRAWN node chips (mirrors the core
// `ink_outside_rect`). Each chip is two triangles. With clip ON the chips are
// scissored to the rect → 0 escapes; with clip OFF (the bug variant) the raw
// chip vertices that sprawl past the rect are counted → > 0, the negative control.
let chip_tris: Vec<[egui::Pos2; 3]> = drawn_centre
.values()
.flat_map(|&(cx, cy)| {
let (l, t, rr, bb) = (cx - half_w, cy - half_h, cx + half_w, cy + half_h);
[
[egui::pos2(l, t), egui::pos2(rr, t), egui::pos2(rr, bb)],
[egui::pos2(l, t), egui::pos2(rr, bb), egui::pos2(l, bb)],
]
})
.collect();
let geom_ink = if clip {
// The scissor confines every chip triangle to the rect.
ink_outside_rect(&chip_tris, rect_e)
} else {
// Bug variant: count RAW chip vertices that land outside the rect (no clip).
let eps = 1e-2;
let mut escapes = 0usize;
for tri in &chip_tris {
for p in tri {
let over = (rect.left() - p.x)
.max(p.x - rect.right())
.max(rect.top() - p.y)
.max(p.y - rect.bottom());
if over > eps {
escapes += 1;
}
}
}
escapes
};
let report = GraphClipReport {
clipped: clip,
nodes_in: model.nodes.len(),
projected: projected.len(),
layout_nan,
nodes_culled,
edges_culled,
nodes_drawn,
edges_drawn,
lit_px: frame.lit_px(),
ink_outside_rect: pink,
geom_ink_outside_rect: geom_ink,
};
// ── EMIT (composite only — the can't-observe-from-outside pixel oracles) ──────
#[cfg(feature = "testmatrix")]
{
facett_core::testmatrix::emit(
"facett-graphview::render_graph_clipped",
"composite_lit",
report.lit_px > 0,
&format!(
"clipped={clip} lit_px={} nodes_drawn={} edges_drawn={}",
report.lit_px, report.nodes_drawn, report.edges_drawn
),
);
facett_core::testmatrix::emit(
"facett-graphview::render_graph_clipped",
"composite_ink_outside_rect",
// When clipping is ON the oracle MUST be 0; when OFF (the bug variant)
// this row is expected to be the demonstrative RED (ink escaped).
!clip || report.ink_outside_rect == 0,
&format!(
"clipped={clip} ink_outside_rect={} geom_ink_outside_rect={} rect=[{:.0},{:.0} {:.0}x{:.0}]",
report.ink_outside_rect, report.geom_ink_outside_rect, rect.x, rect.y, rect.w, rect.h
),
);
}
(frame, report)
}
/// Zero out (reset to `background`) every pixel that lands **outside** the widget
/// rect — the composite pixel scissor, the graph twin of the GPU `set_scissor_rect`
/// / CPU `clip_poly_to_rect`. AA bleed from a chip on the rect boundary can't escape.
fn pixel_scissor(frame: &mut Frame, rect: WidgetRect, background: Color) {
let (w, h) = (frame.width, frame.height);
let bg = [background.r, background.g, background.b, background.a];
for y in 0..h {
for x in 0..w {
// A pixel centre at (x+0.5, y+0.5); keep it iff inside the rect.
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
if rect.contains(px, py) {
continue;
}
let i = ((y * w + x) * 4) as usize;
frame.rgba[i..i + 4].copy_from_slice(&bg);
}
}
}
/// Count painted pixels (non-background) that land outside the widget rect. The
/// composite-stage clip oracle: `0` is the fix invariant.
fn pixel_ink_outside_rect(frame: &Frame, rect: WidgetRect, background: Color) -> usize {
let (w, h) = (frame.width, frame.height);
let bg = [background.r, background.g, background.b];
let mut ink = 0usize;
for y in 0..h {
for x in 0..w {
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
if rect.contains(px, py) {
continue;
}
let i = ((y * w + x) * 4) as usize;
let p = &frame.rgba[i..i + 4];
// "Painted" = differs from the flat background by more than AA noise.
let d = (p[0] as i32 - bg[0] as i32).abs()
+ (p[1] as i32 - bg[1] as i32).abs()
+ (p[2] as i32 - bg[2] as i32).abs();
if p[3] != 0 && d > 6 {
ink += 1;
}
}
}
ink
}
/// The **GPU lane** of the clipped render (behind the `gpu` feature) — the graph
/// twin of map3d's GPU scissor path. It builds the `vello` GPU scene for the
/// rect-culled geometry (proving the vello-0.9 seam compiles + runs over the clipped
/// instances), then routes the same clip oracle through the shared CPU readback (the
/// live wgpu texture readback is the documented #17 follow-up — same contract the
/// reference `render_to_rgba` GPU arm uses, so there is ONE pixel contract).
///
/// Emits a `composite` row tagged `lane=gpu` so the matrix proves the GPU dispatch
/// arm was exercised on the clipped graph, not only the CPU arm.
#[cfg(feature = "gpu")]
#[allow(clippy::too_many_arguments)] // public signature hosts call: kept as is
pub fn render_graph_clipped_gpu(
model: &GraphModel,
decorations: &Decorations,
camera: &Camera,
canvas_w: u32,
canvas_h: u32,
rect: WidgetRect,
background: Color,
clip: bool,
) -> (Frame, GraphClipReport) {
// Build the vello GPU scene for the (clip-relevant) geometry — proves the GPU
// seam type-checks + runs against the clipped model. The scene is the GPU twin
// of the CPU instance batches; the readback is shared (CPU raster), exactly as
// `crate::render_to_rgba`'s GpuVello arm does today.
let _scene = crate::gpu::build_scene(model, decorations, camera);
let (frame, report) = render_graph_clipped(
model, decorations, camera, canvas_w, canvas_h, rect, background, clip,
);
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-graphview::render_graph_clipped_gpu",
"composite_ink_outside_rect",
!clip || report.ink_outside_rect == 0,
&format!(
"lane=gpu clipped={clip} ink_outside_rect={} lit_px={} nodes_drawn={}",
report.ink_outside_rect, report.lit_px, report.nodes_drawn
),
);
(frame, report)
}
/// **STAGE: picking** — resolve a screen-space probe to the node whose chip contains
/// it, honouring the widget rect (a probe outside the rect picks nothing). The same
/// affine the render used; returns the picked node id (borrowed from `model`).
///
/// Return-asserted (the EMIT-DOCTRINE): a probe at a drawn node's centre resolves
/// that node; a probe in a culled/empty region resolves `None`.
pub fn pick<'a>(
model: &'a GraphModel,
camera: &Camera,
rect: WidgetRect,
probe_x: f32,
probe_y: f32,
) -> Option<&'a str> {
if !rect.contains(probe_x, probe_y) {
return None;
}
let half_w = BOX_W * 0.5 * camera.zoom;
let half_h = BOX_H * 0.5 * camera.zoom;
// Topmost-wins: later nodes draw over earlier ones, so scan in reverse.
for n in model.nodes.iter().rev() {
let (cx, cy) = camera.project(n.pos);
if (probe_x - cx).abs() <= half_w && (probe_y - cy).abs() <= half_h {
return Some(n.id.as_str());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{GraphEdge, GraphNode, Pos};
fn three_in_rect() -> GraphModel {
GraphModel {
nodes: vec![
GraphNode { id: "a".into(), label: "a".into(), fill: Color::rgb(200, 80, 80), stroke: Color::WHITE, pos: Pos::new(120.0, 120.0) },
GraphNode { id: "b".into(), label: "b".into(), fill: Color::rgb(80, 200, 80), stroke: Color::WHITE, pos: Pos::new(260.0, 200.0) },
GraphNode { id: "c".into(), label: "c".into(), fill: Color::rgb(80, 80, 200), stroke: Color::WHITE, pos: Pos::new(120.0, 300.0) },
],
edges: vec![
GraphEdge { from: "a".into(), to: "b".into(), color: Color::rgb(220, 220, 240), dashed: false, label: None },
GraphEdge { from: "b".into(), to: "c".into(), color: Color::rgb(220, 220, 240), dashed: false, label: None },
],
}
}
#[test]
fn picking_resolves_inside_rect_and_nothing_outside() {
let m = three_in_rect();
let cam = Camera::default();
let rect = WidgetRect::new(0.0, 0.0, 400.0, 400.0);
// Probe at node "a"'s centre → picks "a".
assert_eq!(pick(&m, &cam, rect, 120.0, 120.0), Some("a"));
// Probe far from any chip but inside the rect → picks nothing.
assert_eq!(pick(&m, &cam, rect, 380.0, 380.0), None);
// Probe outside the rect → picks nothing even if a chip is there.
assert_eq!(pick(&m, &cam, WidgetRect::new(0.0, 0.0, 50.0, 50.0), 120.0, 120.0), None);
}
}