#1180: Align aligns the selected anchor points

With anchor points selected by Direct Selection, the Align buttons moved
the whole path, so two points couldn't be lined up. As in Illustrator,
they now align the selected points: to the points' own bounds (Align To
Selection) or to the artboard. A path selected whole still aligns as an
object.
This commit is contained in:
bflatastic 2026-10-11 21:18:01 +07:00
parent 8fbb09bf4e
commit 06d3e52c24
4 changed files with 73 additions and 5 deletions

File diff suppressed because one or more lines are too long

View File

@ -167,7 +167,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), 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)}",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), 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 align instead: to their own bounds, or to the artboard (#1180)",
has_selection,
align
),
@ -1080,6 +1080,10 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
if h.is_none() && v.is_none() {
return Err(bad("object.align", "give horizontal or vertical"));
}
// Anchor points selected with Direct Selection align themselves, as in Illustrator (#1180).
if !s.doc()?.selection.anchors.is_empty() {
return align_anchors(s, p, &ids, (h, v));
}
let r = reference_rect(s, p, &ids, preview)?;
let key = key_root(s, &ids);
let moves: Vec<(NodeId, Vec2)> = {
@ -1107,6 +1111,49 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
apply_moves(s, "Align", p, &ids, &moves)
}
/// [`align`] of the selected anchor points (whole paths among them give all theirs): each point
/// moves to the edge or centre of the points' bounds, or of the artboard with Align To Artboard.
fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&str>, Option<&str>)) -> Result<Value> {
let targets = super::path::anchor_targets(s)?;
let d = &s.doc()?.doc;
let pts: Vec<Point> = targets
.iter()
.flat_map(|(id, refs)| refs.iter().filter_map(move |(si, ai)| d.node(*id)?.path_data()?.subpaths.get(*si)?.anchors.get(*ai).map(|a| a.p)))
.collect();
let Some(bounds) = pts.iter().skip(1).fold(pts.first().map(|q| Rect::from_points(*q, *q)), |b, q| b.map(|b| b.union_pt(*q))) else {
return ok();
};
// shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does.
let r = if align_to(s, p) == Some("artboard") { reference_rect(s, p, ids, false)? } else { bounds };
let target = |q: Point| {
let x = match h {
Some("left") => r.x0,
Some("center") => r.center().x,
Some("right") => r.x1,
_ => q.x,
};
let y = match v {
Some("top") => r.y0,
Some("center") => r.center().y,
Some("bottom") => r.y1,
_ => q.y,
};
Point::new(x, y)
};
s.edit("Align", |d, _| {
for (id, refs) in &targets {
let path = super::path::path_mut(d, *id)?;
for &(si, ai) in refs {
if let Some(a) = path.anchor_mut(si, ai) {
a.translate(target(a.p) - a.p);
}
}
}
Ok(())
})?;
ok()
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.distribute")?;

View File

@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<&mut PathData> {
pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<&mut PathData> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
@ -378,9 +378,9 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>;
pub(super) type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>;
fn anchor_targets(s: &Session) -> Result<AnchorTargets> {
pub(super) fn anchor_targets(s: &Session) -> Result<AnchorTargets> {
let st = s.doc()?;
let mut out = vec![];
for id in &st.selection.objects {

View File

@ -1700,6 +1700,27 @@ fn file_list_lists_a_folder_within_the_read_root() {
let _ = std::fs::remove_dir_all(base);
}
/// #1180: with anchor points selected (Direct Selection), Align moves those points, not the whole
/// path: to the points' own bounds, or to the artboard.
#[test]
fn align_moves_selected_anchor_points() {
let mut s = session();
let id = NodeId(s.execute("path.create", &json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap());
let anchors = |s: &Session| -> Vec<(f64, f64)> {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect()
};
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap();
s.execute("object.align", &json!({"vertical": "top", "to": "selection"})).unwrap();
assert_eq!(anchors(&s), [(100.0, 40.0), (200.0, 40.0), (150.0, 160.0)]);
s.execute("object.align", &json!({"horizontal": "center", "to": "artboard"})).unwrap();
let mid = s.doc().unwrap().doc.artboards[0].rect.center().x;
assert_eq!(anchors(&s), [(mid, 40.0), (mid, 40.0), (150.0, 160.0)], "the third point stays");
// A path selected whole still moves as an object.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.align", &json!({"vertical": "top", "to": "artboard"})).unwrap();
assert_eq!(anchors(&s)[2].1 - anchors(&s)[0].1, 120.0, "its shape kept");
}
/// #1197: Make Compound Path cuts holes where the paths in front overlap the backmost one, also
/// when they were drawn running the same way.
#[test]