From 2f4cc8a1cc7707b72620fbb90d049689a6defba0 Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:50:33 +0300 Subject: [PATCH] =?UTF-8?q?Fix=20#1756:=20Object=20=E2=86=92=20Expand=20ex?= =?UTF-8?q?pands=20repeats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand had nothing for live repeats, so a radial, grid or mirror repeat stayed live. Its Object option now turns every repeat in the selection (inside groups too, inner ones first) into a group of its instances, keeping the repeat's id and place, in the same undo step; the dialog enables Object for a repeat. Expand Repeat shares the code. --- crates/engine/src/cmd/expand.rs | 14 ++++++-- crates/engine/src/cmd/patterncmds.rs | 54 +++++++++++++++++++--------- crates/engine/src/tests_pattern.rs | 46 ++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index ecfa8db5..86392471 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. Placed documents (Object) become editable copies of the +//! (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 //! 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, 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, 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 ), @@ -106,6 +106,8 @@ struct Expandable { blends: bool, /// Placed documents (Object expands them into the art they show). placed: bool, + /// Repeats (Object expands them into groups of their instances). + repeats: bool, } impl Expandable { @@ -120,6 +122,7 @@ impl Expandable { e.envelope |= matches!(c.kind, NodeKind::Envelope { .. }); e.blends |= matches!(c.kind, NodeKind::Blend { .. }); e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); + e.repeats |= matches!(c.kind, NodeKind::Repeat(_)); 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(_))), @@ -131,7 +134,7 @@ impl Expandable { } fn object(&self) -> bool { - self.text || self.live || self.effects || self.envelope || self.blends || self.placed + self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats } } @@ -164,6 +167,11 @@ fn expand(s: &mut Session, p: &Value) -> Result { let roots = selected_roots(s)?; s.edit("Expand", |d, _| super::live::expand_blends(d, &roots).map(|_| ()))?; } + // Repeats too: their instances are copies of art that expands below. + if object && e.repeats { + let roots = selected_roots(s)?; + s.edit("Expand", |d, _| super::patterncmds::expand_repeats(d, &roots).map(|_| ()))?; + } if object && e.effects { let _ = run_raw(s, "effect.expandAppearance", &json!({})); } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 39adf70b..319dae72 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -708,28 +708,50 @@ fn fix_ids(d: &mut Document, n: &mut Node) { fn repeat_expand(s: &mut Session, _: &Value) -> Result { let reps = selected_repeats(s); let ids = s.edit("Expand Repeat", |d, sel| { - let mut out = vec![]; for r in &reps { - let Some(n) = d.node(*r).cloned() else { continue }; - let mut g = vectorcraft_doc::live::expanded_group(&n, None); - - // The group keeps the repeat's id; every generated instance node gets a fresh one. - if let Some(ch) = g.children_mut() { - for c in ch.iter_mut() { - fix_ids(d, Arc::make_mut(c)); - } - } - let (par, idx, _) = d.position(*r).ok_or(EngineError::NoNode(*r))?; - d.remove(*r)?; - d.insert(par, idx, g)?; - out.push(*r); + expand_repeat(d, *r)?; } - sel.set(out.iter().copied()); - Ok(out) + sel.set(reps.iter().copied()); + Ok(reps.clone()) })?; Ok(ids_json(&ids)) } +/// Repeat `r` replaced by a group of its instances, keeping its id and place; every generated +/// instance node gets a fresh id. +fn expand_repeat(d: &mut Document, r: NodeId) -> Result<()> { + let Some(n) = d.node(r).cloned() else { return Ok(()) }; + let mut g = vectorcraft_doc::live::expanded_group(&n, None); + if let Some(ch) = g.children_mut() { + for c in ch.iter_mut() { + fix_ids(d, Arc::make_mut(c)); + } + } + let (par, idx, _) = d.position(r).ok_or(EngineError::NoNode(r))?; + d.remove(r)?; + d.insert(par, idx, g)?; + Ok(()) +} + +/// Object › Expand: every repeat in the subtrees of `roots` becomes a group of its instances +/// ([`expand_repeat`]), inner ones first so an outer repeat repeats plain art. Returns how many. +pub(crate) fn expand_repeats(d: &mut Document, roots: &[NodeId]) -> Result { + let mut reps = vec![]; + for r in roots { + if let Some(n) = d.node(*r) { + n.walk(&mut |c| { + if pattern::is_repeat(c) { + reps.push(c.id); + } + }); + } + } + for r in reps.iter().rev() { + expand_repeat(d, *r)?; + } + Ok(reps.len()) +} + fn repeat_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.repeat.options"; let reps = selected_repeats(s); diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index 41282173..7a18ac6a 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -390,6 +390,52 @@ fn expand_yields_n_copies() { assert_eq!(u.len(), ids.len()); } +/// #1756: Object → Expand turns radial, grid and mirror repeats into groups of their instances, in +/// one undo step, as Expand Repeat does. +#[test] +fn object_expand_expands_repeats() { + for (cmd, opts) in [("object.repeat.radial", json!({"instances": 5})), ("object.repeat.grid", json!({})), ("object.repeat.mirror", json!({}))] { + let mut s = session(); + let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); + sel(&mut s, &[r]); + let id = id_of(&s.execute(cmd, &opts).unwrap()); + let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!("{cmd}") }; + let n = spec.expand().len(); + assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true), "{cmd}"); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap(); + let g = node(&s, id); + assert!(matches!(g.kind, NodeKind::Group { .. }), "{cmd}"); + assert_eq!(g.children().unwrap().len(), n, "{cmd}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{cmd}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![id], "{cmd}"); + // Nothing left to expand: the instances are plain rectangles. + assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "{cmd}"); + } +} + +/// Expand reaches repeats inside a selected group, and with every option on also outlines the +/// instances' strokes. +#[test] +fn object_expand_expands_repeats_in_groups() { + let mut s = session(); + let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); + sel(&mut s, &[r]); + let rep = id_of(&s.execute("object.repeat.radial", &json!({"instances": 4})).unwrap()); + let other = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[rep, other]); + s.execute("object.group", &json!({})).unwrap(); + s.execute("object.expand", &json!({})).unwrap(); + let mut repeats = 0; + for l in &doc(&s).layers { + l.walk(&mut |c| repeats += usize::from(matches!(c.kind, NodeKind::Repeat(_)))); + } + assert_eq!(repeats, 0); + let g = node(&s, rep); + assert_eq!(g.children().unwrap().len(), 4); + assert!(g.children().unwrap().iter().all(|c| c.appearance.stroke_paint().is_none())); +} + #[test] fn grid_repeat_and_options() { let mut s = session();