From 7c80b286c7a419d872258f9a4eb27726732b3c1d Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Fri, 16 Oct 2026 21:18:14 -0700 Subject: [PATCH] =?UTF-8?q?Fix=20#2540:=20Object=20=E2=86=92=20Expand=20ex?= =?UTF-8?q?pands=20Intertwine=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand had nothing for Intertwine groups. It reported success but left the group live (it only dropped the members' live shapes), and its Stroke and Fill options could not reach the members, so a second Expand found nothing to do. Ungroup takes apart plain groups only, so it did nothing either. Expand's Object option now turns every Intertwine group in the selection (inside groups too, inner ones first) into a plain group of what it draws: its objects, keeping their ids and order, then each part drawn over the others as a clip group. The group keeps its id, place, transparency and appearance, everything happens in one undo step, and the dialog enables Object for an Intertwine group. Ungroup then takes the result apart, as with an expanded blend or repeat. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/expand.rs | 15 +++++-- crates/engine/src/cmd/intertwine.rs | 38 ++++++++++++++++- crates/engine/src/cmd/live.rs | 2 +- crates/engine/src/tests_intertwine.rs | 61 +++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 863924719..da1567ce7 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -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 { "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 { 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)?; diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index 566cb7b20..8947a97b5 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -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 { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +// ---------- 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: +/// . +pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result { + 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)] diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index e1135c00e..0c88f7214 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec) -> Result = 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();