mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Distribute spaces the selected anchor points too, and its buttons work with the points of one path (#1636)
This commit is contained in:
1 parent
dcb963e85b
commit
359d9bb833
3 files changed
+130
-12
No files matched your search
@@ -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 => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user