Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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);
    }
}