From 80b7aee756828819565a3e4524a8c8aa0f3160a5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 09:06:47 +0200 Subject: [PATCH] Isolation mode: a path of the isolated compound path moves on its own (fixes #2354) --- crates/engine/src/cmd/edit.rs | 15 ++++++++++++++- crates/engine/src/cmd/object.rs | 18 +++++++++--------- crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 89946fb09..2c0eff9f4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -324,9 +324,22 @@ pub(crate) fn selected_roots(s: &Session) -> Result> { Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc))) } +/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in +/// an isolated compound path its paths are objects of their own, so the hole of a ring moves +/// without the ring. Paint and the rest still go to the compound path, which holds the paint. +pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { + let st = s.doc()?; + Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), st.isolation)) +} + /// The top-level objects among `ids` (in paint order): compound-path members stand for their /// compound, and ids whose ancestor is also listed, and layers, are dropped. pub(crate) fn roots_of(doc: &Document, ids: Vec) -> Vec { + roots_within(doc, ids, None) +} + +/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path. +fn roots_within(doc: &Document, ids: Vec, isolation: Option) -> Vec { // Every id's place in the tree, found in one walk (a lookup per id is a walk per id). let paths = doc.index_paths(ids.iter().copied()); // A compound-path member acts as its compound (compounds are one object). @@ -336,7 +349,7 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec) -> Vec { let path = paths.get(&id); let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty()); match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) { - Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => (n.id, Some(up.to_vec())), + Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())), _ => (id, path.cloned()), } }) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 8620ce988..4ce950016 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, No use vectorcraft_geom::shapes::CornerKind; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2}; -use super::edit::{duplicate_in, outermost, selected_roots}; +use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_transform}; use super::opacitymask::percent; use super::*; use crate::{EngineError, LastTransform}; @@ -493,14 +493,14 @@ fn transform(s: &mut Session, p: &Value) -> Result { let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; let (ids, anchors) = match ids_param(p, "ids") { Some(v) => (outermost(&s.doc()?.doc, v), false), - None => (selected_roots(s)?, bool_or(p, "anchors", false)), + None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), }; apply_transform(s, "Transform", ids, m, p, anchors) } fn move_cmd(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["dx", "dy"], "object.move")?; - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true) } @@ -524,7 +524,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result { } return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy })); } - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true) } @@ -534,7 +534,7 @@ fn about(o: Point, a: Affine) -> Affine { fn rotate(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["angle"], "object.rotate")?; - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let mut angle = f64_or(p, "angle", 0.0); if bool_or(p, "absolute", false) { @@ -547,7 +547,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result { fn scale(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["sy"], "object.scale")?; - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let sx = f64_req(p, "sx", "object.scale")? / 100.0; let sy = f64_or(p, "sy", sx * 100.0) / 100.0; @@ -558,7 +558,7 @@ fn scale(s: &mut Session, p: &Value) -> Result { } fn reflect(s: &mut Session, p: &Value) -> Result { - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let m = match p.get("axis") { Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0), @@ -578,7 +578,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result { fn shear(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["angle"], "object.shear")?; - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan(); let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical"); @@ -602,7 +602,7 @@ fn transform_again(s: &mut Session, p: &Value) -> Result { }; match last { LastTransform::Matrix(m, options) => { - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; apply_transform(s, "Transform Again", ids, m, &merge(options), true) } LastTransform::Each(params) => super::menucmds::transform_each_again(s, &merge(params)), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3c51e3a85..db552a844 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1888,6 +1888,34 @@ fn isolating_an_object_that_doesnt_exist_says_so() { assert!(!e.to_string().contains("type"), "{e}"); } +/// A ring (a compound path with a hole) and its isolated paths. +fn isolated_ring(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let outer = rect(s, 0.0, 0.0, 40.0, 40.0); + let hole = rect(s, 10.0, 10.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [outer.0, hole.0]})).unwrap(); + let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.isolate", &json!({"id": c.0})).unwrap(); + s.execute("select.set", &json!({"ids": [hole.0]})).unwrap(); + (c, outer, hole) +} + +/// Isolating a compound path makes its paths objects of their own: the hole of a ring, selected +/// alone, moves without the ring around it. Outside isolation it still moves its compound path. +#[test] +fn an_isolated_compound_paths_member_moves_alone() { + let x0 = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + let mut s = session(); + let (_, outer, hole) = isolated_ring(&mut s); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 0]})).unwrap(); + assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 40.0), "only the hole moved"); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 45.0), "Move: only the hole"); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [hole.0]})).unwrap(); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert_eq!((x0(&s, outer), x0(&s, hole)), (5.0, 50.0), "not isolated: the whole compound path moved"); +} + /// `depth` and `childLimit` slice a summary read; a level that shows fewer children /// than it has reports `childCount`, and invalid values are rejected, not reinterpreted. #[test]