From 359d9bb833db7fbdef11c67f16468c5a0b2caf53 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 13:37:29 +0700 Subject: [PATCH] Distribute spaces the selected anchor points too, and its buttons work with the points of one path (#1636) --- crates/engine/src/cmd/object.rs | 73 ++++++++++++++++++++++++++---- crates/engine/src/tests.rs | 33 ++++++++++++++ crates/ui-egui/src/panels/align.rs | 36 +++++++++++++-- 3 files changed, 130 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 7b6ea155..4fb2cd74 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -184,8 +184,8 @@ pub fn specs() -> Vec { "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 { "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) -> Result { + 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 { 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 { 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 { }; 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 => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f4b5361e..1ced8645 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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() { diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 33101596..8f310bf5 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -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::>(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 = node.path_data().unwrap().anchors().map(|(_, _, a)| a.p.x).collect(); + assert_eq!(xs, [10.0, 55.0, 100.0], "{}", app.ui.status); + } }