Live Paint: Gap Options fits an edge's dashes and arrowheads to each piece it cuts

When Gap Options cut an edge in two, each piece took the old edge's
stroke rows unchanged: both arrowheads, the dash phase of the edge's
start, and a brush or width profile spread over the whole edge. Each
piece now takes those rows fitted to its part of the old edge, as Make
fits a source path's rows to the edges cut from it.
This commit is contained in:
dotCooCoo
2026-11-14 12:16:14 -08:00
parent a4c513f101
commit 6a5e6fa212
3 changed files with 63 additions and 3 deletions
+17 -2
View File
@@ -749,8 +749,9 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64
/// Give the faces and edges of `g`, a Live Paint group made again from the sources of `old`, the
/// look of `old`'s face or edge where they lie: each face takes everything but the outline (its
/// appearance, none included, its transparency and the rest) of the old face its inside point is
/// in, and each edge that of the old edge its middle lies on. Faces and edges where `old` had none
/// keep the paint of their sources.
/// in, and each edge that of the old edge its middle lies on, with the old edge's stroke rows fitted
/// to the part of it the new edge runs, as [`fit_to_edge`] fits a source's rows. Faces and edges
/// where `old` had none keep the paint of their sources.
fn repaint_from(g: &mut Node, old: &Node) {
/// How far an edge's middle may be from the old edge it lies on.
const ON_EDGE: f64 = 1e-3;
@@ -773,6 +774,20 @@ fn repaint_from(g: &mut Node, old: &Node) {
n.id = c.id;
n.kind = c.kind.clone();
n.bbox_angle = c.bbox_angle;
// An edge cut from the old one takes the old edge's dashes, arrowheads, width profile
// and brush as they fall on its part of the old edge.
if c.name.as_deref() == Some(EDGE_NAME)
&& n.appearance.items.iter().any(depends_on_place)
&& let Some(old_path) = was.path_data()
{
let tracks: Vec<Track> = old_path.subpaths.iter().map(Track::new).collect();
let run = n.path_data_mut().and_then(|path| edge_run(&tracks, path));
let mut a = (*n.appearance).clone();
for r in &mut a.items {
fit_to_edge(r, run);
}
n.appearance = a.into();
}
*c = Arc::new(n);
}
}
+44
View File
@@ -2753,6 +2753,50 @@ fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() {
assert_eq!(edges, vec![rows]);
}
/// When Gap Options cuts an edge in two, each piece keeps the edge's dashes going on from where it
/// starts along the edge, an arrowhead only at the end of the edge it ends at, and no brush.
#[test]
fn live_paint_gap_options_fits_an_edge_look_to_each_piece() {
use vectorcraft_doc::{Arrowhead, Dash};
let mut s = session();
let l = line(&mut s, (0.0, 50.0), (100.0, 50.0));
let stub = line(&mut s, (50.0, 0.0), (50.0, 48.0));
let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| {
st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false });
st.start_arrow = Some(Arrowhead::Triangle);
st.end_arrow = Some(Arrowhead::Circle);
st.brush = Some("Charcoal".into());
});
let rows = set_rows(&mut s, l, vec![row]);
s.execute("select.set", &json!({"ids": [l.0, stub.0]})).unwrap();
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
let dashed = |s: &Session| {
let mut out: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&node(s, g))
.iter()
.filter_map(|e| {
let st = e.appearance.stroke().filter(|st| st.dash.is_some())?.clone();
let sp = e.path_data()?.subpaths.first()?;
Some(((sp.anchors.first()?.p.x * 1e3).round() / 1e3, (sp.anchors.last()?.p.x * 1e3).round() / 1e3, st))
})
.collect();
out.sort_by(|p, q| p.0.total_cmp(&q.0));
out
};
let before = dashed(&s);
assert_eq!(before.len(), 1, "nothing cuts the line yet");
assert_eq!(vec![AppearanceItem::Stroke(before[0].2.clone())], rows);
let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap();
assert_eq!(r["closed"], json!(1));
let pieces = dashed(&s);
let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect();
assert_eq!(ends, vec![(0.0, 50.0), (50.0, 100.0)], "the line is cut where the gap closes");
let offsets: Vec<f64> = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect();
assert_eq!(offsets, vec![2.0, 52.0], "the dashes go on along the line");
let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect();
assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]);
assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line");
}
/// The edge Make builds from a closed path that nothing cuts starts at the anchor the path starts
/// at and runs the way the path does, whichever anchor that is, so its dashes and brush lie where
/// they do on the path, and it keeps the path's stroke row as it is.
+2 -1
View File
@@ -3614,7 +3614,8 @@ already walks Image Trace. Short agent entry points:
the path (Align Dashes to Corners is turned off on it), the arrowheads stay only at the path's own ends, and a width
profile or brush stays only on an edge that runs the whole path. A gradient applied along a stroke starts again on
each cut edge, and an inside or outside stroke is drawn centered on an edge that is an open piece of a closed path.
A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which
When `livePaint.gapOptions` cuts an edge into pieces, each piece takes the edge's rows fitted to its part of the
edge in the same way. A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which
`livePaint.release` returns unchanged. The source's object effects and the strokes' pen pressure are not carried.
Expand keeps those face fills.
`livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity