Fix #1756: Object → Expand expands repeats

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.
This commit is contained in:
Ayberk A. authored and bflatastic committed 2026-10-14 03:55:41 +07:00
1 parent 3024821c32
commit 2f4cc8a1cc
3 files changed
+95 -19

No files matched your search

+11 -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. 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<CommandSpec> {
"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<Value> {
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!({}));
}
+38 -16
View File
@@ -708,28 +708,50 @@ fn fix_ids(d: &mut Document, n: &mut Node) {
fn repeat_expand(s: &mut Session, _: &Value) -> Result<Value> {
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<usize> {
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<Value> {
const C: &str = "object.repeat.options";
let reps = selected_repeats(s);
+46
View File
@@ -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();