136 lines
5.8 KiB
Rust
136 lines
5.8 KiB
Rust
//! 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:?}");
|
||
}
|