Merge pull request #2622 from odgrim/fix/intertwine-expand-2540

Fix #2540: Object → Expand expands Intertwine groups
This commit is contained in:
BFlat
2026-10-17 13:39:54 +07:00
committed by GitHub
4 changed files with 111 additions and 5 deletions
+12 -3
View File
@@ -1,5 +1,5 @@
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the
//! (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
has_selection,
expand
),
@@ -108,6 +108,8 @@ struct Expandable {
placed: bool,
/// Repeats (Object expands them into groups of their instances).
repeats: bool,
/// Intertwine groups (Object expands them into groups of what they draw).
intertwines: bool,
}
impl Expandable {
@@ -123,6 +125,7 @@ impl Expandable {
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
e.fill |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
@@ -134,7 +137,7 @@ impl Expandable {
}
fn object(&self) -> bool {
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines
}
}
@@ -162,6 +165,12 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
}
// Intertwine groups before what they hold: their objects, and the copies of the parts drawn
// over the others, expand below.
if object && e.intertwines {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?;
}
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
if object && e.blends {
let roots = selected_roots(s)?;
+37 -1
View File
@@ -5,7 +5,8 @@
//! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn
//! around them (`area`), and changes which member is drawn on top there: each member keeps the
//! area where it is drawn over the others (`NodeExtras::intertwine`, see
//! `vectorcraft_doc::intertwine`).
//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it
//! draws ([`expand_intertwines`]), which Ungroup takes apart.
//!
//! An overlap area is a face of the planar arrangement of what the members paint (their fills and
//! strokes, type outlined; other objects by their bounds): the part where two or more of them
@@ -186,6 +187,41 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- Expand ----------
/// Replace Intertwine group `id` by a plain group of the art it draws
/// ([`vectorcraft_doc::intertwine::expand`]: its objects, then each part drawn over the others as a
/// clip group), keeping everything about the group itself; the generated art gets ids of its own.
fn expand_group(d: &mut Document, id: NodeId) -> Result<()> {
let Some(mut g) = d.node(id).cloned() else { return Ok(()) };
let NodeKind::Intertwine { children } = &g.kind else { return Ok(()) };
g.kind = NodeKind::Group { children: vectorcraft_doc::intertwine::expand(children).into_iter().map(Arc::new).collect(), clip: false };
super::live::fix_ids(d, &mut g);
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
Ok(())
}
/// Object › Expand: every Intertwine group in the subtrees of `roots` becomes a group of the art
/// it draws ([`expand_group`]), inner ones first so an outer one copies plain art. Returns how
/// many. A live Intertwine is expanded before it can be ungrouped:
/// <https://community.adobe.com/questions-652/expand-ungroup-800281>.
pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result<usize> {
let mut groups = vec![];
for r in roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if is_intertwine(c) {
groups.push(c.id);
}
});
}
}
for g in groups.iter().rev() {
expand_group(d, *g)?;
}
Ok(groups.len())
}
// ---------- Arrange ----------
#[derive(Clone, Copy, PartialEq)]
+1 -1
View File
@@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<Vec<No
}
/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids.
fn fix_ids(d: &mut Document, n: &mut Node) {
pub(super) fn fix_ids(d: &mut Document, n: &mut Node) {
if n.id == NodeId(0) {
n.id = d.alloc_id();
}
+61
View File
@@ -151,6 +151,67 @@ fn release_gives_the_objects_back_as_they_were() {
assert!(red(&render(&s), 50, 30));
}
/// #2540: Object › Expand turns the group into a plain group of what it draws (the rings, then the
/// part of A drawn over B as a clip group) in one undo step, looking the same; Ungroup then takes
/// it apart.
#[test]
fn expand_turns_the_group_into_plain_art_that_ungroups() {
let mut s = session();
let (g, a, b) = intertwined(&mut s);
arrange(&mut s, json!({"at": [50, 30]}));
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true));
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
let NodeKind::Group { children, clip: false } = &n.kind else { panic!("a group: {}", n.kind_label()) };
assert_eq!(children.len(), 3, "the rings, then the part of A drawn over B");
assert_eq!((children[0].id, children[1].id), (a, b), "the rings keep their ids and order");
assert!(matches!(children[2].kind, NodeKind::Group { clip: true, .. }));
assert!(children.iter().all(|c| c.extras().intertwine.is_none()), "no area is left over");
let mut ids = vec![];
n.walk(&mut |c| ids.push(c.id));
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert!(unique.len() == ids.len() && !ids.contains(&NodeId(0)), "the generated art has ids of its own: {ids:?}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "nothing live is left");
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
// Plain art: Ungroup takes it apart.
s.execute("object.ungroup", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none());
assert_eq!(s.doc().unwrap().selection.objects.len(), 3);
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70));
// Undo brings the Intertwine group back.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(s.doc().unwrap().doc.node(g).unwrap().kind, NodeKind::Intertwine { .. }));
}
/// Expand reaches an Intertwine group inside a selected group, and with every option on also
/// outlines the strokes of the rings and of the part drawn over the other one.
#[test]
fn expand_reaches_intertwine_groups_in_groups_and_outlines_their_strokes() {
let mut s = session();
let (g, ..) = intertwined(&mut s);
arrange(&mut s, json!({"at": [50, 30]}));
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap());
select(&mut s, &[g, c]);
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
s.execute("object.expand", &json!({})).unwrap();
let (mut intertwines, mut strokes) = (0, 0);
s.doc().unwrap().doc.node(outer).unwrap().walk(&mut |n| {
intertwines += usize::from(matches!(n.kind, NodeKind::Intertwine { .. }));
strokes += usize::from(!n.appearance.stroke_paint().is_none());
});
assert_eq!((intertwines, strokes), (0, 0));
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
let info = s.execute("object.expand.info", &json!({})).unwrap();
assert_eq!((&info["object"], &info["stroke"]), (&json!(false), &json!(false)), "nothing is left to expand");
}
#[test]
fn the_arranged_areas_move_with_the_group() {
let mut s = session();