Merge pull request #1216 from storytold/fix/1180-align-anchors

#1180: Align aligns the selected anchor points
This commit is contained in:
BFlat
2026-10-11 22:08:13 +07:00
committed by GitHub
4 changed files with 73 additions and 5 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+48 -1
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")?;
+3 -3
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 {
+21
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]