#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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
arhammusheerandClaude Opus 5.5 authored and bflatastic committed 2026-10-11 23:27:21 +07:00
1 parent 7652439b40
commit 0724aafbb6
42 files changed
+159 -71

No files matched your search

+2 -2
View File
@@ -26,7 +26,7 @@ fn group(children: Vec<Node>) -> Node {
fn compound(paths: Vec<PathData>, c: Color) -> Node {
let children = paths.into_iter().map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect();
let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd });
n.appearance = fill(c);
n.appearance = fill(c).into();
n
}
@@ -121,7 +121,7 @@ pub(crate) fn stitch_tile() -> Node {
let dash = path(shapes::rounded_rectangle(Rect::new(1.5, -0.75, 8.5, 0.75), 0.75), Color::BLACK);
// Invisible spacer that fixes the tile width.
let mut spacer = path(shapes::rectangle(Rect::new(0.0, -0.75, 10.0, 0.75)), Color::BLACK);
spacer.appearance = Appearance::default();
spacer.appearance = Appearance::default().into();
group(vec![spacer, dash])
}
+1 -1
View File
@@ -118,7 +118,7 @@ fn art_brush_arc_length_mapping_is_linear() {
Node::path(NodeId(0), shapes::rectangle(Rect::new(5.0, -1.0, 6.0, 1.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let frame = rect_art(10.0, 2.0);
let mut spacer = frame.clone();
spacer.appearance = Appearance::default();
spacer.appearance = Appearance::default().into();
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]);
let out = stroke_pieces(&art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let g = &out[0];
+2 -2
View File
@@ -479,7 +479,7 @@ impl<'d, 'a> Build<'d, 'a> {
.into_iter()
.map(|sp| {
let mut c = Node::new(self.doc.alloc_id(), kind(PathData::single(sp)));
c.appearance = appearance.clone();
c.appearance = appearance.clone().into();
Arc::new(c)
})
.collect();
@@ -487,7 +487,7 @@ impl<'d, 'a> Build<'d, 'a> {
} else {
Node::new(self.doc.alloc_id(), kind(path))
};
n.appearance = appearance;
n.appearance = appearance.into();
Some(n)
}
+1 -1
View File
@@ -942,7 +942,7 @@ impl Appearance {
})
}
/// Gradient paints of the fills and strokes.
fn gradients(&self) -> impl Iterator<Item = &vectorcraft_color::GradientPaint> {
pub(crate) fn gradients(&self) -> impl Iterator<Item = &vectorcraft_color::GradientPaint> {
self.items.iter().filter_map(|i| match i {
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => Some(&**g),
+1 -1
View File
@@ -619,7 +619,7 @@ impl Lerp {
}
};
n.id = NodeId(0);
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t);
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into();
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32);
n.blend = base.blend;
n.visible = true;
+1 -1
View File
@@ -463,7 +463,7 @@ mod tests {
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default());
let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero });
compound.appearance = Appearance::default_art();
compound.appearance = Appearance::default_art().into();
d.insert(Some(l), 9, compound).unwrap();
let path_only = HitOptions { path_only: true, ..Default::default() };
for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] {
+2
View File
@@ -16,7 +16,9 @@ pub mod hit;
mod id_index;
pub mod inks;
mod inline;
pub mod shared;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub mod links;
pub mod live;
pub mod marks;
+2 -2
View File
@@ -1524,7 +1524,7 @@ fn pattern_art(n: &Node, tiles: PatternTiles) -> Option<Node> {
};
let plain = |items: Vec<AppearanceItem>| {
Arc::new(Node {
appearance: Appearance { items, ..Default::default() },
appearance: Appearance { items, ..Default::default() }.into(),
opacity: 1.0,
blend: Default::default(),
isolate: false,
@@ -1560,7 +1560,7 @@ fn pattern_art(n: &Node, tiles: PatternTiles) -> Option<Node> {
}
Some(Node {
kind: NodeKind::Group { children: pieces, clip: false },
appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() },
appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() }.into(),
..n.clone()
})
}
+8 -4
View File
@@ -514,7 +514,7 @@ pub struct Node {
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub knockout_shape: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub appearance: Appearance,
pub appearance: crate::Shared<Appearance>,
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask: Option<Box<OpacityMask>>,
@@ -594,7 +594,7 @@ impl Node {
isolate: false,
knockout: Knockout::Neutral,
knockout_shape: false,
appearance: Appearance::default(),
appearance: Appearance::default().into(),
mask: None,
trace: None,
wrap: None,
@@ -611,7 +611,7 @@ impl Node {
}
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false });
n.appearance = appearance;
n.appearance = appearance.into();
n
}
pub fn group(id: NodeId, children: Vec<Arc<Node>>) -> Self {
@@ -854,7 +854,11 @@ impl Node {
self.appearance.scale_effects(k, f);
}
}
self.appearance.transform_gradients(a);
// Only through `DerefMut` when there is something to change: the appearance is shared
// copy-on-write (`Shared`), and a move must not copy what it leaves as it is.
if self.appearance.gradients().next().is_some() {
self.appearance.transform_gradients(a);
}
if sc.patterns {
crate::pattern::transform_pattern_paints(self, a);
}
+8
View File
@@ -343,6 +343,14 @@ pub fn uses_pattern(n: &Node, name: Option<&str>) -> bool {
/// Apply `m` to the pattern placement of every pattern paint of `n` (Transform Patterns).
pub fn transform_pattern_paints(n: &mut Node, m: Affine) {
// Without pattern paints there is nothing to change: don't copy the shared appearance.
let patterned = |it: &AppearanceItem| {
matches!(it, AppearanceItem::Fill(f) if matches!(f.paint, Paint::Pattern { .. }))
|| matches!(it, AppearanceItem::Stroke(s) if matches!(s.paint, Paint::Pattern { .. }))
};
if !n.appearance.items.iter().any(patterned) {
return;
}
for it in n.appearance.items.iter_mut() {
let p = match it {
AppearanceItem::Fill(f) => &mut f.paint,
+72
View File
@@ -0,0 +1,72 @@
//! [`Shared`]: a value shared between copies of a node until one of them changes it.
use std::sync::Arc;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// A copy-on-write value: cloning shares it, and the first change through `DerefMut` makes the
/// changed copy its own (`Arc::make_mut`). A node moved or recoloured keeps sharing what it didn't
/// change with the version the undo history holds. Reads, writes, equality and the saved form are
/// exactly those of the value itself.
#[derive(Clone, Default)]
pub struct Shared<T>(Arc<T>);
impl<T> Shared<T> {
pub fn new(v: T) -> Self {
Shared(Arc::new(v))
}
/// Whether `a` and `b` share one value (an unchanged copy).
pub fn ptr_eq(a: &Self, b: &Self) -> bool {
Arc::ptr_eq(&a.0, &b.0)
}
}
impl<T> std::ops::Deref for Shared<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<T: Clone> std::ops::DerefMut for Shared<T> {
fn deref_mut(&mut self) -> &mut T {
Arc::make_mut(&mut self.0)
}
}
impl<T> From<T> for Shared<T> {
fn from(v: T) -> Self {
Shared::new(v)
}
}
impl<T: PartialEq> PartialEq for Shared<T> {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0) || *self.0 == *other.0
}
}
/// A shared value equals the plain value it holds (`node.appearance == style.appearance`).
impl<T: PartialEq> PartialEq<T> for Shared<T> {
fn eq(&self, other: &T) -> bool {
*self.0 == *other
}
}
impl<T: std::fmt::Debug> std::fmt::Debug for Shared<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl<T: Serialize> Serialize for Shared<T> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for Shared<T> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
T::deserialize(d).map(Shared::new)
}
}
+1 -1
View File
@@ -166,7 +166,7 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea
Node {
id,
name: None,
appearance: Appearance { items: vec![item], ..Default::default() },
appearance: Appearance { items: vec![item], ..Default::default() }.into(),
opacity: 1.0,
blend: Default::default(),
isolate: false,
+1 -1
View File
@@ -141,7 +141,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node { appearance: Appearance::default(), ..Node::new(n.id, n.kind.clone()) };
let image = Node { appearance: Appearance::default().into(), ..Node::new(n.id, n.kind.clone()) };
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
}
_ => outline_art(n, symbol_art)?,
+1 -1
View File
@@ -200,7 +200,7 @@ fn art(n: &Node, expand: bool) -> Option<Node> {
}
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
Some(art)
}
+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))?;
+1 -1
View File
@@ -103,7 +103,7 @@ fn effects_and_fills_apply_to_layers_by_id() {
// Two undo steps take both off again.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, layer).appearance, Default::default());
assert_eq!(node(&s, layer).appearance, vectorcraft_doc::Appearance::default());
// Nothing selected and no ids: an error, not a silent no-op.
assert!(s.execute("appearance.addFill", &json!({})).is_err());
}
+1 -1
View File
@@ -34,7 +34,7 @@ fn sel(s: &mut Session, ids: &[NodeId]) {
fn rect(s: &mut Session) -> NodeId {
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap());
s.edit("red", |d, _| {
d.node_mut(a).unwrap().appearance = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.node_mut(a).unwrap().appearance = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
Ok(())
})
.unwrap();
+1 -1
View File
@@ -126,7 +126,7 @@ fn shift_alt_click_appends_the_appearance() {
let mut s = session();
let (_, dst) = source_and_target(&mut s);
click(&mut s, 325.0, 35.0, Mods { alt: true, shift: true, ..Default::default() });
let items = node(&s, dst).appearance.items;
let items = node(&s, dst).appearance.items.clone();
assert_eq!(items.len(), 4, "the source's fill and stroke on top of the target's");
assert_eq!((items[0].paint().color().unwrap().to_hex(), items[2].paint().color().unwrap().to_hex()), ("#ffffff".into(), "#ff0000".into()));
}
+2 -2
View File
@@ -229,7 +229,7 @@ fn outline_stroke_of_line() {
let r = s.execute("shape.line", &json!({"x1": 0, "y1": 50, "x2": 100, "y2": 50})).unwrap();
let l = NodeId(r["id"].as_u64().unwrap());
s.edit("t", |d, _| {
d.node_mut(l).unwrap().appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 10.0);
d.node_mut(l).unwrap().appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 10.0).into();
Ok(())
})
.unwrap();
@@ -304,7 +304,7 @@ fn clean_up_removes_junk() {
let keep = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let unpainted = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
s.edit("t", |d, _| {
d.node_mut(unpainted).unwrap().appearance = Appearance::basic(Paint::None, Paint::None, 1.0);
d.node_mut(unpainted).unwrap().appearance = Appearance::basic(Paint::None, Paint::None, 1.0).into();
Ok(())
})
.unwrap();
+1 -1
View File
@@ -1330,7 +1330,7 @@ impl<'a> Reader<'a> {
}
let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide };
let mut node = self.new_node(kind, &gs);
node.appearance = Appearance { items, ..Appearance::default() };
node.appearance = Appearance { items, ..Appearance::default() }.into();
node.opacity = gs.opacity;
node.blend = gs.blend;
node.knockout_shape = gs.knockout_shape;
+1 -1
View File
@@ -1688,7 +1688,7 @@ pub(crate) fn collapse(nodes: &mut [Arc<Node>], shadings: &[NodeId]) {
&& shadings.contains(&fill.id)
&& let (NodeKind::Path { path, rule, clipping: true, .. }, NodeKind::Path { .. }) = (&clip.kind, &fill.kind)
{
let mut p = Node::path(fill.id, path.clone(), fill.appearance.clone());
let mut p = Node::path(fill.id, path.clone(), (*fill.appearance).clone());
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
*r = *rule;
}
+1 -1
View File
@@ -469,7 +469,7 @@ fn import_invalid_dash_array_strokes_solid() {
// And the exporter never writes an invalid array back.
let mut d2 = doc(100.0, 100.0);
let mut n = rect_node(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0));
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0).into();
if let Some(AppearanceItem::Stroke(st)) = n.appearance.items.last_mut() {
st.dash = Some(Dash { pattern: vec![-5.0, 3.0], offset: 0.0, align_corners: false });
}
+1 -1
View File
@@ -947,7 +947,7 @@ mod tests {
let l = d.layers[0].id;
let id = d.layers[0].children().unwrap()[0].id;
let mut n = (*d.node(id).unwrap()).clone();
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0).into();
if let Some(vectorcraft_doc::AppearanceItem::Stroke(s)) =
n.appearance.items.iter_mut().find(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_)))
{
+3 -3
View File
@@ -860,7 +860,7 @@ impl Importer {
text::fold_scale(&mut obj, grow);
let mut n = self.named(&name, NodeKind::Text(Box::new(obj)));
if let Some(ap) = under {
n.appearance = ap;
n.appearance = ap.into();
}
Some(vec![Arc::new(n)])
}
@@ -1190,7 +1190,7 @@ impl Importer {
.map(|sp| {
let id = self.doc.alloc_id();
let mut c = Node::new(id, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false });
c.appearance = appearance.clone();
c.appearance = appearance.clone().into();
Arc::new(c)
})
.collect();
@@ -1198,7 +1198,7 @@ impl Importer {
} else {
self.named(p.id(), NodeKind::Path { path, rule: r, live: None, clipping: false, guide: false })
};
n.appearance = appearance;
n.appearance = appearance.into();
Some(n)
}
+3 -3
View File
@@ -188,7 +188,7 @@ fn shared_properties_read_as_svg_export_writes_them() {
fn gradients_give_css_gradients() {
let mut d = doc();
let mut n = rect(&mut d, (0.0, 0.0, 200.0, 100.0), 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0).into();
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
assert_eq!(prop(&r, "background-image"), Some("linear-gradient(90deg, #ffffff 0%, #000000 100%)"));
@@ -196,13 +196,13 @@ fn gradients_give_css_gradients() {
let mut g = GradientPaint::new(Gradient::default());
g.geom = Some(GradientGeom { start: Point::new(100.0, 0.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None });
let mut n = rect(&mut d, (0.0, 0.0, 200.0, 100.0), 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0).into();
let id = add(&mut d, n);
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "background-image"), Some("linear-gradient(180deg, #ffffff 0%, #000000 50%)"));
// Radial: a circle at the box's centre.
let radial = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Gradient::default() });
let mut n = rect(&mut d, (0.0, 0.0, 100.0, 100.0), 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(radial)), Paint::None, 0.0);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(radial)), Paint::None, 0.0).into();
let id = add(&mut d, n);
let bg = one(&d, id, &CssOptions::default()).props.into_iter().find(|(k, _)| *k == "background-image").unwrap().1;
assert!(bg.starts_with("radial-gradient(circle ") && bg.contains(" at 50px 50px, #ffffff 0%, #000000 100%)"), "{bg}");
+1 -1
View File
@@ -144,7 +144,7 @@ fn compound_evenodd_roundtrip() {
let i = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default());
let cid = d.alloc_id();
let mut c = Node::new(cid, NodeKind::Compound { children: vec![Arc::new(o), Arc::new(i)], rule: FillRule::EvenOdd });
c.appearance = Appearance::basic(solid("#123456"), Paint::None, 1.0);
c.appearance = Appearance::basic(solid("#123456"), Paint::None, 1.0).into();
let d = doc_with(vec![c]);
let s = export(&d, &ExportOptions::default());
assert_eq!(s.matches("<path").count(), 1, "{s}");
+1 -1
View File
@@ -79,7 +79,7 @@ fn instances_the_def_cant_stand_for_get_their_own_art() {
let mut d = symbol_doc(6.0, &[Affine::translate((40.0, 60.0)), rot, scaled]);
// Stained: its fill tints the art.
let mut stained = instance(&mut d, "My Mark", Affine::translate((40.0, 20.0)));
stained.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0);
stained.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0).into();
stained.appearance.items.retain(|i| i.is_fill());
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut stained.appearance.items[0] {
f.opacity = 0.5;
+1 -1
View File
@@ -612,7 +612,7 @@ mod tests {
std::sync::Arc::new(Node::path(id, path, Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0)))
};
let mut comp = Node::new(c, NodeKind::Compound { children: vec![line(a, 200.0), line(b, 300.0)], rule: Default::default() });
comp.appearance = Appearance::default_art();
comp.appearance = Appearance::default_art().into();
let st = comp.appearance.stroke_mut().unwrap();
st.width = 10.0;
st.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (1.0, 1.0, 1.0)] });