Fix #4922: Expand Dashes into Paths

This commit is contained in:
bflatastic
2026-11-01 09:08:07 +07:00
parent 317423c514
commit 84336967aa
35 changed files with 349 additions and 7 deletions
+110 -3
View File
@@ -2,7 +2,8 @@
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners
//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's
//! merges with Keep Shared Points Together ([`shared_edge`]), and Divide into Dashes.
//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes
//! into Paths.
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
//! and the UI's.
@@ -17,7 +18,7 @@ mod tests;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
use vectorcraft_doc::{AnchorRef, Appearance, AppearanceItem, Document, LineCap, NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point, SubPath};
use vectorcraft_pathops::addons::{self as po, pathtools as pt};
@@ -40,7 +41,7 @@ const MIN_RADIUS: f64 = 0.05;
const MAX_DIAMETER: f64 = 2500.0;
/// The farthest an end is extended at once (pt).
const MAX_EXTEND: f64 = 1e6;
/// Most pieces Divide into Dashes makes at once.
/// Most pieces Divide into Dashes and Expand Dashes into Paths make at once.
const MAX_PIECES: usize = 100_000;
/// Most anchors Add Points puts on each segment.
const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64;
@@ -164,6 +165,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
divide_dashes
),
cmd!(
"addon.pathtools.expandDashes",
"Expand Dashes into Paths",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{ids?: [id…] (default: the selection)} turn the dashed stroke of each path and compound path under the objects into separate open paths, one per dash, laid out as the stroke draws them (its dash pattern, offset and Align Dashes to Corners and Path Ends), each keeping the path's appearance with the dashes off, so the gaps are real gaps: a dash running all the way round a closed path stays closed, and with a round or projecting cap a dash of no length (a dot) becomes a path on its point. The top stroke's dashes are the ones expanded; its arrowheads go, and the path's fills stay on it, below its dashes. Paths without a dashed stroke stay as they are. One undo step; the dashes are selected; at most 100000 → {ids, dashes}",
has_selection,
expand_dashes
),
];
v.extend(shape::specs());
v.extend(corners::specs());
@@ -793,3 +803,100 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "dashes": dashes }))
}
// ---------- Expand Dashes into Paths ----------
/// The paths and compound paths under `roots` whose top stroke is dashed: a compound path's
/// members stand for it, which holds their paint. Clipping paths are left alone.
fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let editable = d.editable();
let mut seen = BTreeSet::new();
let mut out = vec![];
for id in paths_under(d, roots) {
let compound = d.parent_of(id).and_then(|p| d.node(p)).filter(|p| matches!(p.kind, NodeKind::Compound { .. }));
let Some(n) = compound.or_else(|| d.node(id)) else { continue };
let dashed = n.appearance.stroke().and_then(|st| st.dash.as_ref()).is_some_and(|dash| dash.is_dashed());
if dashed && !matches!(n.kind, NodeKind::Path { clipping: true, .. }) && editable(n.id) && seen.insert(n.id) {
out.push(n.id);
}
}
out
}
fn expand_dashes(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.expandDashes";
let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer");
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
// Each dashed path and its dashes, as the canvas draws them.
let mut changed: Vec<(NodeId, Vec<SubPath>)> = vec![];
let mut count = 0;
for id in dashed_paths(d, &roots) {
let Some(n) = d.node(id) else { continue };
let (Some(st), Some(bp)) = (n.appearance.stroke(), n.stroke_path()) else { continue };
let Some(dash) = &st.dash else { continue };
// The line the stroke draws: the path, its ends trimmed under any arrowheads.
let line = vectorcraft_render::effects::stroke::stroke_pieces(&bp, st).line;
// A pattern too fine to draw is drawn solid: it has too many dashes to expand.
let dashed = vectorcraft_render::effects::stroke::dash(&line, dash).ok_or_else(too_many)?;
let mut pieces = PathData::from_bezpath(&dashed.path).subpaths;
// A dot paints its cap; a path that stays on its point is drawn so too. Under a butt cap it
// paints nothing.
if st.cap != LineCap::Butt {
pieces.extend(dashed.dots.iter().map(|dot| SubPath::polyline(&[dot.at, dot.at], false)));
}
count += pieces.len();
if count > MAX_PIECES {
return Err(too_many());
}
if !pieces.is_empty() {
changed.push((id, pieces));
}
}
if changed.is_empty() {
return Err(bad(C, "select paths with dashed strokes to expand"));
}
let ids = s.edit("Expand Dashes into Paths", |d, sel| {
let mut all = vec![];
let mut beside = Beside::replacing(d, &changed.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, pieces) in changed {
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &n.kind else { continue };
let rule = *rule;
let filled = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none());
// The dashes: open paths with the strokes (the top one's dashes and arrowheads off).
let mut look = Appearance::clone(&n.appearance);
look.items.retain(|i| !matches!(i, AppearanceItem::Fill(_)));
if let Some(st) = look.stroke_mut() {
(st.dash, st.start_arrow, st.end_arrow) = (None, None, None);
}
let mut dash = n.clone();
dash.kind = NodeKind::Path { path: PathData::default(), rule, live: None, clipping: false, guide: false };
dash.appearance = look.into();
// The fills stay on the path, below its dashes; without fills it goes.
let mut parts = vec![];
if n.appearance.items.iter().any(filled) {
let mut fill = n.clone();
let mut look = Appearance::clone(&n.appearance);
look.items.retain(filled);
fill.appearance = look.into();
parts.push(fill);
}
for sp in pieces {
let mut piece = d.reid(&dash);
if let NodeKind::Path { path, .. } = &mut piece.kind {
*path = PathData::single(sp);
}
all.push(piece.id);
parts.push(piece);
}
beside.put(d, id, parts)?;
}
beside.finish(d)?;
// The dashes join the objects selected (whole, in one go: there may be many).
let before = std::mem::take(&mut sel.objects);
sel.set(before.into_iter().filter(|id| d.node(*id).is_some()).chain(all.iter().copied()));
Ok(all)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "dashes": ids.len() }))
}
@@ -748,3 +748,126 @@ fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() {
s.execute("select.none", &json!({})).unwrap();
refuses(&mut s, "addon.pathtools.divideDashes", &[json!({})]);
}
/// The dashes made of `r`'s ids: (first x, last x) of each, by its anchors.
fn dash_spans(s: &Session, r: &Value) -> Vec<(f64, f64)> {
ids(r).iter().map(|id| path(s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect()
}
fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6)
}
#[test]
fn expand_dashes_makes_an_open_path_of_each_dash_keeping_the_stroke() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("stroke.set", &json!({"weight": 3, "cap": "round", "dash": [30, 10]})).unwrap();
let n = steps(&s);
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
assert!(near(&dash_spans(&s, &r), &[(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)]), "{:?}", dash_spans(&s, &r));
assert_eq!(r["dashes"], json!(3));
let st = s.doc().unwrap();
for id in ids(&r) {
let node = st.doc.node(id).unwrap();
let stroke = node.appearance.stroke().unwrap();
assert_eq!((stroke.width, stroke.cap, stroke.dash.clone()), (3.0, vectorcraft_doc::LineCap::Round, None), "the stroke stays, dashes off");
assert!(!path(&s, id).subpaths[0].closed);
}
assert_eq!(st.selection.objects, ids(&r));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(l).unwrap().appearance.stroke().unwrap().dash.is_some());
// The offset shifts the pattern as the stroke draws it: 20 into the first dash.
s.execute("stroke.set", &json!({"dashOffset": 20})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({"ids": [l.0]})).unwrap();
assert!(near(&dash_spans(&s, &r), &[(0.0, 10.0), (20.0, 50.0), (60.0, 90.0)]), "{:?}", dash_spans(&s, &r));
}
#[test]
fn expand_dashes_keeps_the_fill_below_and_follows_aligned_corners() {
let mut s = session();
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap());
select(&mut s, &[rect]);
s.execute("stroke.set", &json!({"dash": [20, 20]})).unwrap();
let had_fill = !s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint().is_none();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
// 320 round: eight dashes of 20, a dash bending round each corner it meets.
let pieces = ids(&r);
assert_eq!(pieces.len(), 8);
let st = s.doc().unwrap();
let layer: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
if had_fill {
let kept = st.doc.node(rect).unwrap();
assert!(kept.appearance.stroke().is_none() && kept.appearance.fill().is_some(), "the fill stays on the rectangle");
assert_eq!(layer, [vec![rect], pieces.clone()].concat(), "the fill below its dashes");
} else {
assert_eq!(layer, pieces);
}
assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.fill().is_none()), "an open dash isn't filled");
s.execute("edit.undo", &json!({})).unwrap();
// Aligned to corners: a dash centred on each corner, the four sides each starting and ending
// on half a dash; 12 dashes along the sides.
s.execute("stroke.set", &json!({"dash": [10, 10], "alignDashes": true})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)];
let mut centred = 0;
for id in ids(&r) {
let sp = path(&s, id).subpaths[0].clone();
if let [a, b, c] = sp.anchors.as_slice()
&& corners.iter().any(|k| b.p.distance(*k) < 1e-6)
{
assert!((a.p.distance(b.p) - 5.0).abs() < 1e-6 && (c.p.distance(b.p) - 5.0).abs() < 1e-6, "{sp:?}");
centred += 1;
}
}
assert_eq!((ids(&r).len(), centred), (16, 4));
}
#[test]
fn expand_dashes_turns_dots_into_paths_on_their_point_and_releases_compound_paths() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("stroke.set", &json!({"dash": [0, 20], "cap": "round"})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
assert_eq!(ids(&r).len(), 6, "a dot every 20 from 0 to 100");
assert!(ids(&r).iter().all(|id| path(&s, *id).bounds().unwrap().width() == 0.0));
s.execute("edit.undo", &json!({})).unwrap();
// Under a butt cap a dot paints nothing, so there is nothing to expand.
s.execute("stroke.set", &json!({"cap": "butt"})).unwrap();
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
// A compound path's dashes are paths of their own with its stroke.
let a = line(&mut s, &[(0.0, 100.0), (50.0, 100.0)], false);
let b = line(&mut s, &[(0.0, 120.0), (50.0, 120.0)], false);
select(&mut s, &[a, b]);
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
s.execute("stroke.set", &json!({"weight": 2, "dash": [20, 10], "cap": "butt"})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
// Each 50 line: 20, gap, 20.
let st = s.doc().unwrap();
assert_eq!(ids(&r).len(), 4);
for id in ids(&r) {
let n = st.doc.node(id).unwrap();
assert!(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }), "a plain path");
assert_eq!(n.appearance.stroke_width(), 2.0);
assert!(n.appearance.stroke().unwrap().dash.is_none());
}
assert!(st.doc.node(c).is_none_or(|n| n.appearance.stroke().is_none()), "the compound path's dashes are gone from it");
}
#[test]
fn expand_dashes_refuses_without_dashes_and_past_the_limit() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false);
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
select(&mut s, &[l]);
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({}), json!({"ids": "x"}), json!({"zzz": 1})]);
// 125000 dashes refused; 1000 are fine.
s.execute("stroke.set", &json!({"dash": [0.4, 0.4]})).unwrap();
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
s.execute("stroke.set", &json!({"dash": [50, 50]})).unwrap();
assert_eq!(s.execute("addon.pathtools.expandDashes", &json!({})).unwrap()["dashes"], json!(1000));
}