Expand: selected symbol instances become their art, as Break Link places it

Object > Expand with Object on now turns each symbol instance in the
selection, including one in a selected group or in a placed document's
art, into its art, as Break Link to Symbol places it. It goes one level
deep: the instances in that art stay instances. The art keeps the
instance's id and place in the stacking order, and Expand stays one
undo step.

- Instances with effects stay instances, and Expand Appearance bakes
  them as before. Instances with an effect on a fill or stroke,
  instances in perspective, and those in blends, repeats and Intertwine
  groups stay instances.
- The art keeps the instance's opacity mask, as Break Link gives it.
- A hidden or locked instance's art stays hidden or locked.
- A placed document in an instance's art is expanded too.
- When the instances' art would make more than 1,000,000 objects, they
  stay instances, the limit Break Link uses.
- The Fill and Stroke options (object.expand.info, and the Expand
  dialog) count the fills, strokes and gradients in the art of those
  instances, with a dynamic instance's own changes applied. The info
  reports under symbolArt which of them only that art holds, and the
  dialog enables those options only while Object is on.
This commit is contained in:
dotCooCoo
2026-11-04 15:14:02 +07:00
committed by bflatastic
parent a79ab62712
commit 146d30502a
10 changed files with 503 additions and 43 deletions
+3 -1
View File
@@ -35,7 +35,9 @@ use brushes::{
};
use dynamic::{symbol_instance_reset, symbol_instance_set_appearance};
use symbols::selected_instances;
pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol};
pub(crate) use symbols::{
expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol,
};
use symbols::{
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace,
symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update,
+58 -1
View File
@@ -412,7 +412,64 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId
Ok(out)
}
/// How many objects [`break_links`] may make at once.
/// Object › Expand: the symbol instances under `roots` of `d` ([`expandable_instances`]) replaced
/// by their art, as Break Link places it, each keeping the instance's id and place. The instances
/// that art holds stay instances. A hidden or locked instance's art stays hidden or locked. When
/// the instances together would make more than [`MAX_EXPAND_NODES`] objects, all of them stay
/// instances. Returns whether any instance was expanded.
pub(crate) fn expand_instances_under(d: &mut Document, roots: &[NodeId]) -> Result<bool> {
let ids = expandable_instances(d, roots);
let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, true)));
if total > MAX_EXPAND_NODES as u64 {
return Ok(false);
}
let mut any = false;
for id in ids {
let Some(inst) = d.node(id).cloned() else { continue };
let Some(mut art) = expanded_instance(d, &inst, true) else { continue };
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
art.id = id;
art.visible &= inst.visible;
art.locked |= inst.locked;
d.remove(id)?;
d.insert(par, idx, art)?;
any = true;
}
Ok(any)
}
/// The symbol instances under `roots` of `d` that Object › Expand turns into their art: those
/// without effects (Expand Appearance bakes the others), with no effects on their fills and
/// strokes, not in perspective, and whose symbol `d` holds. The instances in blends, repeats and
/// Intertwine groups are left to Expand, which turns those into plain groups.
pub(crate) fn expandable_instances(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
fn collect(d: &Document, n: &Node, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => {
if n.appearance.effects.is_empty()
&& n.appearance.items.iter().all(|i| i.effects().is_empty())
&& n.projection().is_none()
&& d.symbols.iter().any(|s| &s.name == symbol)
{
out.push(n.id);
}
}
NodeKind::Blend { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {}
_ => {
for c in n.children().into_iter().flatten() {
collect(d, c, out);
}
}
}
}
let mut out = vec![];
for n in roots.iter().filter_map(|r| d.node(*r)) {
collect(d, n, &mut out);
}
out
}
/// How many objects [`break_links`] and [`expand_instances_under`] may make at once.
const MAX_EXPAND_NODES: usize = 1_000_000;
pub(super) fn selected_instances(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
+92 -32
View File
@@ -1,6 +1,7 @@
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient and pattern
//! fills (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
//! art they show, their resources joining the document. Symbol instances (Object) become their art,
//! as Break Link to Symbol places it. 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. A pattern fill becomes the tiles it paints inside a clip
//! group shaped like the object. Create Gradient Mesh shares the sampling.
@@ -40,7 +41,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, 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, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), 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 (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), 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, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), 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
),
@@ -49,11 +50,17 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Options",
[],
None,
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus)",
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
has_selection,
|s, _| {
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)}))
let mut info =
json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)});
if e.symbols {
let [fill, stroke, gradient] = e.symbol_art;
info["symbolArt"] = json!({"fill": fill, "stroke": stroke, "gradient": gradient});
}
Ok(info)
}
),
]
@@ -124,45 +131,74 @@ struct Expandable {
repeats: bool,
/// Intertwine groups (Object expands them into groups of what they draw).
intertwines: bool,
/// Symbol instances (Object expands them into their art, as Break Link to Symbol does).
symbols: bool,
/// Fill, stroke and gradient that only the art of those symbol instances holds: Fill and Stroke
/// reach them only when Object expands the instances.
symbol_art: [bool; 3],
}
impl Expandable {
fn of(d: &Document, roots: &[NodeId]) -> Self {
let mut e = Self::default();
for n in roots.iter().filter_map(|r| d.node(*r)) {
n.walk(&mut |c| {
let shape = matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
e.text |= matches!(c.kind, NodeKind::Text(_));
e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. });
e.effects |= !c.appearance.effects.is_empty();
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.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
e.stroke |= match &c.kind {
// Type's outlines take its characters' stroke (see `type.createOutlines`).
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
_ => shape && !c.appearance.stroke_paint().is_none(),
};
e.gradient |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
};
e.fill |= e.gradient;
e.fill |= match &c.kind {
NodeKind::Text(t) => {
t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some()))
}
_ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()),
};
});
e.add(d, n);
}
let own = [e.fill, e.stroke, e.gradient];
// The Fill and Stroke options also reach the art of the symbol instances that Object
// expands: a dynamic instance's art with its own changes, and each other symbol's art once.
let instances = super::brushsym::expandable_instances(d, roots);
e.symbols = !instances.is_empty();
let mut seen = std::collections::HashSet::new();
for inst in instances.iter().filter_map(|id| d.node(*id)) {
let NodeKind::SymbolInstance { symbol, .. } = &inst.kind else { continue };
let Some(sym) = d.symbols.iter().find(|s| &s.name == symbol) else { continue };
let mut a = Self::default();
if !inst.extras().symbol_overrides.is_empty() {
a.add(d, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst));
} else if seen.insert(symbol.clone()) {
a.add(d, &sym.art);
}
e.fill |= a.fill;
e.gradient |= a.gradient;
e.stroke |= a.stroke;
}
e.symbol_art = [e.fill && !own[0], e.stroke && !own[1], e.gradient && !own[2]];
e
}
/// Adds what `n` and the nodes under it hold.
fn add(&mut self, d: &Document, n: &Node) {
let e = self;
n.walk(&mut |c| {
let shape = matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
e.text |= matches!(c.kind, NodeKind::Text(_));
e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. });
e.effects |= !c.appearance.effects.is_empty();
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.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
e.stroke |= match &c.kind {
// Type's outlines take its characters' stroke (see `type.createOutlines`).
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
_ => shape && !c.appearance.stroke_paint().is_none(),
};
e.gradient |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
};
e.fill |= e.gradient;
e.fill |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())),
_ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()),
};
});
}
fn object(&self) -> bool {
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines || self.symbols
}
}
@@ -209,6 +245,30 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
if object && e.placed {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
}
// Then the symbol instances, those in the placed documents' art included: their art expands
// below, and the instances it holds stay instances.
if object && e.symbols {
let roots = selected_roots(s)?;
// An edit that expands no instance (too many objects to make) is undone, and the
// instances stay as they are.
let mut expanded = None;
let r = s.edit("Expand", |d, _| {
let any = super::brushsym::expand_instances_under(d, &roots)?;
expanded = Some(any);
if any { Ok(()) } else { Err(EngineError::Other("Expand: nothing to expand".into())) }
});
if !matches!((&r, expanded), (Err(_), Some(false))) {
r?;
}
e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
// The placed documents in the instances' art too.
if e.placed {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
}
}
// Intertwine groups before what they hold: their objects, and the copies of the parts drawn
// over the others, expand below.
+1
View File
@@ -50,6 +50,7 @@ mod envelope;
mod envelope_distort;
mod envelope_edit;
pub(super) mod expand;
mod expand_symbols;
mod eyedropper;
mod file;
mod fileinfo;
+286
View File
@@ -0,0 +1,286 @@
//! Object › Expand on symbol instances: a selected instance without effects becomes its art, as
//! Break Link to Symbol places it, in one undo step. The instances that art holds stay instances.
use serde_json::json;
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Rect;
use crate::*;
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn is_instance(s: &Session, id: NodeId) -> bool {
matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::SymbolInstance { .. })
}
/// A 20 × 20 square at (50, 50) made into symbol `name`, its instance selected.
fn square_symbol(s: &mut Session, name: &str) -> NodeId {
let r = rect(s, 50.0, 50.0, 20.0, 20.0);
s.select(|_, sel| sel.set([r])).unwrap();
NodeId(s.execute("symbol.new", &json!({"name": name})).unwrap()["id"].as_u64().unwrap())
}
/// A 20 × 20 square at (50, 50) with a 2 pt blue stroke and no fill, selected.
fn stroked_square(s: &mut Session) -> NodeId {
let r = rect(s, 50.0, 50.0, 20.0, 20.0);
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let blue = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 255));
doc.node_mut(r).unwrap().appearance = vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, blue, 2.0).into();
s.select(|_, sel| sel.set([r])).unwrap();
r
}
/// The geometric boxes of the paths under `n`, in paint order.
fn path_boxes(n: &Node) -> Vec<Rect> {
let mut out = vec![];
n.walk(&mut |c| {
if let NodeKind::Path { path, .. } = &c.kind {
out.extend(path.bounds());
}
});
out
}
fn close(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6)
}
#[test]
fn expand_turns_a_symbol_instance_into_its_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = square_symbol(&mut s, "Square");
s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 40, "height": 30, "reference": 0})).unwrap();
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true));
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let d = &s.doc().unwrap().doc;
let art = d.node(id).expect("the art keeps the instance's id");
assert!(!matches!(art.kind, NodeKind::SymbolInstance { .. }));
let boxes = path_boxes(art);
assert_eq!(boxes.len(), 1, "{boxes:?}");
assert!(close(boxes[0], Rect::new(50.0, 50.0, 90.0, 80.0)), "{boxes:?}");
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), vec![id]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(is_instance(&s, id), "one undo step");
}
/// Expand goes one level deep: the instance that a symbol's art holds stays an instance.
#[test]
fn the_instances_in_the_art_stay_instances() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let inner = square_symbol(&mut s, "Inner");
let other = rect(&mut s, 150.0, 50.0, 20.0, 20.0);
s.select(|_, sel| sel.set([inner, other])).unwrap();
let outer = NodeId(s.execute("symbol.new", &json!({"name": "Outer"})).unwrap()["id"].as_u64().unwrap());
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let art = s.doc().unwrap().doc.node(outer).unwrap();
assert!(matches!(art.kind, NodeKind::Group { .. }), "the selected instance expands: {}", art.kind_label());
let mut instances = vec![];
art.walk(&mut |c| {
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind {
instances.push(symbol.clone());
}
});
assert_eq!(instances, vec!["Inner".to_string()], "the instance in its art stays an instance");
}
/// An instance in a group expands too, and the group stays a group.
#[test]
fn expand_expands_an_instance_inside_a_group() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = square_symbol(&mut s, "Square");
let other = rect(&mut s, 150.0, 50.0, 20.0, 20.0);
s.select(|_, sel| sel.set([id, other])).unwrap();
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(matches!(d.node(group).unwrap().kind, NodeKind::Group { .. }));
assert!(!is_instance(&s, id));
}
/// An instance of a dynamic symbol keeps its own appearance changes when Object › Expand turns it
/// into its art.
#[test]
fn a_dynamic_instance_keeps_its_changes() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ids = [rect(&mut s, 50.0, 50.0, 20.0, 20.0), rect(&mut s, 80.0, 50.0, 20.0, 20.0)];
s.select(|_, sel| sel.set(ids)).unwrap();
let id = NodeId(s.execute("symbol.new", &json!({"name": "Pair", "type": "dynamic"})).unwrap()["id"].as_u64().unwrap());
s.execute("symbol.instance.setAppearance", &json!({"id": id.0, "path": [1], "fill": "#ff0000"})).unwrap();
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let d = &s.doc().unwrap().doc;
let mut fills = vec![];
d.node(id).unwrap().walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. }) {
fills.push(c.appearance.fill_paint().color().map(|c| c.to_hex()));
}
});
assert_eq!(fills.len(), 2, "{fills:?}");
assert_eq!(fills[1].as_deref(), Some("#ff0000"), "{fills:?}");
assert_ne!(fills[0].as_deref(), Some("#ff0000"), "only the changed object: {fills:?}");
}
/// The Stroke option counts the strokes in the art of the selected instances, so the Expand
/// dialog offers it, and Stroke outlines them in the same command.
#[test]
fn expand_options_count_the_art_of_instances() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
stroked_square(&mut s);
s.execute("symbol.new", &json!({"name": "Ring"})).unwrap();
let info = s.execute("object.expand.info", &json!({})).unwrap();
assert_eq!((info["object"].clone(), info["stroke"].clone()), (json!(true), json!(true)), "{info}");
s.execute("object.expand", &json!({"fill": false})).unwrap();
let d = &s.doc().unwrap().doc;
let (mut paths, mut strokes) = (0, 0);
for n in s.doc().unwrap().selection.objects.iter().filter_map(|i| d.node(*i)) {
n.walk(&mut |c| {
paths += usize::from(matches!(c.kind, NodeKind::Path { .. }));
strokes += usize::from(!c.appearance.stroke_paint().is_none());
});
}
assert!(paths > 0, "the selection holds the outlined art");
assert_eq!(strokes, 0, "the stroke is outlined");
}
/// The Fill and Stroke options read a dynamic instance's art with its own changes: an instance
/// that removes the symbol's only stroke has no stroke to expand.
#[test]
fn expand_options_read_a_dynamic_instances_changes() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
stroked_square(&mut s);
let id = NodeId(s.execute("symbol.new", &json!({"name": "Ring", "type": "dynamic"})).unwrap()["id"].as_u64().unwrap());
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["stroke"], json!(true));
s.execute("symbol.instance.setAppearance", &json!({"id": id.0, "path": [], "stroke": {"none": true}})).unwrap();
s.select(|_, sel| sel.set([id])).unwrap();
let info = s.execute("object.expand.info", &json!({})).unwrap();
assert_eq!(info["stroke"], json!(false), "{info}");
}
/// An instance whose opacity masks hold more than a million objects stays an instance under
/// Object › Expand, though the art outside the masks holds about a thousand. With nothing else
/// to expand, Expand reports it and adds no undo step.
#[test]
fn an_instance_whose_masks_hold_too_many_objects_stays_an_instance() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = square_symbol(&mut s, "Masked");
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let square = doc.symbols.iter().find(|s| s.name == "Masked").unwrap().art.clone();
let mask_art = Node::group(doc.alloc_id(), vec![square.clone(); 1_000]);
let mut masked = (*square).clone();
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask_art, true)));
let masked = std::sync::Arc::new(masked);
let big = Node::group(doc.alloc_id(), vec![masked; 1_000]);
assert!(big.count() < 1_000_000 && big.count_with_masks() > 1_000_000);
doc.symbols.iter_mut().find(|s| s.name == "Masked").unwrap().art = std::sync::Arc::new(big);
let steps = s.doc().unwrap().history.undo.len();
assert!(s.execute("object.expand", &json!({"fill": false, "stroke": false})).is_err(), "nothing to expand");
assert!(is_instance(&s, id), "Object › Expand leaves it an instance");
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step");
}
/// A hidden instance and a locked one inside a selected group expand into art that stays hidden
/// and locked.
#[test]
fn hidden_and_locked_instances_expand_into_hidden_and_locked_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let hidden = square_symbol(&mut s, "Square");
let locked = NodeId(s.execute("symbol.place", &json!({"name": "Square", "x": 200, "y": 60})).unwrap()["id"].as_u64().unwrap());
let other = rect(&mut s, 150.0, 150.0, 20.0, 20.0);
s.select(|_, sel| sel.set([hidden, locked, other])).unwrap();
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
doc.node_mut(hidden).unwrap().visible = false;
doc.node_mut(locked).unwrap().locked = true;
s.select(|_, sel| sel.set([group])).unwrap();
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(!is_instance(&s, hidden) && !is_instance(&s, locked), "both expand");
assert!(!d.node(hidden).unwrap().visible, "the hidden instance's art stays hidden");
assert!(d.node(locked).unwrap().locked, "the locked instance's art stays locked");
}
/// An instance with an opacity mask expands into art that keeps the mask, as Break Link gives it.
#[test]
fn an_instance_with_an_opacity_mask_keeps_it_on_its_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let masked = square_symbol(&mut s, "Square");
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let art = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Default::default());
let mask = Box::new(vectorcraft_doc::OpacityMask::new(art, true));
doc.node_mut(masked).unwrap().extras_mut().mask = Some(mask.clone());
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
assert!(!is_instance(&s, masked), "the instance expands");
assert_eq!(s.doc().unwrap().doc.node(masked).unwrap().extras().mask, Some(mask), "the art keeps the mask");
}
/// An instance with an effect on its fill stays an instance under Object › Expand and keeps the
/// effect, beside an instance that expands.
#[test]
fn an_instance_with_an_effect_on_its_fill_stays_an_instance() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let plain = square_symbol(&mut s, "Square");
let filled = NodeId(s.execute("symbol.place", &json!({"name": "Square", "x": 200, "y": 60})).unwrap()["id"].as_u64().unwrap());
let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0));
let mut look = vectorcraft_doc::Appearance::basic(red, vectorcraft_color::Paint::None, 0.0);
look.items[0].effects_mut().push(vectorcraft_render::effects::new_effect("stylize.dropShadow", &json!({"x": 4, "y": 0})).unwrap());
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(filled).unwrap().appearance = look.into();
s.select(|_, sel| sel.set([plain, filled])).unwrap();
let _ = s.execute("object.expand", &json!({"fill": false, "stroke": false}));
assert!(!is_instance(&s, plain), "the plain instance expands");
assert!(is_instance(&s, filled), "the instance with an effect on its fill stays an instance");
let n = s.doc().unwrap().doc.node(filled).unwrap().clone();
assert_eq!(n.appearance.items[0].effects().len(), 1, "it keeps the effect");
}
/// An instance in perspective stays an instance under Object › Expand, beside an instance that
/// expands.
#[test]
fn an_instance_in_perspective_stays_an_instance() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 1000, "height": 800})).unwrap();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
let flat = square_symbol(&mut s, "Square");
let projected = NodeId(s.execute("symbol.place", &json!({"name": "Square", "x": 500, "y": 350})).unwrap()["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [projected.0], "plane": "right"})).unwrap();
assert!(s.doc().unwrap().doc.node(projected).unwrap().projection().is_some(), "the instance is in perspective");
s.select(|_, sel| sel.set([flat, projected])).unwrap();
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
assert!(!is_instance(&s, flat), "the flat instance expands");
assert!(is_instance(&s, projected), "the instance in perspective stays an instance");
}
/// A blend between two instances, the second turned 90°, still expands into the steps it draws:
/// Expand leaves the instances in a blend alone, and the blend's steps stay instances.
#[test]
fn a_blend_of_instances_expands_into_the_steps_it_draws() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let a = square_symbol(&mut s, "Dot");
let b = NodeId(s.execute("symbol.place", &json!({"name": "Dot", "x": 210, "y": 60})).unwrap()["id"].as_u64().unwrap());
s.select(|_, sel| sel.set([b])).unwrap();
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
let g = NodeId(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap()["id"].as_u64().unwrap());
s.select(|_, sel| sel.set([g])).unwrap();
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let d = &s.doc().unwrap().doc;
let (mut blends, mut instances) = (0, 0);
for id in &s.doc().unwrap().selection.objects {
d.node(*id).unwrap().walk(&mut |c| {
blends += usize::from(matches!(c.kind, NodeKind::Blend { .. }));
instances += usize::from(matches!(c.kind, NodeKind::SymbolInstance { .. }));
});
}
assert_eq!((blends, instances), (0, 3), "the steps stay instances");
}
@@ -851,6 +851,24 @@ fn expand_turns_a_placed_document_into_editable_art_with_its_resources() {
assert_eq!(info["object"], false, "nothing left to expand: {info}");
}
/// Expand also expands a placed document in the art of a selected symbol instance, in the same
/// command.
#[test]
fn expand_expands_a_placed_document_in_a_symbol_instances_art() {
let dir = Folder::new("placed-expand-symbol");
let src = dir.file("badge.vectorcraft");
star_source(&src, "star");
let mut s = session();
let id = place(&mut s, &src);
s.select(|_, sel| sel.set([id])).unwrap();
let inst = NodeId(s.execute("symbol.new", &json!({"name": "Badge"})).unwrap()["id"].as_u64().unwrap());
s.execute("object.expand", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
let mut placed = 0;
doc.node(inst).unwrap().walk(&mut |c| placed += usize::from(matches!(c.kind, NodeKind::PlacedDocument(_))));
assert_eq!(placed, 0, "the placed document in the art is expanded");
}
#[test]
fn saving_a_document_refreshes_the_open_documents_that_place_it() {
let dir = Folder::new("placed-refresh");
+32 -4
View File
@@ -4,8 +4,9 @@
//! `object.expand` as one undo step.
//!
//! Fields: `object`, `fill`, `stroke`, `gradient` (`objects` | `mesh`), `steps` (1..1000), and
//! `__object`, `__fill`, `__stroke` (that option has something to expand: `object.expand.info`) and
//! `__gradient` (Fill has a gradient fill to expand).
//! `__object`, `__fill`, `__stroke` (that option has something to expand: `object.expand.info`),
//! `__gradient` (Fill has a gradient fill to expand), and `__fill_symbol`, `__stroke_symbol`,
//! `__gradient_symbol` (only the art of symbol instances holds it, so the option needs Object on).
use serde_json::{Value, json};
use vectorcraft_engine::cmd::expand::{DEFAULT_STEPS, MAX_STEPS};
@@ -43,6 +44,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
fields[format!("__{key}")] = info[key].clone();
}
fields["__gradient"] = info["gradient"].clone();
for key in ["fill", "stroke", "gradient"] {
fields[format!("__{key}_symbol")] = json!(info["symbolArt"][key] == json!(true));
}
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
@@ -56,7 +60,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let on = d.bool(key);
ui.horizontal(|ui| {
ui.add_space(12.0);
if widgets::check(ui, label, on, d.bool(&format!("__{key}"))) {
if widgets::check(ui, label, on, option_enabled(d, key)) {
set(d, key, json!(!on));
}
});
@@ -86,9 +90,15 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// Has option `key` something to expand? What only the art of symbol instances holds needs Object
/// on.
fn option_enabled(d: &Dialog, key: &str) -> bool {
d.bool(&format!("__{key}")) && (key == "object" || d.bool("object") || !d.bool(&format!("__{key}_symbol")))
}
/// Does Expand Gradient To apply? Only gradient fills that Fill expands care.
fn gradient_to_enabled(d: &Dialog) -> bool {
d.bool("__gradient") && d.bool("fill")
option_enabled(d, "gradient") && option_enabled(d, "fill") && d.bool("fill")
}
/// `object.expand` parameters from the fields.
@@ -164,6 +174,24 @@ mod tests {
assert!(gradient_to_enabled(app.ui.dialog.as_ref().unwrap()));
}
/// Stroke for the stroke in a symbol instance's art is enabled only while Object is on, since
/// Object expands the instance.
#[test]
fn stroke_for_a_symbol_instances_art_needs_object() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60})).unwrap();
app.run("paint.setFill", json!({"none": true})).unwrap();
app.run("paint.setStroke", json!({"color": "#000000"})).unwrap();
app.run("symbol.new", json!({"name": "Frame"})).unwrap();
app.run("ui.expandDialog", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
assert!(option_enabled(d, "object") && option_enabled(d, "stroke"));
d.fields.insert("object".into(), json!(false));
assert!(!option_enabled(d, "stroke"), "Stroke can't reach the art with Object off");
frame(&mut app);
}
#[test]
fn the_command_palette_opens_it_and_ui_expand_dialog_too() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+3 -1
View File
@@ -455,7 +455,9 @@ changed since); its preview greys the art and paints the highlighted objects or
Expand: Object → Expand… (`ui.expandDialog`, or `object.expand` without params from the menus, palette and shortcuts)
opens the `expand` dialog for the selection. Fields: `object`, `fill`, `stroke` (all on; `__object`, `__fill`,
`__stroke` say which the selection has something for, as `object.expand.info` does, and the others are disabled),
`__stroke` say which the selection has something for, as `object.expand.info` does, and the others are disabled;
`__fill_symbol`, `__stroke_symbol` and `__gradient_symbol` say that only the art of symbol instances holds it, and
those options are enabled only while `object` is on),
`gradient` (`objects` or `mesh`) and `steps` (1–1000, default 255), which are enabled when `fill` is on and
`__gradient` says the selection has a gradient fill (`object.expand.info`'s `gradient`). `ui.dialog.confirm` runs `object.expand` with them as
one undo step. While the selection has an appearance Expand Appearance expands (effects, several fills or strokes, a
+7 -3
View File
@@ -2803,7 +2803,9 @@ an instance when the effects in the symbol's art, or the instance's own, paint o
## Expand
`object.expand {object?, fill?, stroke?, gradient?, steps?}` (Object → Expand…) turns the selection into plain art in
one undo step. `object` (on by default) outlines type, turns live shapes into paths and bakes effects; `stroke` (on)
one undo step. `object` (on by default) turns the symbol instances in the selection without effects (on the instance
or on its fills and strokes) and not in perspective, those in a placed document's art included, into their art as `symbol.breakLink` places it (the instances
that art holds stay instances; a hidden or locked instance's art stays hidden or locked), outlines type, turns live shapes into paths and bakes effects; `stroke` (on)
outlines strokes into filled paths, the stroked paths that baking effects leaves included (a group's own stroke among
them, #3089); `fill` (on) expands gradient fills: with `gradient: "objects"` (the default) into
`steps` (1–1000, default 255) solid objects (rectangles across a linear gradient, each from its band to the far end so
@@ -2831,9 +2833,11 @@ or a geometry effect on a group or type, which the canvas draws turned or scaled
and those of the object shape the outline of the clip group, the gradient fits that outline and the tiles reach its
box, as the canvas paints the fill; the expanded art does not keep them. With `fill: false` gradient and pattern fills
stay live. `object.expand.info` answers which options have something to expand in the selection
(`{object, fill, stroke, gradient, appearance}`; `gradient` says whether Fill has a gradient fill, which Expand
(`{object, fill, stroke, gradient, appearance, symbolArt?}`; `gradient` says whether Fill has a gradient fill, which Expand
Gradient To applies to; `appearance` whether the selection has appearance attributes Expand Appearance expands first,
for which Object → Expand… is dimmed in the menus), and `object.mesh.create` on a gradient-filled object colours the
for which Object → Expand… is dimmed in the menus; `symbolArt: {fill, stroke, gradient}`, present when the selection
holds symbol instances that `object` expands, whether only their art holds it, which Fill and Stroke reach only with
`object` on), and `object.mesh.create` on a gradient-filled object colours the
mesh points as the gradient paints them, with the colors Object › Expand gives them.
```json
+3 -1
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (OpenType Stylistic Sets menu added; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Object › Expand turns symbol instances into their art; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -23,6 +23,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | minor | Object › Expand turns selected symbol instances into their art as Break Link places it, one level deep, with the Expand dialog's Fill and Stroke reaching that art. Scores and estimates unchanged. |
| 2026-11-03 | minor | The OpenType panel offers the selected font's stylistic sets by custom or numbered name, and allows several to be active. Scores and estimates unchanged. |
| 2026-11-03 | minor | Actions panel: an expanded action's steps can be excluded from playback and Batch… by clearing their checkmarks; action and set checkmarks include or exclude all their steps, and exclusions survive restart, duplication and `.vcactions` save/load. Scores and estimates unchanged. |
| 2026-11-03 | minor | Correct the Type core missing list and remove duplicate target and Type core entries: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. |
@@ -648,6 +649,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it stays within the art's box (guides outside the art are not supported yet) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`.
- **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`.
- **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`.
- **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged).
- **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol. A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`.
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed.