mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 07:42:42 +00:00
Live Paint: double-click floods along split edges, triple-click on new art skips open paths
- A face outline segment that runs along several edge segments (the face has no corner where a crossing line splits the edge) now joins the face to every edge segment it runs along, not only the one under its middle, so a double-click flood no longer stops there. - The first click of a triple-click on selected paths, which makes the Live Paint group, takes the fill to match from the paths' closed subpaths only, as the group's faces do; an open path in front no longer gives the wrong fill.
This commit is contained in:
@@ -330,12 +330,13 @@ fn lp_segments(pd: &PathData) -> Vec<kurbo::CubicBez> {
|
||||
/// The faces of Live Paint group `group` that a flood from face `start` reaches: `start`, and each
|
||||
/// face that shares with a face already reached an edge that is hidden or draws no stroke
|
||||
/// ([`unstroked`]). Hidden faces are not reached and do not pass the flood on. A face runs along an
|
||||
/// edge where a segment of its outline has its middle within [`LP_JOIN_TOL`] of one of the edge's
|
||||
/// segments, over the part of that segment between its outline segment's ends; two faces share the
|
||||
/// edge where those parts overlap along more than [`LP_JOIN_TOL`], so two faces that a Gap
|
||||
/// Detection gap parts on the same side of an edge, whose parts only touch, do not.
|
||||
/// edge segment over the part of it between the points nearest the ends of one of the face's
|
||||
/// outline segments, when that part lies within [`LP_JOIN_TOL`] of the outline segment; one outline
|
||||
/// segment can run along several edge segments. Two faces share the edge where those parts overlap
|
||||
/// along more than [`LP_JOIN_TOL`], so two faces that a Gap Detection gap parts on the same side of
|
||||
/// an edge, whose parts only touch, do not.
|
||||
fn flood_faces(group: &Node, start: NodeId) -> Vec<NodeId> {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest};
|
||||
// Each edge segment, and whether a flood crosses it.
|
||||
let edges: Vec<(kurbo::CubicBez, bool)> = b::edges(group)
|
||||
.into_iter()
|
||||
@@ -351,16 +352,13 @@ fn flood_faces(group: &Node, start: NodeId) -> Vec<NodeId> {
|
||||
}
|
||||
// The faces along each edge segment a flood crosses, each with the part of the segment it runs
|
||||
// along (from and to, in the segment's own parameter). A face outline segment is usually an
|
||||
// edge segment, with the same middle; one that is not (a piece of an edge segment where a Gap
|
||||
// Detection line meets it, or the line itself) is measured against the edge segments near it.
|
||||
// edge segment, with the same ends; one that is not (a piece of an edge segment where a Gap
|
||||
// Detection line meets it, the line itself, or a run along several edge segments where the
|
||||
// face has no corner at the points that split them) is measured against the edge segments
|
||||
// near it.
|
||||
let mut along: std::collections::HashMap<usize, Vec<(NodeId, f64, f64)>> = std::collections::HashMap::new();
|
||||
let mut add = |k: usize, id: NodeId, piece: &kurbo::CubicBez| {
|
||||
if let Some((s, true)) = edges.get(k) {
|
||||
let (a, c) = (s.nearest(piece.p0, 1e-9).t, s.nearest(piece.p3, 1e-9).t);
|
||||
along.entry(k).or_default().push((id, a.min(c), a.max(c)));
|
||||
}
|
||||
};
|
||||
let mut pieces: Vec<(kurbo::CubicBez, NodeId)> = vec![];
|
||||
let near = |p: Point, q: Point| (p - q).hypot() <= LP_JOIN_TOL;
|
||||
for f in b::faces(group).into_iter().filter(|f| f.visible) {
|
||||
for piece in f.path_data().map(lp_segments).unwrap_or_default() {
|
||||
let m = piece.eval(0.5);
|
||||
@@ -368,9 +366,18 @@ fn flood_faces(group: &Node, start: NodeId) -> Vec<NodeId> {
|
||||
let same = (-1..=1)
|
||||
.flat_map(|dx| (-1..=1).map(move |dy| (x.saturating_add(dx), y.saturating_add(dy))))
|
||||
.flat_map(|c| mids.get(&c).map(Vec::as_slice).unwrap_or_default())
|
||||
.find(|(q, _)| (*q - m).hypot() <= LP_JOIN_TOL);
|
||||
.find(|&&(q, k)| {
|
||||
near(q, m)
|
||||
&& edges
|
||||
.get(k)
|
||||
.is_some_and(|(s, _)| (near(s.p0, piece.p0) && near(s.p3, piece.p3)) || (near(s.p0, piece.p3) && near(s.p3, piece.p0)))
|
||||
});
|
||||
match same {
|
||||
Some(&(_, k)) => add(k, f.id, &piece),
|
||||
Some(&(_, k)) => {
|
||||
if edges.get(k).is_some_and(|e| e.1) {
|
||||
along.entry(k).or_default().push((f.id, 0.0, 1.0));
|
||||
}
|
||||
}
|
||||
None => pieces.push((piece, f.id)),
|
||||
}
|
||||
}
|
||||
@@ -378,10 +385,15 @@ fn flood_faces(group: &Node, start: NodeId) -> Vec<NodeId> {
|
||||
if !pieces.is_empty() {
|
||||
let grid = LpEdgeGrid::new(&edges);
|
||||
for (piece, id) in pieces {
|
||||
let m = piece.eval(0.5);
|
||||
for k in grid.near(m) {
|
||||
if edges.get(k).is_some_and(|(s, cross)| *cross && s.nearest(m, 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL) {
|
||||
add(k, id, &piece);
|
||||
for k in grid.near_rect(piece.bounding_box()) {
|
||||
let Some((s, true)) = edges.get(k) else { continue };
|
||||
let (a, c) = (s.nearest(piece.p0, 1e-9).t, s.nearest(piece.p3, 1e-9).t);
|
||||
let (from, to) = (a.min(c), a.max(c));
|
||||
// The part of the edge segment between the piece's ends runs along the piece.
|
||||
let on_piece =
|
||||
[0.25, 0.5, 0.75].iter().all(|u| piece.nearest(s.eval(from + (to - from) * u), 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL);
|
||||
if from < to && on_piece {
|
||||
along.entry(k).or_default().push((id, from, to));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,9 +476,27 @@ impl LpEdgeGrid {
|
||||
(at(p.x, self.origin.x), at(p.y, self.origin.y))
|
||||
}
|
||||
|
||||
/// The segments whose widened bounds may hold `p`.
|
||||
fn near(&self, p: Point) -> impl Iterator<Item = usize> + '_ {
|
||||
self.wide.iter().chain(self.cells.get(&self.cell_of(p)).into_iter().flatten()).copied()
|
||||
/// The segments whose widened bounds may meet `r`, each once.
|
||||
fn near_rect(&self, r: Rect) -> Vec<usize> {
|
||||
let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(r.x0, r.y0)), self.cell_of(Point::new(r.x1, r.y1)));
|
||||
let mut out = self.wide.clone();
|
||||
if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > self.cells.len() as i64 {
|
||||
// More cells than the grid fills: walk the filled ones.
|
||||
for (&(x, y), ks) in &self.cells {
|
||||
if (x0..=x1).contains(&x) && (y0..=y1).contains(&y) {
|
||||
out.extend_from_slice(ks);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for x in x0..=x1 {
|
||||
for y in y0..=y1 {
|
||||
out.extend(self.cells.get(&(x, y)).into_iter().flatten());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2715,6 +2715,36 @@ fn live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps() {
|
||||
assert_eq!(fill(&mut s, g, [125.0, 50.0]), 1, "the gap parts the two faces along the bottom");
|
||||
}
|
||||
|
||||
/// The flood crosses an edge into each face along it when a face's outline runs along the edge in
|
||||
/// one segment and the edge is split where another line meets it.
|
||||
#[test]
|
||||
fn live_paint_bucket_double_click_floods_along_edges_split_at_a_junction() {
|
||||
// A square with a line across its middle, and a line from the top at x = 30 that crosses it and
|
||||
// stops 20 pt below. The bottom face's outline runs along the middle line in one segment, from
|
||||
// x = 0 to 100; the middle line's edges are split at x = 30.
|
||||
let mut s = session();
|
||||
let mut ids = vec![rect(&mut s, 0.0, 0.0, 100.0, 100.0).0];
|
||||
for anchors in [json!([{"x": 0, "y": 50}, {"x": 100, "y": 50}]), json!([{"x": 30, "y": 0}, {"x": 30, "y": 70}])] {
|
||||
ids.push(s.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap());
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let [left, right, bottom] = faces_at(&s, g, &[(15.0, 25.0), (65.0, 25.0), (65.0, 75.0)])[..] else { panic!("three faces") };
|
||||
assert!(left != right && right != bottom && left != bottom);
|
||||
let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 101.0, 101.0));
|
||||
paint_parts(&mut s, &all, &Paint::None, &[]);
|
||||
let down = edges_in(&s, g, Rect::new(30.0, 0.0, 30.0, 70.0));
|
||||
assert!(!down.is_empty() && down.len() < all.len());
|
||||
paint_parts(&mut s, &down, &Paint::solid(Color::rgb(0.0, 0.0, 0.0)), &[]);
|
||||
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
|
||||
let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [65, 75], "color": "#00ff00", "scope": "connected"})).unwrap();
|
||||
assert_eq!(r["count"], 3, "from the bottom face to both faces above the line");
|
||||
assert_eq!([left, right, bottom].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green.clone()]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [15, 25], "color": "#00ff00", "scope": "connected"})).unwrap();
|
||||
assert_eq!(r["count"], 3, "from the upper left face down to the bottom face and up again");
|
||||
}
|
||||
|
||||
/// A triple-click on a face fills every face of the group with that face's fill, joined to it or
|
||||
/// not. Through the tool, the fill compared is the face's fill before the first click.
|
||||
#[test]
|
||||
|
||||
@@ -207,17 +207,19 @@ struct LpPress {
|
||||
}
|
||||
|
||||
/// The fill of the face under `p` in the Live Paint group that `roots` make: the fill of the
|
||||
/// front-most of their paths whose outline holds `p`, else none.
|
||||
/// front-most of their paths whose closed subpaths hold `p`, else none. Open subpaths bound no face.
|
||||
fn made_face_fill(doc: &Document, roots: &[NodeId], p: Point) -> Paint {
|
||||
let mut leaves = vec![];
|
||||
for n in roots.iter().filter_map(|id| doc.node(*id)) {
|
||||
leaf_shapes(n, &mut leaves);
|
||||
}
|
||||
leaves
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|l| node_outline(l).is_some_and(|(path, rule)| !path.is_empty() && path_contains(&path, rule, p)))
|
||||
.map_or(Paint::None, |l| l.appearance.fill_paint())
|
||||
let holds = |l: &Node| {
|
||||
node_outline(l).is_some_and(|(path, rule)| {
|
||||
let closed = vectorcraft_geom::PathData::new(path.subpaths.into_iter().filter(|sp| sp.closed && sp.anchors.len() >= 2).collect());
|
||||
!closed.is_empty() && path_contains(&closed, rule, p)
|
||||
})
|
||||
};
|
||||
leaves.iter().rev().find(|l| holds(l)).map_or(Paint::None, |l| l.appearance.fill_paint())
|
||||
}
|
||||
|
||||
/// How many presses the Live Paint Bucket keeps: the three of a triple-click.
|
||||
|
||||
@@ -1159,6 +1159,44 @@ fn bucket_double_and_triple_click_widen_what_a_click_paints() {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 400.0, 400.0)).is_empty());
|
||||
}
|
||||
|
||||
/// A triple-click whose first click makes the Live Paint group matches the fill of the face the
|
||||
/// group makes there: an open path in front, whose outline bounds no face, does not give it.
|
||||
#[test]
|
||||
fn bucket_triple_click_on_a_made_group_skips_open_paths() {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{PathData, SubPath};
|
||||
let (red, blue) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let back = d.alloc_id();
|
||||
let mut look = Appearance::default_art();
|
||||
look.set_fill(red.clone());
|
||||
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), look)).unwrap();
|
||||
// An open U around (75, 25), filled blue.
|
||||
let u = [Point::new(60.0, 5.0), Point::new(60.0, 45.0), Point::new(90.0, 45.0), Point::new(90.0, 5.0)];
|
||||
let front = d.alloc_id();
|
||||
let mut look = Appearance::default_art();
|
||||
look.set_fill(blue);
|
||||
d.insert(Some(l), 1, Node::path(front, PathData::single(SubPath::polyline(&u, false)), look)).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.set([back, front]);
|
||||
let p = paint();
|
||||
let (made, ..) = live_group_doc();
|
||||
let none = Selection::default();
|
||||
let (cx, later) = (cx(&d, &s, &p), cx(&made, &none, &p));
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0));
|
||||
for _ in 0..2 {
|
||||
t.pointer(&later, &ev(PointerKind::Down, 75.0, 25.0));
|
||||
t.pointer(&later, &ev(PointerKind::Up, 75.0, 25.0));
|
||||
}
|
||||
let acts = t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0));
|
||||
let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") };
|
||||
assert_eq!(id, "livePaint.fill");
|
||||
assert_eq!(v["match"], serde_json::to_value(&red).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_options_choose_what_a_click_paints() {
|
||||
let (d, gid, ..) = live_group_doc();
|
||||
|
||||
Reference in New Issue
Block a user