Merge pull request #1173 from arhammusheer/perf/editing-at-scale

perf: large documents (#1172): big selections 100×+, live effects 18×, Pathfinder 11–20×, 41% less undo memory
This commit is contained in:
BFlat 2026-10-11 23:56:38 +07:00 committed by GitHub
commit 0e36a44137
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 1134 additions and 173 deletions

View File

@ -113,11 +113,124 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
Ok((handle, segment))
}
/// Resident memory of this process in MB (`ps`), if available.
fn rss_mb() -> Option<f64> {
let pid = std::process::id().to_string();
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
}
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
fn deep(n: usize) -> Result<(), String> {
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut rows: Vec<(String, f64)> = vec![];
let mut mem: Vec<(String, f64)> = vec![];
let mut note = |what: &str| {
if let Some(m) = rss_mb() {
mem.push((what.to_string(), m));
}
};
note("start");
// Editing every object of an n-path document.
let mut s = vectorcraft_engine::Session::new();
s.add_document(synthetic(n)?, None);
note("document built");
rows.push((format!("select all, {n} paths"), median_ms(3, || drop(s.execute("select.all", &json!({}))))));
rows.push((format!("nudge the selection ({n} paths)"), median_ms(5, || drop(s.execute("object.move", &json!({"dx": 1, "dy": 0}))))));
rows.push((format!("undo a nudge ({n} paths)"), median_ms(5, || drop(s.execute("edit.undo", &json!({}))))));
rows.push((format!("redo a nudge ({n} paths)"), median_ms(5, || drop(s.execute("edit.redo", &json!({}))))));
// Dragging the whole selection with the Selection tool: one pointer move (the preview).
{
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
use vectorcraft_geom::Vec2;
let view = vectorcraft_engine::ViewInfo { zoom: 1.0, ..Default::default() };
let first = s.doc().map_err(err)?.selection.objects.first().copied();
let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center());
if let Some(down) = at {
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 };
s.select_tool("selection", view).map_err(err)?;
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
let mut dx = 0.0;
rows.push((
format!("drag the selection, per move ({n} paths)"),
median_ms(5, || {
dx += 2.0;
let _ = s.pointer(&ev(PointerKind::Drag, dx), view);
}),
));
s.pointer(&ev(PointerKind::Up, dx), view).map_err(err)?;
}
}
for _ in 0..40 {
s.execute("object.move", &json!({"dx": 1, "dy": 0})).map_err(err)?;
}
note("after 45 whole-selection edits");
drop(s);
// Live effects on 2,000 paths: render a page whose every object has the effect.
let opts = RenderOptions::default();
for (fx, params) in [("path.offsetPath", json!({"offset": 3})), ("distort.roughen", json!({"size": 5, "relative": true}))] {
let mut s = vectorcraft_engine::Session::new();
s.add_document(synthetic(2000)?, None);
s.execute("select.all", &json!({})).map_err(err)?;
s.execute("effect.apply", &json!({"effect": fx, "params": params})).map_err(err)?;
let doc = s.doc().map_err(err)?.doc.clone();
let mut r = Renderer::new();
r.render(&doc, 2880, 1800, Affine::scale(1.6), &opts);
rows.push((format!("render, 2,000 paths with {fx} (warm)"), median_ms(5, || drop(r.render(&doc, 2880, 1800, Affine::scale(1.6), &opts)))));
}
note("after live effects");
// Every Pathfinder operation on 300 overlapping paths.
for op in ["unite", "intersect", "exclude", "minusFront", "minusBack", "divide", "trim", "merge", "crop", "outline"] {
let ms = median_ms(1, || {
let mut s = vectorcraft_engine::Session::new();
if let Ok(d) = synthetic(300) {
s.add_document(d, None);
let _ = s.execute("select.all", &json!({})).and_then(|_| s.execute(&format!("object.pathfinder.{op}"), &json!({})));
}
});
rows.push((format!("Pathfinder {op}, 300 paths"), ms));
}
note("after Pathfinder");
// Typing one character in the middle of a 20,000-character area-type story.
let mut s = vectorcraft_engine::Session::new();
s.add_document(Document::new(1600.0, 1200.0), None);
let text: String = (0..2000).map(|i| if i % 12 == 11 { "paragraph.\n" } else { "lorem ips " }).collect();
let id = s.execute("text.create", &json!({"x": 20, "y": 20, "text": text, "size": 10, "area": {"width": 1500, "height": 1100}})).map_err(err)?;
let id = id.get("id").cloned().unwrap_or(json!(0));
let mut at = 10_000;
rows.push((
"type one character, 20,000-character area text".into(),
median_ms(9, || {
at += 1;
let _ = s.execute("text.editRange", &json!({"id": id, "start": at, "end": at, "insert": "x"}));
}),
));
note("after typing");
// Output hashes, to compare builds: the SVG export and a save → open → save round trip.
let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3));
let doc = synthetic(n)?;
let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default());
let saved = vectorcraft_format::save_file(&doc);
let reopened = vectorcraft_format::load(&saved).map_err(|e| e.to_string())?;
outln!(" output hash: SVG export {:016x}, save→open→save {:016x}", fnv(svg.as_bytes()), fnv(&vectorcraft_format::save_file(&reopened)));
outln!(" tracked (no budget yet)");
for (name, ms) in &rows {
outln!(" {name:<52} {ms:>10.2} ms");
}
for (what, mb) in &mem {
outln!(" memory {what:<45} {mb:>8.0} MB");
}
Ok(())
}
pub fn run(args: &[String]) -> Result<(), String> {
let mut n = 50_000usize;
let mut want_deep = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--deep" => want_deep = true,
"--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?,
other => return Err(format!("unknown perf option `{other}`")),
}
@ -200,6 +313,9 @@ pub fn run(args: &[String]) -> Result<(), String> {
if let Some(e) = failed {
return Err(e);
}
if want_deep {
deep(n)?;
}
let mut over = 0;
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");

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])
}

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];

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)
}

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),

View File

