vectorcraft/crates/trace/src/group.rs

189 lines
8.2 KiB
Rust
Raw 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.

//! Auto Grouping: which traced path lies inside which, so that a result can be grouped the way it
//! is nested (a ring with what fills its hole, a face with its eyes).
use crate::TracedPath;
use kurbo::{PathEl, Point, Rect};
/// How far (px) outside an outline a point may lie and still count as inside it: two paths that
/// share an edge are each fitted on their own.
const NEAR: f64 = 0.5;
/// A path's outer contour as a polygon, and its box.
struct Outer {
pts: Vec<Point>,
bbox: Rect,
/// A closed, filled contour: something can lie inside it.
holds: bool,
/// Where something can: for each pixel row from `bbox.y0 - NEAR` down, the edges (each from
/// the point of that index to the next) that pass through it or within [`NEAR`] of it. A
/// point is tested against the edges of its row, not all of a long outline's.
rows: Vec<Vec<u32>>,
}
fn outer(tp: &TracedPath) -> Option<Outer> {
let (mut pts, mut subpaths, mut closed) = (vec![], 0, false);
kurbo::flatten(tp.path.to_bezpath(), 0.2, |el| match el {
PathEl::MoveTo(p) => {
subpaths += 1;
if subpaths == 1 {
pts.push(p);
}
}
PathEl::LineTo(p) if subpaths == 1 => pts.push(p),
PathEl::ClosePath if subpaths == 1 => closed = true,
_ => {}
});
let first = *pts.first()?;
let bbox = pts.iter().fold(Rect::from_points(first, first), |b, p| b.union_pt(*p));
if !bbox.is_finite() {
return None;
}
let holds = closed && tp.stroke.is_none();
// (Traced paths lie in the image: a box taller than any image is nothing to hold things.)
let holds = holds && bbox.height() < 1e6 && u32::try_from(pts.len()).is_ok();
let mut rows = vec![];
if holds {
rows = vec![vec![]; (bbox.height() + 2.0 * NEAR) as usize + 1];
for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() {
let (top, bottom) = (a.y.min(b.y) - bbox.y0, a.y.max(b.y) - bbox.y0 + 2.0 * NEAR);
for row in rows.iter_mut().take(bottom as usize + 1).skip(top.max(0.0) as usize) {
row.push(i as u32);
}
}
}
Some(Outer { pts, bbox, holds, rows })
}
/// Whether `p` lies inside the closed outline of `o`, or within [`NEAR`] of its edge.
fn inside_or_near(p: Point, o: &Outer) -> bool {
let row = p.y - (o.bbox.y0 - NEAR);
let Some(edges) = (row >= 0.0).then(|| o.rows.get(row as usize)).flatten() else { return false };
let mut inside = false;
for &i in edges {
let (Some(a), Some(b)) = (o.pts.get(i as usize), o.pts.get((i as usize + 1) % o.pts.len().max(1))) else { continue };
if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) {
inside = !inside;
}
let d = *b - *a;
let t = if d.hypot2() > 0.0 { ((p - *a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
if (p - (*a + d * t)).hypot2() <= NEAR * NEAR {
return true;
}
}
inside
}
/// For each of `paths` (bottom-most first), the path it lies inside, if any: the smallest filled
/// path whose outer contour holds all of its own. The path that covers the whole `w` × `h` image
/// (the paper) is nobody's parent: everything would be inside it.
///
/// Drawing each path followed by those inside it paints the picture `paths` paints in its own
/// order: where the paths can overlap (`stacked`), a path only gets a parent below it, and only
/// if no path that would then come after it instead of before it shares any of its box.
pub(crate) fn parents(paths: &[TracedPath], w: usize, h: usize, stacked: bool) -> Vec<Option<usize>> {
let outers: Vec<Option<Outer>> = paths.iter().map(outer).collect();
let paper = Rect::new(1.0, 1.0, w as f64 - 1.0, h as f64 - 1.0);
let mut parent: Vec<Option<usize>> = vec![None; paths.len()];
// How many paths each is inside of.
let mut depth = vec![0usize; paths.len()];
// The paths something can lie inside, by the area of their boxes.
let mut by_area: Vec<usize> = (0..paths.len()).filter(|&p| outers.get(p).and_then(Option::as_ref).is_some_and(|o| o.holds)).collect();
let area = |p: &usize| outers.get(*p).and_then(Option::as_ref).map_or(0.0, |o| o.bbox.area());
by_area.sort_by(|a, b| area(a).total_cmp(&area(b)));
for (x, ox) in outers.iter().enumerate() {
let Some(ox) = ox else { continue };
// The paths whose box holds its own, smallest first.
let mut holders = by_area.iter().filter_map(|&p| Some((p, outers.get(p)?.as_ref()?))).filter(|&(p, op)| {
let (o, i) = (op.bbox, ox.bbox);
o.x0 - NEAR <= i.x0
&& o.y0 - NEAR <= i.y0
&& o.x1 + NEAR >= i.x1
&& o.y1 + NEAR >= i.y1
&& o.area() > i.area()
&& !(stacked && p > x)
&& !(o.x0 <= paper.x0 && o.y0 <= paper.y0 && o.x1 >= paper.x1 && o.y1 >= paper.y1)
});
// The paths under `x` that share some of its box: it may not come to lie under any of them.
// (Found only once some path holds its first point: most paths are inside none.)
let under = std::cell::OnceCell::new();
let under = || {
under.get_or_init(|| {
(0..x)
.filter(|&y| outers.get(y).and_then(Option::as_ref).is_some_and(|oy| oy.bbox.intersect(ox.bbox).area() > 0.0))
.collect::<Vec<_>>()
})
};
let found = holders.find(|&(p, op)| {
ox.pts.first().is_some_and(|pt| inside_or_near(*pt, op))
&& (!stacked || under().iter().all(|&y| !comes_after(&parent, &depth, y, p)))
&& ox.pts.iter().all(|pt| inside_or_near(*pt, op))
});
if let (Some((p, _)), Some(slot)) = (found, parent.get_mut(x)) {
*slot = Some(p);
let deep = depth.get(p).copied().unwrap_or(0) + 1;
if let Some(d) = depth.get_mut(x) {
*d = deep;
}
}
}
parent
}
/// Whether `y` is drawn after `p` and all that is inside `p`: it is in another branch of the
/// nesting, and that branch starts above the one `p` is in.
fn comes_after(parent: &[Option<usize>], depth: &[usize], y: usize, p: usize) -> bool {
let up = |i: usize| parent.get(i).copied().flatten();
let deep = |i: usize| depth.get(i).copied().unwrap_or(0);
let (mut a, mut b) = (y, p);
while deep(a) > deep(b) {
let Some(q) = up(a) else { break };
a = q;
}
while deep(b) > deep(a) {
let Some(q) = up(b) else { break };
b = q;
}
// (Equal: one is the other, or inside it.)
while a != b && up(a) != up(b) {
let (Some(qa), Some(qb)) = (up(a), up(b)) else { break };
(a, b) = (qa, qb);
}
a > b
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::PathData;
fn disc(c: (f64, f64), r: f64, color: u8) -> TracedPath {
let path = PathData::from_bezpath(&kurbo::Shape::to_path(&kurbo::Circle::new(c, r), 0.01));
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None }
}
#[test]
fn a_path_is_inside_the_smallest_path_that_holds_it() {
// The paper, a disc, a disc inside it, a dot inside that, and a disc beside them.
let paths = [
disc((50.0, 50.0), 80.0, 255),
disc((30.0, 50.0), 20.0, 0),
disc((30.0, 50.0), 10.0, 100),
disc((30.0, 50.0), 3.0, 0),
disc((75.0, 50.0), 10.0, 0),
];
for stacked in [false, true] {
assert_eq!(parents(&paths, 100, 100, stacked), [None, None, Some(1), Some(2), None]);
}
}
#[test]
fn stacked_paths_are_not_reordered_where_they_overlap() {
// A dot over a disc, with a wider disc between them in the stack that covers the dot's
// place without holding the first disc: grouped with the first disc, the dot would go under it.
let paths = [disc((30.0, 50.0), 20.0, 0), disc((60.0, 50.0), 24.0, 100), disc((35.0, 50.0), 3.0, 200)];
assert_eq!(parents(&paths, 100, 100, true), [None, None, None]);
// Where paths cannot overlap, either order paints the same.
assert_eq!(parents(&paths, 100, 100, false), [None, None, Some(0)]);
}
}