1use 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#[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#[derive(Clone, Debug, PartialEq)]
89pub struct GraphClipReport {
90 pub clipped: bool,
92 pub nodes_in: usize,
94 pub projected: usize,
95 pub layout_nan: usize,
96 pub nodes_culled: usize,
98 pub edges_culled: usize,
99 pub nodes_drawn: usize,
101 pub edges_drawn: usize,
102 pub lit_px: usize,
104 pub ink_outside_rect: usize,
107 pub geom_ink_outside_rect: usize,
109}
110
111impl GraphClipReport {
112 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#[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#[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
142pub 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 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 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 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 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 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 edges_culled += 1;
250 return;
251 }
252 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 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 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 let pink = pixel_ink_outside_rect(&frame, rect, background);
292
293 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 ink_outside_rect(&chip_tris, rect_e)
310 } else {
311 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 #[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 !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
370fn 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 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
389fn 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 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#[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 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
456pub 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 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 assert_eq!(pick(&m, &cam, rect, 120.0, 120.0), Some("a"));
510 assert_eq!(pick(&m, &cam, rect, 380.0, 380.0), None);
512 assert_eq!(pick(&m, &cam, WidgetRect::new(0.0, 0.0, 50.0, 50.0), 120.0, 120.0), None);
514 }
515}