@ -50,7 +50,9 @@ impl Document {
/// `ids` that are in the document, back to front.
pub fn paint_order(&self, ids: impl IntoIterator<Item = NodeId>) -> Vec<NodeId> {
let mut v: Vec<(Vec<usize>, NodeId)> = ids.into_iter().filter_map(|id| self.index_path(id).map(|p| (p, id))).collect();
let ids: Vec<NodeId> = ids.into_iter().collect();
let paths = self.index_paths(ids.iter().copied());
let mut v: Vec<(Vec<usize>, NodeId)> = ids.into_iter().filter_map(|id| paths.get(&id).map(|p| (p.clone(), id))).collect();
v.sort();
v.into_iter().map(|(_, id)| id).collect()
}

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;

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))] {

142
crates/doc/src/id_index.rs Normal file
View File

@ -0,0 +1,142 @@
//! A cache of every node's index path, so a lookup by id checks one path instead of walking the
//! tree.
//!
//! The tree is edited in many places through public fields, so the cache can't be kept in step
//! with it. It is checked instead: a cached path is used only while the node at that path still
//! has the id (a check as deep as the tree). A path that no longer checks out rebuilds the cache,
//! and an id the cache doesn't have (deleted, or added since) is looked up by walking, as before.
//! An id that appears more than once (a damaged file) is never cached, so lookups keep returning
//! its first place in tree order.
//!
//! The one case a cached lookup can answer differently from a walk: a second node with an
//! existing id put in the tree *after* the cache was built, earlier in tree order than the first.
//! The lookup then returns the original node (also one with that id) instead of the new one. The
//! app never makes such a node (paste and duplicate give new ids, undo restores whole trees);
//! duplicated ids come from damaged files, which are cached afresh when opened.
use std::collections::HashMap;
use std::hash::{BuildHasherDefault, Hasher};
use std::sync::{Arc, RwLock};
use crate::{Node, NodeId};
/// Hasher for node ids: one multiply. Ids are small counters, not attacker-chosen keys, and the
/// default SipHash dominated the cost of building the cache.
#[derive(Default)]
pub struct IdHasher(u64);
impl Hasher for IdHasher {
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x100_0000_01b3);
}
}
fn write_u64(&mut self, v: u64) {
self.0 = (self.0 ^ v).wrapping_mul(0x9e37_79b9_7f4a_7c15);
}
fn finish(&self) -> u64 {
self.0 ^ (self.0 >> 29)
}
}
/// A map keyed by node id with [`IdHasher`].
pub type IdMap<V> = HashMap<NodeId, V, BuildHasherDefault<IdHasher>>;
/// Where each node is: its parent's entry (`u32::MAX` for a top-level layer) and its index among
/// its siblings. One flat table, so building it allocates nothing per node.
#[derive(Default)]
pub(crate) struct Paths {
/// Each id's entry in `at`; `None` for an id that appears more than once.
ids: IdMap<Option<u32>>,
at: Vec<(u32, u32)>,
}
impl Paths {
/// The index path of `id`: `Some(None)` when the id appears more than once, `None` when it
/// isn't in the table.
pub(crate) fn get(&self, id: &NodeId) -> Option<Option<Vec<usize>>> {
let e = (*self.ids.get(id)?)?;
let mut path = Vec::new();
let mut cur = e;
// Each step goes to a parent entry, made before its children: at most `at.len()` steps.
for _ in 0..=self.at.len() {
let Some(&(parent, i)) = self.at.get(cur as usize) else { return Some(None) };
path.push(i as usize);
if parent == u32::MAX {
path.reverse();
return Some(Some(path));
}
cur = parent;
}
Some(None)
}
pub(crate) fn contains_key(&self, id: &NodeId) -> bool {
self.ids.contains_key(id)
}
pub(crate) fn len(&self) -> usize {
self.ids.len()
}
}
/// Lookups that had to walk (the cache didn't have the id, or its path no longer checks out)
/// before the cache is rebuilt: a rebuild costs a few walks, so rebuilding only after a few keeps
/// a lookup's cost within a small factor of a walk whatever the edit pattern (say, delete an
/// object and look the next one up, again and again).
const MISSES_PER_REBUILD: u32 = 4;
/// The cache, shared by clones of a document (each checks every path it uses against its own tree).
#[derive(Default)]
pub struct IdIndex(RwLock<Option<Arc<Paths>>>, std::sync::atomic::AtomicU32);
impl IdIndex {
/// The current cache, if one was built.
pub(crate) fn get(&self) -> Option<Arc<Paths>> {
self.0.read().ok().and_then(|g| g.clone())
}
/// Rebuild the cache from `layers` (one walk) and return it.
pub(crate) fn rebuild(&self, layers: &[Arc<Node>]) -> Arc<Paths> {
fn walk(nodes: &[Arc<Node>], parent: u32, out: &mut Paths) {
for (i, n) in nodes.iter().enumerate() {
let e = out.at.len() as u32;
out.at.push((parent, i as u32));
out.ids.entry(n.id).and_modify(|x| *x = None).or_insert(Some(e));
if let Some(ch) = n.children() {
walk(ch, e, out);
}
}
}
let mut out = Paths::default();
walk(layers, u32::MAX, &mut out);
let out = Arc::new(out);
if let Ok(mut g) = self.0.write() {
*g = Some(out.clone());
}
self.1.store(0, std::sync::atomic::Ordering::Relaxed);
out
}
/// Note a lookup that had to walk (an id the cache didn't have, or a path that no longer
/// checks out); true when it's time to rebuild.
pub(crate) fn missed(&self) -> bool {
self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 >= MISSES_PER_REBUILD
}
}
impl Clone for IdIndex {
fn clone(&self) -> Self {
IdIndex(RwLock::new(self.get()), Default::default())
}
}
/// A cache, not document content: two documents are equal whatever their caches hold.
impl PartialEq for IdIndex {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for IdIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("IdIndex")
}
}

View File

@ -13,9 +13,12 @@ pub mod cmyk;
pub mod corners;
pub mod graph;
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;
@ -658,6 +661,9 @@ pub struct Document {
/// state, never saved.
#[serde(skip)]
pub puppet: Option<Arc<PuppetPins>>,
/// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved.
#[serde(skip)]
index: id_index::IdIndex,
}
fn ppi72() -> f64 {
@ -729,6 +735,7 @@ impl Document {
print_setup: None,
assets: vec![],
puppet: None,
index: Default::default(),
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
@ -758,24 +765,31 @@ impl Document {
/// Find a node anywhere in the tree.
pub fn node(&self, id: NodeId) -> Option<&Node> {
fn find(nodes: &[Arc<Node>], id: NodeId) -> Option<&Node> {
for n in nodes {
if n.id == id {
return Some(n);
}
if let Some(ch) = n.children()
&& let Some(f) = find(ch, id)
{
return Some(f);
}
}
None
}
find(&self.layers, id)
self.index_path(id).and_then(|p| self.node_at(&p))
}
/// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`).
/// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in
/// tree order where `id` appears.
pub fn index_path(&self, id: NodeId) -> Option<Vec<usize>> {
// The cached path, while the node there still has the id ([`id_index`]).
let cached = self.index.get();
match cached.as_ref().and_then(|m| m.get(&id)) {
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == id) => return Some(p),
Some(None) => return self.walk_index_path(id),
_ => {}
}
// A stale path (the tree changed), an id the cache doesn't have (deleted or added since),
// or no cache yet: walk, and rebuild now and then.
let found = self.walk_index_path(id);
let stale = cached.as_ref().is_some_and(|m| m.contains_key(&id));
if cached.is_none() || ((stale || found.is_some()) && self.index.missed()) {
self.index.rebuild(&self.layers);
}
found
}
/// [`Document::index_path`] by walking the tree.
fn walk_index_path(&self, id: NodeId) -> Option<Vec<usize>> {
fn find(nodes: &[Arc<Node>], id: NodeId, path: &mut Vec<usize>) -> bool {
for (i, n) in nodes.iter().enumerate() {
path.push(i);
@ -795,6 +809,85 @@ impl Document {
find(&self.layers, id, &mut p).then_some(p)
}
/// [`Document::index_path`] of every id in `ids` that is in the tree, in one walk of it: an
/// edit of many objects looks them all up for the cost of one search instead of one each. Like
/// `index_path`, an id that appears twice maps to its first place in tree order.
pub fn index_paths(&self, ids: impl IntoIterator<Item = NodeId>) -> std::collections::HashMap<NodeId, Vec<usize>> {
use std::collections::{HashMap, HashSet};
fn walk(nodes: &[Arc<Node>], want: &HashSet<NodeId>, path: &mut Vec<usize>, out: &mut HashMap<NodeId, Vec<usize>>) -> bool {
for (i, n) in nodes.iter().enumerate() {
path.push(i);
if want.contains(&n.id) && !out.contains_key(&n.id) {
out.insert(n.id, path.clone());
if out.len() == want.len() {
return true;
}
}
if let Some(ch) = n.children()
&& walk(ch, want, path, out)
{
return true;
}
path.pop();
}
false
}
let mut want: HashSet<NodeId> = ids.into_iter().collect();
let mut out = HashMap::with_capacity(want.len());
// Cached paths that still check out ([`id_index`]); the rest take one walk. For a good
// share of the document, one walk is quicker than checking each cached path.
if let Some(cache) = self.index.get().filter(|c| want.len().saturating_mul(8) < c.len()) {
want.retain(|id| match cache.get(id) {
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == *id) => {
out.insert(*id, p);
false
}
_ => true,
});
}
if !want.is_empty() {
let mut rest = HashMap::with_capacity(want.len());
walk(&self.layers, &want, &mut Vec::new(), &mut rest);
out.extend(rest);
}
out
}
/// The node at index path `path` (as [`Document::index_path`] gives it).
pub fn node_at(&self, path: &[usize]) -> Option<&Node> {
let (first, rest) = path.split_first()?;
let mut cur: &Node = self.layers.get(*first)?;
for &i in rest {
cur = cur.children()?.get(i)?;
}
Some(cur)
}
/// Mutable access to the node at index path `path`; clones the Arc path (copy-on-write), as
/// [`Document::node_mut`] does.
pub fn node_mut_at(&mut self, path: &[usize]) -> Option<&mut Node> {
let (first, rest) = path.split_first()?;
let mut cur: &mut Arc<Node> = self.layers.get_mut(*first)?;
for &i in rest {
cur = Arc::make_mut(cur).children_mut()?.get_mut(i)?;
}
Some(Arc::make_mut(cur))
}
/// [`Document::ancestry`] of the node at index path `path`.
pub fn ancestry_at(&self, path: &[usize]) -> Option<Vec<NodeId>> {
let mut out = Vec::with_capacity(path.len());
let mut nodes = &self.layers;
for &i in path {
let n = nodes.get(i)?;
out.push(n.id);
if let Some(ch) = n.children() {
nodes = ch;
}
}
Some(out)
}
/// Ids from the top-level layer down to `id` inclusive.
pub fn ancestry(&self, id: NodeId) -> Option<Vec<NodeId>> {
let path = self.index_path(id)?;
@ -1391,6 +1484,8 @@ mod tests {
}
}
#[cfg(test)]
mod tests_lookups;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]

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()
})
}

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);
}

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,

