Distribute spaces the selected anchor points too, and its buttons work with the points of one path (#1636)

This commit is contained in:
bflatastic committed 2026-10-14 02:09:06 +07:00
1 parent dcb963e85b
commit 359d9bb833
3 files changed
+130 -12

No files matched your search

+64 -9
View File
@@ -184,8 +184,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_multi,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
can_distribute,
distribute
),
cmd!(
@@ -193,8 +193,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute Spacing",
["Window", "Align"],
None,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
has_multi,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)",
can_distribute,
distribute_spacing
),
cmd!(
@@ -1262,9 +1262,61 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st
ok()
}
/// Distribute needs two or more objects, or anchor points selected with Direct Selection (they
/// may all be on one path).
fn can_distribute(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|st| !st.selection.anchors.is_empty()) { Ok(()) } else { has_multi(s) }
}
/// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them
/// give all theirs): the points between the first and the last along the axis are spaced evenly,
/// or `gap` apart from the first. A point has no size, so every Distribute option of an axis
/// gives the same result.
fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64>) -> Result<Value> {
let targets = super::path::anchor_targets(s)?;
let d = &s.doc()?.doc;
let along = |q: Point| if horiz { q.x } else { q.y };
let mut pts: Vec<(NodeId, usize, usize, f64)> = targets
.iter()
.flat_map(|(id, refs)| {
refs.iter().filter_map(move |&(si, ai)| Some((*id, si, ai, along(d.node(*id)?.path_data()?.subpaths.get(si)?.anchors.get(ai)?.p))))
})
.collect();
pts.sort_by(|a, b| a.3.total_cmp(&b.3));
let (Some(first), Some(last)) = (pts.first().map(|q| q.3), pts.last().map(|q| q.3)) else {
return ok();
};
// Two points are the ends already; with a gap the second is placed from the first.
if pts.len() < if gap.is_some() { 2 } else { 3 } {
return ok();
}
let step = gap.unwrap_or((last - first) / (pts.len() - 1) as f64);
let moves: Vec<(NodeId, usize, usize, f64)> =
pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, first + step * i as f64 - at)).collect();
if moves.iter().all(|m| m.3.abs() <= 1e-9) {
return ok();
}
s.edit(label, |d, _| {
for &(id, si, ai, delta) in &moves {
if let Some(a) = super::path::path_mut(d, id)?.anchor_mut(si, ai) {
a.translate(if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) });
}
}
Ok(())
})?;
ok()
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.distribute")?;
// Anchor points selected with Direct Selection are distributed themselves, as Align aligns them.
if !s.doc()?.selection.anchors.is_empty() {
return match direction(p, "object.distribute")? {
(None, None) => Err(bad("object.distribute", "give horizontal or vertical")),
(h, _) => distribute_anchors(s, "Distribute", h.is_some(), None),
};
}
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? {
(Some(h), _) => (
@@ -1308,6 +1360,14 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
let spacing = p
.get("spacing")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad("object.distributeSpacing", "`spacing` must be a number")))
.transpose()?;
if !s.doc()?.selection.anchors.is_empty() {
return distribute_anchors(s, "Distribute Spacing", horiz, spacing);
}
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let n = items.len();
@@ -1316,11 +1376,6 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
};
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
let spacing = p
.get("spacing")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad("object.distributeSpacing", "`spacing` must be a number")))
.transpose()?;
let gap = match spacing {
Some(g) => g,
None => {
+33
View File
@@ -2304,6 +2304,39 @@ fn align_moves_selected_anchor_points() {
assert_eq!(anchors(&s)[2].1 - anchors(&s)[0].1, 120.0, "its shape kept");
}
/// #1636: with anchor points selected, the Distribute commands space those points, on one path
/// or across paths, and leave the other points and the paths' objects where they are.
#[test]
fn distribute_spaces_selected_anchor_points() {
let mut s = session();
let new = |s: &mut Session, d: &str| NodeId(s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap());
let a = new(&mut s, "M10 10 L20 50 L100 30 L60 90");
let pts = |s: &Session, id: NodeId| -> Vec<(f64, f64)> {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect()
};
// One path selected whole is one object: nothing to distribute.
assert!(s.execute("object.distribute", &json!({"horizontal": "center"})).is_err());
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0], [0, 1], [0, 2]], "mode": "set"})).unwrap();
for h in ["left", "center", "right"] {
s.execute("object.distribute", &json!({"horizontal": h})).unwrap();
assert_eq!(pts(&s, a), [(10.0, 10.0), (55.0, 50.0), (100.0, 30.0), (60.0, 90.0)], "{h}: a point has no width");
}
let steps = history(&s);
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
assert_eq!(history(&s), steps, "already distributed: no undo step");
s.execute("object.distribute", &json!({"vertical": "top"})).unwrap();
assert_eq!(pts(&s, a), [(10.0, 10.0), (55.0, 50.0), (100.0, 30.0), (60.0, 90.0)], "10, 30, 50 are even already");
s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 5})).unwrap();
assert_eq!(pts(&s, a), [(10.0, 10.0), (55.0, 20.0), (100.0, 15.0), (60.0, 90.0)], "5 apart from the first, in order");
assert!(s.execute("object.distribute", &json!({})).is_err());
// Across paths: the points of both, with the second path's unselected point left alone.
let b = new(&mut s, "M40 200 L400 300");
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0], [0, 2]], "mode": "set"})).unwrap();
s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap();
assert_eq!((pts(&s, a)[0].0, pts(&s, b), pts(&s, a)[2].0), (10.0, vec![(55.0, 200.0), (400.0, 300.0)], 100.0));
}
/// Repeating anchor Align must leave one Undo for the move that actually changed the path.
#[test]
fn repeating_anchor_alignment_does_not_consume_undo() {
+33 -3
View File
@@ -62,7 +62,12 @@ pub fn with_align_to(app: &VectorcraftApp, base: Value) -> Value {
/// (Direct Selection), since the points align and a single path's points must not go to the
/// artboard as a single object does (#1636).
fn objects_aligned(app: &VectorcraftApp) -> usize {
if app.session.active().is_some_and(|st| !st.selection.anchors.is_empty()) { 0 } else { selection_len(app) }
if has_anchors(app) { 0 } else { selection_len(app) }
}
/// Whether anchor points are selected (Direct Selection): Align and Distribute then act on the points.
fn has_anchors(app: &VectorcraftApp) -> bool {
app.session.active().is_some_and(|st| !st.selection.anchors.is_empty())
}
pub const ALIGN: [(&str, &str, &str, &str); 6] = [
@@ -127,6 +132,8 @@ fn align_to(app: &VectorcraftApp) -> AlignTo {
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let n = selection_len(app);
let to = align_to(app);
// Two or more objects, or anchor points, which may all be on one path.
let distributes = n >= 2 || has_anchors(app);
widgets::subheader(ui, tl!("Align Objects:"));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 5.0;
@@ -151,7 +158,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if i == 3 {
ui.add_space(6.0);
}
if widgets::icon_button_enabled(ui, icon, tl!(tip), false, n >= 2, 32.0).clicked() {
if widgets::icon_button_enabled(ui, icon, tl!(tip), false, distributes, 32.0).clicked() {
app.run("object.distribute", json!({*axis: v})).ok();
}
}
@@ -166,7 +173,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let spacing: f64 = pstate::<Option<f64>>(ui.ctx(), "align-spacing").unwrap_or(0.0);
let key = to == AlignTo::Key;
ui.horizontal(|ui| {
let on = n >= 2;
let on = distributes;
let sp = if key { Some(spacing) } else { None };
if widgets::icon_button_enabled(ui, "dc-dist-vspace", tl!("Vertical Distribute Space"), false, on, 28.0).clicked() {
app.run("object.distributeSpacing", json!({"axis": "vertical", "spacing": sp})).ok();
@@ -557,4 +564,27 @@ mod tests {
click(&ctx, &mut app, left.center(), show);
assert_eq!(xs(&app), [0.0, 0.0, 150.0], "to the artboard's left edge: {}", app.ui.status);
}
/// #1636: the Distribute buttons work on the anchor points of one path (one object selected).
#[test]
fn distribute_buttons_space_the_anchor_points_of_one_path() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("path.create", json!({"d": "M10 10 L20 50 L100 30"})).unwrap()["id"].as_u64().unwrap();
app.run("select.anchors", json!({"id": id, "anchors": [[0, 0], [0, 1], [0, 2]], "mode": "set"})).unwrap();
let ctx = egui::Context::default();
frame_events(&ctx, &mut app, vec![], show);
// The 32-point buttons are the Align row and, below it, the Distribute row, whose fifth
// button is Horizontal Distribute Center.
let all = widget_rects(&ctx, egui::vec2(32.0, 32.0));
let top = all.iter().map(|r| r.top()).fold(f32::MIN, f32::max);
let mut row: Vec<_> = all.into_iter().filter(|r| r.top() == top).collect();
row.sort_by(|a, b| a.left().total_cmp(&b.left()));
assert_eq!(row.len(), 6, "the Distribute row");
let hit = row[4].center();
click(&ctx, &mut app, hit, show);
let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap();
let xs: Vec<f64> = node.path_data().unwrap().anchors().map(|(_, _, a)| a.p.x).collect();
assert_eq!(xs, [10.0, 55.0, 100.0], "{}", app.ui.status);
}
}