Live Paint: bucket scopes follow the stroke an edge draws

The Live Paint Bucket's connected and samePaint scopes kept their own
copies of the face flood and the edge walk. They read an edge's stored
stroke paint, so an edge that is fully transparent or whose stroke is
hidden stopped a face flood and matched as stroked. The face flood also
required outlines within 0.0001 pt of an edge, which a curved face that
the planar map refits can miss.

The engine now calls the shared flood_faces and connected_edges that
Live Paint Selection uses (#7342), and compares edges by lp_stroke_paint,
the paint of the topmost stroke that draws. The tool records that same
paint as the match for a double-click or triple-click on an edge. The
params text and docs/mcp.md describe the drawn stroke paint.
This commit is contained in:
dotCooCoo
2026-11-14 11:24:56 -08:00
parent 69ce720e46
commit 7ef603db40
7 changed files with 52 additions and 251 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Bucket (Stroke)",
[],
None,
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges whose stroke paint is match) | \"samePaint\" (a triple-click: also every edge of the group whose stroke paint is match), match?: paint (as the document stores it; scope connected or samePaint only; default: the edge's stroke paint), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges; when every edge it reaches already has the paint and the width, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}",
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully transparent or has no such stroke), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges; when every edge it reaches already has the paint and the width, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}",
has_doc,
lp_stroke_edge
),
+4 -241
View File
@@ -399,243 +399,6 @@ fn match_param(p: &Value, c: &str, allowed: bool, scopes: &str) -> Result<Option
serde_json::from_value::<Paint>(v.clone()).map(Some).map_err(|_| bad(c, "match must be a paint as the document stores it"))
}
/// The greatest distance (pt) between two points that the Live Paint Bucket takes as one point:
/// where edges meet, and where a face's outline runs along an edge.
const LP_JOIN_TOL: f64 = 1e-4;
/// The cell [`LP_JOIN_TOL`] wide that holds `p`, for finding the points near it; two points within
/// [`LP_JOIN_TOL`] of each other lie in the same or neighboring cells. The cast saturates, so a
/// coordinate too large or not finite does not overflow.
fn lp_cell(p: Point) -> (i64, i64) {
((p.x / LP_JOIN_TOL).round() as i64, (p.y / LP_JOIN_TOL).round() as i64)
}
/// Whether `edge` draws no stroke: its stroke paint is none or its weight is 0.
fn unstroked(edge: &Node) -> bool {
edge.appearance.stroke_paint().is_none() || edge.appearance.stroke_width() <= 0.0
}
/// The segments of `pd`.
fn lp_segments(pd: &PathData) -> Vec<kurbo::CubicBez> {
pd.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect()
}
/// 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 segment over the part of it between the points nearest the ends of one of the face's
/// outline segments, when that part's points at a quarter, half and three quarters of its
/// parameter range lie 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, ParamCurveExtrema, ParamCurveNearest};
// Each edge segment, and whether a flood crosses it.
let edges: Vec<(kurbo::CubicBez, bool)> = b::edges(group)
.into_iter()
.flat_map(|e| {
let cross = !e.visible || unstroked(e);
e.path_data().map(lp_segments).unwrap_or_default().into_iter().map(move |s| (s, cross))
})
.collect();
let mut mids: std::collections::HashMap<(i64, i64), Vec<(Point, usize)>> = std::collections::HashMap::new();
for (k, (s, _)) in edges.iter().enumerate() {
let m = s.eval(0.5);
mids.entry(lp_cell(m)).or_default().push((m, k));
}
// 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 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 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);
let (x, y) = lp_cell(m);
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, 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)) => {
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)),
}
}
}
if !pieces.is_empty() {
let grid = LpEdgeGrid::new(&edges);
for (piece, id) in pieces {
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));
}
}
}
}
let mut next: std::collections::HashMap<NodeId, Vec<NodeId>> = std::collections::HashMap::new();
for (k, faces) in &along {
let Some((s, _)) = edges.get(*k) else { continue };
for (i, &(a, a0, a1)) in faces.iter().enumerate() {
for &(c, c0, c1) in faces.iter().skip(i + 1) {
let (from, to) = (a0.max(c0), a1.min(c1));
if a != c && from < to && s.subsegment(from..to).arclen(1e-6) > LP_JOIN_TOL {
next.entry(a).or_default().push(c);
next.entry(c).or_default().push(a);
}
}
}
}
let mut reached = vec![start];
let mut seen = std::collections::HashSet::from([start]);
let mut i = 0;
while let Some(&f) = reached.get(i) {
i += 1;
for &n in next.get(&f).map(Vec::as_slice).unwrap_or_default() {
if seen.insert(n) {
reached.push(n);
}
}
}
reached
}
/// Most grid cells one edge segment is listed in by [`LpEdgeGrid`]; a longer one is tried for
/// every point.
const LP_GRID_SPAN: i64 = 1024;
/// The farthest cell from the grid's origin, on each axis, that [`LpEdgeGrid`] numbers a point in.
const LP_GRID_LIMIT: f64 = 1_048_576.0;
/// The edge segments a flood crosses, by the cells of a square grid (about one cell per segment)
/// that their bounds, widened by [`LP_JOIN_TOL`], cover.
struct LpEdgeGrid {
origin: Point,
cell: f64,
/// Segment indices by cell.
cells: std::collections::HashMap<(i64, i64), Vec<usize>>,
/// Segments whose bounds cover more than [`LP_GRID_SPAN`] cells, near every point.
wide: Vec<usize>,
}
impl LpEdgeGrid {
/// The grid of the segments of `edges` that a flood crosses (those marked true).
fn new(edges: &[(kurbo::CubicBez, bool)]) -> Self {
use kurbo::ParamCurveExtrema;
let boxes: Vec<(usize, Rect)> =
edges.iter().enumerate().filter(|(_, e)| e.1).map(|(k, (s, _))| (k, s.bounding_box().inflate(LP_JOIN_TOL, LP_JOIN_TOL))).collect();
let all = boxes.iter().map(|b| b.1).reduce(|a, c| a.union(c)).unwrap_or_default();
let side = (boxes.len() as f64).sqrt().ceil().max(1.0);
let cell = all.width().max(all.height()) / side;
let cell = if cell.is_finite() && cell > LP_JOIN_TOL { cell } else { LP_JOIN_TOL };
let mut grid = Self { origin: all.origin(), cell, cells: std::collections::HashMap::new(), wide: vec![] };
for (k, b) in boxes {
let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1)));
if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN {
grid.wide.push(k);
continue;
}
for x in x0..=x1 {
for y in y0..=y1 {
grid.cells.entry((x, y)).or_default().push(k);
}
}
}
grid
}
/// The cell `p` is in, counted from the origin (at most [`LP_GRID_LIMIT`] cells away on each
/// axis).
fn cell_of(&self, p: Point) -> (i64, i64) {
let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-LP_GRID_LIMIT, LP_GRID_LIMIT) as i64;
(at(p.x, self.origin.x), at(p.y, self.origin.y))
}
/// 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
}
}
/// The edges of Live Paint group `group` that are not hidden and are joined to edge `start` through
/// edges with stroke paint `paint`: `start`, and each edge with that stroke paint that has a
/// segment end within [`LP_JOIN_TOL`] of a segment end of an edge already reached.
fn connected_edges(group: &Node, start: NodeId, paint: &Paint) -> Vec<NodeId> {
let edges: Vec<&Arc<Node>> =
b::edges(group).into_iter().filter(|e| e.visible && (e.id == start || e.appearance.stroke_paint() == *paint)).collect();
let ends: Vec<Vec<Point>> = edges
.iter()
.map(|e| {
e.path_data()
.map(|pd| pd.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i)).flat_map(|c| [c.p0, c.p3])).collect())
.unwrap_or_default()
})
.collect();
let mut at: std::collections::HashMap<(i64, i64), Vec<(Point, usize)>> = std::collections::HashMap::new();
for (i, pts) in ends.iter().enumerate() {
for &p in pts {
at.entry(lp_cell(p)).or_default().push((p, i));
}
}
let Some(first) = edges.iter().position(|e| e.id == start) else { return vec![] };
let mut reached = vec![first];
let mut seen = std::collections::HashSet::from([first]);
let mut k = 0;
while let Some(&i) = reached.get(k) {
k += 1;
for &p in ends.get(i).map(Vec::as_slice).unwrap_or_default() {
let (x, y) = lp_cell(p);
for dx in -1..=1 {
for dy in -1..=1 {
for &(q, j) in at.get(&(x.saturating_add(dx), y.saturating_add(dy))).map(Vec::as_slice).unwrap_or_default() {
if (q - p).hypot() <= LP_JOIN_TOL && seen.insert(j) {
reached.push(j);
}
}
}
}
}
}
reached.into_iter().filter_map(|i| edges.get(i).map(|e| e.id)).collect()
}
pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.fill";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
@@ -690,7 +453,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
if scope != LpScope::One {
let n = d.node(gid).ok_or(EngineError::NoNode(gid))?;
if scope == LpScope::Connected {
targets.extend(flood_faces(n, fid));
targets.extend(b::flood_faces(n, fid));
} else {
let like = match &like {
Some(m) => m.clone(),
@@ -798,12 +561,12 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
let start = b::visible_edge_near(n, pt, tol).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
let eid = start.id;
let like = like.unwrap_or_else(|| start.appearance.stroke_paint());
let like = like.unwrap_or_else(|| b::lp_stroke_paint(start));
let targets = match scope {
LpScope::One => vec![eid],
LpScope::Connected => connected_edges(n, eid, &like),
LpScope::Connected => b::connected_edges(n, eid, &like),
LpScope::SamePaint => std::iter::once(eid)
.chain(b::edges(n).into_iter().filter(|e| e.visible && e.id != eid && e.appearance.stroke_paint() == like).map(|e| e.id))
.chain(b::edges(n).into_iter().filter(|e| e.visible && e.id != eid && b::lp_stroke_paint(e) == like).map(|e| e.id))
.collect(),
};
let count = targets.len();
+35
View File
@@ -3046,6 +3046,41 @@ fn live_paint_bucket_double_click_floods_across_unstroked_edges() {
assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green, white]);
}
/// An edge that draws no stroke (fully transparent, or with its stroke hidden) lets a flood across
/// although it stores a stroke paint, and an edge scope compares the stroke paint each edge draws
/// with.
#[test]
fn live_paint_bucket_scopes_follow_the_stroke_an_edge_draws() {
let mut s = session();
let g = live_squares(&mut s, &[0.0, 50.0, 100.0]);
let black = Paint::solid(Color::rgb(0.0, 0.0, 0.0));
let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 151.0, 51.0));
paint_parts(&mut s, &all, &black, &[]);
let clear = edges_in(&s, g, Rect::new(50.0, 0.0, 50.0, 50.0));
let hidden = edges_in(&s, g, Rect::new(100.0, 0.0, 100.0, 50.0));
assert!(!clear.is_empty() && !hidden.is_empty());
s.edit("t", |d, _| {
for id in &clear {
d.node_mut(*id).unwrap().opacity = 0.0;
}
for id in &hidden {
d.node_mut(*id).unwrap().appearance.stroke_mut().unwrap().visible = false;
}
Ok(())
})
.unwrap();
let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "connected"})).unwrap();
assert_eq!(r["count"], 3, "the flood crosses both middle edges");
s.execute("edit.undo", &json!({})).unwrap();
let stroke = |s: &mut Session, at: [f64; 2]| {
let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": at, "color": "#00ff00", "width": 3, "scope": "samePaint"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
r["count"].clone()
};
assert_eq!(stroke(&mut s, [25.0, 0.0]), json!(all.len() - clear.len() - hidden.len()), "the edges that draw black");
assert_eq!(stroke(&mut s, [50.0, 25.0]), json!(clear.len() + hidden.len()), "the edges that draw no stroke");
}
/// With Cmd held, the Live Paint Bucket's presses go to the selection tool Cmd lends, and the
/// double-click and triple-click that follow them paint nothing.
#[test]
+2 -2
View File
@@ -42,8 +42,8 @@ pub use live_paint::{
use live_paint::{check_live_paint_options, lp_color, lp_rgb_color};
pub use lookup::{
EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, LP_JOIN_TOL, SOURCES_NAME, builder_members, connected_edges, drawn_outlines,
edge_near, edges, face_at, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap,
path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at,
edge_near, edges, face_at, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, lp_stroke_paint, node_outline,
outline_snap, path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at,
};
pub use map::{
BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs,
+2 -2
View File
@@ -331,7 +331,7 @@ impl LivePaintBucketTool {
/// Note a press at `at` that paints face or edge (`edge`) `n`.
fn note_part(&mut self, n: &Node, edge: bool, at: Point) {
let before = if edge { n.appearance.stroke_paint() } else { n.appearance.fill_paint() };
let before = if edge { lp_stroke_paint(n) } else { n.appearance.fill_paint() };
self.note_press(Some(LpPress { part: Some(n.id), at, before }));
}
@@ -366,7 +366,7 @@ impl LivePaintBucketTool {
&& let Some((g, Some(e))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id))
{
let mut v = json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0), "scope": scope });
if let Ok(like) = serde_json::to_value(self.before(cx, e.id, clicks, e.appearance.stroke_paint())) {
if let Ok(like) = serde_json::to_value(self.before(cx, e.id, clicks, lp_stroke_paint(e))) {
v["match"] = like;
}
return vec![Action::Exec("livePaint.strokeEdge".into(), v)];
+1 -1
View File
@@ -130,7 +130,7 @@ fn lp_cells_around(p: Point) -> impl Iterator<Item = (i64, i64)> {
/// The stroke paint Live Paint edge `edge` draws with: the paint of its topmost stroke that is shown
/// with a paint, a weight above 0 and an opacity above 0, or [`Paint::None`] when the edge is fully
/// transparent or has no such stroke.
pub(super) fn lp_stroke_paint(edge: &Node) -> Paint {
pub fn lp_stroke_paint(edge: &Node) -> Paint {
if edge.opacity <= 0.0 {
return Paint::None;
}
+7 -4
View File
@@ -3680,11 +3680,14 @@ already walks Image Trace. Short agent entry points:
`scope: "samePaint"`; Alt held runs nothing, and so does Cmd/Ctrl (`mods.cmd`), whose presses go to the selection
tool Cmd lends. With Space held (`mods.space`, the view tools), a press, a
double-click or a triple-click runs nothing, and a move outlines no face or edge. On a face, `connected` fills the face and every face reached from it
across edges that draw no stroke (hidden, stroke paint none, or weight 0); a stroked edge, a hidden face, a gap
across edges that draw no stroke (an edge that is hidden or fully transparent, or whose strokes are each hidden,
without paint, 0 pt wide or fully transparent); a stroked edge, a hidden face, a gap
that Gap Detection closes and the group's outside stop it. `samePaint` fills every face
of the group, joined to it or not, whose fill is the `match` paint. On an edge, `connected` paints the edge and the
edges joined to it end to end whose stroke paint is `match`, and `samePaint` paints every edge of the group with
that stroke paint; the stroke's weight is not compared. Each click of a double-click or triple-click runs as it
edges joined to it end to end that draw with the stroke paint `match`, and `samePaint` paints every edge of the
group that draws with that stroke paint; the stroke's weight is not compared. An edge draws with the paint of its
topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully
transparent or has no such stroke. Each click of a double-click or triple-click runs as it
arrives and is its own undo step. `match` is the paint the face or edge had before the first of the double-click's
two clicks or the triple-click's three that painted it, so the clicks before the last do not change what it matches;
a first click that made the Live Paint group counts when the face it filled is the clicked face. Hidden faces and edges are not painted. These rules
@@ -3693,7 +3696,7 @@ already walks Image Trace. Short agent entry points:
`livePaint.fill {scope}` and `livePaint.strokeEdge {scope}` take `scope` as `"one"` (the default), `"connected"` or
`"samePaint"`; any other value is an error and changes nothing. `match` is a paint in the model JSON that
`document.node {id}` writes for an appearance's paints (`{"type": "none"}` for none), taken only with a scope that compares paints, and
defaults to the face's fill or the edge's stroke paint. `livePaint.fill` refuses a `scope` other than `"one"` with
defaults to the face's fill or the stroke paint the edge draws with. `livePaint.fill` refuses a `scope` other than `"one"` with
`points`. Both replies add `count`, the number of faces or edges the click reaches, including those that already had the
paint.
With the Live Paint Bucket selected and no drag in progress, Left and Right without Shift, Alt or Cmd/Ctrl (which