View File

@ -62,9 +62,9 @@ impl Selection {
}
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.clear();
for id in ids {
self.add(id);
}
// What `add` per id does after a clear, without its linear duplicate check per id.
let mut seen = std::collections::HashSet::new();
self.objects.extend(ids.into_iter().filter(|id| seen.insert(*id)));
}
pub fn add(&mut self, id: NodeId) {
self.target = None;
@ -118,10 +118,13 @@ impl Selection {
}
/// Drop ids that no longer exist or are no longer editable.
pub fn prune(&mut self, doc: &Document) {
self.objects.retain(|id| doc.node(*id).is_some());
self.anchors.retain(|id, _| doc.node(*id).is_some());
// Every id this looks up, found in one walk of the tree.
let ids = self.objects.iter().chain(self.anchors.keys()).copied().chain(self.key).chain(self.target);
let found = doc.index_paths(ids);
self.objects.retain(|id| found.contains_key(id));
self.anchors.retain(|id, _| found.contains_key(id));
// The key object stays while another selected object lies outside it.
let keep = self.key.and_then(|k| doc.node(k)).is_some_and(|n| {
let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| {
let mut inside = BTreeSet::new();
n.walk(&mut |c| {
inside.insert(c.id);
@ -131,7 +134,7 @@ impl Selection {
if !keep {
self.key = None;
}
if self.target.is_some_and(|t| doc.node(t).is_none()) {
if self.target.is_some_and(|t| !found.contains_key(&t)) {
self.target = None;
}
self.slices.retain(|id| doc.is_slice(*id));

72
crates/doc/src/shared.rs Normal file
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)
}
}

View File

@ -0,0 +1,156 @@
//! The batched lookups ([`Document::index_paths`], `node_at`, `node_mut_at`, `ancestry_at`) agree
//! with the one-id ones on nested trees with missing and duplicated ids.
use super::*;
use vectorcraft_geom::shapes;
/// Layers with paths and groups nested `depth` deep; every 7th id is reused (a damaged file), so
/// first-in-tree-order matters.
fn tree(seed: u64) -> (Document, Vec<NodeId>) {
let mut d = Document::new(612.0, 792.0);
let mut r = seed | 1;
let mut next = |n: u64| {
r ^= r << 13;
r ^= r >> 7;
r ^= r << 17;
r % n
};
let mut ids = vec![];
let leaf = |d: &mut Document, ids: &mut Vec<NodeId>| {
let id = if ids.len() % 7 == 6 { ids[ids.len() / 2] } else { d.alloc_id() };
ids.push(id);
Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art()))
};
for l in 0..3 {
let lid = d.alloc_id();
let mut layer = Node::layer(lid, &format!("L{l}"), LayerColor::Preset(0));
ids.push(lid);
for _ in 0..(5 + next(10)) {
let child = if next(3) == 0 {
let mut inner: Vec<Arc<Node>> = (0..(1 + next(4))).map(|_| leaf(&mut d, &mut ids)).collect();
for _ in 0..next(3) {
let gid = d.alloc_id();
ids.push(gid);
inner = vec![Arc::new(Node::group(gid, inner))];
}
let gid = d.alloc_id();
ids.push(gid);
Arc::new(Node::group(gid, inner))
} else {
leaf(&mut d, &mut ids)
};
if let Some(ch) = layer.children_mut() {
ch.push(child);
}
}
d.layers.push(Arc::new(layer));
}
ids.push(NodeId(999_999)); // not in the tree
(d, ids)
}
#[test]
fn batched_lookups_match_one_at_a_time() {
for seed in 1..40 {
let (d, ids) = tree(seed);
let paths = d.index_paths(ids.iter().copied());
for id in &ids {
assert_eq!(paths.get(id).cloned(), d.index_path(*id), "seed {seed} id {id:?}");
let p = paths.get(id);
assert_eq!(p.and_then(|p| d.node_at(p)).map(|n| n as *const Node), d.node(*id).map(|n| n as *const Node), "seed {seed} node {id:?}");
assert_eq!(p.and_then(|p| d.ancestry_at(p)), d.ancestry(*id), "seed {seed} ancestry {id:?}");
}
// A subset, and nothing.
let some: Vec<NodeId> = ids.iter().copied().step_by(3).collect();
let sub = d.index_paths(some.iter().copied());
assert!(some.iter().all(|id| sub.get(id).cloned() == d.index_path(*id)));
assert!(d.index_paths(std::iter::empty()).is_empty());
// Mutable access reaches the same node as node_mut, copy-on-write.
let mut a = d.clone();
let mut b = d.clone();
for id in ids.iter().step_by(5) {
if let Some(n) = a.node_mut(*id) {
n.opacity = 0.25;
}
if let Some(n) = d.index_path(*id).and_then(|p| b.node_mut_at(&p)) {
n.opacity = 0.25;
}
}
assert_eq!(serde_json::to_string(&a).ok(), serde_json::to_string(&b).ok(), "seed {seed}");
}
}
/// What a walk of the tree gives for every id, checked against the cached lookups.
fn agree(d: &Document, ids: &[NodeId], what: &str) {
agree_except(d, ids, None, what);
}
/// [`agree`], except for `dup`, an id given a second node after the cache was built: lookups of it
/// may return either of its nodes (see `id_index`), but always one with that id.
fn agree_except(d: &Document, ids: &[NodeId], dup: Option<NodeId>, what: &str) {
if let Some(dup) = dup {
assert_eq!(d.node(dup).map(|n| n.id), Some(dup), "{what}: node {dup:?} has its id");
}
let ids: Vec<NodeId> = ids.iter().copied().filter(|id| Some(*id) != dup).collect();
let ids = &ids[..];
for id in ids {
let walked = d.walk_index_path(*id);
assert_eq!(d.index_path(*id), walked, "{what}: index_path {id:?}");
let by_walk = walked.as_deref().and_then(|p| d.node_at(p)).map(|n| n as *const Node);
assert_eq!(d.node(*id).map(|n| n as *const Node), by_walk, "{what}: node {id:?}");
}
let batched = d.index_paths(ids.iter().copied());
for id in ids {
assert_eq!(batched.get(id).cloned(), d.walk_index_path(*id), "{what}: index_paths {id:?}");
}
}
/// The id cache never answers differently from a walk, however the tree is changed behind its
/// back (through the public fields), including in a clone that shares the cache.
#[test]
fn cached_lookups_follow_any_edit_of_the_tree() {
for seed in 1..30 {
let (mut d, ids) = tree(seed);
agree(&d, &ids, "fresh");
let snapshot = d.clone(); // shares the cache
// Insert at the front of the first layer: every path in it shifts.
let extra = d.alloc_id();
let leaf = Arc::new(Node::path(extra, shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
ch.insert(0, leaf.clone());
}
let mut all = ids.clone();
all.push(extra);
agree(&d, &all, "inserted at the front");
// Remove a child, swap two, and move one into another layer.
if let Some(ch) = Arc::make_mut(&mut d.layers[1]).children_mut()
&& ch.len() > 3
{
ch.remove(1);
ch.swap(0, 2);
}
let moved = Arc::make_mut(&mut d.layers[2]).children_mut().and_then(|ch| (!ch.is_empty()).then(|| ch.remove(0)));
if let Some(m) = moved
&& let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut()
{
ch.push(m);
}
agree(&d, &all, "removed, swapped and moved");
// A new duplicate of an existing id, earlier in tree order than the original.
let dup = ids[ids.len() / 3];
let copy = Arc::new(Node::path(dup, shapes::rectangle(Rect::new(0.0, 0.0, 2.0, 2.0)), Appearance::default_art()));
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
ch.insert(0, copy);
}
agree_except(&d, &all, Some(dup), "a duplicate inserted earlier");
// Delete a whole layer, then look everything up again, missing ids included.
d.layers.remove(1);
agree_except(&d, &all, Some(dup), "a layer deleted");
// A rebuilt cache (as after loading a file) knows the duplicate and walks for it.
let mut fresh = d.clone();
fresh.layers = d.layers.clone();
fresh.index = Default::default();
agree(&fresh, &all, "a damaged tree, cached afresh");
// The clone made before all this still answers for its own tree.
agree(&snapshot, &ids, "the earlier clone");
}
}

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,

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)?,

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)
}

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);
}

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));
}

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;
}

