#1172: share an object's appearance with its undo history until it changes

Every edit of an object copied its whole appearance (its fills, strokes and effects, about
540 bytes for a basic object) into the new node, though a move leaves it as it is. Over 45
whole-selection moves of 10,000 paths, undo history grew by 589 MB; now 349 MB (41 % less), and
a nudge or a drag of the selection takes 12 ms instead of 15.

- Node::appearance is a vectorcraft_doc::Shared<Appearance>: copy-on-write. A clone shares it,
  and the first change through DerefMut copies it (Arc::make_mut). Reads, writes, equality (also
  against a plain Appearance) and the saved form are those of the Appearance itself, so call
  sites read and change it as before. Sites that build or assign a whole appearance add .into(),
  and the few that need a plain copy take (*n.appearance).clone().
- Node::transform changes the appearance only when it has gradients to transform, and pattern
  paints are transformed only when there are some: a move no longer copies an appearance it
  doesn't change.

The workspace suite (5,301 tests) and clippy pass with the change; the doc and engine suites
pass again after the transform guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
arhammusheer
2026-10-11 23:27:21 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 7652439b40
commit 0724aafbb6
42 changed files with 159 additions and 71 deletions
+4 -4
View File
@@ -443,7 +443,7 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
/// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A
/// group keeps no fill or stroke rows of its own.
fn clear(n: &mut Node) {
n.appearance = if is_group(n) { Appearance::default() } else { Appearance::basic(Paint::None, Paint::None, 1.0) };
n.appearance = if is_group(n) { Appearance::default().into() } else { Appearance::basic(Paint::None, Paint::None, 1.0).into() };
n.opacity = 1.0;
n.blend = BlendMode::Normal;
}
@@ -496,7 +496,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
// keeps the rows it had.
let fill =
(fill.is_some() || !is_group(n)).then(|| AppearanceItem::Fill(FillLayer::new(fill.map_or(Paint::None, |f| f.paint().clone()))));
n.appearance = Appearance { items: fill.into_iter().collect(), ..Default::default() };
n.appearance = Appearance { items: fill.into_iter().collect(), ..Default::default() }.into();
if let Some(AppearanceItem::Stroke(mut st)) = stroke {
st.effects.clear();
st.opacity = 1.0;
@@ -758,7 +758,7 @@ impl Picked {
let st = &t.runs[0].style;
(st.basic_appearance(), Some(t.local_bounds()), Some((st.clone(), t.para.clone())))
}
_ => (n.appearance.clone(), n.geometric_bounds(), None),
_ => ((*n.appearance).clone(), n.geometric_bounds(), None),
};
Self { appearance, bounds, opacity: n.opacity, blend: n.blend, text }
}
@@ -831,7 +831,7 @@ impl Picked {
}
let placed = self.placed(n.geometric_bounds());
if ap.fill == FillAttrs::ALL && ap.stroke == StrokeAttrs::ALL {
n.appearance = placed;
n.appearance = placed.into();
} else {
focal_attrs(&mut n.appearance, &placed, ap);
}
+5 -4
View File
@@ -478,7 +478,7 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|e| {
let pd = e.path_data()?;
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), e.appearance.clone()))
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
})
.collect();
let mut sources = source_nodes(&old);
@@ -505,7 +505,7 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(ch) = g.children_mut()
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
{
Arc::make_mut(e).appearance = app;
Arc::make_mut(e).appearance = app.into();
}
}
for r in roots.iter().filter(|r| **r != target) {
@@ -777,8 +777,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
..StrokeLayer::new(paint, width * unit)
})],
..Default::default()
},
None => fill_only(paint),
}
.into(),
None => fill_only(paint).into(),
};
children.push(Arc::new(n));
}
+1 -1
View File
@@ -97,7 +97,7 @@ pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewAr
let (par, idx, _) = d.position(target).ok_or(crate::EngineError::NoNode(target))?;
let mut mask = d.node(target).cloned().ok_or(crate::EngineError::NoNode(target))?;
mask.id = d.alloc_id();
mask.appearance = Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0);
mask.appearance = Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0).into();
if let NodeKind::Path { clipping, .. } = &mut mask.kind {
*clipping = true;
}
+3 -3
View File
@@ -236,13 +236,13 @@ impl Builder {
let appearance = self.appearance(fills, strokes, to_doc);
if children.is_empty() {
let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false });
p.appearance = appearance;
p.appearance = appearance.into();
p
} else {
// A shape clips its children: VectorCraft's clipping path keeps its paint, its
// fill behind the clipped art and its stroke over it, as Affinity draws it.
let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Path { path: data, rule, live: None, clipping: true, guide: false });
clip.appearance = appearance;
clip.appearance = appearance.into();
let mut kids = vec![Arc::new(clip)];
kids.extend(children);
self.base(n, NodeKind::Group { children: kids, clip: true })
@@ -259,7 +259,7 @@ impl Builder {
guide: false,
},
);
clip.appearance = self.appearance(background, strokes, to_doc);
clip.appearance = self.appearance(background, strokes, to_doc).into();
let mut kids = vec![Arc::new(clip)];
kids.extend(children);
let mut g = self.base(n, NodeKind::Group { children: kids, clip: true });
+3 -2
View File
@@ -655,7 +655,7 @@ impl FlattenReport {
art.iter_mut().for_each(opaque);
for path in areas {
let mut n = shape_node(&mut tmp, path.clone(), None);
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0);
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into();
art.push(n);
}
Some(isolated_doc(&tmp, art))
@@ -1054,7 +1054,8 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
n.appearance = Appearance {
items: vec![AppearanceItem::Fill(FillLayer { overprint: fill.overprint, ..FillLayer::new(fill.paint) })],
..Appearance::default()
};
}
.into();
n.opacity = fill.alpha.min(1.0);
children.push(Arc::new(n));
vector += 1;
+1 -1
View File
@@ -575,7 +575,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
let mut bare = node.clone();
bare.opacity = 1.0;
bare.blend = Default::default();
bare.appearance = Appearance::default();
bare.appearance = Appearance::default().into();
bare.visible = true;
let tmp = isolated_doc(&st.doc, vec![bare]);
let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale);
+1 -1
View File
@@ -94,7 +94,7 @@ impl NewArt {
/// Give new object `n` (in `d`) this look: its placed gradients land relative to its bounds, and
/// it links to the style (type keeps its own transparency).
pub(crate) fn apply(&self, d: &mut vectorcraft_doc::Document, n: &mut Node) {
n.appearance = self.appearance.clone();
n.appearance = self.appearance.clone().into();
if self.unit_box
&& n.appearance.has_placed_gradient()
&& let Some(b) = n.geometric_bounds()
+2 -2
View File
@@ -661,7 +661,7 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
let back = paths.first().map_or(0.0, |p| direction(p));
for (i, mut p) in paths.into_iter().enumerate() {
let pn = Arc::make_mut(&mut p);
pn.appearance = Appearance::default();
pn.appearance = Appearance::default().into();
let flip = i > 0 && back != 0.0 && direction(pn) == back;
if let NodeKind::Path { path, live, .. } = &mut pn.kind {
*live = None;
@@ -742,7 +742,7 @@ pub(super) fn as_clipping_path(c: &mut Node) -> Result<()> {
if !matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into()));
}
c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0);
c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into();
match &mut c.kind {
NodeKind::Path { clipping, .. } => *clipping = true,
NodeKind::Text(t) => {
+1 -1
View File
@@ -524,7 +524,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
r.style.stroke = Paint::None;
}
}
_ => n.appearance = Appearance::default_art(),
_ => n.appearance = Appearance::default_art().into(),
}
}
Ok(())
+5 -5
View File
@@ -203,10 +203,10 @@ struct Style {
impl Style {
fn of(n: &Node) -> Self {
Style { appearance: n.appearance.clone(), opacity: n.opacity, blend: n.blend }
Style { appearance: (*n.appearance).clone(), opacity: n.opacity, blend: n.blend }
}
fn apply(&self, n: &mut Node) {
n.appearance = self.appearance.clone();
n.appearance = self.appearance.clone().into();
n.opacity = self.opacity;
n.blend = self.blend;
}
@@ -377,7 +377,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
// Outline: open edges stroked with the face's former fill.
let fill = st.appearance.fill_paint();
let paint = if fill.is_none() { st.appearance.stroke_paint() } else { fill };
n.appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], ..Default::default() };
n.appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], ..Default::default() }.into();
}
children.push(Arc::new(n));
}
@@ -519,7 +519,7 @@ pub(crate) fn outlined_stroke(
overprint: st.overprint,
..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone())
};
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() };
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }.into();
Some(n)
}
@@ -554,7 +554,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
/// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`.
pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>) -> Node {
let mut f = d.reid(l);
f.appearance = Appearance { items, ..Default::default() };
f.appearance = Appearance { items, ..Default::default() }.into();
f.opacity = 1.0;
f.blend = BlendMode::Normal;
f.name = None;
+2 -2
View File
@@ -136,7 +136,7 @@ fn settings(s: &mut Session, p: &Value) -> Result<Value> {
/// `art` in a clip group clipped by `path` filled by `rule` (a clipping path that paints nothing).
pub(crate) fn clip_group(d: &mut Document, path: PathData, rule: FillRule, art: Node) -> Node {
let mut clip = super::pathops::shape_node(d, path, None);
clip.appearance = Appearance::basic(Paint::None, Paint::None, 0.0);
clip.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into();
match &mut clip.kind {
NodeKind::Path { clipping, rule: r, .. } => (*clipping, *r) = (true, rule),
NodeKind::Compound { rule: r, .. } => *r = rule,
@@ -363,7 +363,7 @@ fn split_items(out: &mut Document, n: &Node) -> Option<Node> {
}
let mut stroke_piece = |d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| {
let mut p = super::pathops::shape_node(d, path.clone(), None);
p.appearance = Appearance { items: vec![AppearanceItem::Stroke(st.clone())], ..Default::default() };
p.appearance = Appearance { items: vec![AppearanceItem::Stroke(st.clone())], ..Default::default() }.into();
if let NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. } = &mut p.kind {
*r = rule;
}
+5 -5
View File
@@ -334,7 +334,7 @@ fn generated(d: &mut Document, src: &[Arc<Node>], spec: &ShaperSpec, map: &Build
let edit = edits.get(&i);
let Some(source) = edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, region)).and_then(|i| src.get(i)) else { continue };
let path = po::unite_all(&indices.iter().filter_map(|i| map.regions.get(*i).map(|r| (&r.0.path, FillRule::NonZero))).collect::<Vec<_>>());
let style = edit.and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone());
let style = edit.and_then(|e| e.paint.clone()).unwrap_or_else(|| (*source.appearance).clone());
let mut n = Node::path(d.alloc_id(), path, fill_style(style));
n.opacity = source.opacity;
n.blend = source.blend;
@@ -358,7 +358,7 @@ fn generated(d: &mut Document, src: &[Arc<Node>], spec: &ShaperSpec, map: &Build
continue;
}
let Some(source) = src.get(piece.key as usize) else { continue };
let style = edge_edits.get(&i).and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone());
let style = edge_edits.get(&i).and_then(|e| e.paint.clone()).unwrap_or_else(|| (*source.appearance).clone());
if style.stroke_paint().is_none() {
continue;
}
@@ -448,7 +448,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
for i in related {
let Some(selector) = face_selector(&map, &src, i) else { continue };
if let Some(e) = spec.faces.iter_mut().rev().find(|e| e.selector == selector) {
e.paint = Some(n.appearance.clone());
e.paint = Some((*n.appearance).clone());
} else {
spec.faces.push(FaceEdit {
selector,
@@ -456,7 +456,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
erase_edges: false,
paint_source: None,
merge,
paint: Some(n.appearance.clone()),
paint: Some((*n.appearance).clone()),
});
}
}
@@ -472,7 +472,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
&& piece_point(&piece.path).is_some_and(|point| n.path_data().and_then(|p| p.nearest(point)).is_some_and(|p| p.4 < 1e-5));
if contained && let Some(mut e) = edge_selector(&map, &src, piece) {
e.erase = false;
e.paint = Some(n.appearance.clone());
e.paint = Some((*n.appearance).clone());
spec.edges.push(e);
}
}
+2 -2
View File
@@ -217,7 +217,7 @@ fn unitized(mut ap: Appearance, from: Option<Rect>) -> Appearance {
/// and stroke. Placed gradients come relative to the unit box (from the bounds of the object
/// they're on). The pen pressure of strokes stays with their own path.
pub(crate) fn captured(n: &Node) -> Appearance {
let mut ap = n.appearance.clone().without_pressure();
let mut ap = (*n.appearance).clone().without_pressure();
if !ap.items.is_empty() {
return unitized(ap, n.geometric_bounds());
}
@@ -261,7 +261,7 @@ fn override_chars(n: &mut Node, ap: &Appearance) {
/// Give `n` style `g`: its appearance (a group or layer too: its own, above its contents; type its
/// characters' colour too with `chars`, Override Character Color) and transparency.
fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) {
n.appearance = g.appearance_on(n).into_owned();
n.appearance = g.appearance_on(n).into_owned().into();
if chars {
override_chars(n, &g.appearance);
}
+1 -1
View File
@@ -561,7 +561,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
d.remove(pid)?;
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Text(Box::new(t)));
n.appearance = Appearance::default();
n.appearance = Appearance::default().into();
n.opacity = node.opacity;
n.name = node.name.clone();
d.insert(par, idx, n)?;
+2 -2
View File
@@ -248,14 +248,14 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
}
let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, path, None);
node.appearance = st.appearance();
node.appearance = st.appearance().into();
children.push(Arc::new(node));
}
// Underline and strikethrough bars become paths of their run's paint too (#847).
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) {
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None);
node.appearance = st.appearance();
node.appearance = st.appearance().into();
children.push(Arc::new(node));
}
let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?;