mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Cap the input of live Pathfinder effects
Fixes #3591 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
1ef873639e
commit
c770e900e3
+159
-5
@@ -104,23 +104,115 @@ fn styled(src: &Node, path: PathData, outline: bool) -> Node {
|
||||
n
|
||||
}
|
||||
|
||||
/// Most segments a live Pathfinder effect takes in, as for the Shape Builder.
|
||||
pub const PATHFINDER_MAX_SEGMENTS: usize = po::SHAPE_BUILDER_MAX_SEGMENTS;
|
||||
|
||||
/// Most pairs of input paths whose bounds overlap a live Pathfinder effect takes in. Crossings,
|
||||
/// and so the pieces of the result, grow with these pairs (n bars crossing n make n² crossings)
|
||||
/// and the effect runs on every draw: past this the group draws its art unprocessed (#3591).
|
||||
pub const PATHFINDER_MAX_OVERLAPS: usize = 16_384;
|
||||
|
||||
/// Fenwick tree of counts over `n` slots, for counting the active boxes of [`within_budget`].
|
||||
struct Counts(Vec<isize>);
|
||||
|
||||
impl Counts {
|
||||
fn add(&mut self, slot: usize, d: isize) {
|
||||
let mut i = slot + 1;
|
||||
while let Some(c) = self.0.get_mut(i) {
|
||||
*c += d;
|
||||
i += i.isolate_lowest_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum of the slots before `end`.
|
||||
fn before(&self, end: usize) -> isize {
|
||||
let (mut i, mut s) = (end.min(self.0.len().saturating_sub(1)), 0);
|
||||
while i > 0 {
|
||||
s += self.0.get(i).copied().unwrap_or(0);
|
||||
i -= i.isolate_lowest_one();
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Can a live Pathfinder evaluate `leaves` within [`PATHFINDER_MAX_SEGMENTS`] and
|
||||
/// [`PATHFINDER_MAX_OVERLAPS`]? The overlapping pairs of bounds are counted exactly by a sweep
|
||||
/// in x that keeps the active boxes in two Fenwick trees over their y extents, so the check costs
|
||||
/// O(n log n) however the boxes are laid out.
|
||||
fn within_budget<'a>(leaves: impl IntoIterator<Item = &'a Node>) -> bool {
|
||||
let mut segments = 0usize;
|
||||
let mut boxes: Vec<Rect> = vec![];
|
||||
for l in leaves {
|
||||
let Some((path, _)) = geometry(l) else { continue };
|
||||
segments += path.subpaths.iter().map(|sp| sp.segment_count()).sum::<usize>();
|
||||
if segments > PATHFINDER_MAX_SEGMENTS {
|
||||
return false;
|
||||
}
|
||||
boxes.extend(path.bounds());
|
||||
}
|
||||
let mut ys: Vec<f64> = boxes.iter().flat_map(|b| [b.y0, b.y1]).collect();
|
||||
ys.sort_by(f64::total_cmp);
|
||||
ys.dedup_by(|a, b| a.total_cmp(b).is_eq());
|
||||
// Slots of the y values below `y`, and of those at or below it.
|
||||
let below = |y: f64| ys.partition_point(|v| v.total_cmp(&y).is_lt());
|
||||
let upto = |y: f64| ys.partition_point(|v| v.total_cmp(&y).is_le());
|
||||
// (x, is end, box); starts sort before ends at the same x since touching boxes overlap.
|
||||
let mut events: Vec<(f64, bool, usize)> = boxes.iter().enumerate().flat_map(|(i, b)| [(b.x0, false, i), (b.x1, true, i)]).collect();
|
||||
events.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
// Active boxes counted by the slot of their y0 and of their y1.
|
||||
let mut by_y0 = Counts(vec![0; ys.len() + 1]);
|
||||
let mut by_y1 = Counts(vec![0; ys.len() + 1]);
|
||||
let (mut active, mut overlaps) = (0isize, 0usize);
|
||||
for (_, end, i) in events {
|
||||
let Some(b) = boxes.get(i) else { continue };
|
||||
if end {
|
||||
by_y0.add(below(b.y0), -1);
|
||||
by_y1.add(below(b.y1), -1);
|
||||
active -= 1;
|
||||
continue;
|
||||
}
|
||||
// Active boxes not entirely below b (y1 < b.y0) nor entirely above it (y0 > b.y1).
|
||||
let above = active - by_y0.before(upto(b.y1));
|
||||
let under = by_y1.before(below(b.y0));
|
||||
overlaps += usize::try_from(active - above - under).unwrap_or(0);
|
||||
if overlaps > PATHFINDER_MAX_OVERLAPS {
|
||||
return false;
|
||||
}
|
||||
by_y0.add(below(b.y0), 1);
|
||||
by_y1.add(below(b.y1), 1);
|
||||
active += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects.
|
||||
/// `None` when the group has no Pathfinder effect.
|
||||
/// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`],
|
||||
/// [`PATHFINDER_MAX_OVERLAPS`]) it returns the children as they are.
|
||||
pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Vec<Arc<Node>>> {
|
||||
if !has_pathfinder(group) {
|
||||
return None;
|
||||
}
|
||||
let mut stack: Vec<Arc<Node>> = group.children()?.to_vec();
|
||||
let source: &[Arc<Node>] = group.children()?;
|
||||
let mut stack: Vec<Arc<Node>> = source.to_vec();
|
||||
for e in group.appearance.effects.iter().filter(|e| e.visible) {
|
||||
let Some(op) = op_of(&e.id) else { continue };
|
||||
let shape_mode =
|
||||
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
|
||||
let all: Vec<Vec<Node>> = stack
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut lv = vec![];
|
||||
leaves(c, hook, &mut lv);
|
||||
lv
|
||||
})
|
||||
.collect();
|
||||
if !within_budget(all.iter().flatten()) {
|
||||
return Some(source.to_vec());
|
||||
}
|
||||
// (style source, shape) per stack entry; keys index `sources`.
|
||||
let mut sources: Vec<Node> = vec![];
|
||||
let mut shapes: Vec<po::Shape> = vec![];
|
||||
for c in &stack {
|
||||
let mut lv = vec![];
|
||||
leaves(c, hook, &mut lv);
|
||||
for mut lv in all {
|
||||
if shape_mode && lv.len() > 1 {
|
||||
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(geometry).collect();
|
||||
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
|
||||
@@ -638,4 +730,66 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit
|
||||
/// pitch, each crossing every bar of the other direction: n² crossings.
|
||||
fn crossing_bars(n: usize, effect: &str) -> Node {
|
||||
let len = 4.0 * n as f64;
|
||||
let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let mut children = vec![];
|
||||
for i in 0..n {
|
||||
let at = 4.0 * i as f64;
|
||||
children.push(Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(at, 0.0, at + 2.0, len)), paint.clone())));
|
||||
}
|
||||
for i in 0..n {
|
||||
let at = 4.0 * i as f64;
|
||||
let id = NodeId(100 + (n + i) as u64);
|
||||
children.push(Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, at, len, at + 2.0)), paint.clone())));
|
||||
}
|
||||
let mut g = Node::group(NodeId(10), children);
|
||||
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_past_its_budget_draws_the_source_art() {
|
||||
// n² crossings past the cap: no evaluation (#3591), the source art as it is.
|
||||
for n in [130, 800] {
|
||||
let g = crossing_bars(n, "pathfinder.divide");
|
||||
let out = pathfinder_children(&g, None).unwrap();
|
||||
assert_eq!(out.len(), 2 * n, "{n} x {n} bars");
|
||||
let ids: Vec<NodeId> = out.iter().map(|c| c.id).collect();
|
||||
let src: Vec<NodeId> = g.children().unwrap().iter().map(|c| c.id).collect();
|
||||
assert_eq!(ids, src, "{n} x {n} bars");
|
||||
assert!(out.iter().zip(g.children().unwrap()).all(|(a, b)| a.path_data() == b.path_data()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_within_its_budget_still_divides() {
|
||||
// Each bar is cut into one piece per crossing plus the n pieces between and past them, so
|
||||
// n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly
|
||||
// `PATHFINDER_MAX_OVERLAPS` pairs, must still divide.
|
||||
for n in [8, 128] {
|
||||
let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap();
|
||||
assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_on_many_stacked_bars_still_runs() {
|
||||
// 3000 bars stacked in y all overlap in x, but each overlaps only the next one in y: 2999
|
||||
// overlapping pairs are within the budget, so Add still merges them into one path.
|
||||
let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let children = (0..3000)
|
||||
.map(|i| {
|
||||
let at = 1.5 * i as f64;
|
||||
Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(0.0, at, 100.0, at + 2.0)), paint.clone()))
|
||||
})
|
||||
.collect();
|
||||
let mut g = Node::group(NodeId(10), children);
|
||||
g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
|
||||
let out = pathfinder_children(&g, None).unwrap();
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user