View File

@ -251,22 +251,29 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
let paths = doc.index_paths(ids.iter().copied());
// A compound-path member acts as its compound (compounds are one object).
let mut sel: Vec<NodeId> = ids
let mut sel: Vec<(NodeId, Option<Vec<usize>>)> = ids
.into_iter()
.map(|id| match doc.parent_of(id).and_then(|p| doc.node(p).map(|n| (p, n))) {
Some((p, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => p,
_ => id,
.map(|id| {
let path = paths.get(&id);
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => (n.id, Some(up.to_vec())),
_ => (id, path.cloned()),
}
})
.collect();
sel.dedup();
sel.dedup_by_key(|(id, _)| *id);
let listed: std::collections::HashSet<NodeId> = sel.iter().map(|(id, _)| *id).collect();
sel.iter()
.copied()
.filter(|id| {
let anc = doc.ancestry(*id).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| sel.contains(a))
.filter(|(_, path)| {
let anc = path.as_deref().and_then(|p| doc.ancestry_at(p)).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| listed.contains(a))
})
.filter(|id| doc.node(*id).is_some_and(|n| !n.is_layer()))
.filter(|(_, path)| path.as_deref().and_then(|p| doc.node_at(p)).is_some_and(|n| !n.is_layer()))
.map(|(id, _)| *id)
.collect()
}

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 });

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;

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);

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()

View File

@ -242,7 +242,8 @@ pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) ->
let on = bool_or(p, "patterns", s.prefs.transform_pattern_tiles);
let d = &s.doc()?.doc;
let mut uses = false;
for n in ids.iter().filter_map(|id| d.node(*id)) {
let paths = d.index_paths(ids.iter().copied());
for n in ids.iter().filter_map(|id| paths.get(id)).filter_map(|p| d.node_at(p)) {
n.walk(&mut |c| uses |= vectorcraft_doc::pattern::uses_pattern(c, None));
}
if uses {
@ -263,14 +264,16 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
// nothing: the document and its undo history stay as they are, and Transform Again repeats it.
// With a copy, the reply lists the copies, and there are none.
let d = &s.doc()?.doc;
let none = ids.iter().all(|id| d.node(*id).is_none());
let none = d.index_paths(ids.iter().copied()).is_empty();
let ids = if none || (!copy && xf == Affine::IDENTITY) {
if copy { vec![] } else { ids }
} else {
s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
// Transforms don't move nodes in the tree, so the paths found up front stay right.
let paths = d.index_paths(targets.iter().copied());
for id in &targets {
if let Some(n) = d.node_mut(*id)
if let Some(n) = paths.get(id).and_then(|p| d.node_mut_at(p))
&& !(areas && resize_type_area(n, xf))
{
n.transform(xf, sc);
@ -658,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;
@ -739,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) => {

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(())

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;

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;
}

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);
}
}

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);
}

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)?;

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))?;

View File

@ -421,9 +421,10 @@ fn strip_replay_only(id: &str, p: &mut Value) {
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
fn doc_sane(d: &Document, sel: &Selection) -> bool {
let ok = |r: vectorcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
d.artboards.iter().all(|a| ok(a.rect))
&& d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD)
&& sel.objects.iter().all(|id| d.node(*id).and_then(|n| n.geometric_bounds()).is_none_or(ok))
d.artboards.iter().all(|a| ok(a.rect)) && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) && {
let paths = d.index_paths(sel.objects.iter().copied());
sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok))
}
}
/// Where new art goes (Illustrator's drawing modes, Shift+D cycles).

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());
}

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();

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()));
}

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();

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;

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;
}

View File

