mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
Fix #4950: Path Precision Tools: Divide into Dashes
addon.pathtools.divideDashes cuts the selected paths (or only their direct-selected segments) into separate open paths of a dash length measured along the curve, with an optional gap (gap pieces dropped unless removeGaps is false) and Fit to Corners and Ends; pieces keep the path's appearance, one undo step, at most 100000 pieces. Geometry in vectorcraft-pathops; dialog with unit fields and Preview; tests, sweep cases, catalog rows and docs.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
//! Path Precision Tools (`addon.pathtools.*`), a built-in add-on: the Anchor Eraser Brush's stroke,
|
||||
//! 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`]), and the Shared Edge Merge Tool's
|
||||
//! merges with Keep Shared Points Together ([`shared_edge`]).
|
||||
//! ([`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.
|
||||
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
|
||||
//! and the UI's.
|
||||
|
||||
@@ -40,6 +40,8 @@ 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.
|
||||
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;
|
||||
|
||||
@@ -153,6 +155,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
slide_anchor
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.divideDashes",
|
||||
"Divide into Dashes",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{dash?: pt 0.01..1000000 (10), gap?: pt 0..1000000 (0: the pieces touch), fit?: bool (false: exact lengths, the last piece of each run what is left; true: fit to corners and ends, every run between corners and ends holding whole dashes, all stretched or squeezed alike so it starts and ends on a dash), removeGaps?: bool (true: the gaps are left empty; false: they stay as pieces too)} cut the selected paths into separate open paths along the curve, each dash long with gap between them and keeping the path's appearance, as one undo step. Only the direct-selected segments (or those between two direct-selected anchors) are divided when there are any, the dash pattern running on across segments that follow one another; the rest of such a path stays, as pieces of its own. The pieces are selected; at most 100000 → {ids, dashes}",
|
||||
has_selection,
|
||||
divide_dashes
|
||||
),
|
||||
];
|
||||
v.extend(shape::specs());
|
||||
v.extend(corners::specs());
|
||||
@@ -583,6 +594,16 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
/// The segments of subpath `si` (`sp`) picked: the direct-selected segments `segments` of its
|
||||
/// path, else those between two of its selected `anchors`.
|
||||
fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet<usize>) -> BTreeSet<usize> {
|
||||
let n = sp.anchors.len().max(1);
|
||||
match segments.filter(|set| !set.is_empty()) {
|
||||
Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(),
|
||||
None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n))).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.addPoints";
|
||||
let count = number_or(p, "count", 1.0, C)?;
|
||||
@@ -597,16 +618,10 @@ fn add_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut changed = vec![];
|
||||
for (id, by_sub) in &picked {
|
||||
let Some(path) = d.node(*id).and_then(|n| n.path_data()) else { continue };
|
||||
let picked_segments = st.selection.segments.get(id);
|
||||
let mut np = path.clone();
|
||||
for (si, sp) in np.subpaths.iter_mut().enumerate() {
|
||||
let Some(anchors) = by_sub.get(&si) else { continue };
|
||||
let n = sp.anchors.len();
|
||||
// Picked segments, else those between two selected anchors.
|
||||
let segs: BTreeSet<usize> = match picked_segments {
|
||||
Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(),
|
||||
None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n.max(1)))).collect(),
|
||||
};
|
||||
let segs = picked_segments(st.selection.segments.get(id), si, sp, anchors);
|
||||
let (out, marks) = pt::add_points(sp, Some(&segs), count as usize);
|
||||
added += marks.len();
|
||||
*sp = out;
|
||||
@@ -721,3 +736,60 @@ fn crossings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>(), "crossings": count }))
|
||||
}
|
||||
|
||||
// ---------- Divide into Dashes ----------
|
||||
|
||||
fn divide_dashes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.divideDashes";
|
||||
let spec = pt::DashSpec {
|
||||
dash: given(p, "dash", Some((0.01, 1e6)), C)?.unwrap_or(10.0),
|
||||
gap: given(p, "gap", Some((0.0, 1e6)), C)?.unwrap_or(0.0),
|
||||
fit: switch_or(p, "fit", false, C)?,
|
||||
};
|
||||
let remove_gaps = switch_or(p, "removeGaps", true, C)?;
|
||||
let picked = picked_anchors(s, true)?;
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), C)?;
|
||||
let (mut made, mut dashes) = (0, 0);
|
||||
// Each path divided: what stays of it (the subpaths not divided, as one path) and its pieces.
|
||||
let mut changed: Vec<(NodeId, Vec<PathData>)> = vec![];
|
||||
for (id, by_sub) in &picked {
|
||||
let Some(path) = d.node(*id).and_then(|n| n.path_data()) else { continue };
|
||||
let (mut kept, mut pieces) = (vec![], vec![]);
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
let segs = by_sub.get(&si).map(|anchors| picked_segments(st.selection.segments.get(id), si, sp, anchors)).unwrap_or_default();
|
||||
if segs.is_empty() {
|
||||
kept.push(sp.clone());
|
||||
continue;
|
||||
}
|
||||
let only = (segs.len() < sp.segment_count()).then_some(&segs);
|
||||
let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made))
|
||||
.ok_or_else(|| bad(C, "that makes too many pieces (at most 100000): make the dashes longer"))?;
|
||||
made += out.len();
|
||||
for (piece, kind) in out.into_iter().filter(|(_, k)| !(remove_gaps && *k == pt::Piece::Gap)) {
|
||||
dashes += usize::from(kind == pt::Piece::Dash);
|
||||
pieces.push(PathData::single(piece));
|
||||
}
|
||||
}
|
||||
if !pieces.is_empty() {
|
||||
changed.push((*id, (!kept.is_empty()).then(|| PathData::new(kept)).into_iter().chain(pieces).collect()));
|
||||
}
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Err(bad(C, "select paths, or segments, to divide"));
|
||||
}
|
||||
let ids = s.edit("Divide into Dashes", |d, sel| {
|
||||
let mut all = vec![];
|
||||
let mut beside = Beside::replacing(d, &changed.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in changed {
|
||||
all.extend(replace_with_pieces(d, &mut beside, id, pieces)?);
|
||||
}
|
||||
beside.finish(d)?;
|
||||
// The pieces 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().chain(all.iter().copied()));
|
||||
Ok(all)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "dashes": dashes }))
|
||||
}
|
||||
|
||||
@@ -674,3 +674,77 @@ fn the_slide_tool_previews_and_commits_one_step() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(at(&s), Point::new(200.0, 100.0));
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_into_dashes_cuts_a_path_into_open_pieces_keeping_its_look() {
|
||||
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!({"weight": 3})).unwrap();
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "gap": 10})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1, "one undo step");
|
||||
// 30 + 10 + 30 + 10 + 20: the gaps are left empty.
|
||||
let pieces = ids(&r);
|
||||
assert_eq!((pieces.len(), r["dashes"].clone()), (3, json!(3)));
|
||||
let st = s.doc().unwrap();
|
||||
let spans: Vec<(f64, f64)> = pieces.iter().map(|id| path(&s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect();
|
||||
let want = [(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)];
|
||||
assert!(spans.iter().zip(want).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6), "{spans:?}");
|
||||
assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.stroke_width() == 3.0), "the stroke stays");
|
||||
assert_eq!(st.selection.objects, pieces);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, l).anchor_count(), 2);
|
||||
// Kept gaps; fitted 25s with no gap.
|
||||
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "gap": 10, "removeGaps": false})).unwrap();
|
||||
assert_eq!((ids(&r).len(), r["dashes"].clone()), (5, json!(3)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "fit": true})).unwrap();
|
||||
let spans: Vec<f64> = ids(&r).iter().map(|id| path(&s, *id).bounds().unwrap().width()).collect();
|
||||
assert_eq!(spans.len(), 3);
|
||||
assert!(spans.iter().all(|w| (w - 100.0 / 3.0).abs() < 1e-6), "{spans:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_into_dashes_divides_only_the_selected_segments() {
|
||||
let mut s = session();
|
||||
let u = line(&mut s, &[(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (0.0, 40.0)], false);
|
||||
// The middle side, between anchors 1 and 2.
|
||||
select_anchors(&mut s, u, &[1, 2]);
|
||||
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 10})).unwrap();
|
||||
let pieces = ids(&r);
|
||||
assert_eq!((pieces.len(), r["dashes"].clone()), (6, json!(4)), "the first side, four dashes, the last side");
|
||||
assert_eq!(path(&s, pieces[0]).bounds().unwrap().width(), 40.0);
|
||||
let layer: Vec<NodeId> = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
||||
assert_eq!(layer, pieces, "in the path's place, in order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() {
|
||||
let mut s = session();
|
||||
let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false);
|
||||
select(&mut s, &[l]);
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.pathtools.divideDashes",
|
||||
&[
|
||||
json!({"dash": 0}),
|
||||
json!({"dash": -5}),
|
||||
json!({"dash": 1e308}),
|
||||
json!({"dash": "10"}),
|
||||
json!({"gap": -1}),
|
||||
json!({"gap": 2e6}),
|
||||
json!({"fit": "yes"}),
|
||||
json!({"removeGaps": 1}),
|
||||
json!({"dash": 0.5}),
|
||||
],
|
||||
);
|
||||
// 200000 pieces refused above; 1000 is fine.
|
||||
assert_eq!(s.execute("addon.pathtools.divideDashes", &json!({"dash": 100})).unwrap()["dashes"], json!(1000));
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
refuses(&mut s, "addon.pathtools.divideDashes", &[json!({})]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user