Isolation mode: a path of the isolated compound path moves on its own (fixes #2354)

This commit is contained in:
Marco Arnulfo
2026-10-16 14:57:22 +07:00
committed by bflatastic
parent d838af024b
commit 80b7aee756
3 changed files with 51 additions and 10 deletions
+14 -1
View File
@@ -324,9 +324,22 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
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<Vec<NodeId>> {
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<NodeId>) -> Vec<NodeId> {
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<NodeId>, isolation: Option<NodeId>) -> Vec<NodeId> {
// 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<NodeId>) -> Vec<NodeId> {
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()),
}
})
+9 -9
View File
@@ -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<Value> {
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<Value> {
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<Value> {
}
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<Value> {
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<Value> {
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
}
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
};
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)),
+28
View File
@@ -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]