@ -145,6 +145,19 @@ fn preview_mime(bytes: &[u8]) -> &'static str {
/// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON
/// value.
fn split_tail(text: &[u8]) -> (&[u8], &[u8]) {
// JSON holds no raw NUL, so the marker (which has one) starts at the first NUL, if the file
// has one. The writer puts it right after the JSON's last byte; found there, it is where the
// first JSON value ends, without parsing the JSON twice. Anything else takes the parse.
let Some(nul) = text.iter().position(|&b| b == 0) else {
// No NUL, so no marker: the parse below would find none either.
return (text, &[]);
};
if let Some(at) = nul.checked_sub(1)
&& text.get(at..).is_some_and(|r| r.starts_with(BLOB_MAGIC))
&& at.checked_sub(1).and_then(|k| text.get(k)).is_some_and(|b| matches!(b, b'}' | b']' | b'"'))
{
return (text.get(..at).unwrap_or(text), text.get(at + BLOB_MAGIC.len()..).unwrap_or_default());
}
let mut values = serde_json::Deserializer::from_slice(text).into_iter::<serde::de::IgnoredAny>();
if values.next().is_some_and(|v| v.is_ok()) {
let end = values.byte_offset();

View File

@ -50,24 +50,22 @@ pub(crate) fn inside(rule: FillRule, w: i32) -> bool {
}
}
/// Winding numbers for an arbitrary number of input sets (tag = input index).
#[derive(Clone, Debug, Default)]
pub(crate) struct Multi(pub(crate) Vec<i32>);
/// Winding numbers for an arbitrary number of input sets (tag = input index): the non-zero ones,
/// as (tag, winding) sorted by tag. A face is covered by a few of the inputs, so this stays short
/// where a vector of every input's winding would make each Pathfinder test cost one step per input.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct Multi(Vec<(usize, i32)>);
impl Multi {
fn get(&self, i: usize) -> i32 {
self.0.get(i).copied().unwrap_or(0)
self.0.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| self.0.get(k)).map_or(0, |e| e.1)
}
/// The non-zero windings, by input.
pub(crate) fn iter(&self) -> impl DoubleEndedIterator<Item = (usize, i32)> + '_ {
self.0.iter().copied()
}
}
impl PartialEq for Multi {
fn eq(&self, other: &Self) -> bool {
let n = self.0.len().max(other.0.len());
(0..n).all(|i| self.get(i) == other.get(i))
}
}
impl Eq for Multi {}
impl std::ops::Add for Multi {
type Output = Multi;
fn add(mut self, rhs: Self) -> Multi {
@ -78,11 +76,21 @@ impl std::ops::Add for Multi {
impl std::ops::AddAssign for Multi {
fn add_assign(&mut self, rhs: Self) {
if self.0.len() < rhs.0.len() {
self.0.resize(rhs.0.len(), 0);
}
for (a, b) in self.0.iter_mut().zip(rhs.0) {
*a += b;
// In place, input by input (the sweep mostly adds one input's ±1 to a short list): add to
// the input's winding, or insert it in tag order, and drop it if it reaches zero.
for (t, k) in rhs.0 {
match self.0.binary_search_by_key(&t, |e| e.0) {
Ok(i) => {
if let Some(e) = self.0.get_mut(i) {
e.1 += k;
if e.1 == 0 {
self.0.remove(i);
}
}
}
Err(i) if k != 0 => self.0.insert(i, (t, k)),
Err(_) => {}
}
}
}
}
@ -90,14 +98,11 @@ impl std::ops::AddAssign for Multi {
impl WindingNumber for Multi {
type Tag = usize;
fn single(tag: usize, positive: bool) -> Self {
let mut v = vec![0; tag + 1];
v[tag] = if positive { 1 } else { -1 };
Multi(v)
Multi(vec![(tag, if positive { 1 } else { -1 })])
}
fn of_tag(&self, tag: usize) -> Self {
let mut v = vec![0; tag + 1];
v[tag] = self.get(tag);
Multi(v)
let w = self.get(tag);
Multi(if w == 0 { vec![] } else { vec![(tag, w)] })
}
}
@ -288,7 +293,24 @@ impl Arrangement {
}
pub(crate) fn mask(&self, w: &Multi) -> Vec<bool> {
self.rules.iter().enumerate().map(|(i, &r)| inside(r, w.get(i))).collect()
// A zero winding is outside under both rules, so only the non-zero ones can set a flag.
let mut m = vec![false; self.rules.len()];
for (i, k) in w.iter() {
if let (Some(&r), Some(b)) = (self.rules.get(i), m.get_mut(i)) {
*b = inside(r, k);
}
}
m
}
/// The inputs that cover a face of winding `w` (`mask(w)`'s true indices), back to front.
pub(crate) fn covered<'a>(&'a self, w: &'a Multi) -> impl DoubleEndedIterator<Item = usize> + 'a {
w.iter().filter(|&(i, k)| self.rules.get(i).is_some_and(|&r| inside(r, k))).map(|(i, _)| i)
}
/// [`Arrangement::contours`] with the test on the windings themselves (see [`Self::covered`]).
pub(crate) fn contours_where(&self, pred: impl Fn(&Multi) -> bool) -> Contours {
self.top.contours(pred)
}
pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours {

View File

@ -75,8 +75,10 @@ fn arrangement(shapes: &[Shape]) -> Option<Arrangement> {
Arrangement::new(bps).ok()
}
fn topmost(m: &[bool]) -> Option<usize> {
m.iter().rposition(|&b| b)
/// The one item of `it`, if it has exactly one.
fn only(mut it: impl Iterator<Item = usize>) -> Option<usize> {
let first = it.next()?;
it.next().is_none().then_some(first)
}
fn one(path: PathData, key: u64) -> Vec<Shape> {
@ -102,10 +104,16 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
let p = &arr.tidy;
match op {
PathfinderOp::Unite => unite(),
PathfinderOp::MinusFront => one(all_contours_to_path(&arr.contours(|m| m[0] && !m[1..].iter().any(|&b| b)), p), shapes[0].key),
PathfinderOp::MinusBack => one(all_contours_to_path(&arr.contours(|m| m[n - 1] && !m[..n - 1].iter().any(|&b| b)), p), front_key),
PathfinderOp::Intersect => one(all_contours_to_path(&arr.contours(|m| m.iter().all(|&b| b)), p), front_key),
PathfinderOp::Exclude => one(all_contours_to_path(&arr.contours(|m| m.iter().filter(|&&b| b).count() % 2 == 1), p), front_key),
// The tests read the covering inputs straight off the windings ([`Arrangement::covered`]);
// each is the coverage-mask test in its comment.
// m[0] && !m[1..].any()
PathfinderOp::MinusFront => one(all_contours_to_path(&arr.contours_where(|w| only(arr.covered(w)) == Some(0)), p), shapes[0].key),
// m[n - 1] && !m[..n - 1].any()
PathfinderOp::MinusBack => one(all_contours_to_path(&arr.contours_where(|w| only(arr.covered(w)) == Some(n - 1)), p), front_key),
// m.all()
PathfinderOp::Intersect => one(all_contours_to_path(&arr.contours_where(|w| arr.covered(w).count() == n), p), front_key),
// m.count() is odd
PathfinderOp::Exclude => one(all_contours_to_path(&arr.contours_where(|w| arr.covered(w).count() % 2 == 1), p), front_key),
PathfinderOp::Divide => regions_of(&arr)
.into_iter()
.filter_map(|r| {
@ -113,7 +121,10 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
Some(Shape::new(r.path, FillRule::NonZero, key))
})
.collect(),
PathfinderOp::Trim => (0..n).flat_map(|i| one(all_contours_to_path(&arr.contours(|m| topmost(m) == Some(i)), p), shapes[i].key)).collect(),
// topmost(m) == Some(i)
PathfinderOp::Trim => {
(0..n).flat_map(|i| one(all_contours_to_path(&arr.contours_where(|w| arr.covered(w).next_back() == Some(i)), p), shapes[i].key)).collect()
}
PathfinderOp::Merge => {
let mut keys: Vec<u64> = Vec::new();
for s in shapes {
@ -123,7 +134,8 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
}
let mut out = Vec::new();
for k in keys {
let c = arr.contours(|m| topmost(m).is_some_and(|t| shapes[t].key == k));
// topmost(m) is a shape with key k
let c = arr.contours_where(|w| arr.covered(w).next_back().is_some_and(|t| shapes[t].key == k));
for g in c.grouped() {
out.extend(one(contours_to_path(&c, g, p), k));
}
@ -136,7 +148,11 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
}
(0..n - 1)
.flat_map(|i| {
let c = arr.contours(|m| m[n - 1] && topmost(&m[..n - 1]) == Some(i));
// m[n - 1] && topmost(&m[..n - 1]) == Some(i): the front shape, then i below it
let c = arr.contours_where(|w| {
let mut c = arr.covered(w).rev();
c.next() == Some(n - 1) && c.next() == Some(i)
});
one(all_contours_to_path(&c, p), shapes[i].key)
})
.collect()
@ -342,9 +358,11 @@ impl FaceMerger {
/// Pathfinder Outline: split every shape's boundary at junctions of the arrangement.
fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec<Shape> {
// Sorted by x (then y), so a segment needs only the junctions inside its control box.
let junctions = arr.junctions();
let scale = shapes.iter().filter_map(|s| s.path.control_bounds()).map(|r| r.width().max(r.height())).fold(1.0, f64::max);
let tol = scale * 1e-7;
let reach = tol.max(1e-6);
// (chain, key, source index)
let mut chains: Vec<(Vec<Seg>, u64)> = Vec::new();
for s in shapes {
@ -359,11 +377,19 @@ fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec<Shape> {
for i in 0..sp.segment_count() {
let c = sp.segment(i);
let line = sp.segment_is_line(i);
// A point within `reach` of the curve is within `reach` of its control box (the
// curve stays inside its control points' hull), so the other junctions can't match.
let b = kurbo::Shape::bounding_box(&c).union(kurbo::Rect::from_points(c.p1, c.p2)).inflate(reach, reach);
let lo = junctions.partition_point(|j| j.x < b.x0);
let mut ts: Vec<f64> = junctions
.get(lo..)
.unwrap_or_default()
.iter()
.take_while(|j| j.x <= b.x1)
.filter(|j| j.y >= b.y0 && j.y <= b.y1)
.filter_map(|&j| {
let nr = c.nearest(j, 1e-9);
(nr.distance_sq.sqrt() <= tol.max(1e-6)).then_some(nr.t)
(nr.distance_sq.sqrt() <= reach).then_some(nr.t)
})
.collect();
ts.sort_by(f64::total_cmp);
@ -401,24 +427,41 @@ fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec<Shape> {
}
}
}
// De-duplicate coincident chains, keeping the front-most key (later shapes overwrite).
// De-duplicate coincident chains, keeping the front-most key (later shapes overwrite). A
// match has its midpoint within `near` of this one's, so only the chains in the grid cells
// around it are candidates; the first of them in `out` order is the one a scan would find.
let mut out: Vec<(Vec<Seg>, u64)> = Vec::new();
let mut sigs: Vec<(Point, Point, Point)> = Vec::new();
let sig = |c: &[Seg]| -> (Point, Point, Point) {
let a = c[0].c.p0;
let b = c[c.len() - 1].c.p3;
let mid = mid_point(c);
(a, b, mid)
};
let d = reach * 10.0;
let near = |p: Point, q: Point| p.distance(q) <= d;
let cell = |p: Point| ((p.x / d).floor() as i64, (p.y / d).floor() as i64);
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (chain, key) in chains {
let (a, b, m) = sig(&chain);
let near = |p: Point, q: Point| p.distance(q) <= tol.max(1e-6) * 10.0;
if let Some(e) = out.iter_mut().find(|(c, _)| {
let (a2, b2, m2) = sig(c);
near(m, m2) && ((near(a, a2) && near(b, b2)) || (near(a, b2) && near(b, a2)))
}) {
e.1 = key;
} else {
out.push((chain, key));
let (cx, cy) = cell(m);
let found = (cx - 1..=cx + 1)
.flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y)))
.filter_map(|k| grid.get(&k))
.flatten()
.copied()
.filter(|&i| {
let (a2, b2, m2) = sigs[i];
near(m, m2) && ((near(a, a2) && near(b, b2)) || (near(a, b2) && near(b, a2)))
})
.min();
match found {
Some(i) => out[i].1 = key,
None => {
grid.entry((cx, cy)).or_default().push(out.len());
sigs.push((a, b, m));
out.push((chain, key));
}
}
}
out.into_iter()

View File

@ -0,0 +1,94 @@
//! The exact output of every Pathfinder operation, pinned so that work on the windings and the
//! coverage tests can't change it: overlapping ellipses, rectangles and stars (even-odd and
//! non-zero, repeated keys for Merge) at a few counts.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_geom::{FillRule, Point, Rect, shapes};
use vectorcraft_pathops::{PathfinderOp, Shape, pathfinder};
fn shapes_of(n: usize, seed: u64) -> Vec<Shape> {
let mut r = seed | 1;
let mut next = || {
r ^= r << 13;
r ^= r >> 7;
r ^= r << 17;
(r % 1_000_000) as f64 / 1_000_000.0
};
(0..n)
.map(|i| {
let (x, y, s) = (next() * 200.0, next() * 150.0, 15.0 + next() * 40.0);
let path = match i % 3 {
0 => shapes::ellipse(Rect::from_center_size(Point::new(x, y), (2.0 * s, 1.4 * s))),
1 => shapes::rectangle(Rect::new(x, y, x + 1.5 * s, y + s)),
_ => shapes::star(Point::new(x, y), s, s / 2.0, 5, 0.3),
};
let rule = if i % 4 == 3 { FillRule::EvenOdd } else { FillRule::NonZero };
Shape::new(path, rule, (i % 5) as u64)
})
.collect()
}
fn fnv(s: &str) -> u64 {
s.bytes().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3))
}
#[test]
fn every_pathfinder_operation_is_pinned() {
use PathfinderOp::*;
let ops = [Unite, MinusFront, MinusBack, Intersect, Exclude, Divide, Trim, Merge, Crop, Outline];
let mut got = vec![];
for (n, seed) in [(2, 3), (5, 7), (12, 11), (30, 13)] {
let input = shapes_of(n, seed);
for op in ops {
let out = pathfinder(op, &input);
let text: String = out.iter().map(|s| format!("{}|{:?}|{:?};", s.key, s.rule, s.path)).collect();
got.push(fnv(&text));
}
}
assert_eq!(got.len(), PINNED.len());
assert_eq!(got, PINNED);
}
/// Computed with the dense windings (main at b08482d6).
const PINNED: [u64; 40] = [
0x5e62_baae_d8b4_dba5,
0x2bbc_e138_a97c_5a28,
0xce78_992e_750b_1092,
0x30cf_cc0b_861c_48b4,
0xe6e4_2a29_1e16_5e86,
0x390b_0633_5066_6292,
0x06b7_3884_f961_c343,
0x06b7_3884_f961_c343,
0x0384_d599_5a03_dafd,
0x3dcd_029a_dd3f_fa38,
0x8249_71f8_fda4_2bb6,
0xe7f2_8633_cef9_a76c,
0x1a11_084e_721e_ac3b,
0xcbf2_9ce4_8422_2325,
0xa786_d973_a0d7_ca2a,
0x389b_c89a_0ccf_578f,
0xff9d_cf53_d872_681e,
0xff9d_cf53_d872_681e,
0xcbf2_9ce4_8422_2325,
0x6be3_02a6_d9c9_e80e,
0x3525_cabe_b235_aaef,
0xf969_aa07_a44d_1770,
0xed13_60c7_0aaa_7854,
0xcbf2_9ce4_8422_2325,
0x6550_a381_db6b_a35f,
0x1ba9_6dfb_89ea_ac78,
0x1344_b467_dd20_5f02,
0x948f_e2a1_d657_0d4c,
0x49a4_5c3c_2d96_14af,
0x16a7_ce9e_4a3c_0974,
0x0b12_c643_a446_da04,
0xcbf2_9ce4_8422_2325,
0xeb6a_6614_cea5_a508,
0xcbf2_9ce4_8422_2325,
0xb9c4_2803_7db9_bd4f,
0x34b6_90d5_e443_b799,
0xdfaf_ecbd_2d58_3227,
0x6d5e_6e8d_b05b_8688,
0x59dc_9268_a73f_e92e,
0x5a83_5cbd_1f8c_5143,
];

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 });
}

View File

@ -261,6 +261,16 @@ pub(crate) struct ShadowEntry {
pub(crate) stamp: u64,
}
/// Live-effect geometry of a node, kept across frames: the object-level result and each
/// appearance item's own result on it (filled in as items are drawn). The node is kept alive, so
/// its address names it alone and a hit is exact.
pub(crate) struct FxGeom {
node: Arc<Node>,
g: Arc<BezPath>,
items: Vec<Option<Arc<BezPath>>>,
pub(crate) stamp: u64,
}
impl Renderer {
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster:
/// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
@ -340,12 +350,43 @@ impl Renderer {
/// `draw_shape` for nodes with live effects.
pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
let g = effected_path(n, bp);
// The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path,
// made from the node alone, so the effects give the same result every frame.
let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned();
let mut cached = owner.map(|o| {
let key = Arc::as_ptr(&o) as usize;
let stamp = self.stamp;
let e = match self.fx_geom.remove(&key) {
Some(e) if Arc::ptr_eq(&e.node, &o) => e,
_ => FxGeom { g: Arc::new(effected_path(n, bp)), items: vec![None; n.appearance.items.len()], node: o, stamp },
};
(key, FxGeom { stamp, ..e })
});
let g = match &cached {
Some((_, e)) => e.g.clone(),
None => Arc::new(effected_path(n, bp)),
};
self.draw_shape_fx_geom(ctx, f, n, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items));
if let Some((key, e)) = cached {
self.fx_geom.insert(key, e);
}
}
/// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry.
fn draw_shape_fx_geom(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
n: &Node,
g: &BezPath,
rule: FillRule,
items: Option<&mut Vec<Option<Arc<BezPath>>>>,
) {
if g.elements().is_empty() {
return;
}
if f.opts.outline {
self.hairline(ctx, f, &g, [0, 0, 0, 255]);
self.hairline(ctx, f, g, [0, 0, 0, 255]);
return;
}
let gctx = GeomContext::of(n);
@ -353,8 +394,9 @@ impl Renderer {
// Document-space reach of the painted geometry (strokes, arrowheads…).
let outset = n.appearance.outset();
let reach = g.bounding_box().inflate(outset, outset);
let content = Content { node: n, cache: Some(0), reach, outline: Some((&g, rule)) };
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, &g, rule, &gctx));
let content = Content { node: n, cache: Some(0), reach, outline: Some((g, rule)) };
let mut items = items;
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut()));
ctx.set_transform(Affine::IDENTITY);
}
@ -698,32 +740,49 @@ impl Renderer {
/// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects and
/// painting per-item raster effects around that item alone.
fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) {
#[allow(clippy::too_many_arguments)]
fn paint_items(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
n: &Node,
g: &BezPath,
rule: FillRule,
gctx: &GeomContext,
mut cache: Option<&mut Vec<Option<Arc<BezPath>>>>,
) {
let bounds = g.bounding_box();
for (j, item) in n.appearance.items.iter().enumerate() {
if !item.visible() || item.paint().is_none() {
continue;
}
let ig = apply(item_effects(item), g, &gctx.item(item));
// The item's own geometry effects on `g`, from the cache when there is one.
let slot = cache.as_deref_mut().and_then(|c| c.get_mut(j));
let ig: Arc<BezPath> = match slot {
Some(Some(ig)) => ig.clone(),
Some(s) => s.insert(Arc::new(apply(item_effects(item), g, &gctx.item(item)))).clone(),
None => Arc::new(apply(item_effects(item), g, &gctx.item(item))),
};
let ig = &*ig;
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
let rfx = effects::raster_effects(item_effects(item));
if rfx.is_empty() {
self.paint_item(ctx, f, n, item, &ig, rule, ib);
self.paint_item(ctx, f, n, item, ig, rule, ib);
continue;
}
// The item's silhouette: its fill area, or the outline of its stroke.
let outline = match item {
AppearanceItem::Fill(_) => ig.clone(),
AppearanceItem::Stroke(st) => {
let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(&ig));
effects::stroke::line_outline(&effects::stroke::stroke_pieces(&ig, st).line, st, w, f.px * 0.25)
let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(ig));
effects::stroke::line_outline(&effects::stroke::stroke_pieces(ig, st).line, st, w, f.px * 0.25)
}
};
let outset = n.appearance.outset();
let orule = if item.is_fill() { rule } else { FillRule::NonZero };
// Each item's shadows get their own cache slots.
let content = Content { node: n, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) };
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, &ig, rule, ib));
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, ig, rule, ib));
ctx.set_transform(Affine::IDENTITY);
}
}
@ -790,6 +849,85 @@ mod tests {
const WHITE: u32 = 765;
/// A page of objects with object-level and item-level geometry effects (Offset Path, Roughen,
/// Warp, Outline Stroke, Transform) under drop shadows and in groups.
fn effects_page() -> Document {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let fx = |id: &str, p: serde_json::Value| Effect { id: id.into(), params: p, visible: true };
for i in 0..24 {
let (x, y) = (10.0 + (i % 6) as f64 * 30.0, 10.0 + (i / 6) as f64 * 45.0);
let id = d.alloc_id();
let r = Rect::new(x, y, x + 20.0, y + 30.0);
let shape = if i % 2 == 0 { shapes::ellipse(r) } else { shapes::star(r.center(), 12.0, 5.0, 5, 0.0) };
let mut n = Node::path(id, shape, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.4, 0.9)), Paint::solid(Color::BLACK), 1.5));
n.appearance.effects = match i % 4 {
0 => vec![fx("path.offsetPath", json!({"offset": 3}))],
1 => vec![fx("distort.roughen", json!({"size": 4, "relative": true})), fx("stylize.dropShadow", json!({}))],
2 => vec![fx("warp.arc", json!({"bend": 30})), fx("path.outlineStroke", json!({}))],
_ => vec![fx("path.offsetPath", json!({"offset": -2})), fx("distort.transform", json!({"moveH": 4, "rotate": 10}))],
};
if let Some(f) = n.appearance.fill_mut() {
f.effects.push(fx("path.offsetPath", json!({"offset": 2})));
}
d.insert(Some(l), usize::MAX, n).unwrap();
}
d
}
/// Cached live-effect geometry draws exactly what computing it afresh draws: a second (warm)
/// frame, a frame after panning, and a fresh renderer all give the same pixels, and an edited
/// object (a new node) draws its new geometry, not the cached one.
#[test]
fn cached_effect_geometry_draws_the_same_pixels() {
let d = effects_page();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let view = Affine::scale(2.0);
let mut r = Renderer::new();
let cold = r.render(&d, 400, 400, view, &opts);
let warm = r.render(&d, 400, 400, view, &opts);
assert!(cold.pixels == warm.pixels, "a warm frame differs from the cold one");
let _ = r.render(&d, 400, 400, Affine::translate((15.0, 0.0)) * view, &opts);
assert!(r.render(&d, 400, 400, view, &opts).pixels == cold.pixels, "after a pan");
assert!(Renderer::new().render(&d, 400, 400, view, &opts).pixels == cold.pixels, "a fresh renderer");
// Edit one object's effect: the warm renderer must draw the new result.
let mut e = d.clone();
let id = e.layers[0].children().unwrap()[0].id;
e.node_mut(id).unwrap().appearance.effects = vec![Effect { id: "path.offsetPath".into(), params: json!({"offset": 9}), visible: true }];
let edited = r.render(&e, 400, 400, view, &opts);
assert!(edited.pixels != cold.pixels, "the edit shows");
assert!(edited.pixels == Renderer::new().render(&e, 400, 400, view, &opts).pixels, "and matches a fresh render");
}
/// The multithreaded rasterizer draws the same pixels with any number of workers, so the
/// automatic thread count can follow the machine.
#[test]
fn pixels_do_not_depend_on_the_thread_count() {
let mut d = effects_page();
// Overlapping translucent and blended objects on top of the effects.
let l = d.layers[0].id;
for i in 0..400 {
let id = d.alloc_id();
let (x, y) = ((i * 37 % 190) as f64, (i * 53 % 190) as f64);
let mut n = Node::path(
id,
shapes::ellipse(Rect::new(x, y, x + 14.0, y + 9.0)),
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.3, 0.1)), Paint::solid(Color::BLACK), 0.7),
);
n.opacity = 0.6;
d.insert(Some(l), usize::MAX, n).unwrap();
}
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut frames = vec![];
for threads in [Some(0), Some(1), Some(4), Some(8)] {
crate::set_default_threads(threads);
let mut r = Renderer::new();
frames.push(r.render(&d, 1200, 900, Affine::scale(6.0), &opts).pixels);
}
crate::set_default_threads(None);
assert!(frames.windows(2).all(|w| w[0] == w[1]), "the thread count changed pixels");
}
#[test]
fn thumbnails_of_glowing_objects_render_without_panicking() {
// Regression: thumbnails used vello's default (multithreaded) context, which panics on filters.
@ -809,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(_)))
{

View File

@ -358,6 +358,10 @@ pub struct Renderer {
strokes: PtrMap<(usize, u64, i32), StrokeEntry>,
/// The node being drawn on the plain-path fast path (owner of cached stroke outlines).
cur: Option<Arc<Node>>,
/// The node `draw_shape_fx` may cache the live-effect geometry of (the one `draw_arc` is drawing).
fx_owner: Option<Arc<Node>>,
/// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`.
fx_geom: PtrMap<usize, fx::FxGeom>,
stamp: u64,
/// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer).
alpha: f32,
@ -480,6 +484,8 @@ impl Renderer {
geom: PtrMap::default(),
strokes: PtrMap::default(),
cur: None,
fx_owner: None,
fx_geom: PtrMap::default(),
stamp: 0,
alpha: 1.0,
ctx: None,
@ -572,6 +578,9 @@ impl Renderer {
if self.shadows.len() > 256 {
self.shadows.retain(|_, e| g - e.stamp <= 3);
}
if self.fx_geom.len() > 256 {
self.fx_geom.retain(|_, e| g - e.stamp <= 3);
}
// Filtered rasters are large: only those of the last frame stay.
self.pixel_fx.retain(|_, e| e.stamp == g);
if !inks {
@ -911,7 +920,10 @@ impl Renderer {
if vectorcraft_doc::live::is_live(a) || effects::has_pathfinder(a) {
return self.draw_live(ctx, f, a);
}
// Its live-effect geometry depends on the node alone, so it can be kept across frames.
let outer = self.fx_owner.replace(a.clone());
self.draw_node(ctx, f, a, true);
self.fx_owner = outer;
}
fn draw_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, force: bool) {
@ -1755,7 +1767,9 @@ pub fn set_default_threads(n: Option<u16>) {
THREADS_OVERRIDE.store(n.map_or(-1, i32::from), std::sync::atomic::Ordering::Relaxed);
}
/// Rasterizer worker threads: 0 on wasm; otherwise up to 4 (vello's sweet spot), leaving a core free.
/// Rasterizer worker threads: 0 on wasm; otherwise one per core up to 8, leaving a core free. On a
/// 10-core machine 8 workers render a 50,000-path page 1.4× faster than 4 (9 are slower again);
/// the pixels don't depend on the count (`pixels_do_not_depend_on_the_thread_count`).
pub fn default_threads() -> u16 {
#[cfg(target_arch = "wasm32")]
{
@ -1770,7 +1784,7 @@ pub fn default_threads() -> u16 {
if std::env::var_os("VECTORCRAFT_RENDER_THREADS").is_some() {
return std::env::var("VECTORCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0);
}
std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).min(4) as u16).unwrap_or(0)
std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).min(8) as u16).unwrap_or(0)
}
}

View File

@ -656,9 +656,13 @@ impl Writer<'_> {
fn path_d(&self, p: &PathData, m: Affine) -> String {
let mut s = String::new();
let pt = |p: Point| {
// Each point as "x y", appended in place (no string per number or per point).
let dec = self.opts.decimals;
let pt = |s: &mut String, p: Point| {
let p = m * p;
format!("{} {}", self.num(p.x), self.num(p.y))
crate::push_num(s, p.x, dec);
s.push(' ');
crate::push_num(s, p.y, dec);
};
for sp in &p.subpaths {
let n = sp.anchors.len();
@ -669,16 +673,22 @@ impl Writer<'_> {
s.push(' ');
}
s.push('M');
s.push_str(&pt(sp.anchors[0].p));
pt(&mut s, sp.anchors[0].p);
for i in 0..sp.segment_count() {
let (a, b) = (&sp.anchors[i], &sp.anchors[(i + 1) % n]);
if sp.segment_is_line(i) {
if sp.closed && i == n - 1 {
break;
}
s.push_str(&format!(" L{}", pt(b.p)));
s.push_str(" L");
pt(&mut s, b.p);
} else {
s.push_str(&format!(" C{} {} {}", pt(a.h_out), pt(b.h_in), pt(b.p)));
s.push_str(" C");
pt(&mut s, a.h_out);
s.push(' ');
pt(&mut s, b.h_in);
s.push(' ');
pt(&mut s, b.p);
}
}
if sp.closed {

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)
}

View File

@ -567,10 +567,26 @@ pub fn xml_escape(s: &str) -> String {
/// Format a number with at most `decimals` places, trimming trailing zeros.
pub fn fmt_num(v: f64, decimals: u8) -> String {
let mut s = String::new();
push_num(&mut s, v, decimals);
s
}
/// [`fmt_num`] appended to `out`, without allocating.
pub fn push_num(out: &mut String, v: f64, decimals: u8) {
use std::fmt::Write as _;
let v = if v.is_finite() { v } else { 0.0 };
let s = format!("{:.*}", decimals as usize, v);
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
if s == "-0" || s.is_empty() { "0".into() } else { s }
let start = out.len();
let _ = write!(out, "{:.*}", decimals as usize, v); // writing to a String can't fail
let s = out.get(start..).unwrap_or_default();
if s.contains('.') {
let keep = s.trim_end_matches('0').trim_end_matches('.').len();
out.truncate(start + keep);
}
if matches!(out.get(start..), Some("-0" | "")) {
out.truncate(start);
out.push('0');
}
}
#[cfg(test)]

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}");

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}");

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;

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)] });