figcraft/crates/render/src/boolean_cache_tests.rs

136 lines
5.8 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Live boolean editing in the renderer: per-operand operations, how much of a group is
//! recomputed when one operand changes, the bounded path caches, and one-sided widths on screen.
use super::*;
use crate::boolean::{PathCache, boolean_steps, fold_cached};
use figcraft_color::{Color, Paint};
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind, WidthPoint, WidthProfile};
use figcraft_geom::kurbo::{BezPath, Shape};
fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
let i = ((y * p.width() + x) * 4) as usize;
p.data().get(i).is_some_and(|r| *r < 40)
}
fn render(d: &Document, p: NodeId) -> Pixmap {
render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &Options::default()).unwrap()
}
/// A boolean group doing `op` over squares 20 wide at x = 10, 25, 40, … (overlapping neighbours),
/// on a page of its own.
fn row_of_squares(op: BoolOp, n: usize) -> (Document, NodeId, NodeId, Vec<NodeId>) {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let b = d.create(p, NodeKind::BooleanOp { op }, "B", 0.0, 0.0, 0.0, 0.0).unwrap();
d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let kids = (0..n).map(|i| d.create(b, NodeKind::Rectangle, "R", 10.0 + 15.0 * i as f64, 10.0, 20.0, 20.0).unwrap()).collect();
d.fit_to_children(b).unwrap();
(d, p, b, kids)
}
fn outline(d: &Document, id: NodeId) -> BezPath {
node_outline(d, d.node(id).unwrap()).unwrap().0
}
#[test]
fn operands_combine_by_their_own_operations() {
// Union of three squares in a row, the middle one subtracted instead.
let (mut d, p, b, kids) = row_of_squares(BoolOp::Union, 3);
let union = outline(&d, b).area().abs();
assert!((union - 20.0 * 50.0).abs() < 0.5, "{union}");
d.get_mut(kids[1]).unwrap().bool_op = Some(BoolOp::Subtract);
let mixed = outline(&d, b);
// (A − B) ∪ C: A keeps x 10..25, C is whole (x 40..60).
assert!((mixed.area().abs() - 20.0 * (15.0 + 20.0)).abs() < 0.5, "{}", mixed.area());
let pix = render(&d, p);
assert!(dark(&pix, 15, 20) && !dark(&pix, 32, 20) && dark(&pix, 50, 20));
// The bottom operand's own operation changes nothing.
d.get_mut(kids[0]).unwrap().bool_op = Some(BoolOp::Intersect);
assert!((outline(&d, b).area().abs() - mixed.area().abs()).abs() < 1e-9);
}
#[test]
fn editing_one_operand_recombines_only_from_it_up() {
// Distinct sizes so no other test's cached results can be reused.
let (mut d, _, b, kids) = row_of_squares(BoolOp::Union, 8);
for (i, k) in kids.iter().enumerate() {
d.get_mut(*k).unwrap().height = 20.0 + i as f64 * 0.37;
}
let steps = |d: &Document| {
let before = boolean_steps();
let _ = outline(d, b);
boolean_steps() - before
};
assert_eq!(steps(&d), 7, "the first time: every operand above the bottom one");
assert_eq!(steps(&d), 0, "unchanged: from the cache");
// Moving the sixth operand (a drag frame) recombines it and the two above it.
let mut moved = d.clone();
moved.get_mut(kids[5]).unwrap().transform = figcraft_geom::Affine::translate((3.0, 1.5)) * d.node(kids[5]).unwrap().transform;
assert_eq!(steps(&moved), 3);
// The top one: one step. Going back: cached.
let mut top = moved.clone();
top.get_mut(kids[7]).unwrap().height = 33.3;
assert_eq!(steps(&top), 1);
assert_eq!(steps(&moved), 0);
assert_eq!(steps(&d), 0);
// Hiding an operand drops it from the fold (those above it are recombined).
let mut hidden = d.clone();
hidden.get_mut(kids[6]).unwrap().visible = false;
assert_eq!(steps(&hidden), 1);
}
#[test]
fn other_groups_are_not_recomputed() {
let (mut d, _, b, kids) = row_of_squares(BoolOp::Exclude, 4);
let (d2, _, b2, _) = row_of_squares(BoolOp::Intersect, 5);
for (i, k) in kids.iter().enumerate() {
d.get_mut(*k).unwrap().width = 21.0 + i as f64 * 0.29;
}
let _ = outline(&d, b);
let _ = outline(&d2, b2);
let before = boolean_steps();
d.get_mut(kids[3]).unwrap().width = 30.1;
let _ = outline(&d, b);
let _ = outline(&d2, b2);
assert_eq!(boolean_steps() - before, 1, "only the edited group's top step");
}
#[test]
fn caches_drop_the_least_recently_used_half() {
let mut c = PathCache::new(4);
let p = std::sync::Arc::new(BezPath::new());
for k in 0..4 {
c.insert(k, p.clone());
}
// Using 0 and 1 keeps them when the cache fills up.
assert!(c.get(0).is_some() && c.get(1).is_some());
c.insert(9, p.clone());
assert!(c.get(0).is_some() && c.get(1).is_some() && c.get(9).is_some());
assert!(c.get(2).is_none() && c.get(3).is_none());
// Junk and degenerate folds.
assert!(fold_cached(&[]).elements().is_empty());
let mut bad = BezPath::new();
bad.move_to((f64::NAN, 0.0));
bad.line_to((1.0, 1.0));
let sq = figcraft_geom::Rect::new(0.0, 0.0, 1.0, 1.0).to_path(0.1);
assert!(fold_cached(&[(sq, BoolOp::Union), (bad, BoolOp::Union)]).elements().is_empty());
}
#[test]
fn one_sided_widths_paint_one_side() {
// A line from (10, 100) to (190, 100), 20 wide, all of it on the right (+y, looking along it).
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let l = d.create(p, NodeKind::Line, "L", 10.0, 100.0, 180.0, 0.0).unwrap();
let n = d.get_mut(l).unwrap();
n.strokes = vec![Paint::solid(Color::BLACK)];
n.stroke.weight = 20.0;
n.stroke.width_profile = Some(WidthProfile { points: vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)] });
let pix = render(&d, p);
assert!(dark(&pix, 100, 110) && dark(&pix, 100, 118), "the right side, 20 deep");
assert!(!dark(&pix, 100, 95), "nothing on the left");
// The paint bounds reach the widest side.
let b = d.world_render_bounds(l).unwrap();
assert!(b.y1 >= 120.0 - 1e-6, "{b:?}");
}