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();