From 66e22f4ba17d2b91fcfe7cacceb22139364dfee4 Mon Sep 17 00:00:00 2001 From: Wadah Badawi <350738630+WadahBadawi@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:50:02 +0400 Subject: [PATCH] M2.1: recurse into groups and compound paths for path.average --- crates/engine/src/cmd/path.rs | 17 ++++++- crates/engine/src/tests/draw2.rs | 83 ++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 3503ed14f..fb63bf12e 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1001,8 +1001,21 @@ pub(super) fn anchor_targets(s: &Session) -> Result { } fn average(s: &mut Session, p: &Value) -> Result { - let t = anchor_targets(s)?; - let d0 = &s.doc()?.doc; + let st = s.doc()?; + let t: AnchorTargets = if st.selection.anchors.is_empty() { + let editable = st.doc.editable(); + paths_in(&st.doc, &st.selection.objects) + .into_iter() + .filter(|id| editable(*id)) + .filter_map(|id| { + let pd = st.doc.node(id)?.path_data()?; + Some((id, pd.anchors().map(|(s, a, _)| (s, a)).collect())) + }) + .collect() + } else { + anchor_targets(s)? + }; + let d0 = &st.doc; let pts: Vec = t .iter() .flat_map(|(id, v)| v.iter().filter_map(move |(si, ai)| d0.node(*id)?.path_data()?.subpaths.get(*si)?.anchors.get(*ai).map(|a| a.p))) diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index c5d7038a3..30b1c4a3f 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -4165,3 +4165,86 @@ fn shape_counts_that_are_not_whole_numbers_are_errors() { assert!(s.execute("shape.spiral", &json!({"cx": 1, "cy": 1, "radius": 5, "segments": bad})).is_err(), "{bad}"); } } + +fn all_at(pts: &[Point], c: Point) -> bool { + !pts.is_empty() && pts.iter().all(|p| (p.x - c.x).abs() < 1e-6 && (p.y - c.y).abs() < 1e-6) +} + +#[test] +fn average_averages_anchors_in_selected_groups() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 200.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [g]})).unwrap(); + s.execute("path.average", &json!({"axis": "both"})).unwrap(); + let c = Point::new(150.0, 150.0); + assert!(all_at(&anchor_points(&s, a), c), "{:?}", anchor_points(&s, a)); + assert!(all_at(&anchor_points(&s, b), c), "{:?}", anchor_points(&s, b)); +} + +#[test] +fn average_averages_anchors_in_a_selected_compound_path() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 200.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [cp.0]})).unwrap(); + s.execute("path.average", &json!({"axis": "both"})).unwrap(); + let st = s.doc().unwrap(); + let node = st.doc.node(cp).unwrap(); + assert!(matches!(node.kind, NodeKind::Compound { .. }), "{}", node.kind_label()); + let pts: Vec = + node.children().unwrap().iter().flat_map(|c| c.path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect::>()).collect(); + assert_eq!(pts.len(), 8, "{pts:?}"); + assert!(all_at(&pts, Point::new(150.0, 150.0)), "{pts:?}"); +} + +#[test] +fn average_with_nothing_selected_is_an_error() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": []})).unwrap(); + assert!(s.execute("path.average", &json!({"axis": "both"})).is_err()); +} + +#[test] +fn average_leaves_sibling_geometry_unchanged_on_partial_selection() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 200.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let orig_b = path(&s, b).clone(); + + // Select group, but add-select one anchor on descendant a + s.execute("select.set", &json!({"ids": [g]})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + + // Average should not move b's anchors + s.execute("path.average", &json!({"axis": "both"})).unwrap(); + assert_eq!(path(&s, b), orig_b); +} + +#[test] +fn average_skips_locked_descendants_in_group() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 200.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let orig_b = path(&s, b).clone(); + + // Lock descendant b + s.execute("object.lock", &json!({"ids": [b.0]})).unwrap(); + + // Select group and run average + s.execute("select.set", &json!({"ids": [g]})).unwrap(); + s.execute("path.average", &json!({"axis": "both"})).unwrap(); + + // b was locked so it remains unchanged; a is averaged on its own corners. + assert_eq!(path(&s, b), orig_b); + assert!(all_at(&anchor_points(&s, a), Point::new(50.0, 50.0)), "{:?}", anchor_points(&s, a)); +}